Compare commits
89 Commits
8f63c4969a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a91f32c70c | |||
| 902867be3f | |||
| 3dd767f23d | |||
| 084f92c661 | |||
| 5d8babaede | |||
| 30bae06c0c | |||
| 4dd819488e | |||
| 701476179e | |||
| ada85504f3 | |||
| 9fa065908d | |||
| d66f51ab8b | |||
| dd2938ee53 | |||
| 0c7f3104d4 | |||
| aa726c01af | |||
| 3facb6c3ad | |||
| c9433470ab | |||
| d4aa7eb8cf | |||
| 7e8ed585a9 | |||
| af0e63c9d6 | |||
| 09aabbb86e | |||
| 40d9efee18 | |||
| ecf974d9c0 | |||
| f816c0c879 | |||
| 565994977f | |||
| 8fd00d1887 | |||
| bc02779496 | |||
| 02615f16ae | |||
| c6fb8210b3 | |||
| 6f4f25480a | |||
| df1ba748d4 | |||
| e8971fbf0c | |||
| 8539999627 | |||
| 5881a21d9a | |||
| ff60d6ff26 | |||
| 789eea782a | |||
| 3679465956 | |||
| e02a1889b2 | |||
| 8b52cd20b4 | |||
| b88eba0da8 | |||
| a0ff9d1b25 | |||
| 05acad5af8 | |||
| 18236fc6d6 | |||
| c0f77ba69c | |||
| c33fe57433 | |||
| a93751fb01 | |||
| f536f454ba | |||
| d8fe1f8097 | |||
| 5e9471a778 | |||
| 86831a3627 | |||
| 27b229b3d7 | |||
| e2fc1d24cf | |||
| ee4e3c1dd4 | |||
| 82d15d82db | |||
| 022c42dccb | |||
| bc15e75e3e | |||
| 4077d06b2d | |||
| 0c16fb28c2 | |||
| 0ed4dd84b8 | |||
| 7c7fcf87d1 | |||
| 81f6861df8 | |||
| 5761556d84 | |||
| acc754b93e | |||
| 45ab32955a | |||
| f44da4b8e2 | |||
| 3cf36d436b | |||
| 2cde6ba5a5 | |||
| 720b47a6e1 | |||
| f07a8440b2 | |||
| 3a4de152b0 | |||
| 3ccdee339a | |||
| 3408d42fed | |||
| e8685f16f3 | |||
| d7a3253740 | |||
| b50323b521 | |||
| b75335dafd | |||
| 970e04af30 | |||
| 8e7a5425d4 | |||
| dbb9e8d72e | |||
| 26dac1a10b | |||
| 2a60def469 | |||
| 29f37bd916 | |||
| 654720ed05 | |||
| f3da24a712 | |||
| aafc41f4b2 | |||
| 70c7cba613 | |||
| e7c0a496c3 | |||
| 5560c18816 | |||
| 1aea0f558e | |||
| 52b0a3bff5 |
@@ -0,0 +1,76 @@
|
||||
# Universelle CI-Ampel — wird bei der Repo-Anlage automatisch eingepflanzt
|
||||
# (gitea-repo-create.sh, Wasserdicht-Runde 22.07.2026) und läuft auf dem
|
||||
# Gitea-Actions-Runner (arcane-VM).
|
||||
# GRUNDSATZ: Rot ist ein Ergebnis („nicht bewiesen"), kein Ärgernis.
|
||||
# • Reines Doku-Repo (noch kein Code) → GRÜN mit Vermerk.
|
||||
# • Code ohne Tests → ROT. Tests sind Pflicht, kein Deko.
|
||||
# Der Workflow erkennt selbst, was das Projekt ist (Python / Node / beides).
|
||||
name: Ampel
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
ampel:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ampel — erkennt Projekt-Typ selbst und prüft entsprechend
|
||||
shell: bash
|
||||
run: |
|
||||
# Der Runner startet bash mit -e — das schalten wir ab: die Ampel wertet
|
||||
# JEDEN Fehler selbst (rot=1) und liefert am Ende EIN Klartext-Urteil.
|
||||
# (Lehre Lauf #5: ungeschütztes pytest unter -e schnitt den Bericht ab.)
|
||||
set -u +e
|
||||
rot=0
|
||||
py_datei=$(find . -name '*.py' -not -path './.git/*' -not -path '*/node_modules/*' -print -quit)
|
||||
pkg_dateien=$(find . -name package.json -not -path '*/node_modules/*' -not -path './.git/*')
|
||||
|
||||
if [ -z "$py_datei" ] && [ -z "$pkg_dateien" ]; then
|
||||
echo "✅ Doku-Repo (noch kein Code) — Ampel GRÜN mit Vermerk."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "$py_datei" ]; then
|
||||
echo "== Python erkannt =="
|
||||
{ python3 -m venv /tmp/ampel-venv && . /tmp/ampel-venv/bin/activate; } || { echo "❌ Python-Setup kaputt (venv)"; exit 1; }
|
||||
pip install -q ruff pytest || { echo "❌ Werkzeug-Installation kaputt"; exit 1; }
|
||||
echo "-- Ruff (Linter: toter Code, kaputte Imports, Schlampereien)"
|
||||
ruff check . || rot=1
|
||||
echo "-- Abhängigkeiten installierbar? (halluzinierte Pakete fliegen hier auf)"
|
||||
while IFS= read -r req; do
|
||||
[ -n "$req" ] || continue
|
||||
pip install -q -r "$req" || { echo "❌ $req nicht installierbar"; rot=1; }
|
||||
done < <(find . -name 'requirements*.txt' -not -path '*/node_modules/*' -not -path './.git/*')
|
||||
echo "-- Importierbar? (kaputte Modul-Struktur fliegt hier auf)"
|
||||
python -m compileall -q . || rot=1
|
||||
echo "-- Pytest (keine Tests gefunden = ROT)"
|
||||
ec=0; pytest -q || ec=$?
|
||||
if [ $ec -eq 5 ]; then
|
||||
echo "❌ KEINE TESTS GEFUNDEN — Tests sind Pflicht, kein Deko (AGENTS.md)."
|
||||
rot=1
|
||||
elif [ $ec -ne 0 ]; then
|
||||
rot=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$pkg_dateien" ]; then
|
||||
echo "== Node/TypeScript erkannt =="
|
||||
while IFS= read -r pkg; do
|
||||
[ -n "$pkg" ] || continue
|
||||
d=$(dirname "$pkg")
|
||||
echo "-- $d"
|
||||
if [ ! -f "$d/package-lock.json" ]; then
|
||||
echo "❌ $d: kein package-lock.json — Build nicht reproduzierbar."
|
||||
echo " Fix: dort 'npm install --package-lock-only' ausführen und committen."
|
||||
rot=1; continue
|
||||
fi
|
||||
(cd "$d" && npm ci --no-audit --no-fund) || { rot=1; continue; }
|
||||
if grep -q '"build"' "$pkg"; then (cd "$d" && npm run build) || rot=1; fi
|
||||
if grep -q '"test"' "$pkg"; then (cd "$d" && npm test --silent) || rot=1; fi
|
||||
done <<< "$pkg_dateien"
|
||||
fi
|
||||
|
||||
if [ $rot -ne 0 ]; then
|
||||
echo "❌ AMPEL ROT — nichts heißt ‚fertig', solange das rot ist."
|
||||
fi
|
||||
exit $rot
|
||||
@@ -2,3 +2,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.claude/launch.json
|
||||
frontend/node_modules/
|
||||
|
||||
@@ -1,85 +1,142 @@
|
||||
# Mission Control
|
||||
|
||||
Web-Dashboard zur Verwaltung eines **lokalen LLM-Stacks (`llama-swap`)** auf dem Bosgame M5.
|
||||
FastAPI-Backend + Vanilla-JS-Dashboard. **Leitprinzip: KISS — kein Build-Schritt, kein Frontend-Framework, keine Datenbank.** Concerns sind getrennt (SoC) — aber *ohne* Build: native ES-Module im Frontend, FastAPI-`APIRouter` im Backend.
|
||||
FastAPI-Backend + Svelte 5-Frontend (Vite-Build). **Leitprinzip: KISS — SoC ohne Overhead.**
|
||||
|
||||
## Architektur
|
||||
|
||||
**Backend** (Top-Level-Helfer + ein Router je Bereich):
|
||||
- **`app.py`** — dünner Einstieg: baut `FastAPI`, hängt die Router ein, liefert das statische UI aus, registriert den Exception-Handler. Sonst nichts.
|
||||
- **`config.py`** — alle Env-Vars + die gemeinsame `ruamel.yaml`-Instanz.
|
||||
- **`auth.py`** — optionale Token-Auth (`X-MC-Token`).
|
||||
- **`jobengine.py`** — In-Memory-Job-System (Threads + Subprocess) mit Live-Log; fährt Downloads/Updates.
|
||||
- **`llamaswap.py`** — spricht `llama-swap` an (`/running`, `/v1/models`, unload) und liest/schreibt dessen `config.yaml` per `ruamel.yaml` (Kommentare bleiben erhalten).
|
||||
- **`routers/*.py`** — ein Router je Bereich. Aktuell: `models.py` (`status`, `download`, `register`, `unload`, `chat`), `jobs.py` (`jobs`), `maintenance.py` (`update`, `logs`-WebSocket), `system.py` (`status`, `stream`-WebSocket), `cookbook.py` (`analyze`). Alle Endpoints unter `/api/*`.
|
||||
- **`app.py`** — dünner Einstieg: `FastAPI`, hängt die Router ein, liefert das statische UI aus, Exception-Handler + `Cache-Control: no-cache`-Middleware.
|
||||
- **`config.py`** — alle Env-Vars + die gemeinsame `ruamel.yaml`-Instanz. Auch `SOURCE_DIR`/`PROD_DIR` fürs Self-Update.
|
||||
- **`auth.py`** — optionale Token-Auth: Header `X-MC-Token` **oder** Query `?token=` (Query ist Pflicht für WebSockets).
|
||||
- **`jobengine.py`** — In-Memory-Job-System (Threads + Subprocess) mit Live-Log. Secrets gehen über **stdin** (kein Leak in Log/`ps`).
|
||||
- **`llamaswap.py`** — spricht `llama-swap` an und liest/schreibt dessen `config.yaml` per `ruamel.yaml`.
|
||||
- **`hw_math.py`** — Odysseus-Fit-Mathe: VRAM/RAM-Bedarf, tps-Schätzung, `max_ctx_for`, `extract_params_b`.
|
||||
- **`recipes.py`** — kuratierte Use-Case-Stacks fürs Cookbook 2.0.
|
||||
- **`mcp_memory.py`** — stdio-MCP-Server: Gedächtnis-Tools für Cline/OpenCode/Claude Code.
|
||||
- **`routers/*.py`** — ein Router je Bereich:
|
||||
- `models.py` — status/download/register/update_model/unload/chat
|
||||
- `jobs.py` — Jobs-Liste
|
||||
- `maintenance.py` — update/self-update/os-update/service-restart/reboot/logs-WebSocket
|
||||
- `system.py` — status + stream-WebSocket (Live-Metriken psutil/sysfs)
|
||||
- `cookbook.py` — analyze/evaluate/recipes/install-recipe/upgrades
|
||||
- `integration.py` — test (Engine-Verbindungstest)
|
||||
- `news.py` — RSS-Aggregation via stdlib
|
||||
- `memory.py` — Gedächtnis-CRUD (SQLite, WAL-Mode), 5 Kategorien
|
||||
- `hermes.py` — Chat-WS **proxyt zum Hermes-Agent-Server (:8642)** + transcribe(Whisper)/tts(Piper)/status/pubkey + **Cockpit-Reads** agent/cron/skills (Phase 4)
|
||||
- `hermes_ui.py` — **Reverse-Proxy** auf das eingebettete Hermes-Web-Dashboard (`/hermes-ui/`, HTTP + WS-Bridge `pty/ws/pub/events`, `X-Forwarded-Prefix`). Der Chat lebt jetzt hier (iframe), nicht mehr im Eigenbau.
|
||||
- **`hermes_control.py`** — read-only Control-Plane: liest Agent-Status (`gateway_state.json` + Cron-Heartbeat), Cron-Jobs & Skills via `hermes`-CLI (`COLUMNS=400`, Rich-Tabelle geparst). TTL-Cache.
|
||||
- **`model_caps.py`** — Modell-Capability-Tags (Phase 10): dependency-freier GGUF-Header-Reader (architecture/expert_count/context_length/parameter_count, offline) + cmd-Flags (`--jinja`/`--mmproj`) + Familien-Fallback → MoE/Tools(yes|likely|no)/Vision/Coder/Reasoning/Embedding. Genutzt von `models.py` (`meta.capabilities`).
|
||||
|
||||
**Frontend** (`static/`, dünne Hülle + ES-Module, kein Build):
|
||||
- **`index.html`** — nur Gerüst: Sidebar-Nav, Topbar, Alert-Banner, ein `.view`-Container je Bereich (Hash-Routing). Lädt `css/*` und `js/main.js` als Modul.
|
||||
- **`css/base.css`** — Design-Tokens (`:root`), Reset, App-Layout (Sidebar/Topbar/Content). **`css/components.css`** — Karten, KPI-Kacheln, Listen, Forms, Log, Toast.
|
||||
- **`js/core/*`** — `api.js` (Fetch + Token), `ui.js` (DOM-Helfer, Toast, Icons), `nav.js` (View-Switch).
|
||||
- **`js/panels/*`** — ein Panel je Bereich (`overview`, `models`, `maintenance`, `jobs`). Panel-Vertrag: `{ id, mount?(), onStatus?(s), onJobs?(jobs) }`.
|
||||
- **`js/main.js`** — bootet Panels, pflegt Topbar/Alert, baut die WebSocket-Verbindung für Live-System-Metriken (`/api/system/stream`) auf, fährt das reguläre Polling (`/api/status` 3 s, `/api/jobs` 1.5 s) und verteilt an die Panels.
|
||||
**Frontend** (`frontend/src/`, Svelte 5 + Vite, Build → `static/dist/`):
|
||||
- **`index.html`** — Gerüst: Sidebar-Nav (10 Tabs), Topbar, Alert-Banner, View-Container je Bereich.
|
||||
- **`frontend/src/main.ts`** — bootet alle Svelte-Panels, Topbar/Alert, WebSocket Live-Metriken, Polling.
|
||||
- **`frontend/src/panels/`** — je ein Panel: Overview, Models, Jobs(Aktivität), Server, Cookbook, Connect(Verbinden), News, Guides, Memory(Gedächtnis), Hermes.
|
||||
- **`frontend/src/stores/`** — Svelte 5 Runes: `status.svelte.ts`, `jobs.svelte.ts`, `system.svelte.ts`.
|
||||
- **Design-System (v3):** EINE Akzentfarbe Teal `#2dd4bf`. Grün/Gelb/Rot nur für Status. Tokens in `css/base.css`.
|
||||
|
||||
- **`mission-control.service`** — systemd-Unit (uvicorn auf Port 9000).
|
||||
- **Konfiguration** rein über Env-Vars: `MC_LLAMA_SWAP_URL`, `MC_CONFIG_PATH`, `MC_MODELS_DIR`, `MC_CMD_TEMPLATE`, `MC_UPDATE_CMD`, `MC_DEFAULT_TTL`, `MC_TOKEN`.
|
||||
## Der Stack drumherum
|
||||
|
||||
## Der Stack drumherum (Kontext)
|
||||
|
||||
- **Bosgame M5**: AMD Strix Halo (gfx1151), Ubuntu 26.04, Kernel 7.0, ~124 GB GTT-Speicher. LAN-IP `192.168.178.151`.
|
||||
- **Inferenz**: vorgebaute llama.cpp-ROCm-Binaries → `llama-swap` auf `:8080` (läuft mit `-watch-config`).
|
||||
- **3 Modelle / 5 Rollen**: `coder`, `scout`, `vision` (Qwen3-Familie). Modelle werden geswappt, nicht parallel geladen.
|
||||
- **Mission Control**: Produktiv unter `/opt/mission-control` (als Dienst), Source-Repo unter `~/mission-control`.
|
||||
- **Bosgame M5**: AMD Strix Halo (gfx1151), Ubuntu 26.04, Kernel 7.0, ~124 GB GTT. LAN-IP `192.168.178.151`.
|
||||
- **Inferenz**: llama.cpp-ROCm-Binaries → `llama-swap` auf `*:8080` (LAN-offen, `-watch-config`).
|
||||
- **3 Modelle / 5 Rollen**: `coder` (Qwen3-30B-A3B), `scout` (Qwen3-8B), `vision` (Qwen3-VL).
|
||||
- **Mission Control**: Produktiv unter `/opt/mission-control`, Source unter `~/mission-control`.
|
||||
- **Gedächtnis**: SQLite unter `/srv/models/mission-control-memory.db` (5 Kategorien: user/instruction/stable/versioned/ephemeral).
|
||||
- **Hermes Agent**: aktuell Eigenbau als Teil von Mission Control; **ab v9 das Nous Hermes-Agent-Framework** als eigener Dienst (`hermes-agent.service`, OpenAI-API auf `:8642`, base_url → llama-swap, MCP-Client bindet `mcp_memory.py` ein). Voice (Whisper STT + Piper TTS) bleibt in Mission Control und wrappt :8642.
|
||||
|
||||
## Entwickeln & Deployen
|
||||
|
||||
**Wo entwickelt wird:** primär auf einem **Windows-PC** (Repo z. B. `F:\Coding Stuff\mission-control`),
|
||||
Zielsystem ist der **Linux-Bosgame**. Lokal nur Smoke-Test (rendert/bootet sauber?), echter
|
||||
Funktionstest nur auf dem Bosgame (dort läuft llama-swap).
|
||||
**Wo entwickelt wird:** Windows-PC (`F:\Coding Stuff\mission-control`), Ziel: Linux-Bosgame.
|
||||
Lokal nur Smoke-Test (rendert/bootet?), echter Funktionstest auf dem Bosgame.
|
||||
|
||||
**Lokaler Smoke-Test (Windows):**
|
||||
```bash
|
||||
python -m venv .venv && .venv/Scripts/python -m pip install -r requirements.txt
|
||||
.venv/Scripts/python -m uvicorn app:app --port 9001
|
||||
```
|
||||
Ohne llama-swap ist alles im Offline-Zustand (rote Pill, Warn-Banner) — das ist erwartet.
|
||||
|
||||
**Bosgame-Zugang (SSH, key-basiert, passwortlos):**
|
||||
**Bosgame-Zugang:**
|
||||
```bash
|
||||
ssh -i ~/.ssh/id_ed25519_hermes -o IdentitiesOnly=yes hitonabi@192.168.178.151
|
||||
```
|
||||
User ist **`hitonabi`** (klein!). `sudo` braucht ein Passwort (kein passwortloses sudo).
|
||||
User: `hitonabi`. sudo NOPASSWD-Whitelist für `systemctl restart mission-control|llama-swap` und `journalctl`.
|
||||
|
||||
**Deploy-Kette (Windows → Gitea → Bosgame):**
|
||||
1. Lokal bauen + Smoke-Test → `git push` (origin = Gitea `http://192.168.178.153:3000/Hitonabi/mission-control.git`).
|
||||
2. Bosgame: `cd ~/mission-control && git pull --ff-only` (Source-Repo).
|
||||
3. Nach `/opt` ausrollen — **ohne sudo** (`hitonabi` besitzt `/opt/mission-control`), `.venv` ausnehmen:
|
||||
```bash
|
||||
rsync -a --exclude='.git' --exclude='.venv' --exclude='__pycache__' --exclude='*.pyc' ~/mission-control/ /opt/mission-control/
|
||||
```
|
||||
4. `sudo systemctl restart mission-control` (Passwort nötig). Prod = `:9000`, Dev-Spielwiese = `:9001`.
|
||||
5. **Niemals direkt in `/opt` arbeiten.** Logs: `journalctl -u mission-control -f`.
|
||||
**Frontend-Build:**
|
||||
```bash
|
||||
cd frontend && npm run build # vor jedem git push
|
||||
```
|
||||
Output: `static/dist/main.js` + `static/dist/main.css` — wird committet (kein Build-Schritt auf dem Server).
|
||||
|
||||
Statische Dateien werden je Request frisch von Platte gelesen → UI-Änderungen wirken schon nach
|
||||
rsync; **Python-Code-Änderungen brauchen den Restart**.
|
||||
**Deploy-Kette:**
|
||||
```
|
||||
npm run build → git push (Gitea :3000) → Bosgame: git pull → rsync → systemctl restart
|
||||
```
|
||||
Oder über den Self-Update-Button in Mission Control (empfohlen).
|
||||
|
||||
**Self-Update-Button** (`POST /api/self-update`): git pull → rsync → restart — alles in einem Klick.
|
||||
|
||||
## Konfiguration (Env-Vars)
|
||||
|
||||
| Variable | Default | Zweck |
|
||||
|---|---|---|
|
||||
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | llama-swap URL |
|
||||
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap Config |
|
||||
| `MC_MODELS_DIR` | `/srv/models` | GGUF-Ablageort |
|
||||
| `MC_TOKEN` | leer | Auth-Token (optional, LAN-only) |
|
||||
| `MC_UPDATE_CMD` | leer | Engine-Update-Befehl |
|
||||
| `MC_DEFAULT_TTL` | `300` | Sekunden bis Auto-Unload |
|
||||
| `MC_SOURCE_DIR` | `~/mission-control` | Self-Update Quelle |
|
||||
| `MC_MEMORY_DB` | `{MODELS_DIR}/mission-control-memory.db` | SQLite Gedächtnis |
|
||||
| `HERMES_API_URL` | `http://127.0.0.1:8642/v1` | Hermes-Agent-Server (OpenAI-API) |
|
||||
| `HERMES_API_KEY` | aus `~/.hermes/.env` | API-Key des Hermes-Servers (sonst Env) |
|
||||
| `HERMES_HOME` | `~/.hermes` | Hermes-Agent State-Dir (Cockpit-Reads) |
|
||||
| `HERMES_BIN` | `~/.local/bin/hermes` | Hermes-CLI (Cron/Skills-Reads) |
|
||||
| `HERMES_DASHBOARD_URL` | `http://127.0.0.1:9119` | Hermes-Web-Dashboard (eingebettet via `/hermes-ui/`) |
|
||||
| `HERMES_WINDOWS_HOST` | leer | Windows-PC IP für SSH |
|
||||
| `HERMES_WINDOWS_USER` | `TobisPC` | Windows SSH-Username |
|
||||
| `HERMES_SSH_KEY` | `~/.ssh/id_ed25519_hermes_agent` | SSH-Key für Hermes |
|
||||
| `PIPER_BIN` | `/opt/mission-control/piper/piper` | Piper TTS Binary |
|
||||
| `PIPER_VOICE` | `.../de_DE-kerstin-low.onnx` | Piper Stimm-Modell |
|
||||
| `WHISPER_MODEL` | `medium` | Whisper Modell-Größe |
|
||||
|
||||
## Konventionen
|
||||
|
||||
- KISS über alles: kein schweres Framework, kein Build-Schritt, solange es ohne geht.
|
||||
- **SoC ohne Build**: neuer Bereich = ein `routers/<bereich>.py` (FastAPI-Router) + ein `static/js/panels/<bereich>.js` (ES-Modul nach Panel-Vertrag) + ein Nav-Eintrag in `index.html`. Gemeinsame Backend-Logik in `config/auth/jobengine/llamaswap`, gemeinsame Frontend-Logik in `js/core/*`. Keine schweren Libs, kein CDN — nur relative `import`s.
|
||||
- Endpoint-URLs bleiben unter `/api/*`; neue Bereiche degradieren sauber, wenn ihre Quelle (sysfs, `amd-smi`, `systemctl`) fehlt (z. B. beim Entwickeln auf Windows).
|
||||
- Funktion darf **nicht** von `localStorage` abhängen (nur das Token-Feld nutzt es, das ist ok).
|
||||
- **Sicherheit**: Das Backend führt Shell-Befehle aus → ausschließlich im vertrauenswürdigen LAN betreiben, niemals offen ins Internet.
|
||||
- **Neuer Bereich**: `routers/<bereich>.py` + `frontend/src/panels/<Bereich>Panel.svelte` + Nav/View in `index.html` + Import in `main.ts`. Danach `npm run build`.
|
||||
- **Backend SoC**: gemeinsame Logik in `config/auth/jobengine/llamaswap/hw_math`. Alles unter `/api/*`.
|
||||
- **Sicherheit**: Shell-Befehle → nur LAN. Secrets via stdin. Token optional.
|
||||
- **SQLite**: WAL-Mode aktiv, `check_same_thread=False` — ausreichend für Single-User.
|
||||
- **`asyncio.get_running_loop()`** statt `get_event_loop()` (Python 3.10+ kompatibel).
|
||||
- **Memory-Kategorien**: `user` | `instruction` | `stable` | `versioned` | `ephemeral` — Enum in `routers/memory.py`.
|
||||
|
||||
## Gotchas (wichtig!)
|
||||
## Gotchas
|
||||
|
||||
- **`${PORT}`** in der generierten `llama-swap`-Config muss **literal** stehen bleiben → beim Bauen des cmd-Strings `str.replace` benutzen, NICHT `.format` (sonst KeyError auf `PORT`).
|
||||
- `llama-swap` muss mit **`-watch-config`** laufen, sonst greift das Auto-Einpflegen neuer Modelle nicht.
|
||||
- HuggingFace-Downloads mit **`HF_HUB_DISABLE_XET=1`** (sonst reproduzierbarer Hänger bei ~6 MB).
|
||||
- Vision-Modelle in llama.cpp brauchen zusätzlich **`--mmproj <projektor>` und `--jinja`**.
|
||||
- **`${PORT}`** in llama-swap-Config → `str.replace` statt `.format` (KeyError sonst).
|
||||
- **`-watch-config`** bei llama-swap nötig für Auto-Einpflegen.
|
||||
- **`HF_HUB_DISABLE_XET=1`** bei HuggingFace-Downloads (Hänger bei ~6 MB).
|
||||
- **Vision-Modelle**: brauchen `--mmproj <projektor>` und `--jinja`.
|
||||
- **Gitea-Push** kann transient mit „Failed to authenticate" fehlschlagen → einfach nochmal.
|
||||
- **Piper TTS**: Binary + Stimm-Modell müssen separat installiert werden (nicht via pip).
|
||||
- **Whisper**: lädt beim ersten Aufruf ~1.4 GB Modell herunter (einmalig, dann gecacht).
|
||||
- **Hermes SSH-Key**: muss auf dem Bosgame generiert werden (`~/.ssh/id_ed25519_hermes_agent`).
|
||||
- **system_status-Keys**: `/api/system/status` liefert verschachtelt (`cpu.percent`, `ram.used` in Bytes) — nicht flach. Konsumenten müssen entsprechend lesen.
|
||||
- **Hermes-Server-Key**: `routers/hermes.py` proxyt zu `:8642` und liest den Key bei Bedarf aus `~/.hermes/.env` (`API_SERVER_KEY`) — MC muss als User `hitonabi` laufen, sonst kein Lesezugriff.
|
||||
- **Hermes-Dashboard (Phase 5)**: braucht Extras `uv pip install --python ~/.hermes/hermes-agent/venv/bin/python -e ".[web,pty]"` und einen einmaligen UI-Build (erster `hermes dashboard`-Start ohne `--skip-build`; Output → `hermes_cli/web_dist`). Dienst: `~/.config/systemd/user/hermes-dashboard.service` (an `127.0.0.1:9119` gebunden, `--skip-build`). Wird unter `/hermes-ui/` per `X-Forwarded-Prefix` geproxyt — nie ohne diesen Header proxen, sonst absolute Asset-Pfade kaputt. `/hermes-ui/*` ist bewusst **ohne** MC-Token (LAN-only; Dashboard self-auth auf Loopback).
|
||||
- **OpenCode Config**: Datei heißt `opencode.jsonc` (nicht `.json`), Key ist `"providers"` (Plural).
|
||||
- **ConnectPanel hinter NPM-Proxy**: `location.hostname` zeigt Proxy-Domain → LAN-IP Override im Verbinden-Tab setzen (localStorage).
|
||||
|
||||
## Projektstatus & Roadmap
|
||||
## Projektstatus
|
||||
|
||||
Die v2-Roadmap ist **vollständig umgesetzt** (siehe `ROADMAP.md`).
|
||||
Wir befinden uns nun im **Feinschliff- und Wartungsmodus**.
|
||||
**v7 (Memory Layer)**: SQLite-Gedächtnis mit 5 Kategorien + MCP-Server für alle Tools live.
|
||||
**v8 (Hermes Agent)**: Chat-Agent mit Voice (Whisper STT + Piper TTS), Tool Calling, WebSocket-Streaming live.
|
||||
**v8.1 (Bug-Fix)**: system_status-Keys, Model-Routing, asyncio, sudo-PW für Engine-Update, Chat-History in sessionStorage.
|
||||
**v8.2 (UX)**: Guide-Tabs + Begriffe oben, Mobile Bottom-Nav (≤520px), Download-Links, ConnectPanel-Fixes.
|
||||
**v8.3 (Memory Import)**: Import aus Cloud-KIs (Claude/Gemini/ChatGPT) — zeilenweiser Batch-POST.
|
||||
**v9 (Hermes-Agent-Adoption, Entscheidung 2026-06-23)**: Eigenbau-Agent → Nous Hermes-Agent-Framework (MIT). MC wird Control-Plane. Eigenes Memory bleibt via MCP erhalten. Phasen 0–3 live (harter Cutover: `hermes_agent.py` entfernt) **+ Phase 4**: Agent-**Cockpit** (Status/Cron/Skills) **+ Phase 5 (Kurswechsel)**: Eigenbau-Chat ersetzt durch das **eingebettete Hermes-Web-Dashboard** (`hermes-dashboard`-Dienst :9119, MC-Reverse-Proxy `/hermes-ui/`, iframe) **+ Phase 6**: volle lokale Rechte (`approvals.mode:auto`, `hooks_auto_accept`, `subagent_auto_approve`) **+ Phase 7**: Agent-Modell **Hermes 4 14B Q6_K** (llama-swap-Alias `hermes`, `--jinja`) **+ Phase 8**: Cockpit zeigt Lernen (`USER.md`) + Aktivität (`hermes insights`) **+ Phase 9**: `mcp_memory.py`-Tool-Beschreibungen proaktiv → geteiltes Memory wächst autonom mit. Offen: Kurator/Dedup fürs MC-SQLite, Self-Update für `hermes-gateway`+`hermes-dashboard`. Siehe ROADMAP v9.
|
||||
|
||||
**Nordstern:** den Server nie wieder via SSH/Putty anfassen müssen — 100 % Automatisierung / Klicki-Bunti.
|
||||
**Memory-Architektur (entschieden 2026-06-24):** Zwei komplementäre Schichten, keine Doppelung-Panik. **MC-SQLite (via MCP) = geteilte „Verfassung"** für ALLE Tools (Hermes, Cline, OpenCode, Zed) — kuratierbare, deterministische Fakten/Regeln; wächst autonom durch proaktives `add_memory`. **Hermes-nativ (`~/.hermes/memories/USER.md` + `state.db` FTS5) = Hermes' privates Profil-/Gesprächsgedächtnis** (nur Hermes). Linie: „soll das jedes Tool wissen?" → MC-SQLite; „Hermes' Eindruck/Gesprächsfaden" → nativ. Dokumenten-„Second Brain" (Obsidian/RAG) ist davon getrennt und nur bei Bedarf zu bauen.
|
||||
|
||||
Offene v8-Einrichtungsschritte (kein Code, nur Setup):
|
||||
- Piper Binary + Kerstin-Stimme installieren (Befehle im Hermes-Tab → ⚙ Setup)
|
||||
- Windows OpenSSH Server aktivieren + SSH-Key einrichten
|
||||
|
||||
**Nordstern:** Den Bosgame nie wieder via SSH/Putty anfassen — 100 % Automatisierung / Klicki-Bunti.
|
||||
|
||||
@@ -1,85 +1,114 @@
|
||||
# Mission Control
|
||||
|
||||
Eine schlanke Steuerzentrale für deinen lokalen `llama-swap`-Stack auf dem Bosgame M5.
|
||||
Ein FastAPI-Backend + ein HTML-Dashboard. Kein Build-Schritt, keine Datenbank.
|
||||
Persönliche Steuerzentrale für einen lokalen LLM-Stack auf dem Bosgame M5.
|
||||
FastAPI-Backend + Svelte 5-Frontend. Kein Cloud-Zwang, keine Datenbank-Abhängigkeit.
|
||||
|
||||
## Was sie kann
|
||||
## Was es kann
|
||||
|
||||
- **Modelle & Ports** sehen — liest `llama-swap` (`/running`, `/v1/models`) + deine `config.yaml`
|
||||
- **Modell holen** — lädt eine GGUF-Datei von HuggingFace (`hf download`) als Hintergrund-Job mit Live-Log
|
||||
- **Einpflegen** — schreibt das Modell automatisch in deine `config.yaml`; `llama-swap` lädt mit `-watch-config` neu
|
||||
- **Wartung** — Container/Toolbox aktualisieren, Modelle aus dem Speicher werfen
|
||||
- **Schnelltest** — Chat-Box, um ein Modell zu wecken und zu prüfen
|
||||
**LLM-Stack verwalten**
|
||||
- Modelle & Status sehen (llama-swap `/running`, `/v1/models`, `config.yaml`)
|
||||
- Modelle von HuggingFace laden — als Hintergrund-Job mit Live-Log
|
||||
- Automatisch in llama-swap einpflegen (`-watch-config`)
|
||||
- Optimale Kontextfenster berechnen (hardware-aware, `hw_math`)
|
||||
|
||||
Was sie **bewusst nicht** macht: Chat-Logs, Inferenz-Monitoring im Detail — dafür hat `llama-swap`
|
||||
schon `/ui` und `/log`. Mission Control ergänzt nur, was fehlt.
|
||||
**Cookbook**
|
||||
- Use-Case-getrieben: Coding · Chat · Vision · Agenten · Schnell & sparsam
|
||||
- Hardware-Ampel pro Setup, „Beste Wahl für dein System"
|
||||
- 1-Klick „Komplettes Setup installieren"
|
||||
|
||||
**Wartung & Server**
|
||||
- OS-Updates, Dienste steuern (llama-swap, Mission Control), Reboot
|
||||
- Live-Logs (journalctl via WebSocket)
|
||||
- Self-Update-Button (git pull → rsync → restart)
|
||||
|
||||
**Verbinden**
|
||||
- Copy-Paste-Configs für Cline, OpenCode, Zed, Continue, Jan AI, OpenWebUI — inkl. Download-Links
|
||||
- LAN-IP Override für NPM-Proxy-Setups (localStorage)
|
||||
- Gedächtnis-MCP: alle Tools teilen dasselbe persistente Gedächtnis
|
||||
|
||||
**Gedächtnis** *(v7/v8.3)*
|
||||
- SQLite-Datenbank mit 5 Kategorien: `user` · `instruction` · `stable` · `versioned` · `ephemeral`
|
||||
- MCP-Server (`mcp_memory.py`): Cline, OpenCode, Claude Code teilen dasselbe Gedächtnis
|
||||
- Import aus Cloud-KIs (Claude/Gemini/ChatGPT): zeilenweise einfügen → Batch-POST
|
||||
- Temporäre Einträge (ephemeral) nach 7 Tagen automatisch gelöscht
|
||||
|
||||
**Hermes Agent** *(v8)*
|
||||
- Lokaler KI-Assistent direkt im Browser — Text und Sprache
|
||||
- Whisper STT (lokal, CPU) + Piper TTS (weibliche Stimme Kerstin)
|
||||
- Tool Calling: Dateien lesen, Befehle ausführen, System abfragen, Web suchen
|
||||
- Gedächtnis-Integration: Hermes kennt dich und deinen Stack von Anfang an
|
||||
- Chat-History überlebt Browser-Reload · Stopp-Button · Verlauf löschen
|
||||
- Setup-Wizard für Piper + Windows SSH-Zugriff
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Python 3.11+
|
||||
- `hf` CLI installiert (`pip install -U "huggingface_hub[cli]"`)
|
||||
- ein laufendes `llama-swap` — gestartet **mit `-watch-config`**, sonst greift das Auto-Einpflegen nicht
|
||||
- `llama-swap` laufend mit `-watch-config`
|
||||
- `hf` CLI (`pip install -U "huggingface_hub[cli]"`)
|
||||
- Für Voice: `faster-whisper` + Piper Binary (Anleitung im Hermes-Tab)
|
||||
|
||||
## Installation
|
||||
## Schnellstart (Bosgame)
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/mission-control && sudo chown $USER /opt/mission-control
|
||||
cp -r *.py routers static /opt/mission-control/
|
||||
git clone http://192.168.178.153:3000/Hitonabi/mission-control.git ~/mission-control
|
||||
rsync -a --exclude='.git' ~/mission-control/ /opt/mission-control/
|
||||
cd /opt/mission-control
|
||||
python3 -m venv .venv && . .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app:app --host 0.0.0.0 --port 9000
|
||||
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Dann im Browser im LAN: `http://<bosgame-ip>:9000`
|
||||
|
||||
### Als Dienst (Autostart)
|
||||
|
||||
Pfade in `mission-control.service` anpassen, dann:
|
||||
|
||||
Als Dienst:
|
||||
```bash
|
||||
sudo cp mission-control.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now mission-control
|
||||
sudo systemctl daemon-reload && sudo systemctl enable --now mission-control
|
||||
```
|
||||
|
||||
## Konfiguration (Umgebungsvariablen)
|
||||
Browser im LAN: `http://192.168.178.151:9000`
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Alle Einstellungen über Umgebungsvariablen in der systemd-Unit:
|
||||
|
||||
| Variable | Default | Zweck |
|
||||
|---|---|---|
|
||||
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | wo `llama-swap` lauscht |
|
||||
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | die `llama-swap`-Config |
|
||||
| `MC_MODELS_DIR` | `/srv/models` | wohin GGUFs geladen werden |
|
||||
| `MC_CMD_TEMPLATE` | siehe unten | Startbefehl pro Modell |
|
||||
| `MC_UPDATE_CMD` | _(leer)_ | Befehl für „Container aktualisieren" |
|
||||
| `MC_DEFAULT_TTL` | `300` | Sekunden bis Auto-Unload |
|
||||
| `MC_TOKEN` | _(leer)_ | optionales Zugriffs-Token |
|
||||
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | llama-swap URL |
|
||||
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap Config |
|
||||
| `MC_MODELS_DIR` | `/srv/models` | GGUF-Ablageort |
|
||||
| `MC_TOKEN` | leer | Auth-Token (optional) |
|
||||
| `MC_UPDATE_CMD` | leer | Engine-Update-Befehl |
|
||||
| `MC_SOURCE_DIR` | `~/mission-control` | Self-Update Quelle |
|
||||
| `MC_MEMORY_DB` | `{MODELS_DIR}/mission-control-memory.db` | Gedächtnis-Datenbank |
|
||||
| `HERMES_API_URL` | `http://127.0.0.1:8642/v1` | Hermes-Agent-Server (OpenAI-API) |
|
||||
| `HERMES_API_KEY` | aus `~/.hermes/.env` | API-Key des Hermes-Servers (sonst Env) |
|
||||
| `HERMES_WINDOWS_HOST` | leer | Windows-PC IP für SSH-Zugriff |
|
||||
| `PIPER_BIN` | `/opt/mission-control/piper/piper` | Piper TTS Binary |
|
||||
| `PIPER_VOICE` | `.../de_DE-kerstin-low.onnx` | Piper Stimme |
|
||||
| `WHISPER_MODEL` | `medium` | Whisper Modell-Größe |
|
||||
|
||||
### Wichtig: `MC_CMD_TEMPLATE` an deinen Start anpassen
|
||||
## Sicherheit
|
||||
|
||||
Das ist der Befehl, der pro Modell in die `config.yaml` geschrieben wird. `{model}` und `{ctx}`
|
||||
werden von Mission Control ersetzt, **`${PORT}` bleibt stehen** (das ersetzt `llama-swap` selbst).
|
||||
- Ausschließlich im vertrauenswürdigen LAN betreiben
|
||||
- `MC_TOKEN` setzen für minimalen Schutz
|
||||
- Für Remote-Zugriff: SSH-Tunnel oder Tailscale, niemals direktes Port-Forwarding
|
||||
- Hermes-SSH-Key hat nur Zugriff auf explizit freigegebene Windows-Pfade
|
||||
|
||||
## Architektur
|
||||
|
||||
Direkt auf dem Host (llama-server im PATH):
|
||||
```
|
||||
llama-server -m {model} --host 127.0.0.1 --port ${PORT} -c {ctx} -ngl 999 -fa 1 --no-mmap
|
||||
Browser (Windows PC)
|
||||
├── Svelte 5 UI (static/dist/main.js)
|
||||
└── WebSocket (Live-Metriken, Hermes-Chat, Logs)
|
||||
|
||||
FastAPI (Bosgame :9000)
|
||||
├── routers/models.py LLM-Verwaltung
|
||||
├── routers/system.py Live-Metriken (psutil/sysfs)
|
||||
├── routers/maintenance.py Updates, Logs, Neustart
|
||||
├── routers/cookbook.py Modell-Empfehlungen
|
||||
├── routers/memory.py Gedächtnis (SQLite)
|
||||
├── routers/hermes.py Agent-Chat, Voice (Whisper/Piper)
|
||||
└── ...
|
||||
|
||||
llama-swap (:8080) Inference (Qwen3-Familie)
|
||||
SQLite Gedächtnis (/srv/models/mission-control-memory.db)
|
||||
mcp_memory.py MCP-Server für Cline/OpenCode/Claude Code
|
||||
```
|
||||
|
||||
Über den kyuz0-Container (distrobox) — Beispiel, an deinen Toolbox-Namen anpassen:
|
||||
```
|
||||
distrobox enter llama-vulkan-radv -- llama-server -m {model} --host 127.0.0.1 --port ${PORT} -c {ctx} -ngl 999 -fa 1 --no-mmap
|
||||
```
|
||||
|
||||
> `-fa 1` (Flash Attention) und `--no-mmap` sind auf Strix Halo Pflicht, sonst drohen Crashes/Slowdowns.
|
||||
|
||||
## ⚠️ Sicherheit
|
||||
|
||||
Mission Control führt Shell-Befehle aus (Downloads, Updates) und schreibt deine Config.
|
||||
**Niemals offen ins Internet hängen.** Betrieb nur im vertrauenswürdigen LAN. Wenn du
|
||||
trotzdem etwas Schutz willst, setz `MC_TOKEN` und trag es oben rechts im Dashboard ein.
|
||||
Für echten Remote-Zugriff: per SSH-Tunnel oder Tailscale, nicht per Portfreigabe.
|
||||
|
||||
## API (falls du es skripten willst)
|
||||
|
||||
`GET /api/status` · `POST /api/download` · `POST /api/register` · `POST /api/unload[?model=]`
|
||||
· `POST /api/update` · `POST /api/chat` · `GET /api/jobs` · `GET /api/jobs/{id}`
|
||||
|
||||
+126
-68
@@ -1,86 +1,144 @@
|
||||
# Mission Control - Roadmap v2
|
||||
# Mission Control — Roadmap
|
||||
|
||||
**Nordstern:** Den Bosgame nie wieder via SSH/Putty bedienen müssen. **100 % Automatisierung oder Klicki-Bunti.**
|
||||
**Nordstern:** Den Bosgame nie wieder via SSH/Putty bedienen. **100 % Automatisierung oder Klicki-Bunti.**
|
||||
**Größeres Ziel:** Mission Control als persönliches **Agentic OS** — lokaler, privater KI-Assistent der das gesamte Setup kennt und steuert.
|
||||
|
||||
---
|
||||
|
||||
## Projektstand (Stand 2026-06-20)
|
||||
## v9 — Hermes-Agent-Adoption (🔜 IN ARBEIT, Entscheidung 2026-06-23)
|
||||
|
||||
**✅ Schritt 1 erledigt & live auf `:9000`** — *SoC-Refactor + Design 2.0 als Fundament* (Commit `3649394`).
|
||||
Backend in Helfer + `routers/*` zerlegt, Frontend in ES-Module (`js/core` + `js/panels`) + ausgelagertes
|
||||
CSS, neues Dashboard-Layout (Sidebar-Nav, Topbar, Alert-Banner, Hero, KPI-Kacheln, Health-Signale,
|
||||
Modell-Listen, Aktivitäts-Stream) angelehnt an `docs/mission-control-overview.png`. Bestehende Funktionen
|
||||
1:1 migriert, Endpoint-URLs unverändert. Architektur + Deploy-Weg stehen in **CLAUDE.md**.
|
||||
**Strategiewechsel:** Der hand-gebaute Agent (`hermes_agent.py`) wird durch das **Nous Research Hermes Agent**-Framework (MIT, [github.com/nousresearch/hermes-agent](https://github.com/nousresearch/hermes-agent)) ersetzt. Mission Control wird zur **Control-Plane** darum herum.
|
||||
|
||||
**✅ Schritt 2 erledigt & live** — *Feature 3: Live-Auslastung*. `system.py` Router mit `psutil` und sysfs für CPU/RAM/Disk/GPU/Temp.
|
||||
**Warum:** Der Eigenbau ist schwach wegen Scaffolding (blinde DuckDuckGo-Suche, Memory-Voll-Dump in den Prompt, Fake-Streaming) — *nicht* wegen des Modells (Qwen3 ist SOTA bei Tool-Calling). Hermes Agent liefert die gesamte alte v9-Wunschliste fertig & gepflegt: Cron-Scheduler, Cross-Session-Memory (FTS5), Skills (procedural memory), 40+ Tools.
|
||||
|
||||
**Reihenfolge (abgestimmt):** Design/Architektur zuerst (✅), dann Quick Wins, Security-Brocken zuletzt:
|
||||
`1 (✅) Fundament → 2 (✅) Feature 3 Live-Auslastung → 3 (✅) Feature 6 Mehr LLM-Metriken → 4 (✅) Feature 1 Server-Management → 5 (✅) Feature 4 Cookbook → 6 (✅) Feature 7 Integrations-Anleitungen → 7 (✅) Feature 2 Live-Terminal`.
|
||||
**Verifizierte Kompatibilität (Primärquelle):**
|
||||
- 100 % lokal: spricht jeden OpenAI-kompatiblen Endpoint → llama-swap/llama.cpp (Agent-Modell braucht `--jinja`).
|
||||
- MCP-Client (≥v0.2.0) **und** MCP-Server (≥v0.6.0) → eigenes `mcp_memory.py` plugt als MCP-Server ein, **v7/v8.3-Memory + MemoryPanel bleiben erhalten**.
|
||||
- OpenAI-API-Server auf **:8642** (`API_SERVER_ENABLED=true`, `API_SERVER_KEY`) → HermesPanel-Chat + Voice (Whisper/Piper) zeigen dorthin.
|
||||
- Config: `~/.hermes/config.yaml`. Braucht Python 3.11, Node.js, ripgrep, ffmpeg.
|
||||
|
||||
**Arbeitsweise je Schritt:** neuer `routers/<x>.py` + `js/panels/<x>.js` + Nav-Eintrag, sauber degradierend.
|
||||
Bauen + Smoke-Test auf Windows, dann push→pull→rsync→restart auf den Bosgame (CLAUDE.md „Entwickeln & Deployen").
|
||||
Ein Commit je Schritt. **Ich (KI) habe key-basierten SSH-Zugang zum Bosgame und kann selbst deployen+restarten.**
|
||||
**Phasen:**
|
||||
- [x] **Phase 0 — Proof auf echter HW (✅ 2026-06-23):** Hermes Agent **v0.17.0** installiert (`~/.hermes/`, Code in `~/.hermes/hermes-agent`). `coder` (Qwen3-Coder-30B-A3B) in llama-swap um `--jinja` ergänzt (Backup: `/etc/llama-swap/config.yaml.bak-pre-jinja`). `hermes config`: provider=custom, base_url=`http://127.0.0.1:8080/v1`, ctx 65536. Proof via `hermes -z … --yolo`: echter Tool-Call (`free -h` → „21 GiB frei", exakt deckungsgleich mit Live-Wert). Befunde: ripgrep fehlt (grep-Fallback, später `apt install ripgrep`); Hermes hat **eigenes lokales STT** (faster-whisper); terminal.backend=local; Version nicht sauber pinbar → v0.17.0 in `~/.hermes/PINNED_VERSION`, **kein** `hermes update`.
|
||||
- [x] **Phase 1 — Memory-Bridge (✅ 2026-06-23):** `mcp_memory.py` via `hermes mcp add mission-control-memory --command /opt/mission-control/.venv/bin/python --env MC_URL=http://127.0.0.1:9000 --args …/mcp_memory.py` registriert (5/5 Tools, Test ✓). Erkenntnis: `mcp_memory.py` ist stdio-MCP→HTTP-Wrapper auf MC `/api/memory` — **MC :9000 muss laufen**. Proof: Hermes liest beide Bestands-Memories *und* schrieb neuen Eintrag (verifiziert in SQLite, 2→3). v7/v8.3-Gedächtnis ohne Migration im Agenten. Hinweis: Hermes' **eigenes** Session-Memory läuft zusätzlich → zwei Schichten im Blick behalten.
|
||||
- [x] **Phase 2 — Control-Plane (✅ 2026-06-23):** Hermes als **systemd-User-Service** `hermes-gateway` (`hermes gateway install`, Linger aktiv → boot-/logout-fest, kein sudo). API-Server `:8642` aktiviert (`~/.hermes/.env`: `API_SERVER_ENABLED/KEY/PORT/HOST`). Produktionspfad bewiesen: Tool-Call via `curl :8642/v1/chat/completions`. mcp_memory.py läuft als Kindprozess im Dienst. **UI-Hälfte:** `routers/hermes.py`-Chat-WS proxyt jetzt streamend zu `:8642` (Key aus `~/.hermes/.env`); WS-Contract (`thinking/token/done/error`) beibehalten → **HermesPanel unverändert, kein Frontend-Build nötig**. Status-Endpoint meldet zusätzlich Hermes-Erreichbarkeit.
|
||||
- [x] **Phase 3 — Aufräumen (✅ 2026-06-23, harter Cutover):** `hermes_agent.py` gelöscht (ReAct-Loop, tote Tools, Modell-Routing weg); `config.py` um `HERMES_SIMPLE/COMPLEX_MODEL` bereinigt, `HERMES_API_URL/KEY` ergänzt; `routers/hermes.py` zu reinem Proxy verschlankt. **Rest-offen:** Self-Update auch für `hermes-gateway` (aktuell nur `mission-control`); HermesPanel-Altlast (Windows-SSH-Setup-Sektion) ist nur noch kosmetisch.
|
||||
- [x] **Phase 4 — Auszahlung (✅ 2026-06-24):** Agent-**Cockpit** in der HermesPanel (aufklappbar, „📡 Cockpit"). Read-only Control-Plane-Reads in `hermes_control.py` (TTL-Cache 10 s): **Status** aus `~/.hermes/gateway_state.json` + Cron-Heartbeat (Liveness), **Cron-Jobs** via `hermes cron list --all`, **Skills** via `hermes skills list` + `.usage.json` (Nutzungszähler). Rich-Tabellen werden mit `COLUMNS=400` ohne Truncation erzeugt und über `│`/`┃` geparst. Endpunkte `GET /api/hermes/{agent,cron,skills}`. Auf der Box verifiziert: Status `running`/Scheduler aktiv, 71 Skills geparst, 0 Cron-Jobs. **Offen-Rest:** Schreib-Aktionen (Job anlegen/pausieren, Skill an/aus) aus der UI — bewusst später, erst Sichtbarkeit.
|
||||
- [x] **Memory-Feintuning (✅ 2026-06-23):** `~/.hermes/SOUL.md` (Persona, frisch je Nachricht geladen) instruiert Hermes, das MCP-Gedächtnis bei Nutzer-/Projektfragen **proaktiv** via `get_memories` zu konsultieren, bevor er nachfragt — Name/Profil bleiben im Gedächtnis (nicht hart in SOUL.md). Verifiziert: weicher Prompt „nenne meinen Namen" → „Hallo Tobi!". **Hinweis:** SOUL.md liegt auf dem Bosgame (`~/.hermes/`), nicht im Repo — bei Neuaufsetzen mitschreiben.
|
||||
- [x] **Phase 5 — UI-Adoption (✅ 2026-06-24, Kurswechsel):** Statt Eigenbau-Chat das **mitgelieferte Hermes-Web-Dashboard** einbetten (Chat mit Live-Tool-Aktivität, Approval-Prompts, Settings, Sessions). Dashboard läuft als systemd-User-Dienst `hermes-dashboard` (`hermes dashboard --host 127.0.0.1 --port 9119 --skip-build`, Extras `[web,pty]`); MC reverse-proxyt es unter `/hermes-ui/` (`routers/hermes_ui.py`, HTTP + WS-Bridge für `pty/ws`) mit `X-Forwarded-Prefix` → Dashboard rewritet Assets/Base-Path selbst und injiziert seinen Session-Token (kein zweiter Login). HermesPanel: Chat → iframe; Eigenbau-Chat/Voice/`_proxy_chat` entfernt. **Damit gelöst:** Kontextverlust + „lernt nicht" (UI spricht direkt mit dem Agent-Loop, kein Proxy-Bug) und Tool-Sichtbarkeit. Verifiziert: HTTP-Kette (Asset-Rewrite, 1,9-MB-Bundle) + Chat-WS `/api/ws` durch MC identisch zu direkt.
|
||||
- [x] **Phase 6 — Rechte & Multi-Agent (✅ 2026-06-24):** „lokal = volle Rechte" via `hermes config set`: `approvals.mode: auto`, `approvals.cron_mode: allow`, `hooks_auto_accept: true`, `delegation.subagent_auto_approve: true`. Tirith/`allow_private_urls:false` bleiben an (Injection-Schutz). Verifiziert: Agent legt über die API autonom eine Datei an (Schreibrechte ohne manuelle Freigabe). Multi-Agent damit entsperrt.
|
||||
- [x] **Phase 7 — Agent-Modell Hermes 4 14B (✅ 2026-06-24):** GGUF `bartowski/NousResearch_Hermes-4-14B-GGUF` **Q6_K** (~12 GB) → `/srv/models/Hermes-4-14B-GGUF/`. llama-swap-Eintrag `Hermes-4-14B` (Alias `hermes`, `--jinja`, `-c 65536`, Backup `config.yaml.bak-pre-hermes4`). `hermes config set model.default Hermes-4-14B` + Gateway-Restart. **Verifiziert:** llama-swap lädt das Modell (`state: ready`), Agent macht sauberen Tool-Call (kein `<function>`-Leak, RAM via Terminal abgefragt). Kaltstart-Load ~3,5 min, dann ttl 1800 s. A/B gegen Qwen3-Coder jederzeit per `model.default` umschaltbar.
|
||||
- [x] **Phase 9 — Gedächtnis wächst autonom + deterministischer Kurator (✅ 2026-06-24):** Tool-Beschreibungen in `mcp_memory.py` auf ein **proaktives Protokoll** (get→Session-Start, add→proaktiv+dedup, search→vor add) → das geteilte MC-SQLite wächst aus allen Tool-Sessions mit. **Kurator: deterministisch in MC** — `POST /api/memory/dedupe` (exakt/enthalten/`SequenceMatcher`≥0.85 je Kategorie, behält den vollständigsten Eintrag; Dry-Run + Anwenden) + „🧹 Aufräumen"-Button im Memory-Tab. **Warum nicht LLM-Cron:** Test zeigte, dass sowohl Hermes 4 14B (Tool-Call als Text, nicht ausgeführt) als auch Qwen3-Coder (Erfolg nur halluziniert) die Edits NICHT real ausführten → LLM-Cron-Job wieder entfernt. Verifiziert: 18→16 (2 echte Dubletten), konservativ. Nebenbei Bugfix `_parse_cron` (cron list = Block-Format). **Offen:** optional deterministischer Schedule (`cron --no-agent --script` → ruft den Endpoint).
|
||||
- [x] **Phase 8 — Cockpit-Ausbau (✅ 2026-06-24):** Cockpit zeigt jetzt **Aktivität** (`hermes insights`: Sessions/Nachrichten/Tool-Calls/Tokens/aktiv + Top-Tool-Balken) und **„Was Hermes über dich gelernt hat"** (`USER.md`-Profil, `§`-getrennt + `MEMORY.md`). Reads in `hermes_control.py` (`learned_profile`, `insights`), Endpunkte `/api/hermes/{learned,insights}`. Beantwortet sichtbar „lernt er mit?" — ja (7 Profil-Fakten, 262 Tool-Calls/30 T verifiziert).
|
||||
- [x] **Phase 10 — Modell-Metadaten & Capabilities (✅ 2026-06-24):**
|
||||
- **Schritt 1 (installierte Modelle):** `model_caps.py` — dependency-freier **GGUF-Header-Reader** (offline) liest `architecture`/`expert_count`/`context_length`/`parameter_count`; + cmd-Flags (`--jinja`=Tools bestätigt, `--mmproj`=Vision) + dünner Familien-Fallback. Tags: MoE(+aktive B), Tools (yes|likely|no), Vision, Coder, Reasoning, Embedding, native ctx/params. Models-Tab „KANN" = Capability-Chips. Verifiziert (qwen3moe→MoE, qwen3vl→Vision, native ctx 262k).
|
||||
- **Schritt 2 (Discovery):** Profi-Suche mit `&full=true` (HF-Tags); Multi-Capability-Chips + **Facetten-Filter** (Tools/MoE/Logik/Bild/Code, Mehrfachauswahl, clientseitig). Discover liefert HF-Tags je Modell mit. HF/GGUF = Quelle der Wahrheit, kein Scraping.
|
||||
- **Offen-Rest:** Tool-Erkennung in der Profi-Suche ist heuristisch (Name+Tags+Familie); authoritatives `gguf.chat_template` nur fürs Detail-Modal denkbar (per-Repo-Call). Reasoning bleibt das schwächste Signal.
|
||||
- [~] **Phase 11 — Cookbook 3.0 (Schritt 1+2 ✅ 2026-06-24):**
|
||||
- **Selbst-aktualisierende Setups:** `RECIPE_TEMPLATES` (recipes.py) definieren das Konzept als **Rollen-Slots**; `build_dynamic_recipes` (cookbook.py) füllt sie live aus `discover` (Hardware-Fit, MoE bevorzugt, dedupe, leere Slots ausgelassen). `all_recipes` = Auto-Setups + eigene; statische RECIPES nur Fallback. Verifiziert: Elite = Qwen3-Coder-30B + Nemotron-Reasoning-30B-A3B + Qwen3-VL + gemma-4-26B-A4B, alle „perfect".
|
||||
- **Entschlackt:** Segmented-Tabs `[📦 Fertige Setups] [🔍 Modelle finden]` — nur eine Ansicht zur Zeit; Discover + Profi-Suche unter einem Tab.
|
||||
- **Offen-Rest:** „agent"-Slot fällt oft weg, weil die discover-Agent-Kategorie (Name-Keywords) dünn ist → besser über die **Tool-Capability** (caps `tools`) statt Name-Kategorie befüllen. Volle Verschmelzung (Discover als Leerzustand der Suche) + schlankere Karten optional.
|
||||
|
||||
**→ Letzte Errungenschaften:**
|
||||
- **WebSockets:** `/api/system/stream` für super-fluide 2Hz System-Metriken (CPU/RAM/GPU) ohne HTTP-Overhead.
|
||||
- **Cookbook 2.0:** Suchergebnisse laden Metriken asynchron ("Lazy Loading"), inklusive Hardware-Fit-Berechnung. UI ist auf Premium-Niveau angehoben (Hover-Effekte, Badge-Colors).
|
||||
- **Log-Streaming:** `GET /running`-Polls werden herausgefiltert, Live-Konsolen sind sauber.
|
||||
**Entscheidungen (gesetzt):** Memory via MCP einbinden (nicht migrieren) · **Kurswechsel 2026-06-24: mitgeliefertes Hermes-Web-Dashboard per iframe einbetten** (ersetzt Eigenbau-Chat; der alte „nicht hermes-webui"-Entscheid ist überholt — die UI ist offiziell, mit dem Install versioniert und auf genau dieses Ziel gebaut) · Agent-Modell **Hermes 4 14B** · stabiler OpenAI-API-Server bleibt für programmatischen Zugriff.
|
||||
|
||||
**→ Aktueller Modus = Wartung & Feinschliff.**
|
||||
- Die v2 Roadmap ist damit zu 100% umgesetzt.
|
||||
- Fokus liegt ab sofort auf Stabilität, Bug-Hunting und dem finalen "Polishing".
|
||||
**Risiken:** sehr junges Projekt (hohes Release-Tempo) → Version pinnen · zwei Memory-Schichten (eigene via MCP + Hermes-Session-Recall) im Blick behalten.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
## v8.3 — Memory Import (✅ UMGESETZT & live, Stand 2026-06-23)
|
||||
|
||||
### 1. Server-Management ("Update-Panel 2.0") (✅ Erledigt)
|
||||
Aktuell gibt es nur "Container aktualisieren" + "Alles aus dem Speicher". Ziel: den kompletten Server aus der UI verwalten.
|
||||
- [x] OS-/Core-Updates (`apt update/upgrade`) per Knopf, mit Live-Output
|
||||
- [x] Dienste steuern (`llama-swap`, `mission-control`: Status, Restart)
|
||||
- [x] Reboot / Health-Übersicht
|
||||
- [x] Referenz: altes `ai-control`-Skript als Funktionsvorlage
|
||||
- ⚠️ **Scope-/Security-Sprung**: macht MC zum Server-Admin-Panel. Rechte minimal halten (sudoers-Whitelist für genau die erlaubten Befehle, statt Vollzugriff).
|
||||
|
||||
### 2. Live-Terminal / Log via SSH (✅ Erledigt)
|
||||
- [x] Unter "Server" -> "Console" kann man das Journal verfolgen.
|
||||
- [x] Simpler "Log" Endpunkt in `maintenance.py` (`journalctl -u llama-swap -n 100 -f` über Websockets).
|
||||
- [x] UI: Schwarze Konsole, umschaltbar zwischen `llama-swap` und `mission-control`.
|
||||
- ⚠️ **Security-kritisch**: Authentifizierung via Token als Query-Parameter, da Browser keine Custom Websocket-Header senden können.
|
||||
|
||||
### 3. Live-Auslastung im Dashboard (✅ Erledigt)
|
||||
- [x] CPU / RAM / GPU-VRAM+GTT / Temperatur live anzeigen
|
||||
- [x] Quellen: **sysfs + psutil** — `amd-smi`/`rocm-smi` sind auf dem Bosgame NICHT installiert!
|
||||
GPU-Mem: `/sys/class/drm/card1/device/mem_info_*`; Temp: hwmon-`name` `amdgpu`/`k10temp`.
|
||||
|
||||
### 4. Cookbook + "Modell holen" verschmelzen (✅ Erledigt)
|
||||
- Bisher: Textfelder für HuggingFace-Repo + Pfad unter "Modelle". Das ist super für Custom-Zeug.
|
||||
- [x] Neu: Ein Klick-Cookbook (Sidebar-Tab "Cookbook") mit kuratierter Liste (z.B. Qwen2.5-Coder 32B, Llama3 Vision, etc.) inkl. Hardware-Aware "What Fits" Logik (wie bei Odysseus).
|
||||
- [x] Klick auf Modellkarte im Cookbook triggert den Download via `/api/download`.
|
||||
- [x] UI-Aufräumen: "Modell holen" Panel wandert ins Cookbook, unter "Modelle" bleibt nur die Tabelle & Chat.
|
||||
|
||||
### 5. Design 2.0
|
||||
- [x] **Grundgerüst + Design-Sprache in Schritt 1 umgesetzt** (Sidebar-Nav, Topbar, Hero, getönte
|
||||
KPI-Kacheln, Health-Signale, Listen, Aktivitäts-Stream, Alert-Banner) — Tokens in `css/base.css`.
|
||||
- [x] Feinschliff pro Feature durchgeführt.
|
||||
|
||||
### 6. Mehr LLM-Metriken (✅ Erledigt)
|
||||
- [x] Fähigkeiten pro Modell anzeigen (Text / Bild / Code)
|
||||
- [x] Tokens/Sek, Kontextgröße, Quant, Dateigröße auf Platte
|
||||
- [x] Status pro Modell: geladen / idle / Ladezeit
|
||||
|
||||
### 7. Integrations-Anleitungen (Copy-Paste) (✅ Erledigt)
|
||||
- [x] Unter "Guides" (neuer Tab links) gibt es Copy-Paste Templates.
|
||||
- [x] Templates für die Integration von llama-swap/mission-control in:
|
||||
- Cline / Cursor
|
||||
- N8N / Zapier
|
||||
- OpenWebUI
|
||||
- LangChain / Python Code
|
||||
- **MemoryPanel**: Import-Button öffnet Formular — Text einfügen, jede Zeile wird ein eigener Eintrag (Batch-POST).
|
||||
- Quelle wählbar: `claude` · `gemini` · `chatgpt` · `import` · `manual` — Kategorie frei wählbar.
|
||||
|
||||
---
|
||||
|
||||
## Tech-Leitplanken
|
||||
## v8.2 — UX & Mobile (✅ UMGESETZT & live, Stand 2026-06-23)
|
||||
|
||||
- **KISS beibehalten** — kein schweres Framework, solange es ohne geht.
|
||||
- **Sicherheit zuerst** bei allem mit Shell-/SSH-Zugriff: LAN-only, Auth, minimale Rechte.
|
||||
- Backend-Logik in `app.py`, UI in `static/index.html` — Trennung sauber halten.
|
||||
- **GuidesPanel**: "Begriffe kurz erklärt" jetzt ganz oben; Tab-Navigation mit Active-Highlighting + Scroll-to-Group.
|
||||
- **ConnectPanel**: Download-Links für Zed (`zed.dev`) und Cline (VS Code + JetBrains Marketplace) ergänzt; OpenCode `.jsonc`-Pfad und `"providers"`-Key korrigiert; LAN-IP Override für NPM-Proxy-Setups.
|
||||
- **Mobile (≤520px)**: Sidebar → Bottom-Navigation; kompaktere Buttons/Padding; Touch-freundliche Mindesthöhen.
|
||||
|
||||
---
|
||||
|
||||
## v8.1 — Bug-Fix Release (✅ UMGESETZT & live, Stand 2026-06-23)
|
||||
|
||||
Alle kritischen Bugs aus dem ersten Bosgame-Praxistest behoben:
|
||||
|
||||
- **Hermes system_status**: Key-Mismatch behoben — zeigt jetzt echte CPU/RAM/GPU-Werte statt `?`
|
||||
- **Model-Routing**: Keyword-Liste erweitert (recherchiere/suche/vergleiche/…); kein Downgrade wenn komplexes Modell bereits geladen
|
||||
- **HermesPanel**: Chat-History überlebt Browser-Reload (sessionStorage); ⏹ Stopp-Button während Generierung; Verlauf-löschen
|
||||
- **Engine-Update**: fragt jetzt nach sudo-Passwort (analog OS-Update)
|
||||
- **asyncio**: `get_event_loop()` → `get_running_loop()` in Whisper + TTS
|
||||
- **Guide-Titel**: `&` → `&` (HTML-Entity in Textknoten war ein Bug)
|
||||
- `import os` in `routers/maintenance.py` ergänzt (war NameError in `updates()`)
|
||||
|
||||
---
|
||||
|
||||
## v8 — Hermes Agent / Agentic OS Kern (✅ UMGESETZT & live, Stand 2026-06-23)
|
||||
|
||||
**Hermes**: dauerhafter lokaler KI-Assistent auf dem Bosgame. Text + Voice, Tool Calling, Gedächtnis.
|
||||
|
||||
- **`hermes_agent.py`**: ReAct-Agent mit Tool-Set (read_file, list_dir, run_command, system_status, memory r/w, web_search). Modell-Routing: scout für einfache Tasks, coder für komplexe.
|
||||
- **`routers/hermes.py`**: WS `/chat` (streaming), POST `/transcribe` (Whisper STT, CPU), POST `/tts` (Piper TTS, weibliche Stimme Kerstin), GET `/status`, GET `/pubkey`.
|
||||
- **HermesPanel.svelte**: Chat-UI mit Token-Streaming, Tool-Anzeige, Mikrofon-Button (MediaRecorder), TTS-Wiedergabe, Setup-Wizard.
|
||||
|
||||
**Offene Schritte (nicht Code, sondern Einrichtung):**
|
||||
- [ ] Piper Binary + Kerstin-Stimme auf dem Bosgame installieren (Befehle im Setup-Wizard)
|
||||
- [ ] Windows OpenSSH Server aktivieren (5 PowerShell-Befehle im Setup-Wizard)
|
||||
- [ ] SSH-Key auf Bosgame generieren: `ssh-keygen -t ed25519 -C "hermes-agent@bosgame" -f ~/.ssh/id_ed25519_hermes_agent -N ""`
|
||||
- [ ] `HERMES_WINDOWS_HOST` + `HERMES_WINDOWS_USER` in `mission-control.service` setzen
|
||||
|
||||
---
|
||||
|
||||
## v7 — Memory Layer / Gedächtnis (✅ UMGESETZT & live, Stand 2026-06-23)
|
||||
|
||||
Persistentes, tool-übergreifendes Gedächtnis für alle KI-Tools via MCP.
|
||||
|
||||
- **`routers/memory.py`**: SQLite CRUD + Export, WAL-Mode, 5 Kategorien mit Enum-Validierung.
|
||||
- **`mcp_memory.py`**: stdio-MCP-Server — Tools: get/add/search/update/delete_memory. Einmalig in Cline/OpenCode/Claude Code einbinden.
|
||||
- **MemoryPanel.svelte**: Gedächtnis-Tab mit Kategorie-Filter, Inline-Edit, Add-Formular.
|
||||
- **ConnectPanel.svelte**: Gedächtnis-MCP Setup-Guide mit Config-Snippets.
|
||||
- **5 Kategorien**: `user` (wer du bist) · `instruction` (Verhaltensregeln für alle Tools) · `stable` (Projektfakten) · `versioned` (Tech-Versionen) · `ephemeral` (7 Tage, dann weg).
|
||||
|
||||
---
|
||||
|
||||
## v6 — Workflow & Politur (✅ UMGESETZT & live, Stand 2026-06-21)
|
||||
|
||||
- Dashboard zeigt echtes Modell hinter dem Alias; Toolbar-Update-Badges (OS + Modell-Upgrades).
|
||||
- News als Magazin-Layout; Guide neu mit Tutorials/Workflows + Konzept-Karten.
|
||||
- Cookbook: Modelle diversifiziert (Qwen3/Gemma/Mistral/DeepSeek), „Beste Wahl für dein System".
|
||||
|
||||
---
|
||||
|
||||
## v5 — „Anfänger-Lotse" (✅ UMGESETZT & live)
|
||||
## v4 — „Der Lotse" (✅ UMGESETZT & live)
|
||||
## v3 — Redesign + Beginner-UX + Security (✅ UMGESETZT & live)
|
||||
|
||||
Details in Git-History.
|
||||
|
||||
---
|
||||
|
||||
## Nächste Features (Vorschläge)
|
||||
|
||||
### ~~v8.4 — Hermes Windows-SSH~~ (❌ überholt durch v9-Adoption)
|
||||
Hermes Agent bringt SSH-Terminal-Backend von Haus aus mit — Eigenbau-SSH-Tools unnötig.
|
||||
~~read_file_windows / write_file_windows / run_command_windows via paramiko~~
|
||||
|
||||
### ~~v9 (alt) — Agentic OS Orchestrierung Eigenbau~~ (❌ ersetzt durch Hermes-Agent-Adoption)
|
||||
Cron, Task-Queue, Push-Notifications, Multi-Step-Workflows — alles im Hermes-Agent-Framework bereits enthalten, siehe v9-Adoption oben.
|
||||
|
||||
### v8.5 — `.well-known/opencode` Remote Config (bleibt relevant)
|
||||
- [ ] `GET /.well-known/opencode` — vollständige, dynamisch generierte OpenCode-Config (providers + models + MCP)
|
||||
- [ ] OpenCode Desktop lädt beim Start automatisch alle Configs von Mission Control
|
||||
- [ ] Einmalig: `OPENCODE_REMOTE_CONFIG=http://192.168.178.151:9000/.well-known/opencode` setzen
|
||||
|
||||
### v9.1 — Guides & Connect weiter verbessern
|
||||
- [ ] Zed als primäre Editor-Empfehlung (Inline-Completion + Agent, lokal)
|
||||
- [ ] Antigravity entfernen oder als "Cloud-first, lokal wackelig" markieren
|
||||
- [ ] Continue als "unsichere Zukunft (Cursor-Akquisition)" markieren
|
||||
- [ ] `.well-known/opencode`-Snippet in Connect-Tab ergänzen (nach v8.5)
|
||||
|
||||
### Langfristig
|
||||
- [ ] Hermes-Persönlichkeit konfigurierbar (Name, Tonalität) über Gedächtnis `instruction`-Einträge
|
||||
- [ ] Voice-to-Voice latency optimieren (Whisper ROCm wenn stable)
|
||||
- [ ] Mehrsprachige TTS-Stimmen (EN + DE umschaltbar)
|
||||
- [ ] Hermes kann Mission Control selbst weiterentwickeln (Agentic self-improvement)
|
||||
|
||||
@@ -17,18 +17,35 @@ Neue Bereiche kommen als routers/<bereich>.py + static/js/panels/<bereich>.js da
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from routers import jobs, maintenance, models, system, cookbook
|
||||
from routers import jobs, hermes, hermes_ui, maintenance, memory, models, system, cookbook, integration, news
|
||||
|
||||
app = FastAPI(title="Mission Control")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def _no_cache_static(request, call_next):
|
||||
"""UI + statische Module immer revalidieren lassen (304 wenn unveraendert),
|
||||
damit Aenderungen nach einem rsync sofort wirken und kein Stale-JS haengen bleibt."""
|
||||
response = await call_next(request)
|
||||
path = request.url.path
|
||||
if path == "/" or path.startswith("/static"):
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
app.include_router(models.router)
|
||||
app.include_router(jobs.router)
|
||||
app.include_router(maintenance.router)
|
||||
app.include_router(system.router)
|
||||
app.include_router(cookbook.router)
|
||||
app.include_router(integration.router)
|
||||
app.include_router(news.router)
|
||||
app.include_router(memory.router)
|
||||
app.include_router(hermes.router)
|
||||
app.include_router(hermes_ui.router)
|
||||
|
||||
_STATIC = Path(__file__).parent / "static"
|
||||
|
||||
@@ -38,9 +55,29 @@ def index():
|
||||
return FileResponse(_STATIC / "index.html")
|
||||
|
||||
|
||||
_FAVICON = (
|
||||
b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'>"
|
||||
b"<rect width='32' height='32' rx='7' fill='#0f1720'/>"
|
||||
b"<circle cx='16' cy='16' r='8' fill='none' stroke='#2dd4bf' stroke-width='3'/>"
|
||||
b"<circle cx='16' cy='16' r='2.5' fill='#2dd4bf'/></svg>"
|
||||
)
|
||||
|
||||
|
||||
@app.get("/favicon.ico")
|
||||
def favicon():
|
||||
return Response(content=_FAVICON, media_type="image/svg+xml")
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory=_STATIC), name="static")
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
def _http_exc(_req, exc: HTTPException):
|
||||
return JSONResponse(status_code=exc.status_code, content={"error": exc.detail})
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
def _any_exc(_req, exc: Exception):
|
||||
"""Unerwartete Fehler als lesbare Meldung ans (vertrauenswuerdige LAN-)UI geben,
|
||||
statt nur einen generischen 500 ohne Hinweis. Erleichtert Anfaengern die Diagnose."""
|
||||
return JSONResponse(status_code=500, content={"error": str(exc) or exc.__class__.__name__})
|
||||
|
||||
@@ -6,6 +6,7 @@ damit es genau eine Quelle der Wahrheit fuer Pfade, URLs und Defaults gibt.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
@@ -16,19 +17,91 @@ from ruamel.yaml import YAML
|
||||
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"))
|
||||
# Eigene Cookbook-Setups (vom Nutzer angelegt). Bewusst NICHT im App-Verzeichnis, sonst
|
||||
# wuerde ein Deploy (rsync) sie ueberschreiben -> persistent neben den Modellen ablegen.
|
||||
USER_RECIPES_PATH = Path(os.environ.get("MC_USER_RECIPES", str(MODELS_DIR / "mission-control-recipes.json")))
|
||||
MEMORY_DB = Path(os.environ.get("MC_MEMORY_DB", str(MODELS_DIR / "mission-control-memory.db")))
|
||||
# Cache der automatischen Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
|
||||
# Ebenfalls persistent neben den Modellen (uebersteht Deploys). TTL = wie lange der Cache
|
||||
# als frisch gilt, bevor lazy neu von den Quellen geladen wird (Default 12 h).
|
||||
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mission-control-discover.json")))
|
||||
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200"))
|
||||
# Befehl, der zum Starten eines Modells in die config.yaml geschrieben wird.
|
||||
# {model} = Pfad zur GGUF-Datei, {ctx} = Kontextlaenge, ${PORT} bleibt fuer llama-swap stehen.
|
||||
# WICHTIG: an deinen Container-/llama-server-Aufruf anpassen (siehe README).
|
||||
CMD_TEMPLATE = os.environ.get(
|
||||
"MC_CMD_TEMPLATE",
|
||||
_DEFAULT_CMD_TEMPLATE = (
|
||||
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
|
||||
"-c {ctx} -ngl 999 -fa 1 --no-mmap",
|
||||
"-c {ctx} -ngl 999 -fa 1 --no-mmap"
|
||||
)
|
||||
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
|
||||
# Schutz: Eine unquotierte systemd `Environment=MC_CMD_TEMPLATE=...`-Zeile wird an
|
||||
# Leerzeichen gesplittet und kommt als blosses "llama-server" an. Fehlt der {model}-
|
||||
# Platzhalter, ist die Vorlage unbrauchbar -> sicheren Default verwenden.
|
||||
if "{model}" not in CMD_TEMPLATE:
|
||||
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
|
||||
# Befehl fuer "Container/Toolbox aktualisieren". Standard: kyuz0 refresh-Skript.
|
||||
UPDATE_CMD = os.environ.get("MC_UPDATE_CMD", "")
|
||||
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
|
||||
TOKEN = os.environ.get("MC_TOKEN", "") # leer = keine Auth (nur LAN!)
|
||||
|
||||
# Hermes Agent (Nous-Framework, eigener Dienst auf :8642 — siehe ROADMAP v9).
|
||||
# Mission Control proxyt nur noch dorthin; Modell-Routing macht Hermes selbst.
|
||||
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642/v1").rstrip("/")
|
||||
|
||||
|
||||
def _read_hermes_api_key() -> str:
|
||||
"""API-Key des Hermes-Agent-Servers: bevorzugt Env HERMES_API_KEY,
|
||||
sonst aus ~/.hermes/.env (Schluessel API_SERVER_KEY) — so muss das
|
||||
Secret nicht dupliziert werden."""
|
||||
k = os.environ.get("HERMES_API_KEY", "")
|
||||
if k:
|
||||
return k
|
||||
envf = Path(os.path.expanduser("~/.hermes/.env"))
|
||||
if envf.exists():
|
||||
try:
|
||||
for line in envf.read_text(errors="replace").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("API_SERVER_KEY="):
|
||||
return line.split("=", 1)[1].strip()
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
HERMES_API_KEY = _read_hermes_api_key()
|
||||
# Control-Plane-Reads (Phase 4): Hermes legt Status/Cron/Skills lokal unter
|
||||
# ~/.hermes ab; die `hermes`-CLI ist die kanonische Quelle fuer Cron/Skills.
|
||||
# MC laeuft als hitonabi auf der Box -> direkter Lese-/Exec-Zugriff.
|
||||
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
|
||||
HERMES_BIN = os.path.expanduser(os.environ.get("HERMES_BIN", "~/.local/bin/hermes"))
|
||||
# Hermes-Web-Dashboard (eingebaute UI mit Chat/Tool-Aktivitaet, lokal gebunden).
|
||||
# MC reverse-proxyt es unter /hermes-ui/ (X-Forwarded-Prefix) und bettet es per
|
||||
# iframe ein -> eine Oberflaeche, kein zweiter Login (Dashboard self-auth auf Loopback).
|
||||
HERMES_DASHBOARD_URL = os.environ.get("HERMES_DASHBOARD_URL", "http://127.0.0.1:9119").rstrip("/")
|
||||
HERMES_WINDOWS_HOST = os.environ.get("HERMES_WINDOWS_HOST", "")
|
||||
HERMES_WINDOWS_USER = os.environ.get("HERMES_WINDOWS_USER", "TobisPC")
|
||||
HERMES_SSH_KEY = Path(os.path.expanduser(os.environ.get("HERMES_SSH_KEY", "~/.ssh/id_ed25519_hermes_agent")))
|
||||
PIPER_BIN = Path(os.environ.get("PIPER_BIN", "/opt/mission-control/piper/piper"))
|
||||
PIPER_VOICE = Path(os.environ.get("PIPER_VOICE", "/opt/mission-control/piper/voices/de_DE-kerstin-low.onnx"))
|
||||
WHISPER_MODEL_SIZE = os.environ.get("WHISPER_MODEL", "medium")
|
||||
|
||||
# Self-Update ("Mission Control aktualisieren"): Quelle = git-Repo, Prod = laufende Installation.
|
||||
SOURCE_DIR = os.path.expanduser(os.environ.get("MC_SOURCE_DIR", "~/mission-control"))
|
||||
PROD_DIR = str(Path(__file__).resolve().parent)
|
||||
|
||||
# Env fuer alle HuggingFace-Downloads: XET deaktivieren (mit aktivem XET haengt der
|
||||
# Download reproduzierbar bei ~6 MB, siehe CLAUDE.md). Token wird je Job ergaenzt.
|
||||
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
|
||||
|
||||
|
||||
def hf_bin() -> str:
|
||||
"""Pfad zur `hf`-CLI fuer Modell-Downloads. Bevorzugt die im venv installierte
|
||||
(neben dem laufenden Python), da der Dienst-PATH das venv/bin meist nicht
|
||||
enthaelt. Faellt sonst auf ein global installiertes `hf` zurueck."""
|
||||
cand = os.path.join(os.path.dirname(sys.executable), "hf")
|
||||
return cand if os.path.exists(cand) else "hf"
|
||||
|
||||
|
||||
# Gemeinsame YAML-Instanz (preserve_quotes haelt Kommentare/Quotes in config.yaml).
|
||||
yaml = YAML()
|
||||
yaml.preserve_quotes = True
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# docs
|
||||
|
||||
Referenz-Material für Mission Control.
|
||||
|
||||
## `mission-control-overview.png` (vom User nachzulegen)
|
||||
|
||||
Das Design-2.0-Referenzbild („Gateway Control Plane"-Dashboard). Das in Schritt 1 umgesetzte
|
||||
Layout ist daran angelehnt: linke Icon-Sidebar, Topbar mit Status-Pill, Alert-Banner, Hero-Karte
|
||||
mit Mini-Stats, Reihe getönter KPI-Kacheln, Multi-Spalten-Panels (Health-Signale als Key-Value
|
||||
+ Progress-Bar, Listen im „Session-Router"-Stil, Aktivität als „Incident-Stream").
|
||||
|
||||
**Einfach die PNG-Datei hier als `mission-control-overview.png` ablegen.** ROADMAP.md und CLAUDE.md
|
||||
verweisen bereits darauf.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 280 KiB |
Generated
+1449
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "mission-control-frontend",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"svelte": "^5.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { mount } from 'svelte'
|
||||
import { api, getToken, setToken, getHfToken, setHfToken } from '@core/api.js'
|
||||
import { initNav } from '@core/nav.js'
|
||||
import { ICON } from '@core/ui.js'
|
||||
import { statusStore } from './stores/status.svelte.js'
|
||||
import { jobsStore } from './stores/jobs.svelte.js'
|
||||
import { systemStore } from './stores/system.svelte.js'
|
||||
|
||||
// Svelte panels — vollständig migriert
|
||||
import OverviewPanel from './panels/OverviewPanel.svelte'
|
||||
import ModelsPanel from './panels/ModelsPanel.svelte'
|
||||
import JobsPanel from './panels/JobsPanel.svelte'
|
||||
import ServerPanel from './panels/ServerPanel.svelte'
|
||||
import CookbookPanel from './panels/CookbookPanel.svelte'
|
||||
import ConnectPanel from './panels/ConnectPanel.svelte'
|
||||
import NewsPanel from './panels/NewsPanel.svelte'
|
||||
import GuidesPanel from './panels/GuidesPanel.svelte'
|
||||
import MemoryPanel from './panels/MemoryPanel.svelte'
|
||||
import HermesPanel from './panels/HermesPanel.svelte'
|
||||
|
||||
let prevModelStates: Record<string, string> = {}
|
||||
|
||||
function flashSwap() {
|
||||
const el = document.getElementById('top-active-text')
|
||||
if (el) { el.classList.add('swap-flash'); setTimeout(() => el.classList.remove('swap-flash'), 900) }
|
||||
}
|
||||
|
||||
function applyStatus(s: any) {
|
||||
const dot = document.getElementById('swdot')
|
||||
const label = document.getElementById('swlabel')
|
||||
const activeText = document.getElementById('top-active-text')
|
||||
|
||||
if (!s) {
|
||||
dot && (dot.className = 'dot off')
|
||||
label && (label.textContent = 'Backend nicht erreichbar')
|
||||
activeText && (activeText.textContent = 'Backend offline')
|
||||
showAlert('Backend nicht erreichbar – läuft uvicorn?', false)
|
||||
} else {
|
||||
const host = (s.swap_url || '').replace(/^https?:\/\//, '')
|
||||
dot && (dot.className = 'dot ' + (s.swap_ok ? 'on' : 'off'))
|
||||
label && (label.textContent = (s.swap_ok ? 'LLM-Engine: Online – ' : 'LLM-Engine: Offline – ') + host)
|
||||
const LOADED = ['running', 'ready', 'loading', 'starting']
|
||||
const active = (s.models || []).find((m: any) => LOADED.includes(m.state))
|
||||
if (activeText) {
|
||||
if (active) {
|
||||
const ctx = active.meta?.ctx ? ` <span style="color:var(--mut);font-size:11.5px">· ${Math.round(active.meta.ctx / 1024)}k ctx</span>` : ''
|
||||
const stateLabel = active.state === 'loading' || active.state === 'starting' ? ' <span style="color:var(--warn);font-size:11.5px">lädt…</span>' : ''
|
||||
activeText.innerHTML = `<b style="color:var(--accent)">${active.name}</b>${ctx}${stateLabel}`
|
||||
} else {
|
||||
activeText.innerHTML = '<span style="color:var(--mut)">Kein Modell im VRAM</span>'
|
||||
}
|
||||
}
|
||||
if (s.swap_ok) hideAlert()
|
||||
else showAlert(`LLM-Engine nicht erreichbar unter <b>${host}</b> – läuft der llama-swap Dienst?`, true)
|
||||
|
||||
const newStates: Record<string, string> = {}
|
||||
for (const m of (s.models || [])) newStates[m.name] = m.state
|
||||
for (const [name, state] of Object.entries(newStates)) {
|
||||
const prev = prevModelStates[name]
|
||||
if (prev && prev !== state && (state === 'running' || state === 'loading')) { flashSwap(); break }
|
||||
}
|
||||
prevModelStates = newStates
|
||||
}
|
||||
setSecChip(s)
|
||||
statusStore.set(s)
|
||||
}
|
||||
|
||||
function setSecChip(s: any) {
|
||||
const chip = document.getElementById('sec-chip')
|
||||
const tx = document.getElementById('sec-chip-tx')
|
||||
if (!chip || !tx) return
|
||||
chip.classList.toggle('ok', !!s?.secured)
|
||||
tx.textContent = s?.secured ? 'Geschützt · Heimnetz' : 'Nur im Heimnetz (offen)'
|
||||
}
|
||||
|
||||
function applyJobs(jobs: any[]) {
|
||||
jobsStore.set(jobs || [])
|
||||
}
|
||||
|
||||
function applySystem(sys: any) {
|
||||
systemStore.set(sys)
|
||||
}
|
||||
|
||||
function showAlert(html: string, warn: boolean) {
|
||||
const a = document.getElementById('alert')!
|
||||
a.className = 'alert' + (warn ? ' warn' : '')
|
||||
a.innerHTML = `<span class="a-dot"></span><span>${html}</span>`
|
||||
a.style.display = 'flex'
|
||||
}
|
||||
function hideAlert() {
|
||||
const a = document.getElementById('alert')
|
||||
if (a) a.style.display = 'none'
|
||||
}
|
||||
|
||||
// Update badges
|
||||
const goView = (v: string) => (document.querySelector(`.nav-item[data-view="${v}"]`) as HTMLElement)?.click()
|
||||
|
||||
function mkBadge(text: string, cls: string, view: string, title?: string) {
|
||||
const s = document.createElement('span')
|
||||
s.className = 'upd-badge' + (cls ? ' ' + cls : '')
|
||||
s.textContent = text
|
||||
if (title) s.title = title
|
||||
s.onclick = () => goView(view)
|
||||
return s
|
||||
}
|
||||
|
||||
async function pollUpdates() {
|
||||
try {
|
||||
const u = await api('/api/updates')
|
||||
const el = document.getElementById('update-badges'); if (!el) return
|
||||
const age = u.apt_cache_age_h as number | null
|
||||
const stale = age != null && age > 24
|
||||
const ageNote = age != null ? ` · apt-Cache: vor ${age}h` : ''
|
||||
el.innerHTML = ''
|
||||
el.appendChild(mkBadge(
|
||||
u.os > 0 ? `OS: ${u.os} Updates` : stale ? 'OS: ?' : 'OS: aktuell',
|
||||
u.os > 0 ? 'warn' : stale ? 'warn' : 'ok', 'server',
|
||||
u.os > 0 ? `${u.os} Pakete${ageNote}` : stale ? `apt-Cache veraltet (${age}h)` : `Keine Updates${ageNote}`
|
||||
))
|
||||
el.appendChild(mkBadge(u.engine > 0 ? 'Engine: Update' : 'Engine: aktuell', u.engine > 0 ? 'warn' : 'ok', 'server'))
|
||||
el.appendChild(mkBadge(u.models > 0 ? `Modelle: ${u.models}` : 'Modelle: aktuell', u.models > 0 ? 'warn' : 'ok', 'news'))
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
try { applyStatus(await api('/api/status')) }
|
||||
catch { applyStatus(null) }
|
||||
}
|
||||
async function pollJobs() {
|
||||
try { applyJobs(await api('/api/jobs')) }
|
||||
catch { /* noop */ }
|
||||
}
|
||||
|
||||
function connectSystemStream() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}/api/system/stream`)
|
||||
ws.onmessage = e => { try { applySystem(JSON.parse(e.data)) } catch {} }
|
||||
ws.onclose = () => { setTimeout(connectSystemStream, 3000) }
|
||||
ws.onerror = () => ws.close()
|
||||
}
|
||||
|
||||
// ---- Boot ----
|
||||
|
||||
// Icons in Nav/Logo injizieren
|
||||
document.getElementById('logo')?.innerHTML !== undefined &&
|
||||
(document.getElementById('logo')!.innerHTML = ICON.logo)
|
||||
document.querySelectorAll('[data-ic]').forEach(n => {
|
||||
(n as HTMLElement).innerHTML = ICON[(n as HTMLElement).dataset.ic || ''] || ''
|
||||
})
|
||||
|
||||
// Svelte panels mounten
|
||||
const panels: [string, any][] = [
|
||||
['overview', OverviewPanel],
|
||||
['models', ModelsPanel],
|
||||
['activity', JobsPanel],
|
||||
['server', ServerPanel],
|
||||
['cookbook', CookbookPanel],
|
||||
['connect', ConnectPanel],
|
||||
['news', NewsPanel],
|
||||
['guides', GuidesPanel],
|
||||
['memory', MemoryPanel],
|
||||
['hermes', HermesPanel],
|
||||
]
|
||||
for (const [view, Panel] of panels) {
|
||||
const el = document.querySelector(`.view[data-view="${view}"]`)
|
||||
if (el) mount(Panel, { target: el })
|
||||
}
|
||||
|
||||
initNav('overview')
|
||||
|
||||
// Token / Settings
|
||||
const tokenInput = document.getElementById('token') as HTMLInputElement | null
|
||||
if (tokenInput) {
|
||||
tokenInput.value = getToken()
|
||||
tokenInput.addEventListener('change', e => { setToken((e.target as HTMLInputElement).value); pollStatus() })
|
||||
}
|
||||
const hfInput = document.getElementById('hf-token') as HTMLInputElement | null
|
||||
if (hfInput) {
|
||||
hfInput.value = getHfToken()
|
||||
hfInput.addEventListener('change', e => setHfToken((e.target as HTMLInputElement).value))
|
||||
}
|
||||
const sbtn = document.getElementById('nav-settings')
|
||||
const smod = document.getElementById('settings-modal')
|
||||
const scls = document.getElementById('sm-close')
|
||||
if (sbtn && smod && scls) {
|
||||
sbtn.addEventListener('click', () => (smod!.style.display = 'flex'))
|
||||
scls.addEventListener('click', () => (smod!.style.display = 'none'))
|
||||
}
|
||||
|
||||
function tickClock() {
|
||||
const c = document.getElementById('clock')
|
||||
if (c) c.textContent = new Date().toTimeString().slice(0, 5)
|
||||
}
|
||||
|
||||
document.addEventListener('mc:refresh', pollStatus as EventListener)
|
||||
pollStatus(); pollJobs(); connectSystemStream()
|
||||
setInterval(tickClock, 1000)
|
||||
setInterval(pollStatus, 3000)
|
||||
setInterval(pollJobs, 1500)
|
||||
pollUpdates()
|
||||
setInterval(pollUpdates, 60000)
|
||||
@@ -0,0 +1,396 @@
|
||||
<script lang="ts">
|
||||
import { statusStore } from '../stores/status.svelte.js'
|
||||
import { api } from '@core/api.js'
|
||||
import { esc, icon } from '@core/ui.js'
|
||||
|
||||
const s = $derived(statusStore.value)
|
||||
const allModels = $derived(((s?.models || []) as any[]).filter((m: any) => !m.incomplete))
|
||||
|
||||
let selectedTool = $state('zed')
|
||||
let selectedModelIds = $state<string[]>([])
|
||||
let testResult = $state('')
|
||||
let testOk = $state(false)
|
||||
let selectedMemTool = $state('cline')
|
||||
let ipOverride = $state<string>(localStorage.getItem('mc_ip_override') || '')
|
||||
|
||||
function isLanIp(h: string) { return /^\d{1,3}(\.\d{1,3}){3}$/.test(h) }
|
||||
function behindProxy() { return location.protocol === 'https:' || !isLanIp(location.hostname) }
|
||||
function baseUrl() {
|
||||
const host = ipOverride.trim() || location.hostname
|
||||
return `http://${host}:8080/v1`
|
||||
}
|
||||
|
||||
const url = $derived(baseUrl())
|
||||
|
||||
function saveIpOverride() {
|
||||
const v = ipOverride.trim()
|
||||
if (v) localStorage.setItem('mc_ip_override', v)
|
||||
else localStorage.removeItem('mc_ip_override')
|
||||
}
|
||||
|
||||
const memBlock = $derived(memMcpBlock(selectedMemTool))
|
||||
|
||||
const activeIds = $derived(
|
||||
selectedModelIds.length ? selectedModelIds : allModels.map((m: any) => m.name)
|
||||
)
|
||||
const firstModel = $derived(activeIds[0] || 'coder')
|
||||
const visionModel = $derived(allModels.find((m: any) => m.meta?.caps?.includes('Bild'))?.name)
|
||||
|
||||
function zedBlock(url: string, ids: string[]) {
|
||||
const ms = ids.length ? allModels.filter((m: any) => ids.includes(m.name)) : (allModels.length ? allModels : [{ name: 'coder', meta: {} }])
|
||||
const entries = ms.map((m: any) => {
|
||||
const img = (m.meta?.caps || []).some((c: string) => /Bild|Vision/i.test(c))
|
||||
const ctx = m.meta?.ctx || 131072 // nutze konfigurierten Kontext des Modells (Standard: 128k)
|
||||
return ` { "name": "${m.name}", "display_name": "${m.name}", "max_tokens": ${ctx}${img ? `, "capabilities": { "tools": true, "images": true }` : ''} }`
|
||||
}).join(',\n')
|
||||
return `"language_models": {\n "openai_compatible": {\n "bosgame": {\n "api_url": "${url}",\n "available_models": [\n${entries}\n ]\n }\n }\n}`
|
||||
}
|
||||
|
||||
function openCodeBlock(url: string, ids: string[]) {
|
||||
const ms = ids.length
|
||||
? allModels.filter((m: any) => ids.includes(m.name))
|
||||
: (allModels.length ? allModels : [{ name: 'coder', meta: {} }])
|
||||
const modelMap = ms.map((m: any) => {
|
||||
const ctx = m.meta?.ctx || 131072
|
||||
return ` "${m.name}": { "name": "${m.name}", "contextWindowSize": ${ctx} }`
|
||||
}).join(',\n')
|
||||
return `"providers": {\n "llama-swap": {\n "npm": "@ai-sdk/openai-compatible",\n "name": "Bosgame (llama-swap)",\n "options": {\n "baseURL": "${url}",\n "apiKey": "local"\n },\n "models": {\n${modelMap}\n }\n }\n}`
|
||||
}
|
||||
|
||||
function continueBlock(url: string, ids: string[]) {
|
||||
const ms = ids.length
|
||||
? allModels.filter((m: any) => ids.includes(m.name))
|
||||
: (allModels.length ? allModels : [{ name: 'coder', meta: {} }])
|
||||
const entries = ms.map((m: any) =>
|
||||
` {\n "title": "llama-swap / ${m.name}",\n "provider": "openai",\n "model": "${m.name}",\n "apiBase": "${url}",\n "apiKey": "local"\n }`
|
||||
).join(',\n')
|
||||
return `{\n "models": [\n${entries}\n ]\n}`
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
testResult = 'Teste…'
|
||||
try {
|
||||
const r = await api('/api/integration/test')
|
||||
testOk = r.ok
|
||||
testResult = r.ok
|
||||
? `✓ Verbunden — ${r.models.length} Modell(e): ${r.models.join(', ')}`
|
||||
: `✗ Keine Verbindung: ${r.error}`
|
||||
} catch (e: any) { testOk = false; testResult = '✗ ' + e.message }
|
||||
}
|
||||
|
||||
function copyCode(el: HTMLElement) {
|
||||
const code = el.querySelector('code')
|
||||
if (code) navigator.clipboard?.writeText(code.textContent || '')
|
||||
}
|
||||
function copyVal(inp: HTMLInputElement) {
|
||||
navigator.clipboard?.writeText(inp.value)
|
||||
}
|
||||
|
||||
function memMcpBlock(tool: string) {
|
||||
const pyPath = tool === 'bosgame'
|
||||
? '/opt/mission-control/.venv/bin/python'
|
||||
: 'python'
|
||||
const scriptPath = tool === 'bosgame'
|
||||
? '/opt/mission-control/mcp_memory.py'
|
||||
: 'C:\\\\Users\\\\<DeinUsername>\\\\mission-control\\\\mcp_memory.py'
|
||||
return `{\n "mcpServers": {\n "mission-control-memory": {\n "command": "${pyPath}",\n "args": ["${scriptPath}"],\n "env": {\n "MC_URL": "http://192.168.178.151:9000"\n }\n }\n }\n}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="pagehead">
|
||||
<div>
|
||||
<h1>Verbinden</h1>
|
||||
<div class="sub">Schritt für Schritt: KI-Modelle in deine Tools einbinden — getestet, erklärt, mit Copy-Paste-Config.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Connection -->
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>1 · Verbindung prüfen</h3></div>
|
||||
<div class="card-sub">Stelle sicher, dass die LLM-Engine erreichbar ist, bevor du Tools konfigurierst.</div>
|
||||
<label>Engine-Adresse (für alle Tools)</label>
|
||||
<div class="flex gap-2" style="align-items:stretch;margin-bottom:4px">
|
||||
<input class="mono-sm" readonly value={url} style="flex:1;margin:0">
|
||||
<button class="ghost" onclick={e => { const inp = (e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement; copyVal(inp) }}>Kopieren</button>
|
||||
</div>
|
||||
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
|
||||
<input class="mono-sm" placeholder="LAN-IP überschreiben, z.B. 192.168.178.151 (leer = auto)"
|
||||
bind:value={ipOverride}
|
||||
oninput={saveIpOverride}
|
||||
style="flex:1;margin:0;font-size:12px;opacity:.7">
|
||||
</div>
|
||||
{#if behindProxy()}
|
||||
<div class="alert warn" style="margin:0 0 12px">
|
||||
<span class="a-dot"></span>
|
||||
<span>Du öffnest Mission Control über einen <b>Proxy/Namen</b>. Die Engine läuft auf <b>Port 8080 ohne HTTPS</b> direkt am Bosgame.
|
||||
Trag in deinen Tools die <b>LAN-IP</b> ein: <code>http://192.168.178.151:8080/v1</code> — nicht den Proxy-Namen.</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="btn-row" style="display:flex;align-items:center;gap:10px">
|
||||
<button class="primary" onclick={testConnection}>Verbindung testen</button>
|
||||
{#if testResult}
|
||||
<span class="mono-sm" style="color:{testOk ? '#7ee29a' : '#ff9b95'}">{testResult}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-Swap explanation -->
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>Wie funktioniert das Auto-Swap?</h3></div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
{#each [
|
||||
['⚡', 'Kein manuelles Starten nötig', 'Wenn du in Zed oder OpenCode „coder" auswählst und eine Anfrage sendest, lädt llama-swap das Modell automatisch — in ca. 2–5 Sekunden.'],
|
||||
['🔄', 'Modelle wechseln sich automatisch ab', 'Rufst du ein anderes Modell auf, entlädt llama-swap das aktuelle (nach TTL-Ablauf) und lädt das neue. Es läuft immer nur eines gleichzeitig.'],
|
||||
['🧠', 'Zwei Modelle gleichzeitig aktiv halten', 'Du hast 124 GB RAM — genug für z.B. Scout 8B (~5 GB) dauerhaft geladen + ein größeres Modell das swappt. Dafür beim Scout im Modelle-Tab → Konfigurieren die TTL auf 99999 setzen.']
|
||||
] as [emoji, title, desc]}
|
||||
<div class="tile" style="display:flex;gap:12px;align-items:flex-start">
|
||||
<span style="font-size:20px;line-height:1;flex:0 0 auto">{emoji}</span>
|
||||
<div>
|
||||
<div style="font-weight:500;font-size:13.5px">{title}</div>
|
||||
<div class="card-sub" style="margin:4px 0 0">{desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Steps 2–4: nur anzeigen wenn Modelle installiert sind -->
|
||||
{#if allModels.length}
|
||||
|
||||
<!-- Step 2: Model selector -->
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<h3>2 · Modelle auswählen</h3>
|
||||
<span class="meta">{selectedModelIds.length ? `${selectedModelIds.length} von ${allModels.length} ausgewählt` : 'alle werden eingeschlossen'}</span>
|
||||
</div>
|
||||
<div class="card-sub">Wähle, welche Modelle im Config-Snippet erscheinen sollen.</div>
|
||||
<div class="flex gap-2" style="flex-wrap:wrap">
|
||||
{#each allModels as m}
|
||||
{@const active = selectedModelIds.includes(m.name)}
|
||||
<span class="chip" style="cursor:pointer;{active ? 'background:rgba(45,212,191,.2);border-color:var(--accent);color:var(--accent)' : ''}"
|
||||
role="button" tabindex="0"
|
||||
onclick={() => selectedModelIds = active ? selectedModelIds.filter(x => x !== m.name) : [...selectedModelIds, m.name]}
|
||||
onkeydown={e => e.key === 'Enter' && (selectedModelIds = active ? selectedModelIds.filter(x => x !== m.name) : [...selectedModelIds, m.name])}>
|
||||
{m.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{#if selectedModelIds.length}
|
||||
<div style="margin-top:10px">
|
||||
<a href="#" onclick={e => { e.preventDefault(); selectedModelIds = [] }} style="color:var(--mut);font-size:12.5px">Auswahl aufheben (alle)</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Tool picker -->
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>3 · Tool wählen</h3></div>
|
||||
<div class="card-sub" style="margin-bottom:8px">Für welches Werkzeug brauchst du die Konfiguration?</div>
|
||||
|
||||
<div style="font-size:10.5px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--accent);margin-bottom:6px">Chat</div>
|
||||
<div class="grid grid-3" style="gap:8px;margin-bottom:14px">
|
||||
{#each [
|
||||
['janai', 'Jan AI', 'Einfachstes Chat-Interface — Desktop-App, kein Setup'],
|
||||
['openwebui', 'OpenWebUI', 'Browser-Chat für Teams — mehr Features, mehr Setup'],
|
||||
] as [id, label, desc]}
|
||||
<button class="card-btn{selectedTool === id ? ' cb-best' : ''}" style="text-align:left;padding:12px 16px" onclick={() => selectedTool = id}>
|
||||
<div style="font-weight:500;font-size:13.5px">{label}</div>
|
||||
<div class="card-sub" style="margin:3px 0 0">{desc}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div style="font-size:10.5px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--accent);margin-bottom:6px">Coding-Assistenten</div>
|
||||
<div class="grid grid-3" style="gap:8px">
|
||||
{#each [
|
||||
['continue', 'Continue', 'VS Code/JetBrains — Hilfe ohne Agent-Overhead'],
|
||||
['cline', 'Cline', 'VS Code/JetBrains/Zed — voller Coding-Agent'],
|
||||
['antigravity', 'Antigravity', 'Googles agentic IDE — VS Code-basiert'],
|
||||
['opencode', 'OpenCode', 'Terminal-Agent — autonom, mächtig'],
|
||||
['zed', 'Zed', 'Schneller nativer Editor mit KI-Panel'],
|
||||
] as [id, label, desc]}
|
||||
<button class="card-btn{selectedTool === id ? ' cb-best' : ''}" style="text-align:left;padding:12px 16px" onclick={() => selectedTool = id}>
|
||||
<div style="font-weight:500;font-size:13.5px">{label}</div>
|
||||
<div class="card-sub" style="margin:3px 0 0">{desc}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Config snippet -->
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>4 · Config-Snippet</h3></div>
|
||||
{#if selectedTool === 'janai'}
|
||||
<p><b>Jan AI</b> ist eine Desktop-App — kein Browser, kein Server, kein Admin-Panel. Einfach installieren und loslegen.</p>
|
||||
<p>Jan AI öffnen → linke Leiste → <b>Remote-Modelle</b> → <b>+ Modell hinzufügen</b>:</p>
|
||||
<label>Basis-URL (kopieren und in Jan AI einfügen)</label>
|
||||
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
|
||||
<input class="mono-sm" readonly value={url} style="flex:1;margin:0">
|
||||
<button class="ghost" onclick={e => copyVal((e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement)}>Kopieren</button>
|
||||
</div>
|
||||
<label>Modell-ID (in Jan AI als „Model name" eintragen)</label>
|
||||
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
|
||||
<input class="mono-sm" readonly value={firstModel} style="flex:1;margin:0">
|
||||
<button class="ghost" onclick={e => copyVal((e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement)}>Kopieren</button>
|
||||
</div>
|
||||
<div class="hint">API-Key: ein beliebiger Wert (z.B. <code>local</code>). Jan AI fragt danach beim Einrichten.</div>
|
||||
<div class="hint" style="margin-top:8px">Jan AI herunterladen: <a href="https://jan.ai" target="_blank" rel="noopener">jan.ai</a> — kostenlos, open source, Windows/macOS/Linux.</div>
|
||||
{:else if selectedTool === 'continue'}
|
||||
{@const block = continueBlock(url, activeIds)}
|
||||
<p>Datei anlegen oder öffnen: <code>~/.continue/config.json</code></p>
|
||||
<div class="alert warn" style="margin:8px 0 12px;padding:10px 14px"><span class="a-dot"></span><span>Falls bereits eine <code>config.json</code> vorhanden: nur den <code>"models"</code>-Eintrag ergänzen, nicht die ganze Datei ersetzen.</span></div>
|
||||
<div style="position:relative">
|
||||
<button class="ghost" style="position:absolute;top:8px;right:8px;z-index:1" onclick={e => copyCode(e.currentTarget!.parentElement!)}>Kopieren</button>
|
||||
<div class="log" style="max-height:none"><code>{block}</code></div>
|
||||
</div>
|
||||
<p style="margin-top:12px">Danach Continue in VS Code neu starten → im Chat-Panel das Modell <b>llama-swap / {firstModel}</b> wählen.</p>
|
||||
<div class="hint">Continue: <a href="https://continue.dev" target="_blank" rel="noopener">continue.dev</a> — kostenlose VS Code / JetBrains Extension. Hinweis: 2026 von Cursor akquiriert — aktuell weiter aktiv und kostenlos.</div>
|
||||
{:else if selectedTool === 'antigravity'}
|
||||
<p><b>Antigravity</b> (Googles agentic IDE, VS Code-basiert) unterstützt OpenAI-kompatible Endpoints direkt:</p>
|
||||
<p>Settings → Models → <b>Custom Endpoint</b> → <b>+ hinzufügen</b>:</p>
|
||||
<label>Basis-URL</label>
|
||||
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
|
||||
<input class="mono-sm" readonly value={url} style="flex:1;margin:0">
|
||||
<button class="ghost" onclick={e => copyVal((e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement)}>Kopieren</button>
|
||||
</div>
|
||||
<label>Modell-ID</label>
|
||||
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
|
||||
<input class="mono-sm" readonly value={firstModel} style="flex:1;margin:0">
|
||||
<button class="ghost" onclick={e => copyVal((e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement)}>Kopieren</button>
|
||||
</div>
|
||||
<div class="hint">API-Key: ein beliebiger Wert (z.B. <code>local</code>). Danach das Modell <b>{firstModel}</b> in der Model-Liste auswählen.</div>
|
||||
<div class="hint" style="margin-top:8px">Antigravity herunterladen: <a href="https://antigravityide.net" target="_blank" rel="noopener">antigravityide.net</a> — kostenlose Einzelperson-Tier, VS Code-basiert.</div>
|
||||
{:else if selectedTool === 'zed'}
|
||||
{@const block = zedBlock(url, activeIds)}
|
||||
<p>Öffne die Zed-Settings mit <code>Cmd/Strg + ,</code> und <b>füge diesen Block</b> in deine bestehende <code>settings.json</code> ein. Falls bereits ein <code>"bosgame"</code>-Eintrag existiert: vorher löschen.</p>
|
||||
<div class="alert warn" style="margin-bottom:12px;padding:10px 14px"><span class="a-dot"></span><span>Nur diesen Block einfügen, <b>nicht die ganze Datei ersetzen</b> — sonst gehen andere Einstellungen verloren.</span></div>
|
||||
<div style="position:relative">
|
||||
<button class="ghost" style="position:absolute;top:8px;right:8px;z-index:1" onclick={e => copyCode(e.currentTarget!.parentElement!)}>Kopieren</button>
|
||||
<div class="log" style="max-height:none"><code>{block}</code></div>
|
||||
</div>
|
||||
<p style="margin-top:12px"><b>API-Key:</b> Öffne den Agent-Panel → Anbieter <b>bosgame</b> → trag irgendeinen Wert ein (z.B. <code>local</code>).</p>
|
||||
<div class="hint">Danach im Agent-Panel das Modell <b>bosgame / {firstModel}</b> auswählen.</div>
|
||||
<div class="hint" style="margin-top:4px">Zed herunterladen: <a href="https://zed.dev" target="_blank" rel="noopener">zed.dev</a> — kostenlos, native Performance, Windows/macOS/Linux.</div>
|
||||
{:else if selectedTool === 'opencode'}
|
||||
{@const block = openCodeBlock(url, activeIds)}
|
||||
<p>Datei anlegen oder öffnen (OpenCode Desktop nutzt <b>.jsonc</b> — JSON mit Kommentaren):</p>
|
||||
<p class="mono-sm" style="line-height:1.6">Windows: <code>%USERPROFILE%\.config\opencode\opencode.jsonc</code></p>
|
||||
<div class="alert warn" style="margin:8px 0 12px;padding:10px 14px"><span class="a-dot"></span><span>Den <code>"providers"</code>-Block in die bestehende <code>opencode.jsonc</code> einfügen (nicht die ganze Datei ersetzen). Falls bereits ein <code>"llama-swap"</code>-Eintrag existiert: <b>vorher löschen</b>.</span></div>
|
||||
<div style="position:relative">
|
||||
<button class="ghost" style="position:absolute;top:8px;right:8px;z-index:1" onclick={e => copyCode(e.currentTarget!.parentElement!)}>Kopieren</button>
|
||||
<div class="log" style="max-height:none"><code>{block}</code></div>
|
||||
</div>
|
||||
<p style="margin-top:12px">Danach in OpenCode <code>/models</code> → <b>llama-swap/{firstModel}</b> wählen.</p>
|
||||
<div class="hint">OpenCode Desktop: <a href="https://opencode.ai" target="_blank" rel="noopener">opencode.ai</a></div>
|
||||
{:else if selectedTool === 'cline'}
|
||||
<p>In Cline/Cursor den Provider <b>„OpenAI Compatible"</b> wählen:</p>
|
||||
<label>Basis-URL</label>
|
||||
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
|
||||
<input class="mono-sm" readonly value={url} style="flex:1;margin:0">
|
||||
<button class="ghost" onclick={e => copyVal((e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement)}>Kopieren</button>
|
||||
</div>
|
||||
<label>Modell-ID</label>
|
||||
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
|
||||
<input class="mono-sm" readonly value={firstModel} style="flex:1;margin:0">
|
||||
<button class="ghost" onclick={e => copyVal((e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement)}>Kopieren</button>
|
||||
</div>
|
||||
<div class="hint">API-Key: ein beliebiger Wert (z.B. <code>local</code>).</div>
|
||||
<div class="hint" style="margin-top:4px">Cline für VS Code: <a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank" rel="noopener">VS Code Marketplace</a> · für JetBrains: <a href="https://plugins.jetbrains.com/plugin/24604-cline" target="_blank" rel="noopener">JetBrains Marketplace</a></div>
|
||||
{:else if selectedTool === 'openwebui'}
|
||||
<p>Settings → Admin Panel → Connections → neue OpenAI-Verbindung:</p>
|
||||
<label>Basis-URL</label>
|
||||
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
|
||||
<input class="mono-sm" readonly value={url} style="flex:1;margin:0">
|
||||
<button class="ghost" onclick={e => copyVal((e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement)}>Kopieren</button>
|
||||
</div>
|
||||
<label>API-Key</label>
|
||||
<div class="flex gap-2" style="align-items:stretch">
|
||||
<input class="mono-sm" readonly value="dummy-key" style="flex:1;margin:0">
|
||||
<button class="ghost" onclick={e => copyVal((e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement)}>Kopieren</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Vision -->
|
||||
{#if visionModel}
|
||||
{@const snippet = `curl ${url}/chat/completions \\\n -H "Content-Type: application/json" \\\n -d '{\n "model": "${visionModel}",\n "messages": [{"role":"user","content":[{"type":"text","text":"Was ist auf dem Bild falsch?"},{"type":"image_url","image_url":{"url":"data:image/png;base64,<DEIN_BILD>"}}]}]\n}'`}
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<h3>Bilder einbinden (Vision)</h3>
|
||||
<span class="chip" style="background:rgba(63,185,80,.12);border-color:rgba(63,185,80,.25);color:#7ee29a">{@html icon('check')} „{visionModel}" bereit</span>
|
||||
</div>
|
||||
<div class="card-sub">Mit Vision-Modellen kannst du Screenshots oder Fehlerbilder direkt mitschicken.</div>
|
||||
<div style="position:relative;margin-top:8px">
|
||||
<button class="ghost" style="position:absolute;top:8px;right:8px;z-index:1" onclick={e => copyCode(e.currentTarget!.parentElement!)}>Kopieren</button>
|
||||
<div class="log" style="max-height:none"><code>{snippet}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
<!-- Kein Modell installiert: Schritt 0 erklären statt leeres Snippet ausgeben -->
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>2 · Erst ein Modell installieren</h3></div>
|
||||
<div class="empty-c" style="padding:8px 0 4px">
|
||||
<div class="e-t">Noch kein Modell eingerichtet</div>
|
||||
<div class="e-s">Bevor du ein Tool verbinden kannst, braucht die Engine mindestens ein Modell. Das Config-Snippet wird dann automatisch mit deinen echten Modellnamen befüllt.</div>
|
||||
<button class="primary" style="margin-top:14px"
|
||||
onclick={() => document.querySelector(".nav-item[data-view='cookbook']")?.dispatchEvent(new MouseEvent('click'))}>
|
||||
→ Zum Cookbook (Modell holen)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Memory MCP — unabhaengig von Modellen, immer sichtbar -->
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<h3>Gedächtnis-MCP</h3>
|
||||
<span class="chip" style="background:rgba(45,212,191,.1);border-color:rgba(45,212,191,.3);color:var(--accent)">Neu in v7</span>
|
||||
</div>
|
||||
<div class="card-sub" style="margin-bottom:14px">
|
||||
Damit Cline, OpenCode und Claude Code sich Projektfakten, Entscheidungen und Präferenzen
|
||||
über Sessions hinweg merken — geteilt über alle Tools, verwaltet im
|
||||
<a href="#"
|
||||
onclick={e => { e.preventDefault(); (document.querySelector(".nav-item[data-view='memory']") as HTMLElement)?.click() }}
|
||||
style="color:var(--accent)">Gedächtnis-Tab</a>.
|
||||
</div>
|
||||
|
||||
<!-- Tool selector -->
|
||||
<div class="flex gap-2" style="margin-bottom:14px;flex-wrap:wrap">
|
||||
{#each [
|
||||
['cline', 'Cline / Claude Code', 'Windows — Tools auf dem Entwicklungs-PC'],
|
||||
['opencode', 'OpenCode', 'Windows — Terminal-Agent'],
|
||||
['bosgame', 'Direkt am Bosgame', 'Wenn das Tool am Server läuft'],
|
||||
] as [id, label, desc]}
|
||||
<button class="card-btn{selectedMemTool === id ? ' cb-best' : ''}"
|
||||
style="text-align:left;padding:10px 14px"
|
||||
onclick={() => selectedMemTool = id}>
|
||||
<div style="font-weight:500;font-size:13px">{label}</div>
|
||||
<div class="card-sub" style="margin:2px 0 0;font-size:11.5px">{desc}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Config block -->
|
||||
<div style="position:relative">
|
||||
<button class="ghost" style="position:absolute;top:8px;right:8px;z-index:1"
|
||||
onclick={e => copyCode(e.currentTarget!.parentElement!)}>Kopieren</button>
|
||||
<div class="log" style="max-height:none"><code>{memBlock}</code></div>
|
||||
</div>
|
||||
|
||||
{#if selectedMemTool === 'cline'}
|
||||
<p style="margin-top:12px">In <b>Cline</b>: MCP-Einstellungen öffnen (Stecker-Icon in der Cline-Sidebar) → „Add MCP Server" → diesen Block einfügen.</p>
|
||||
<p>In <b>Claude Code</b>: Block in <code>~/.claude/settings.json</code> unter <code>"mcpServers"</code> einfügen.</p>
|
||||
<div class="alert warn" style="margin-top:8px;padding:10px 14px"><span class="a-dot"></span>
|
||||
<span>Einmalig auf dem Windows-PC: <code>pip install mcp httpx</code> ausführen — danach <code>mcp_memory.py</code> aus dem Repo-Ordner erreichbar machen (Pfad im Snippet anpassen).</span>
|
||||
</div>
|
||||
{:else if selectedMemTool === 'opencode'}
|
||||
<p style="margin-top:12px">Block in <code>~/.config/opencode/opencode.json</code> unter <code>"mcpServers"</code> einfügen.</p>
|
||||
<div class="alert warn" style="margin-top:8px;padding:10px 14px"><span class="a-dot"></span>
|
||||
<span>Einmalig: <code>pip install mcp httpx</code> — und Pfad zu <code>mcp_memory.py</code> im Snippet anpassen.</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p style="margin-top:12px">Block in die MCP-Config des jeweiligen Tools einfügen. Der Bosgame-Pfad <code>/opt/mission-control/mcp_memory.py</code> ist bereits korrekt — kein weiteres Setup nötig.</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,644 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { statusStore } from '../stores/status.svelte.js'
|
||||
import { systemStore } from '../stores/system.svelte.js'
|
||||
import { api, getHfToken } from '@core/api.js'
|
||||
import { esc, toast, confirmModal, infoDot } from '@core/ui.js'
|
||||
|
||||
const GGUF_HELP = 'GGUF ist das lokale Dateiformat für KI-Modelle. Jede Datei ist eine quantisierte Variante desselben Modells — Q4_K_M ist meist der beste Kompromiss aus Qualität und Geschwindigkeit.'
|
||||
const CTX_HELP = 'Kontext = das Kurzzeitgedächtnis des Modells. Größer = merkt sich mehr, braucht aber mehr Speicher und wird etwas langsamer.'
|
||||
const MOE_HELP = 'MoE (Mixture of Experts): viele Parameter fürs Wissen, aber pro Wort ist nur ein kleiner Teil aktiv → fast große-Modell-Qualität bei hohem Tempo. Ideal für deine APU.'
|
||||
const FIT_RANK: Record<string, number> = { perfect: 0, marginal: 1, too_tight: 2 }
|
||||
const ROLE_LABEL: Record<string, string> = { vision: 'Bilder', coder: 'Coden', reasoning: 'Logik', agent: 'Agenten & Tools', scout: 'Allrounder', reviewer: 'Review', manager: 'Manager' }
|
||||
const CAP_KW = [
|
||||
{ label: 'Bilder', kw: ['-vl-', '-vl', 'vision', 'llava', 'multimodal', '-mm-', 'pixtral'] },
|
||||
{ label: 'Coden', kw: ['coder', '-code-', 'code-', 'codestral', 'starcoder'] },
|
||||
{ label: 'Logik', kw: ['-r1', 'deepseek-r1', 'reasoning', 'qwq', 'magistral', '-think', 'thinking', '-o1'] },
|
||||
{ label: 'Agenten & Tools', kw: ['hermes', '-tool', 'command-r', 'watt', '-fc-', 'function'] },
|
||||
]
|
||||
|
||||
function capLabel(name: string) {
|
||||
const l = (name || '').toLowerCase()
|
||||
for (const c of CAP_KW) if (c.kw.some((k: string) => l.includes(k))) return c.label
|
||||
return 'Allrounder'
|
||||
}
|
||||
|
||||
// ---- Capability-Tags (Phase 10, Schritt 2): aus Name + HF-Tags ----
|
||||
const TOOL_FAM = ['qwen2.5', 'qwen3', 'qwen2', 'hermes', 'mistral', 'mixtral', 'devstral',
|
||||
'command-r', 'command_r', 'llama-3.1', 'llama3.1', 'llama-3.3', 'llama-4', 'llama4',
|
||||
'functionary', 'watt', 'firefunction', 'granite', 'glm-4', 'ministral', 'gemma-3', 'gemma3']
|
||||
const REASON_KW = ['-r1', 'deepseek-r1', 'qwq', 'magistral', '-think', 'thinking', '-o1',
|
||||
'gpt-oss', 'reasoning', 'exaone-deep', 'phi-4-reasoning']
|
||||
|
||||
function caps(name: string, tags: any[] = []) {
|
||||
const l = (name || '').toLowerCase()
|
||||
const t = (tags || []).map((x: any) => String(x).toLowerCase())
|
||||
const toolTag = t.some((x: string) => x.includes('function-calling') || x.includes('tool-use') || x.includes('tools'))
|
||||
const toolFam = TOOL_FAM.some((f: string) => l.includes(f))
|
||||
return {
|
||||
moe: isMoe(l),
|
||||
active_b: moeActive(l),
|
||||
vision: ['-vl', 'vision', 'llava', 'pixtral', 'multimodal', '-mm-'].some(k => l.includes(k)) || t.includes('image-text-to-text'),
|
||||
coder: ['coder', '-code', 'code-', 'codestral', 'starcoder'].some(k => l.includes(k)),
|
||||
reasoning: REASON_KW.some(k => l.includes(k)) || t.includes('reasoning'),
|
||||
tools: toolTag ? 'yes' : (toolFam ? 'likely' : 'no'),
|
||||
}
|
||||
}
|
||||
|
||||
// Facetten-Filter (Mehrfachauswahl) für die Profi-Suche
|
||||
let facets = $state<Record<string, boolean>>({ tools: false, moe: false, reasoning: false, vision: false, coder: false })
|
||||
const FACET_DEFS: [string, string][] = [['tools', '🛠 Tools'], ['moe', '🧩 MoE'], ['reasoning', '🧠 Logik'], ['vision', '👁 Bild'], ['coder', '💻 Code']]
|
||||
|
||||
function matchesFacets(name: string, tags: any[] = []) {
|
||||
const active = Object.keys(facets).filter(k => facets[k])
|
||||
if (!active.length) return true
|
||||
const c: any = caps(name, tags)
|
||||
return active.every(f => f === 'tools' ? c.tools !== 'no' : c[f])
|
||||
}
|
||||
|
||||
function capsChipsHtml(name: string, tags: any[] = []) {
|
||||
const c: any = caps(name, tags)
|
||||
const out: string[] = []
|
||||
if (c.coder) out.push('<span class="chip">💻 Code</span>')
|
||||
if (c.vision) out.push('<span class="chip">👁 Bild</span>')
|
||||
if (c.reasoning) out.push('<span class="chip">🧠 Logik</span>')
|
||||
if (c.moe) out.push(`<span class="chip" title="${esc(MOE_HELP)}">🧩 MoE${c.active_b ? ` · ~${c.active_b}B` : ''}</span>`)
|
||||
if (c.tools === 'yes') out.push('<span class="chip" title="Tool-Calling unterstützt">🛠 Tools</span>')
|
||||
else if (c.tools === 'likely') out.push('<span class="chip" style="opacity:.55" title="laut Modell-Familie wahrscheinlich tool-fähig">🛠 Tools?</span>')
|
||||
if (!c.coder && !c.vision) out.unshift('<span class="chip">💬 Chat</span>')
|
||||
return out.join(' ')
|
||||
}
|
||||
function isMoe(s = '') {
|
||||
const l = s.toLowerCase()
|
||||
return /\d+x\d+(\.\d+)?b/.test(l) || /(?<![a-z])a\d+(\.\d+)?b\b/.test(l) || l.includes('mixtral') || /\bmoe\b/.test(l)
|
||||
}
|
||||
function moeActive(s = '') {
|
||||
const m = s.toLowerCase().match(/(?<![a-z])a(\d+(?:\.\d+)?)b\b/)
|
||||
return m ? m[1] : null
|
||||
}
|
||||
function fitCls(l: string) { return l === 'perfect' ? 'ok' : l === 'marginal' ? 'warn' : 'bad' }
|
||||
function fitWord(l: string) { return l === 'perfect' ? 'Passt' : l === 'marginal' ? 'Knapp' : 'Zu groß' }
|
||||
function metricLine(fit: any) { return `~${(fit.req_gb ?? 0).toFixed(1)} GB · ~${Math.round(fit.tps ?? 0)} Tok/s` }
|
||||
|
||||
const sys = $derived(systemStore.value)
|
||||
|
||||
// ---- Tabs (Cookbook 3.0: entschlackt — eine Ansicht zur Zeit) ----
|
||||
let cookTab = $state<'setups' | 'find'>('setups')
|
||||
|
||||
// ---- Recipes ----
|
||||
let recipes = $state<any[]>([])
|
||||
let recommended = $state<string | null>(null)
|
||||
let recipeLoading = $state(true)
|
||||
|
||||
// ---- Discover ----
|
||||
let discoverData = $state<any | null>(null)
|
||||
let discoverLoading = $state(false)
|
||||
let discoverStale = $state(false)
|
||||
|
||||
// ---- Search ----
|
||||
let searchQuery = $state('')
|
||||
let searchResults = $state<any[]>([])
|
||||
let searchLoading = $state(false)
|
||||
let activeFilter = $state('')
|
||||
let sortBy = $state('downloads')
|
||||
let cardFits = $state<(string | null)[]>([])
|
||||
let bestResultIdx = $state(-1)
|
||||
|
||||
// ---- Recipe detail modal ----
|
||||
let recipeModal = $state<any | null>(null)
|
||||
|
||||
// ---- Model detail modal ----
|
||||
let modelModal = $state(false)
|
||||
let modelModalTitle = $state('')
|
||||
let modelModalRepo = $state('')
|
||||
let modelAnalysis = $state<any | null>(null)
|
||||
let modelFile = $state('')
|
||||
let modelRole = $state('')
|
||||
let modelCtx = $state(8192)
|
||||
let modelLoading = $state(false)
|
||||
let modelFit = $state<any | null>(null)
|
||||
|
||||
// ---- New/Edit recipe modal ----
|
||||
let newRecipeOpen = $state(false)
|
||||
let newRecipeTitle = $state('')
|
||||
let newRecipeDesc = $state('')
|
||||
let newRecipeModels = $state<{ repo: string; role: string; quant: string }[]>([{ repo: '', role: '', quant: 'Q4_K_M' }])
|
||||
let editRecipeId = $state<string | null>(null)
|
||||
|
||||
function refresh() { document.dispatchEvent(new Event('mc:refresh')) }
|
||||
function goView(v: string) { document.querySelector(`.nav-item[data-view="${v}"]`)?.dispatchEvent(new MouseEvent('click')) }
|
||||
|
||||
onMount(() => {
|
||||
loadRecipes()
|
||||
loadDiscover()
|
||||
})
|
||||
|
||||
async function loadRecipes() {
|
||||
recipeLoading = true
|
||||
try {
|
||||
const d = await api('/api/cookbook/recipes')
|
||||
recipes = d.recipes || []
|
||||
recommended = d.recommended_id || null
|
||||
} catch (e: any) { toast(e.message, true) }
|
||||
recipeLoading = false
|
||||
}
|
||||
|
||||
async function loadDiscover(force = false) {
|
||||
discoverLoading = true
|
||||
try {
|
||||
const d = await api('/api/cookbook/discover' + (force ? '?force=true' : ''))
|
||||
discoverData = d
|
||||
discoverStale = d.stale
|
||||
} catch (e: any) { toast('Empfehlungen nicht ladbar: ' + e.message, true) }
|
||||
discoverLoading = false
|
||||
}
|
||||
|
||||
// ---- Search ----
|
||||
function parseHfRepo(s: string) {
|
||||
s = s.trim()
|
||||
const u = s.match(/huggingface\.co\/([^\s/]+\/[^\s/?#]+)/i)
|
||||
if (u) return u[1]
|
||||
if (/^[\w.-]+\/[\w.-]+$/.test(s)) return s
|
||||
return null
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
const raw = searchQuery.trim()
|
||||
const repo = parseHfRepo(raw)
|
||||
const q = raw
|
||||
if (!q && !repo) { searchResults = []; return }
|
||||
searchLoading = true; cardFits = []; bestResultIdx = -1
|
||||
try {
|
||||
if (repo) {
|
||||
searchResults = [{ id: repo, author: repo.split('/')[0], downloads: 0 }]
|
||||
} else {
|
||||
const url = `https://huggingface.co/api/models?search=${encodeURIComponent(q)}&filter=gguf&full=true&sort=${encodeURIComponent(sortBy)}&direction=-1&limit=40`
|
||||
const r = await fetch(url)
|
||||
searchResults = await r.json()
|
||||
}
|
||||
// Fetch fits in background
|
||||
searchResults.forEach((m: any, i: number) => fetchFitForCard(i, m.id))
|
||||
} catch (e: any) { toast(e.message, true); searchResults = [] }
|
||||
searchLoading = false
|
||||
}
|
||||
|
||||
async function fetchFitForCard(i: number, repo_id: string) {
|
||||
try {
|
||||
const res = await api('/api/cookbook/analyze', { method: 'POST', body: JSON.stringify({ repo_id, ctx: 8192 }) })
|
||||
if (!res.files?.length) { cardFits[i] = null; updateBest(); return }
|
||||
const best = res.files.find((f: any) => f.quant?.includes('Q4_K_M')) || res.files[0]
|
||||
cardFits[i] = best.fit.level; updateBest()
|
||||
} catch { cardFits[i] = null; updateBest() }
|
||||
}
|
||||
|
||||
function updateBest() {
|
||||
let bi = -1, br = 99
|
||||
cardFits.forEach((lvl, i) => {
|
||||
if (lvl == null) return
|
||||
const r = FIT_RANK[lvl] ?? 99
|
||||
if (r < br) { br = r; bi = i }
|
||||
})
|
||||
bestResultIdx = (bi >= 0 && br <= 1) ? bi : -1
|
||||
}
|
||||
|
||||
// ---- Recipe detail modal ----
|
||||
function openRecipe(id: string) {
|
||||
recipeModal = recipes.find((r: any) => r.id === id)
|
||||
}
|
||||
|
||||
async function installRecipe(id: string) {
|
||||
const btn = document.getElementById('cb-r-install-btn') as HTMLButtonElement
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Starte…' }
|
||||
try {
|
||||
const r = await api('/api/cookbook/install-recipe', { method: 'POST', body: JSON.stringify({ recipe_id: id, hf_token: getHfToken() }) })
|
||||
toast(`${r.count} Download${r.count !== 1 ? 's' : ''} läuft — Modelle erscheinen danach automatisch im Modelle-Tab.`)
|
||||
recipeModal = null; goView('activity')
|
||||
} catch (e: any) { toast(e.message, true); if (btn) { btn.disabled = false; btn.textContent = 'Installieren' } }
|
||||
}
|
||||
|
||||
// ---- Model detail modal (from search/discover) ----
|
||||
async function openModel(repo: string, title: string) {
|
||||
modelModal = true; modelModalTitle = title; modelModalRepo = repo
|
||||
modelRole = ''; modelCtx = 8192; modelAnalysis = null; modelFit = null; modelFile = ''
|
||||
modelLoading = true
|
||||
try {
|
||||
modelAnalysis = await api('/api/cookbook/analyze', { method: 'POST', body: JSON.stringify({ repo_id: repo, ctx: 8192 }) })
|
||||
if (modelAnalysis?.files?.length) {
|
||||
const best = modelAnalysis.files.find((f: any) => f.quant?.includes('Q4_K_M')) || modelAnalysis.files[0]
|
||||
modelFile = best.filename
|
||||
// Auto-set optimal ctx
|
||||
if (best.optimal_ctx && modelCtx === 8192) modelCtx = best.optimal_ctx
|
||||
updateModelFit()
|
||||
}
|
||||
} catch (e: any) { toast(e.message, true) }
|
||||
modelLoading = false
|
||||
}
|
||||
|
||||
function updateModelFit() {
|
||||
if (!modelAnalysis?.files?.length || !modelFile) return
|
||||
modelFit = modelAnalysis.files.find((f: any) => f.filename === modelFile) || null
|
||||
if (modelFit?.optimal_ctx && modelCtx === 8192) modelCtx = modelFit.optimal_ctx
|
||||
}
|
||||
|
||||
async function doDownload() {
|
||||
if (!modelModalRepo || !modelFile) return toast('Bitte eine GGUF-Datei wählen.', true)
|
||||
modelLoading = true
|
||||
try {
|
||||
const params_b = modelAnalysis?.params_b ?? 7.0
|
||||
const quant = modelFit?.quant ?? 'Q4_K_M'
|
||||
await api('/api/cookbook/install-model', { method: 'POST', body: JSON.stringify({
|
||||
repo: modelModalRepo, role: modelRole, params_b, quant,
|
||||
file: modelFile, ctx: modelCtx, hf_token: getHfToken()
|
||||
})})
|
||||
toast('Download läuft — Modell erscheint danach automatisch im Modelle-Tab.')
|
||||
modelModal = false; goView('activity')
|
||||
} catch (e: any) { toast('Fehler: ' + e.message, true) }
|
||||
modelLoading = false
|
||||
}
|
||||
|
||||
// ---- Install discovered model ----
|
||||
async function installDiscovered(repo: string, role: string, params_b: number, btnEl: HTMLButtonElement) {
|
||||
btnEl.disabled = true; btnEl.textContent = 'Starte…'
|
||||
try {
|
||||
await api('/api/cookbook/install-model', { method: 'POST', body: JSON.stringify({ repo, role, params_b, quant: 'Q4_K_M', hf_token: getHfToken() }) })
|
||||
toast('Download läuft — Modell erscheint danach automatisch im Modelle-Tab.'); goView('activity')
|
||||
} catch (e: any) { toast(e.message, true); btnEl.disabled = false; btnEl.textContent = 'Installieren' }
|
||||
}
|
||||
|
||||
// ---- New/edit recipe ----
|
||||
function openNewRecipe() {
|
||||
editRecipeId = null; newRecipeTitle = ''; newRecipeDesc = ''
|
||||
newRecipeModels = [{ repo: '', role: '', quant: 'Q4_K_M' }]
|
||||
newRecipeOpen = true
|
||||
}
|
||||
function openEditRecipe(id: string) {
|
||||
const r = recipes.find((x: any) => x.id === id); if (!r) return
|
||||
editRecipeId = id; newRecipeTitle = r.title; newRecipeDesc = r.desc || ''
|
||||
newRecipeModels = r.models.map((m: any) => ({ repo: m.repo, role: m.role, quant: m.quant || 'Q4_K_M' }))
|
||||
newRecipeOpen = true
|
||||
}
|
||||
async function saveRecipe() {
|
||||
if (!newRecipeTitle.trim()) return toast('Bitte einen Titel angeben.', true)
|
||||
const models = newRecipeModels.filter((m: any) => m.repo.trim())
|
||||
if (!models.length) return toast('Mindestens ein Modell (Repo) angeben.', true)
|
||||
try {
|
||||
if (editRecipeId) {
|
||||
await api('/api/cookbook/user-recipe/' + encodeURIComponent(editRecipeId), { method: 'PUT', body: JSON.stringify({ title: newRecipeTitle, desc: newRecipeDesc, models }) })
|
||||
toast('Setup aktualisiert.')
|
||||
} else {
|
||||
await api('/api/cookbook/user-recipe', { method: 'POST', body: JSON.stringify({ title: newRecipeTitle, desc: newRecipeDesc, models }) })
|
||||
toast('Setup gespeichert.')
|
||||
}
|
||||
newRecipeOpen = false; loadRecipes()
|
||||
} catch (e: any) { toast(e.message, true) }
|
||||
}
|
||||
async function deleteRecipe(id: string) {
|
||||
const r = recipes.find((x: any) => x.id === id)
|
||||
if (!await confirmModal({ title: `„${r?.title || id}" löschen?`, body: 'Dein eigenes Setup wird aus dem Cookbook entfernt. Bereits installierte Modelle bleiben unangetastet.', confirmLabel: 'Löschen', danger: true })) return
|
||||
try { await api('/api/cookbook/user-recipe/' + encodeURIComponent(id), { method: 'DELETE' }); toast('Setup gelöscht.'); loadRecipes() }
|
||||
catch (e: any) { toast(e.message, true) }
|
||||
}
|
||||
|
||||
// ---- MoE chips ----
|
||||
function moeChipHtml(name: string) {
|
||||
if (!isMoe(name)) return ''
|
||||
const a = moeActive(name)
|
||||
return `<span class="chip" title="${esc(MOE_HELP)}">MoE${a ? ` · ~${a}B aktiv` : ''}</span>`
|
||||
}
|
||||
function capChipHtml(label: string) { return label ? `<span class="chip">${esc(label)}</span>` : '' }
|
||||
</script>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- RECIPES (Use-Case Setups) -->
|
||||
<div class="pagehead">
|
||||
<div><h1>Cookbook</h1><div class="sub">Modelle finden, installieren und eigene Setups zusammenstellen.</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Segmented-Tabs -->
|
||||
<div class="flex gap-2" style="margin-bottom:14px">
|
||||
<button class="{cookTab === 'setups' ? 'primary' : 'ghost'}" style="padding:7px 16px"
|
||||
onclick={() => cookTab = 'setups'}>📦 Fertige Setups</button>
|
||||
<button class="{cookTab === 'find' ? 'primary' : 'ghost'}" style="padding:7px 16px"
|
||||
onclick={() => cookTab = 'find'}>🔍 Modelle finden</button>
|
||||
</div>
|
||||
|
||||
{#if cookTab === 'setups'}
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<h3>Fertige Setups — automatisch aktuell für deine Hardware</h3>
|
||||
<button class="ghost" style="margin-left:auto" onclick={openNewRecipe}>+ Eigenes Setup</button>
|
||||
</div>
|
||||
<div class="card-sub">Hier starten: Fertige Modell-Bundles für deinen Anwendungsfall — ein Klick installiert alles. Die Hardware-Ampel zeigt vorab, ob das Setup auf deinen PC passt.</div>
|
||||
{#if recipeLoading}
|
||||
<div class="empty" style="text-align:center;padding:30px">Lade Setups…</div>
|
||||
{:else}
|
||||
<div id="cb-recipes" class="grid grid-3" style="gap:10px;margin-top:6px">
|
||||
{#each recipes as r}
|
||||
{@const best = r.id === recommended}
|
||||
<button class="card-btn{best ? ' cb-best' : ''}" style="text-align:left;position:relative"
|
||||
onclick={() => openRecipe(r.id)}>
|
||||
{#if r.user}
|
||||
<span title="Setup bearbeiten" style="position:absolute;top:10px;right:36px;color:var(--accent);cursor:pointer;font-size:14px;line-height:1" role="button" tabindex="0"
|
||||
onclick={e => { e.stopPropagation(); openEditRecipe(r.id) }} onkeydown={e => { if(e.key==='Enter'){e.stopPropagation();openEditRecipe(r.id)} }}>✎</span>
|
||||
<span title="Setup löschen" style="position:absolute;top:9px;right:12px;color:var(--err);cursor:pointer;font-size:18px;line-height:1" role="button" tabindex="0"
|
||||
onclick={e => { e.stopPropagation(); deleteRecipe(r.id) }} onkeydown={e => { if(e.key==='Enter'){e.stopPropagation();deleteRecipe(r.id)} }}>×</span>
|
||||
{/if}
|
||||
{#if best}<div class="cb-best-tag">★ Beste Wahl für dein System</div>
|
||||
{:else if r.user}<div class="cb-best-tag" style="background:var(--act);color:#fff">Dein Setup</div>{/if}
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="flex items-center gap-2">
|
||||
<h3 style="margin:0;font-size:15px">{r.title}</h3>
|
||||
</span>
|
||||
<span class="fit-badge {fitCls(r.fit_level)}">{fitWord(r.fit_level)}</span>
|
||||
</div>
|
||||
<p>{r.desc}</p>
|
||||
<div class="text-xs text-mut">{r.models.length} Modell{r.models.length > 1 ? 'e' : ''} im Setup</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if cookTab === 'find'}
|
||||
<!-- ============================================================ -->
|
||||
<!-- DISCOVER -->
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<h3>Aktuell die besten Modelle für dein System</h3>
|
||||
<span class="meta" style="margin-left:auto">
|
||||
{#if discoverData?.updated}
|
||||
{@const ageMin = Math.round((Date.now() / 1000 - discoverData.updated) / 60)}
|
||||
{#if ageMin < 60}
|
||||
Quellen befragt vor {ageMin} min
|
||||
{:else if ageMin < 1440}
|
||||
Quellen befragt vor {Math.round(ageMin / 60)} h
|
||||
{:else}
|
||||
Quellen befragt vor {Math.round(ageMin / 1440)} Tg.
|
||||
{/if}
|
||||
{#if discoverStale}<span style="color:var(--warn)"> · ⚠ Cache veraltet</span>{/if}
|
||||
{/if}
|
||||
</span>
|
||||
<button class="ghost" style="margin-left:8px" disabled={discoverLoading} onclick={() => loadDiscover(true)}>
|
||||
{discoverLoading ? 'Suche…' : 'Aktualisieren'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-sub">Top-Modelle je Kategorie von HuggingFace — Hardware-Fit wird live für dein System berechnet.</div>
|
||||
{#if discoverLoading && !discoverData}
|
||||
<div class="empty" style="text-align:center;padding:30px">Frage Quellen ab…</div>
|
||||
{:else if discoverData?.categories}
|
||||
{#each discoverData.categories as cat}
|
||||
<div style="margin-bottom:8px">
|
||||
<div class="flex items-center gap-2" style="margin:6px 0 8px">
|
||||
<h4 style="margin:0;font-size:13.5px;font-weight:600">{cat.title}</h4>
|
||||
</div>
|
||||
<div class="grid grid-3">
|
||||
{#each cat.models as m}
|
||||
{@const best = cat.recommended && m.repo === cat.recommended}
|
||||
<div class="card{best ? ' res-best' : ''}" style="display:flex;flex-direction:column;position:relative">
|
||||
{#if best}<div class="cb-best-tag">★ Beste Wahl für dein System</div>{/if}
|
||||
<div class="flex justify-between" style="align-items:flex-start;gap:8px">
|
||||
<div style="min-width:0">
|
||||
<h3 style="margin:0;font-size:14px;font-weight:500;word-break:break-word">{m.name}</h3>
|
||||
<div class="text-xs text-mut" style="margin-top:3px">{m.author} · {m.params_b}B</div>
|
||||
</div>
|
||||
<span class="fit-badge {fitCls(m.fit.level)}">{m.fit.text}</span>
|
||||
</div>
|
||||
<div class="flex gap-2" style="flex-wrap:wrap;margin-top:7px">
|
||||
{@html capsChipsHtml(m.repo, m.tags)}
|
||||
</div>
|
||||
<div class="mono-sm text-mut" style="margin-top:8px;font-size:11.5px">{metricLine(m.fit)} · ⬇ {(m.downloads || 0).toLocaleString()}</div>
|
||||
{#each (m.requirements || []) as req}
|
||||
<div class="li-sub" style="color:var(--warn);margin-top:3px">⚠ {req}</div>
|
||||
{/each}
|
||||
<div style="flex:1;min-height:8px"></div>
|
||||
<div class="flex justify-end" style="margin-top:10px">
|
||||
{#if m.installed}
|
||||
<span class="fit-badge ok" title="bereits eingerichtet als {m.installed}">✓ installiert</span>
|
||||
{:else}
|
||||
<button class="primary" style="padding:6px 12px;font-size:12.5px"
|
||||
onclick={e => installDiscovered(m.repo, m.role, m.params_b, e.currentTarget as HTMLButtonElement)}>
|
||||
Installieren
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- PRO SEARCH -->
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>Profi-Suche (HuggingFace)</h3></div>
|
||||
<div class="card-sub">Direkt HuggingFace durchsuchen — oder eine Modell-URL einfügen für den direkten Sprung.</div>
|
||||
<div class="flex gap-2" style="align-items:stretch;margin-bottom:10px">
|
||||
<input bind:value={searchQuery} placeholder="Suchbegriff oder HuggingFace-URL…" style="flex:1;margin:0"
|
||||
onkeydown={e => e.key === 'Enter' && doSearch()}>
|
||||
<select bind:value={sortBy} style="width:auto;margin:0">
|
||||
<option value="downloads">Downloads</option>
|
||||
<option value="trending">Trending</option>
|
||||
<option value="created_at">Neu</option>
|
||||
</select>
|
||||
<button class="primary" disabled={searchLoading} onclick={doSearch}>{searchLoading ? 'Lade…' : 'Suchen'}</button>
|
||||
</div>
|
||||
<!-- Facetten-Filter (Mehrfachauswahl, clientseitig auf den Treffern) -->
|
||||
<div class="flex gap-2" style="flex-wrap:wrap;margin-bottom:12px;align-items:center">
|
||||
<span class="text-xs text-mut" style="margin-right:2px">Filter:</span>
|
||||
{#each FACET_DEFS as [key, label]}
|
||||
<button class="{facets[key] ? 'primary' : 'ghost'}" style="padding:4px 10px;border-radius:999px;font-size:12px"
|
||||
onclick={() => facets[key] = !facets[key]}>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
{#if Object.values(facets).some(v => v)}
|
||||
<button class="ghost" style="padding:4px 10px;border-radius:999px;font-size:12px;color:var(--mut)"
|
||||
onclick={() => facets = { tools: false, moe: false, reasoning: false, vision: false, coder: false }}>
|
||||
✕ zurücksetzen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Results -->
|
||||
{#if searchResults.length}
|
||||
<div class="grid grid-3" id="cb-grid">
|
||||
{#each searchResults as m, i}
|
||||
{#if matchesFacets(m.id, m.tags)}
|
||||
<div class="card{i === bestResultIdx ? ' res-best' : ''}" style="display:flex;flex-direction:column;cursor:pointer" onclick={() => openModel(m.id, m.id.split('/').pop())}>
|
||||
{#if i === bestResultIdx}<div class="cb-best-tag">★ Beste Wahl für dein System</div>{/if}
|
||||
<div class="flex justify-between" style="align-items:flex-start;gap:8px">
|
||||
<div style="min-width:0">
|
||||
<h3 style="margin:0;font-size:14.5px;font-weight:500;word-break:break-word">{m.id.split('/').pop()}</h3>
|
||||
<div class="text-xs text-mut" style="margin-top:3px">{m.author || ''}</div>
|
||||
</div>
|
||||
<span class="fit-badge {cardFits[i] ? fitCls(cardFits[i]!) : 'warn'}">{cardFits[i] ? fitWord(cardFits[i]!) : 'prüfe…'}</span>
|
||||
</div>
|
||||
<div class="flex gap-2" style="flex-wrap:wrap;margin-top:7px">
|
||||
{@html capsChipsHtml(m.id, m.tags)}
|
||||
</div>
|
||||
<div style="flex:1;margin-top:12px"></div>
|
||||
<div class="flex justify-between items-center text-xs text-mut" style="border-top:1px solid var(--line);padding-top:11px">
|
||||
<span class="mono-sm">{cardFits[i] ? metricLine({ req_gb: 0, tps: 0 }) : 'Hardware-Fit…'}</span>
|
||||
<span class="mono-sm">⬇ {(m.downloads || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- RECIPE DETAIL MODAL -->
|
||||
{#if recipeModal}
|
||||
{@const r = recipeModal}
|
||||
<div class="modal-overlay" role="dialog" onclick={e => { if ((e.target as HTMLElement).classList.contains('modal-overlay')) recipeModal = null }}>
|
||||
<div class="modal-card" style="max-width:560px">
|
||||
<button class="ghost" style="position:absolute;top:14px;right:14px" onclick={() => recipeModal = null}>Schließen</button>
|
||||
<h3>{r.title}</h3>
|
||||
<p class="text-mut text-sm" style="margin:-4px 0 16px">{r.desc}</p>
|
||||
<div class="list">
|
||||
{#each r.models as m}
|
||||
<div class="li" style="align-items:flex-start">
|
||||
<div class="li-main">
|
||||
<div class="flex items-center gap-2" style="flex-wrap:wrap">
|
||||
<span class="li-id">{m.name}</span>
|
||||
<span class="tag text">{m.role}</span>
|
||||
{@html capsChipsHtml(m.repo, m.tags)}
|
||||
</div>
|
||||
{#if m.why}<div class="li-sub">{m.why}</div>{/if}
|
||||
{#if m.fit}
|
||||
<div class="li-sub mono-sm">~{(m.fit.req_gb ?? 0).toFixed(1)} GB · ~{Math.round(m.fit.tps ?? 0)} Tok/s · optimal ~{Math.round((m.optimal_ctx || 8192) / 1024)}k Kontext</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if m.fit}<span class="fit-badge {fitCls(m.fit.level)}">{m.fit.text}</span>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if r.models.length && r.models[0].fit}
|
||||
{@const maxRam = Math.max(...r.models.map((m: any) => m.fit?.req_gb ?? 0))}
|
||||
<div class="hint" style="margin:12px 0">Größter Spitzenbedarf: <b>~{maxRam.toFixed(1)} GB</b>. Es läuft immer nur <b>ein</b> Modell gleichzeitig — das größte bestimmt, ob das Setup passt.</div>
|
||||
{/if}
|
||||
{#if r.fit_level === 'too_tight'}
|
||||
{@const smallerRec = recipes.find((x: any) => !x.user && x.fit_level !== 'too_tight' && x.id !== r.id)}
|
||||
<div class="hint" style="color:var(--warn);margin-bottom:10px;border-left:2px solid var(--warn);padding-left:10px">
|
||||
⚠ Dieses Setup übersteigt deinen verfügbaren Speicher.
|
||||
{#if smallerRec}
|
||||
Alternative: <a href="#" style="color:var(--accent)"
|
||||
onclick={e => { e.preventDefault(); recipeModal = null; setTimeout(() => openRecipe(smallerRec.id), 50) }}>
|
||||
„{smallerRec.title}" ansehen
|
||||
</a> — passt auf dein System.
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<button id="cb-r-install-btn" class="primary{r.fit_level === 'too_tight' ? ' warn' : ''}" style="width:100%"
|
||||
onclick={() => installRecipe(r.id)}>
|
||||
{r.fit_level === 'too_tight' ? 'Trotzdem installieren (nicht empfohlen)' : 'Komplettes Setup installieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- MODEL DETAIL MODAL -->
|
||||
{#if modelModal}
|
||||
<div class="modal-overlay" role="dialog" onclick={e => { if ((e.target as HTMLElement).classList.contains('modal-overlay')) modelModal = false }}>
|
||||
<div class="modal-card" style="max-width:560px">
|
||||
<button class="ghost" style="position:absolute;top:14px;right:14px" onclick={() => modelModal = false}>Schließen</button>
|
||||
<h3>{modelModalTitle}</h3>
|
||||
<p class="mono-sm" style="margin:-4px 0 16px">{modelModalRepo}</p>
|
||||
{#if modelLoading && !modelAnalysis}
|
||||
<div class="hint">Lade Dateien von HuggingFace…</div>
|
||||
{:else if modelAnalysis?.files?.length}
|
||||
<label>Quantisierung (GGUF-Datei) wählen</label>
|
||||
<select bind:value={modelFile} onchange={updateModelFit}>
|
||||
{#each modelAnalysis.files as f}
|
||||
{@const mark = f.fit.level === 'perfect' ? '●' : f.fit.level === 'marginal' ? '◐' : '○'}
|
||||
<option value={f.filename}>{mark} {f.filename}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="row" style="margin-top:4px">
|
||||
<div>
|
||||
<label>Rolle (optional)</label>
|
||||
<input bind:value={modelRole} placeholder="z.B. coder, vision, scout">
|
||||
</div>
|
||||
<div>
|
||||
<label>Kontext-Größe {@html infoDot(CTX_HELP)}</label>
|
||||
<input type="number" bind:value={modelCtx}>
|
||||
</div>
|
||||
</div>
|
||||
{#if modelFit?.optimal_ctx}
|
||||
<div class="hint" style="margin:-6px 0 14px">
|
||||
Empfohlener Kontext für deine Hardware: <b>~{Math.round(modelFit.optimal_ctx / 1024)}k</b> —
|
||||
<a href="#" onclick={e => { e.preventDefault(); modelCtx = modelFit.optimal_ctx }}>übernehmen</a>
|
||||
</div>
|
||||
{/if}
|
||||
{#if modelFit}
|
||||
<div class="tile" style="display:flex;justify-content:space-between;align-items:center;margin:8px 0 18px">
|
||||
<div>
|
||||
<div style="font-size:13px">Ressourcen-Check</div>
|
||||
<div class="hint" style="margin:4px 0 0">~{(modelFit.fit.req_gb ?? 0).toFixed(1)} GB · ~{Math.round(modelFit.fit.tps ?? 0)} Tok/s · {modelFit.quant || 'GGUF'}</div>
|
||||
</div>
|
||||
<span class="fit-badge {fitCls(modelFit.fit.level)}">{modelFit.fit.text}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if modelFit?.fit.level === 'too_tight' && modelAnalysis?.files}
|
||||
{@const better = modelAnalysis.files.find((f: any) => f.fit.level !== 'too_tight' && f.filename !== modelFile)}
|
||||
{#if better}
|
||||
<div class="hint" style="color:var(--warn);margin:-8px 0 14px;border-left:2px solid var(--warn);padding-left:10px">
|
||||
⚠ Diese Datei ist zu groß für deinen Speicher. Kleinere Alternative:
|
||||
<a href="#" style="color:var(--accent)" onclick={e => { e.preventDefault(); modelFile = better.filename; updateModelFit() }}>
|
||||
{better.filename} ({fitWord(better.fit.level)})
|
||||
</a> — einfach auswählen.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="hint" style="color:var(--warn);margin:-8px 0 14px;border-left:2px solid var(--warn);padding-left:10px">
|
||||
⚠ Alle Varianten dieses Modells sind zu groß für deinen Speicher. Ein kleineres Modell aus den Empfehlungen wäre die bessere Wahl.
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<button class="primary{modelFit?.fit.level === 'too_tight' ? ' warn' : ''}" style="width:100%" disabled={modelLoading}
|
||||
onclick={doDownload}>
|
||||
{modelLoading ? 'Starte…' : modelFit?.fit.level === 'too_tight' ? 'Trotzdem holen (zu groß — nicht empfohlen)' : 'Herunterladen & Einpflegen'}
|
||||
</button>
|
||||
{:else if !modelLoading}
|
||||
<div class="hint">Keine GGUF-Dateien im Repo gefunden.</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- NEW/EDIT RECIPE MODAL -->
|
||||
{#if newRecipeOpen}
|
||||
<div class="modal-overlay" role="dialog" onclick={e => { if ((e.target as HTMLElement).classList.contains('modal-overlay')) newRecipeOpen = false }}>
|
||||
<div class="modal-card" style="max-width:640px">
|
||||
<button class="ghost" style="position:absolute;top:14px;right:14px" onclick={() => newRecipeOpen = false}>Schließen</button>
|
||||
<h3>{editRecipeId ? 'Setup bearbeiten' : 'Eigenes Setup erstellen'}</h3>
|
||||
<p class="text-mut text-sm" style="margin:-4px 0 16px">Stell dir aus beliebigen Modellen ein eigenes Setup zusammen.</p>
|
||||
<label>Titel</label>
|
||||
<input bind:value={newRecipeTitle} placeholder="z.B. Mein Schreib-Stack">
|
||||
<label>Kurzbeschreibung (optional)</label>
|
||||
<input bind:value={newRecipeDesc} placeholder="Wofür ist dieses Setup gut?">
|
||||
<label style="margin-top:10px">Modelle</label>
|
||||
{#each newRecipeModels as m, i}
|
||||
<div class="flex gap-2" style="margin-bottom:8px;align-items:center">
|
||||
<input bind:value={m.repo} placeholder="Repo, z.B. unsloth/Qwen3-8B-GGUF" style="flex:2;margin:0">
|
||||
<input bind:value={m.role} placeholder="Rolle (z.B. coder)" style="flex:1;margin:0">
|
||||
<select bind:value={m.quant} style="width:auto;margin:0">
|
||||
{#each ['Q4_K_M','Q5_K_M','Q6_K','Q8_0','Q3_K_M'] as q}<option>{q}</option>{/each}
|
||||
</select>
|
||||
<button class="ghost del" style="margin:0;padding:6px 10px" onclick={() => newRecipeModels = newRecipeModels.filter((_, j) => j !== i)}>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button class="ghost" style="margin-top:8px" onclick={() => newRecipeModels = [...newRecipeModels, { repo: '', role: '', quant: 'Q4_K_M' }]}>+ Modell hinzufügen</button>
|
||||
<div class="hint" style="margin-top:10px">Die <b>Repo-ID</b> findest du über die <b>Profi-Suche</b> weiter unten (z.B. <code>unsloth/Qwen3-8B-GGUF</code>).</div>
|
||||
<button class="primary" style="width:100%;margin-top:14px" onclick={saveRecipe}>
|
||||
{editRecipeId ? 'Änderungen speichern' : 'Setup speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import { GUIDE_GROUPS, CONCEPTS } from './guides-data.js'
|
||||
|
||||
let activeGroup = $state(0)
|
||||
|
||||
function scrollTo(gi: number) {
|
||||
activeGroup = gi
|
||||
document.getElementById(`guide-group-${gi}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="pagehead">
|
||||
<div>
|
||||
<h1>Guide</h1>
|
||||
<div class="sub">Schritt für Schritt erklärt — von der ersten Einrichtung bis zu echten Agenten-Workflows.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Begriffe oben -->
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>Begriffe kurz erklärt</h3></div>
|
||||
<div class="concept-grid" style="margin-top:8px">
|
||||
{#each CONCEPTS as [term, text]}
|
||||
<div class="concept">
|
||||
<h4>{term}</h4>
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab-Navigation -->
|
||||
<div class="guide-tabs">
|
||||
{#each GUIDE_GROUPS as group, gi}
|
||||
<button
|
||||
class="guide-tab{activeGroup === gi ? ' active' : ''}"
|
||||
onclick={() => scrollTo(gi)}>
|
||||
{group.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Guide-Karten -->
|
||||
{#each GUIDE_GROUPS as group, gi}
|
||||
<div class="card guide-card" id="guide-group-{gi}">
|
||||
<div class="guide-group-hd">{group.label}</div>
|
||||
{#each group.tutorials as [title, body], i}
|
||||
<details class="guide-acc" open={gi === 0 && i === 0}>
|
||||
<summary>{title}</summary>
|
||||
<div class="acc-body">{@html body}</div>
|
||||
</details>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
.guide-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin: -6px 0 2px;
|
||||
}
|
||||
|
||||
.guide-tab {
|
||||
font-size: 12px;
|
||||
padding: 5px 13px;
|
||||
border-radius: 999px;
|
||||
background: var(--tile);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--mut);
|
||||
cursor: pointer;
|
||||
transition: .13s;
|
||||
font-family: var(--sans);
|
||||
}
|
||||
.guide-tab:hover { color: var(--tx); border-color: var(--line2); }
|
||||
.guide-tab.active {
|
||||
background: rgba(45,212,191,.12);
|
||||
border-color: rgba(45,212,191,.30);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.guide-card {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.guide-group-hd {
|
||||
padding: 9px 18px 7px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
background: rgba(45, 212, 191, 0.06);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* Comparison / info tables inside acc-body */
|
||||
:global(.tut-table) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-top: 8px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
:global(.tut-row) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
:global(.tut-row:last-child) { border-bottom: none; }
|
||||
:global(.tut-cell-hd) {
|
||||
padding: 10px 14px;
|
||||
font-weight: 500;
|
||||
border-right: 1px solid var(--line);
|
||||
background: rgba(255,255,255,.025);
|
||||
line-height: 1.5;
|
||||
}
|
||||
:global(.tut-cell-hd.bad) {
|
||||
color: var(--mut);
|
||||
text-decoration: line-through;
|
||||
font-style: italic;
|
||||
}
|
||||
:global(.tut-cell-bd) {
|
||||
padding: 10px 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* Source / link lines at bottom of tutorials */
|
||||
:global(.tut-sources) {
|
||||
margin-top: 14px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 11.5px;
|
||||
color: var(--mut);
|
||||
}
|
||||
:global(.tut-sources a) {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
:global(.tut-sources a:hover) { text-decoration: underline; }
|
||||
|
||||
/* Code blocks inside tutorials */
|
||||
:global(.tut-code) {
|
||||
display: block;
|
||||
background: var(--bg, #0d0d0d);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
font-family: monospace;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.65;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
margin: 10px 0;
|
||||
color: var(--fg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,353 @@
|
||||
<script lang="ts">
|
||||
import { api } from '@core/api.js'
|
||||
import { toast } from '@core/ui.js'
|
||||
|
||||
// ---- State ----
|
||||
let status = $state<any>(null)
|
||||
let setupOpen = $state(false)
|
||||
let cockpitOpen = $state(false)
|
||||
let cockpitLoading = $state(false)
|
||||
let agent = $state<any>(null)
|
||||
let cronJobs = $state<any[]>([])
|
||||
let skills = $state<any[]>([])
|
||||
let learned = $state<any>(null)
|
||||
let insights = $state<any>(null)
|
||||
let pubkey = $state('')
|
||||
|
||||
// ---- Status laden ----
|
||||
async function loadStatus() {
|
||||
try { status = await api('/api/hermes/status') }
|
||||
catch { status = null }
|
||||
}
|
||||
|
||||
async function loadPubkey() {
|
||||
try {
|
||||
const r = await api('/api/hermes/pubkey')
|
||||
pubkey = r.pubkey
|
||||
} catch { pubkey = '' }
|
||||
}
|
||||
|
||||
// ---- Cockpit (Phase 4): Agent-Status, Cron, Skills ----
|
||||
async function loadCockpit() {
|
||||
cockpitLoading = true
|
||||
try {
|
||||
const [a, c, s, l, i] = await Promise.all([
|
||||
api('/api/hermes/agent'),
|
||||
api('/api/hermes/cron'),
|
||||
api('/api/hermes/skills'),
|
||||
api('/api/hermes/learned'),
|
||||
api('/api/hermes/insights'),
|
||||
])
|
||||
agent = a
|
||||
cronJobs = c.jobs || []
|
||||
skills = s.skills || []
|
||||
learned = l
|
||||
insights = i
|
||||
} catch { agent = null }
|
||||
cockpitLoading = false
|
||||
}
|
||||
|
||||
// Skills sortiert: meistgenutzt zuerst, dann alphabetisch
|
||||
let skillsSorted = $derived(
|
||||
[...skills].sort((a, b) =>
|
||||
(b.use_count || 0) - (a.use_count || 0) || (a.name || '').localeCompare(b.name || ''))
|
||||
)
|
||||
let skillsEnabled = $derived(skills.filter(s => (s.status || '').includes('enabled')).length)
|
||||
|
||||
function copyKey() {
|
||||
navigator.clipboard?.writeText(pubkey)
|
||||
toast('SSH-Key kopiert')
|
||||
}
|
||||
|
||||
// ---- Boot ----
|
||||
$effect(() => {
|
||||
loadStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- ==================== HEADER (schlank, fuer Full-Bleed-Chat) ==================== -->
|
||||
<div class="pagehead" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
||||
<h1 style="margin:0;font-size:20px">Hermes</h1>
|
||||
<div class="flex gap-2" style="flex-shrink:0;flex-wrap:wrap;align-items:center">
|
||||
<!-- Hermes-Status -->
|
||||
<span class="chip" style="font-size:11px;{status?.hermes === 'ready'
|
||||
? 'color:#7ee29a;border-color:rgba(126,226,154,.4)'
|
||||
: 'color:#ff9b95;border-color:rgba(255,155,149,.4)'}">
|
||||
● Agent: {status?.hermes === 'ready' ? 'online' : 'offline'}
|
||||
</span>
|
||||
<!-- Cockpit (Phase 4) -->
|
||||
<button class="ghost" style="padding:4px 10px;font-size:12px;{cockpitOpen ? 'border-color:var(--accent);color:var(--accent)' : ''}"
|
||||
onclick={() => { cockpitOpen = !cockpitOpen; if (cockpitOpen) loadCockpit() }}>
|
||||
📡 Cockpit
|
||||
</button>
|
||||
<!-- Setup -->
|
||||
<button class="ghost" style="padding:4px 10px;font-size:12px"
|
||||
onclick={() => { setupOpen = !setupOpen; if (setupOpen) { loadStatus(); loadPubkey() } }}>
|
||||
⚙ Setup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== COCKPIT (Phase 4) ==================== -->
|
||||
{#if cockpitOpen}
|
||||
<div class="card" style="border-color:rgba(45,212,191,.3)">
|
||||
<div class="card-h" style="display:flex;justify-content:space-between;align-items:center">
|
||||
<h3>Agent-Cockpit</h3>
|
||||
<button class="ghost" style="font-size:11px;padding:2px 10px" onclick={loadCockpit}>
|
||||
{cockpitLoading ? '…' : '↻ Aktualisieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Agent-Status -->
|
||||
<div class="tile" style="margin-bottom:10px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">
|
||||
<div>
|
||||
<b>Status</b>
|
||||
<div class="card-sub" style="font-size:12px">Hermes-Gateway-Dienst (:8642) auf dem Bosgame</div>
|
||||
</div>
|
||||
<div class="flex gap-2" style="flex-wrap:wrap;align-items:center">
|
||||
<span class="chip" style="font-size:11px;{agent?.gateway_state === 'running'
|
||||
? 'color:#7ee29a;border-color:rgba(126,226,154,.4)' : 'color:#ff9b95;border-color:rgba(255,155,149,.4)'}">
|
||||
● {agent?.gateway_state ?? 'offline'}
|
||||
</span>
|
||||
<span class="chip" style="font-size:11px;{agent?.cron_running
|
||||
? 'color:var(--accent);border-color:var(--accent)' : 'color:var(--mut)'}">
|
||||
⏱ Scheduler {agent?.cron_running ? 'läuft' : 'inaktiv'}
|
||||
</span>
|
||||
{#if agent?.heartbeat_age_s != null}
|
||||
<span class="chip" style="font-size:11px;color:var(--mut)">Ticker vor {agent.heartbeat_age_s}s</span>
|
||||
{/if}
|
||||
{#if agent?.version}
|
||||
<span class="chip" style="font-size:11px;color:var(--mut)" title={agent.version}>{agent.version.split(' · ')[0]}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aktivität (Phase 8) -->
|
||||
{#if insights && (insights.sessions || insights.total_tokens)}
|
||||
<div class="tile" style="margin-bottom:10px">
|
||||
<div style="margin-bottom:8px"><b>Aktivität</b> <span class="card-sub" style="font-size:11px">(letzte 30 Tage)</span></div>
|
||||
<div style="display:flex;gap:18px;flex-wrap:wrap;font-size:12.5px;margin-bottom:8px">
|
||||
{#if insights.sessions}<span><b style="color:var(--accent)">{insights.sessions}</b> <span class="card-sub">Sessions</span></span>{/if}
|
||||
{#if insights.messages}<span><b style="color:var(--accent)">{insights.messages}</b> <span class="card-sub">Nachrichten</span></span>{/if}
|
||||
{#if insights.tool_calls}<span><b style="color:var(--accent)">{insights.tool_calls}</b> <span class="card-sub">Tool-Calls</span></span>{/if}
|
||||
{#if insights.total_tokens}<span><b style="color:var(--accent)">{insights.total_tokens}</b> <span class="card-sub">Tokens</span></span>{/if}
|
||||
{#if insights.active_time}<span><b style="color:var(--accent)">{insights.active_time}</b> <span class="card-sub">aktiv</span></span>{/if}
|
||||
</div>
|
||||
{#if insights.top_tools?.length}
|
||||
<div class="card-sub" style="font-size:11px;margin-bottom:4px">Meistgenutzte Tools</div>
|
||||
<div style="display:flex;flex-direction:column;gap:3px">
|
||||
{#each insights.top_tools.slice(0, 6) as t}
|
||||
<div style="display:flex;align-items:center;gap:8px;font-size:12px">
|
||||
<span style="min-width:230px">{t.name}</span>
|
||||
<span style="flex:1;height:6px;background:rgba(255,255,255,.05);border-radius:3px;overflow:hidden">
|
||||
<span style="display:block;height:100%;width:{t.pct}%;background:var(--accent)"></span>
|
||||
</span>
|
||||
<span class="card-sub" style="font-size:11px;min-width:64px;text-align:right">{t.calls}× · {t.pct}%</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Was Hermes gelernt hat (Phase 8) -->
|
||||
{#if learned?.user_facts?.length || learned?.memory}
|
||||
<div class="tile" style="margin-bottom:10px">
|
||||
<div style="margin-bottom:6px"><b>Was Hermes über dich gelernt hat</b>
|
||||
<span class="card-sub" style="font-size:11px">(automatisches Profil)</span></div>
|
||||
{#if learned.user_facts?.length}
|
||||
<ul style="margin:0 0 6px;padding-left:18px;display:flex;flex-direction:column;gap:3px">
|
||||
{#each learned.user_facts as f}
|
||||
<li style="font-size:12.5px;line-height:1.45">{f}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if learned.memory}
|
||||
<div class="card-sub" style="font-size:11.5px;border-top:1px solid rgba(255,255,255,.06);padding-top:6px;margin-top:2px">
|
||||
{learned.memory}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Cron-Scheduler -->
|
||||
<div class="tile" style="margin-bottom:10px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
||||
<b>Geplante Aufgaben <span class="card-sub" style="font-size:11px">({cronJobs.length})</span></b>
|
||||
</div>
|
||||
{#if cronJobs.length === 0}
|
||||
<div class="card-sub" style="font-size:12px">
|
||||
Keine geplanten Jobs. Hermes kann selbst welche anlegen — sag ihm z. B.
|
||||
„Erinnere mich jeden Morgen um 9 Uhr an die offenen Modell-Upgrades".
|
||||
</div>
|
||||
{:else}
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
{#each cronJobs as job}
|
||||
<div style="display:flex;align-items:center;gap:8px;font-size:12.5px;
|
||||
padding:6px 8px;background:rgba(255,255,255,.02);border-radius:6px">
|
||||
{#each Object.entries(job) as [k, v]}
|
||||
{#if v}<span style="color:var(--mut)"><span style="opacity:.6">{k}:</span> {v}</span>{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Skills -->
|
||||
<div class="tile">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
||||
<b>Skills <span class="card-sub" style="font-size:11px">({skillsEnabled} aktiv von {skills.length})</span></b>
|
||||
</div>
|
||||
{#if skills.length === 0}
|
||||
<div class="card-sub" style="font-size:12px">Keine Skills geladen (oder Agent offline).</div>
|
||||
{:else}
|
||||
<div style="max-height:260px;overflow-y:auto;display:flex;flex-direction:column;gap:3px">
|
||||
{#each skillsSorted as s}
|
||||
<div style="display:flex;align-items:center;gap:8px;font-size:12.5px;padding:4px 8px;
|
||||
border-radius:6px;{(s.status || '').includes('enabled') ? '' : 'opacity:.45'}">
|
||||
<span style="width:7px;height:7px;border-radius:50%;flex-shrink:0;
|
||||
background:{(s.status || '').includes('enabled') ? 'var(--accent)' : 'var(--mut)'}"></span>
|
||||
<span style="font-weight:500">{s.name}</span>
|
||||
{#if s.category}<span class="card-sub" style="font-size:11px">{s.category}</span>{/if}
|
||||
<span style="flex:1"></span>
|
||||
{#if s.use_count > 0}
|
||||
<span class="chip" style="font-size:10px;color:var(--accent);border-color:rgba(45,212,191,.3)">
|
||||
{s.use_count}× genutzt
|
||||
</span>
|
||||
{/if}
|
||||
<span class="card-sub" style="font-size:10px">{s.source}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ==================== SETUP-WIZARD ==================== -->
|
||||
{#if setupOpen}
|
||||
<div class="card" style="border-color:rgba(45,212,191,.3)">
|
||||
<div class="card-h"><h3>Hermes Setup-Wizard</h3></div>
|
||||
|
||||
<!-- Whisper -->
|
||||
<div class="tile" style="margin-bottom:10px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div>
|
||||
<b>Whisper (Sprache → Text)</b>
|
||||
<div class="card-sub" style="font-size:12px">faster-whisper muss auf dem Bosgame installiert sein</div>
|
||||
</div>
|
||||
<span class="chip" style="font-size:11px;{status?.whisper !== 'not_installed' ? 'color:#7ee29a' : 'color:#ff9b95'}">
|
||||
{status?.whisper !== 'not_installed' ? '✓ OK' : '✗ fehlt'}
|
||||
</span>
|
||||
</div>
|
||||
{#if status?.whisper === 'not_installed'}
|
||||
<div class="log" style="margin-top:8px"><code>pip install faster-whisper</code></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Piper -->
|
||||
<div class="tile" style="margin-bottom:10px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div>
|
||||
<b>Piper TTS (Text → Sprache)</b>
|
||||
<div class="card-sub" style="font-size:12px">Piper-Binary + deutsche Stimme</div>
|
||||
</div>
|
||||
<span class="chip" style="font-size:11px;{status?.piper === 'ready' ? 'color:#7ee29a' : 'color:#ff9b95'}">
|
||||
{status?.piper === 'ready' ? '✓ OK' : '✗ fehlt'}
|
||||
</span>
|
||||
</div>
|
||||
{#if status?.piper !== 'ready'}
|
||||
<div class="log" style="margin-top:8px"><code>{[
|
||||
'# 1. Piper Binary (Linux x86_64)',
|
||||
'mkdir -p /opt/mission-control/piper/voices',
|
||||
'cd /tmp && wget -q https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz',
|
||||
'tar -xzf piper_linux_x86_64.tar.gz -C /opt/mission-control/piper/ --strip-components=1',
|
||||
'',
|
||||
'# 2. Deutsche Stimme — Kerstin (weiblich, ~25 MB)',
|
||||
'wget -q -O /opt/mission-control/piper/voices/de_DE-kerstin-low.onnx \\',
|
||||
' "https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/de/de_DE/kerstin/low/de_DE-kerstin-low.onnx"',
|
||||
'wget -q -O /opt/mission-control/piper/voices/de_DE-kerstin-low.onnx.json \\',
|
||||
' "https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/de/de_DE/kerstin/low/de_DE-kerstin-low.onnx.json"',
|
||||
].join('\n')}</code></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Windows SSH -->
|
||||
<div class="tile">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div>
|
||||
<b>Windows SSH-Verbindung</b>
|
||||
<div class="card-sub" style="font-size:12px">Damit Hermes Dateien auf deinem Windows-PC ändern kann</div>
|
||||
</div>
|
||||
<span class="chip" style="font-size:11px;{status?.ssh_ok ? 'color:#7ee29a' : 'color:var(--mut)'}">
|
||||
{status?.ssh_ok ? '✓ verbunden' : 'optional'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if !status?.ssh_ok}
|
||||
<div style="margin-top:12px">
|
||||
<div style="font-size:12.5px;font-weight:500;margin-bottom:8px">Einrichtung (einmalig, ~3 Minuten):</div>
|
||||
|
||||
<div style="margin-bottom:8px">
|
||||
<div class="card-sub" style="font-size:11.5px;margin-bottom:4px">1. PowerShell als Admin — OpenSSH Server installieren:</div>
|
||||
<div class="log"><code>Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0</code></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:8px">
|
||||
<div class="card-sub" style="font-size:11.5px;margin-bottom:4px">2. Dienst starten und dauerhaft aktivieren:</div>
|
||||
<div class="log"><code>{"Start-Service sshd\nSet-Service -Name sshd -StartupType Automatic"}</code></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:8px">
|
||||
<div class="card-sub" style="font-size:11.5px;margin-bottom:4px">3. SSH-Ordner erstellen:</div>
|
||||
<div class="log"><code>{"New-Item -ItemType Directory -Force -Path \"$env:USERPROFILE\\.ssh\""}</code></div>
|
||||
</div>
|
||||
|
||||
{#if pubkey}
|
||||
<div style="margin-bottom:8px">
|
||||
<div class="card-sub" style="font-size:11.5px;margin-bottom:4px">4. Bosgame-Key eintragen (in Windows PowerShell):</div>
|
||||
<div style="position:relative">
|
||||
<button class="ghost" style="position:absolute;top:6px;right:6px;font-size:11px;padding:2px 8px;z-index:1"
|
||||
onclick={copyKey}>Kopieren</button>
|
||||
<div class="log"><code>{"echo \"" + pubkey + "\" >> \"$env:USERPROFILE\\.ssh\\authorized_keys\""}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-sub" style="font-size:11.5px">SSH-Key nicht gefunden — bitte auf dem Bosgame generieren:
|
||||
<code style="display:block;margin-top:4px">ssh-keygen -t ed25519 -C "hermes-agent@bosgame" -f ~/.ssh/id_ed25519_hermes_agent -N ""</code>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div style="margin-bottom:8px">
|
||||
<div class="card-sub" style="font-size:11.5px;margin-bottom:4px">5. Windows-IP in Mission Control Env-Var setzen:</div>
|
||||
<div class="log"><code>HERMES_WINDOWS_HOST=192.168.178.XXX</code></div>
|
||||
</div>
|
||||
|
||||
<button class="primary" style="margin-top:4px" onclick={() => { loadStatus(); toast('Status aktualisiert') }}>
|
||||
Verbindung testen
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ==================== CHAT (eingebettetes Hermes-Web-Dashboard, full-bleed) ==================== -->
|
||||
<div class="card" style="padding:0;overflow:hidden;border-radius:12px">
|
||||
{#if status?.hermes === 'ready'}
|
||||
<iframe
|
||||
title="Hermes Chat"
|
||||
src="/hermes-ui/chat"
|
||||
style="width:100%;height:calc(100dvh - 116px);min-height:520px;border:0;display:block;background:#0f1720"
|
||||
></iframe>
|
||||
{:else}
|
||||
<div class="empty-c" style="padding:48px 24px">
|
||||
<div class="e-t">Hermes-Agent offline</div>
|
||||
<div class="e-s">Der Hermes-Gateway (:8642) bzw. das Dashboard (:9119) ist nicht erreichbar.
|
||||
Status im Cockpit prüfen oder den Dienst neu starten.</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import { jobsStore } from '../stores/jobs.svelte.js'
|
||||
import { systemStore } from '../stores/system.svelte.js'
|
||||
import { api } from '@core/api.js'
|
||||
import { toast, fmtBytes } from '@core/ui.js'
|
||||
|
||||
const jobs = $derived(jobsStore.value)
|
||||
const tracked = $derived(jobsStore.tracked)
|
||||
const sys = $derived(systemStore.value)
|
||||
|
||||
const MAX_HIST = 60
|
||||
let hist = $state({ cpu: [] as number[], ram: [] as number[], gpu: [] as number[] })
|
||||
|
||||
$effect(() => {
|
||||
if (!sys) return
|
||||
const g = sys.gpu
|
||||
const gpuPct = g && (g.vram.total + g.gtt.total) > 0
|
||||
? ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100 : 0
|
||||
hist.cpu = [...hist.cpu, sys.cpu?.percent ?? 0].slice(-MAX_HIST)
|
||||
hist.ram = [...hist.ram, sys.ram.percent].slice(-MAX_HIST)
|
||||
hist.gpu = [...hist.gpu, gpuPct].slice(-MAX_HIST)
|
||||
})
|
||||
|
||||
const gpuPct = $derived(() => {
|
||||
const g = sys?.gpu
|
||||
if (g && (g.vram.total + g.gtt.total) > 0)
|
||||
return ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100
|
||||
return 0
|
||||
})
|
||||
|
||||
const finishedCount = $derived(jobs.filter(j => j.state !== 'running' && j.state !== 'queued').length)
|
||||
const failedCount = $derived(jobs.filter(j => j.state === 'failed').length)
|
||||
|
||||
function jobPct(j: any) {
|
||||
if (j.state !== 'running') return null
|
||||
if (typeof j.progress === 'number') return Math.min(100, j.progress)
|
||||
const last = (j.log || []).at(-1) || ''
|
||||
const m = last.match(/(\d+(?:\.\d+)?)\s*%/)
|
||||
return m ? Math.min(100, parseFloat(m[1])) : null
|
||||
}
|
||||
|
||||
function fmtEta(s: number | null) {
|
||||
if (s == null || s < 0) return ''
|
||||
return s >= 90 ? `noch ~${Math.round(s / 60)} min` : `noch ~${Math.max(1, Math.round(s))} s`
|
||||
}
|
||||
|
||||
function statusBadge(s: string) {
|
||||
if (s === 'done') return '<span class="badge b-run">fertig</span>'
|
||||
if (s === 'failed') return '<span class="badge b-err">fehler</span>'
|
||||
if (s === 'canceled') return '<span class="badge">abgebrochen</span>'
|
||||
return '<span class="badge b-load">läuft…</span>'
|
||||
}
|
||||
|
||||
function dotClass(s: string) {
|
||||
return s === 'done' ? 'on' : (s === 'failed' || s === 'canceled') ? '' : 'load'
|
||||
}
|
||||
|
||||
async function cancelJob(id: string) {
|
||||
try { await api(`/api/jobs/${id}/cancel`, { method: 'POST' }); toast('Abbruch angefordert…') }
|
||||
catch (e: any) { toast(e.message, true) }
|
||||
}
|
||||
|
||||
async function deleteJob(id: string) {
|
||||
try { await api(`/api/jobs/${id}`, { method: 'DELETE' }) }
|
||||
catch (e: any) { toast(e.message, true) }
|
||||
}
|
||||
|
||||
async function clearJobs() {
|
||||
try {
|
||||
const r = await api('/api/jobs/clear', { method: 'POST' })
|
||||
toast(`${r.removed} Einträge entfernt.`)
|
||||
} catch (e: any) { toast(e.message, true) }
|
||||
}
|
||||
|
||||
function sparkBars(arr: number[], cssVar: string) {
|
||||
return arr.map(v =>
|
||||
`<div style="width:4px;background:var(${cssVar});opacity:.55;height:${Math.max(2, v)}%;border-radius:2px"></div>`
|
||||
).join('')
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="pagehead">
|
||||
<div>
|
||||
<h1>Aktivität</h1>
|
||||
<div class="sub">Live-Auslastung und laufende Aufgaben (Downloads, Updates) mit Protokoll.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System KPI Tiles -->
|
||||
{#if sys}
|
||||
{@const gpu = gpuPct()}
|
||||
<div class="tiles">
|
||||
<div class="tile">
|
||||
<div class="t-l">Prozessor (CPU)</div>
|
||||
<div class="t-v">{Math.round(sys.cpu?.percent ?? 0)}<small> %</small></div>
|
||||
<div class="t-s">{sys.cpu?.temp != null ? `${Math.round(sys.cpu.temp)}° CPU-Temp` : 'Auslastung'}</div>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<div class="t-l">Arbeitsspeicher</div>
|
||||
<div class="t-v">{Math.round(sys.ram.percent)}<small> %</small></div>
|
||||
<div class="t-s">{fmtBytes(sys.ram.used)} / {fmtBytes(sys.ram.total)}</div>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<div class="t-l">Grafikspeicher</div>
|
||||
<div class="t-v">{Math.round(gpu)}<small> %</small></div>
|
||||
<div class="t-s">{sys.gpu_temp != null ? `${Math.round(sys.gpu_temp)}° GPU-Temp` : 'VRAM + GTT'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Metrics Card -->
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>System-Metriken (Bosgame)</h3></div>
|
||||
<div class="card-sub">Live-Auslastung deines Mini-PCs, alle 0,5 Sekunden.</div>
|
||||
<div class="kv" style="margin-bottom:6px">
|
||||
<div class="kv-row"><span class="kv-k">Arbeitsspeicher (RAM)</span><span class="kv-v">{fmtBytes(sys.ram.used)} / {fmtBytes(sys.ram.total)}</span></div>
|
||||
<div class="kv-row"><span class="kv-k">Grafikspeicher (VRAM + GTT)</span>
|
||||
<span class="kv-v">{sys.gpu ? `${fmtBytes(sys.gpu.vram.used + sys.gpu.gtt.used)} / ${fmtBytes(sys.gpu.vram.total + sys.gpu.gtt.total)}` : '–'}</span>
|
||||
</div>
|
||||
<div class="kv-row"><span class="kv-k">Speicherplatz (Disk)</span><span class="kv-v">{Math.round(sys.disk?.percent ?? 0)} % belegt</span></div>
|
||||
<div class="kv-row"><span class="kv-k">Temperatur (GPU / CPU)</span>
|
||||
<span class="kv-v">{sys.gpu_temp != null ? Math.round(sys.gpu_temp) + '°' : '–'} / {sys.cpu?.temp != null ? Math.round(sys.cpu.temp) + '°' : '–'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-3" style="margin-top:14px">
|
||||
<div><div class="meta text-xs">CPU-Verlauf</div><div style="display:flex;align-items:flex-end;gap:2px;height:38px;margin-top:10px">{@html sparkBars(hist.cpu, '--accent')}</div></div>
|
||||
<div><div class="meta text-xs">RAM-Verlauf</div><div style="display:flex;align-items:flex-end;gap:2px;height:38px;margin-top:10px">{@html sparkBars(hist.ram, '--purple')}</div></div>
|
||||
<div><div class="meta text-xs">VRAM-Verlauf</div><div style="display:flex;align-items:flex-end;gap:2px;height:38px;margin-top:10px">{@html sparkBars(hist.gpu, '--on')}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Jobs Card -->
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<h3>Hintergrund-Aufgaben</h3>
|
||||
<span class="meta">{jobs.length ? (failedCount ? failedCount + ' Fehler' : jobs.length + ' gesamt') : ''}</span>
|
||||
{#if finishedCount}
|
||||
<button class="ghost" style="margin-left:auto;padding:4px 11px;font-size:12px" onclick={clearJobs}>Verlauf leeren</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="card-sub">Downloads & Updates erscheinen hier mit Live-Protokoll — zum Aufklappen klicken.</div>
|
||||
|
||||
{#if !jobs.length}
|
||||
<div class="empty-c">
|
||||
<div class="e-t">Gerade nichts los.</div>
|
||||
<div class="e-s">Alles ruhig — keine laufenden Aufgaben.</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#each jobs as j}
|
||||
{@const p = jobPct(j)}
|
||||
{@const eta = j.state === 'running' ? fmtEta(j.eta_s) : ''}
|
||||
{@const rate = j.state === 'running' && j.rate_bps ? fmtBytes(j.rate_bps) + '/s' : ''}
|
||||
{@const extra = [eta, rate].filter(Boolean).join(' · ')}
|
||||
{@const open = tracked.includes(j.id)}
|
||||
<div class="job">
|
||||
<div class="job-h" role="button" tabindex="0"
|
||||
onclick={() => jobsStore.toggle(j.id)}
|
||||
onkeydown={e => e.key === 'Enter' && jobsStore.toggle(j.id)}>
|
||||
<span class="li-dot {dotClass(j.state)}"></span>
|
||||
<span class="mid">{j.label}</span>
|
||||
{#if p != null}
|
||||
<span class="mono-sm" style="margin-left:auto">{Math.round(p)} %{extra ? ` · ${extra}` : ''}</span>
|
||||
{/if}
|
||||
{#if j.state === 'running'}
|
||||
<button class="ghost" style="{p == null ? 'margin-left:auto;' : ''}margin-right:2px;padding:2px 9px;font-size:12px"
|
||||
onclick={e => { e.stopPropagation(); cancelJob(j.id) }}>Abbrechen</button>
|
||||
{:else}
|
||||
<button class="ghost" title="Aus Verlauf entfernen" style="margin-left:auto;margin-right:2px;padding:2px 9px;font-size:14px;line-height:1"
|
||||
onclick={e => { e.stopPropagation(); deleteJob(j.id) }}>×</button>
|
||||
{/if}
|
||||
{@html statusBadge(j.state)}
|
||||
</div>
|
||||
{#if p != null && j.state === 'running'}
|
||||
<div class="bar" style="margin-top:8px"><i style="width:{p}%"></i></div>
|
||||
{/if}
|
||||
{#if open}
|
||||
<div class="log">{(j.log || []).join('\n')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,351 @@
|
||||
<script lang="ts">
|
||||
import { api } from '@core/api.js'
|
||||
import { toast, confirmModal } from '@core/ui.js'
|
||||
|
||||
type Mem = {
|
||||
id: string
|
||||
content: string
|
||||
category: string
|
||||
source: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
let memories = $state<Mem[]>([])
|
||||
let loading = $state(true)
|
||||
let filter = $state('all')
|
||||
let adding = $state(false)
|
||||
let newContent = $state('')
|
||||
let newCategory = $state('stable')
|
||||
let editId = $state<string | null>(null)
|
||||
let editContent = $state('')
|
||||
let editCategory = $state('stable')
|
||||
let importing = $state(false)
|
||||
let importText = $state('')
|
||||
let importCategory = $state('stable')
|
||||
let importSource = $state('import')
|
||||
let importBusy = $state(false)
|
||||
|
||||
const importLines = $derived(
|
||||
importText.split('\n').map(l => l.trim()).filter(l => l.length > 0)
|
||||
)
|
||||
|
||||
const filtered = $derived(
|
||||
filter === 'all' ? memories : memories.filter(m => m.category === filter)
|
||||
)
|
||||
|
||||
const CAT: Record<string, { label: string; color: string }> = {
|
||||
user: { label: 'Über mich', color: '#a78bfa' },
|
||||
instruction:{ label: 'Verhalten', color: '#fb923c' },
|
||||
stable: { label: 'Stabil', color: 'var(--accent)' },
|
||||
versioned: { label: 'Versioniert', color: '#f59e0b' },
|
||||
ephemeral: { label: 'Temporär', color: 'var(--mut)' },
|
||||
}
|
||||
|
||||
function relTime(iso: string) {
|
||||
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
|
||||
if (s < 60) return 'gerade eben'
|
||||
if (s < 3600) return `vor ${Math.floor(s / 60)} Min`
|
||||
if (s < 86400) return `vor ${Math.floor(s / 3600)} Std`
|
||||
return `vor ${Math.floor(s / 86400)} Tagen`
|
||||
}
|
||||
|
||||
function countCat(cat: string) {
|
||||
return memories.filter(m => m.category === cat).length
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try { memories = await api('/api/memory') }
|
||||
catch (e: any) { toast('Laden fehlgeschlagen: ' + e.message, true) }
|
||||
finally { loading = false }
|
||||
}
|
||||
|
||||
async function addMem() {
|
||||
if (!newContent.trim()) return
|
||||
try {
|
||||
const m = await api('/api/memory', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content: newContent.trim(), category: newCategory, source: 'manual' })
|
||||
})
|
||||
memories = [m, ...memories]
|
||||
newContent = ''; newCategory = 'stable'; adding = false
|
||||
toast('Gespeichert')
|
||||
} catch (e: any) { toast('Fehler: ' + e.message, true) }
|
||||
}
|
||||
|
||||
async function saveMem(m: Mem) {
|
||||
try {
|
||||
const updated = await api(`/api/memory/${m.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ content: editContent, category: editCategory })
|
||||
})
|
||||
memories = memories.map(x => x.id === m.id ? updated : x)
|
||||
editId = null
|
||||
toast('Aktualisiert')
|
||||
} catch (e: any) { toast('Fehler: ' + e.message, true) }
|
||||
}
|
||||
|
||||
async function delMem(m: Mem) {
|
||||
const ok = await confirmModal({
|
||||
title: 'Eintrag löschen?',
|
||||
body: `„${m.content.length > 80 ? m.content.slice(0, 80) + '…' : m.content}" wird dauerhaft entfernt.`,
|
||||
danger: true
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await api(`/api/memory/${m.id}`, { method: 'DELETE' })
|
||||
memories = memories.filter(x => x.id !== m.id)
|
||||
toast('Gelöscht')
|
||||
} catch (e: any) { toast('Fehler: ' + e.message, true) }
|
||||
}
|
||||
|
||||
function startEdit(m: Mem) {
|
||||
editId = m.id; editContent = m.content; editCategory = m.category
|
||||
}
|
||||
|
||||
// Deterministischer Kurator: Dubletten (exakt/enthalten/ähnlich) je Kategorie zusammenführen
|
||||
let deduping = $state(false)
|
||||
async function dedupe() {
|
||||
deduping = true
|
||||
try {
|
||||
const preview = await api('/api/memory/dedupe', { method: 'POST', body: JSON.stringify({ apply: false }) })
|
||||
if (preview.duplicate_count === 0) { toast('Keine Dubletten gefunden 🎉'); return }
|
||||
const ok = await confirmModal({
|
||||
title: `${preview.duplicate_count} Dubletten entfernen?`,
|
||||
body: `${preview.duplicate_count} doppelte Einträge werden gelöscht; die ${preview.groups.length} vollständigeren bleiben erhalten. Nur exakte/enthaltene/sehr ähnliche Einträge derselben Kategorie.`,
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
const res = await api('/api/memory/dedupe', { method: 'POST', body: JSON.stringify({ apply: true }) })
|
||||
toast(`${res.removed} Dubletten entfernt`)
|
||||
await load()
|
||||
} catch (e: any) { toast('Fehler: ' + e.message, true) }
|
||||
finally { deduping = false }
|
||||
}
|
||||
|
||||
async function runImport() {
|
||||
if (importLines.length === 0) return
|
||||
importBusy = true
|
||||
let ok = 0
|
||||
for (const line of importLines) {
|
||||
try {
|
||||
const m = await api('/api/memory', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content: line, category: importCategory, source: importSource })
|
||||
})
|
||||
memories = [m, ...memories]
|
||||
ok++
|
||||
} catch {}
|
||||
}
|
||||
importBusy = false
|
||||
importing = false
|
||||
importText = ''
|
||||
toast(`${ok} von ${importLines.length} Einträgen importiert`)
|
||||
}
|
||||
|
||||
$effect(() => { load() })
|
||||
</script>
|
||||
|
||||
<div class="pagehead" style="display:flex;justify-content:space-between;align-items:flex-start">
|
||||
<div>
|
||||
<h1>Gedächtnis</h1>
|
||||
<div class="sub">Fakten, Entscheidungen und Kontext — geteilt von allen KI-Tools via MCP.</div>
|
||||
</div>
|
||||
<div class="flex gap-2" style="flex-shrink:0;margin-top:4px">
|
||||
<button class="ghost" onclick={dedupe} disabled={deduping} title="Doppelte Einträge zusammenführen">
|
||||
{deduping ? '…' : '🧹 Aufräumen'}
|
||||
</button>
|
||||
<button class="ghost" onclick={() => { importing = !importing; adding = false; importText = '' }}>
|
||||
{importing ? 'Abbrechen' : '⇩ Import'}
|
||||
</button>
|
||||
<button class="primary" onclick={() => { adding = !adding; importing = false; newContent = '' }}>
|
||||
{adding ? 'Abbrechen' : '+ Eintrag'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import form -->
|
||||
{#if importing}
|
||||
<div class="card" style="border-color:rgba(45,212,191,.25)">
|
||||
<div class="card-h"><h3>Import aus Claude / Gemini / ChatGPT</h3></div>
|
||||
<div class="card-sub" style="margin-bottom:10px">
|
||||
Füge Erkenntnisse aus anderen KI-Sessions ein — jede Zeile wird ein eigener Eintrag.
|
||||
</div>
|
||||
<label>Einträge (eine Zeile = ein Eintrag)</label>
|
||||
<textarea
|
||||
rows="8"
|
||||
placeholder={"Tobi arbeitet am liebsten abends.\nProjektsprache: Deutsch.\nBosgame M5 läuft mit Ubuntu 26.04.\n..."}
|
||||
style="width:100%;box-sizing:border-box;resize:vertical;margin-bottom:10px;font-family:var(--mono);font-size:12.5px"
|
||||
bind:value={importText}
|
||||
></textarea>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:14px">
|
||||
<div style="flex:1;min-width:160px">
|
||||
<label>Kategorie für alle Einträge</label>
|
||||
<select bind:value={importCategory} style="margin-bottom:0">
|
||||
<option value="user">Über mich — Wer du bist, Präferenzen</option>
|
||||
<option value="instruction">Verhalten — Regeln für alle KI-Tools</option>
|
||||
<option value="stable">Stabil — Projektfakten</option>
|
||||
<option value="versioned">Versioniert — Tech-Versionen</option>
|
||||
<option value="ephemeral">Temporär — 7 Tage, dann weg</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex:1;min-width:120px">
|
||||
<label>Quelle</label>
|
||||
<select bind:value={importSource} style="margin-bottom:0">
|
||||
<option value="import">import</option>
|
||||
<option value="claude">claude</option>
|
||||
<option value="gemini">gemini</option>
|
||||
<option value="chatgpt">chatgpt</option>
|
||||
<option value="manual">manual</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{#if importLines.length > 0}
|
||||
<div class="card-sub" style="margin-bottom:10px">{importLines.length} Eintrag/Einträge erkannt</div>
|
||||
{/if}
|
||||
<div class="btn-row">
|
||||
<button class="primary" onclick={runImport} disabled={importLines.length === 0 || importBusy}>
|
||||
{importBusy ? 'Importiere…' : `${importLines.length} Einträge speichern`}
|
||||
</button>
|
||||
<button class="ghost" onclick={() => { importing = false; importText = '' }}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add form -->
|
||||
{#if adding}
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>Neuer Eintrag</h3></div>
|
||||
<label>Inhalt</label>
|
||||
<textarea
|
||||
rows="3"
|
||||
placeholder="z.B.: Mission Control läuft auf Port 9000 — oder: Wir nutzen SQLite statt Qdrant"
|
||||
style="width:100%;box-sizing:border-box;resize:vertical;margin-bottom:10px"
|
||||
bind:value={newContent}
|
||||
onkeydown={e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) addMem() }}
|
||||
></textarea>
|
||||
<label>Kategorie</label>
|
||||
<select bind:value={newCategory} style="margin-bottom:14px">
|
||||
<option value="user">Über mich — Wer du bist, Präferenzen, Arbeitsweise</option>
|
||||
<option value="instruction">Verhalten — Wie alle KI-Tools sich verhalten sollen</option>
|
||||
<option value="stable">Stabil — Projektfakten, die sich selten ändern</option>
|
||||
<option value="versioned">Versioniert — Tech-Versionen (bei Updates überschreiben)</option>
|
||||
<option value="ephemeral">Temporär — Aktuelle Aufgabe (nach 7 Tagen gelöscht)</option>
|
||||
</select>
|
||||
<div class="btn-row">
|
||||
<button class="primary" onclick={addMem} disabled={!newContent.trim()}>Speichern</button>
|
||||
<button class="ghost" onclick={() => { adding = false; newContent = '' }}>Abbrechen</button>
|
||||
<span class="card-sub" style="font-size:11.5px;margin-left:4px">Strg+Enter zum Speichern</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Category filter -->
|
||||
<div class="card" style="padding:10px 16px">
|
||||
<div class="flex gap-2" style="flex-wrap:wrap;align-items:center">
|
||||
<span style="font-size:11.5px;color:var(--mut);margin-right:4px">Filter:</span>
|
||||
{#each [
|
||||
['all', 'Alle', '', memories.length],
|
||||
['user', 'Über mich', '#a78bfa', countCat('user')],
|
||||
['instruction', 'Verhalten', '#fb923c', countCat('instruction')],
|
||||
['stable', 'Stabil', 'var(--accent)', countCat('stable')],
|
||||
['versioned', 'Versioniert', '#f59e0b', countCat('versioned')],
|
||||
['ephemeral', 'Temporär', 'var(--mut)', countCat('ephemeral')],
|
||||
] as [val, lbl, clr, cnt]}
|
||||
<span class="chip"
|
||||
role="button" tabindex="0"
|
||||
style="cursor:pointer;{filter === val
|
||||
? `background:rgba(45,212,191,.12);border-color:${clr || 'var(--accent)'};color:${clr || 'var(--accent)'}`
|
||||
: ''}"
|
||||
onclick={() => filter = val}
|
||||
onkeydown={e => e.key === 'Enter' && (filter = val)}>
|
||||
{lbl} <span style="color:var(--mut);margin-left:2px">{cnt}</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Memory list -->
|
||||
{#if loading}
|
||||
<div class="card"><div class="card-sub">Lade…</div></div>
|
||||
{:else if filtered.length === 0}
|
||||
<div class="card">
|
||||
<div class="empty-c" style="padding:8px 0 4px">
|
||||
<div class="e-t">{memories.length === 0 ? 'Noch keine Einträge' : 'Keine Treffer'}</div>
|
||||
<div class="e-s">
|
||||
{memories.length === 0
|
||||
? 'Klicke „+ Eintrag" um manuell etwas zu speichern, oder verbinde ein KI-Tool via MCP.'
|
||||
: 'Andere Kategorie wählen oder Eintrag anlegen.'}
|
||||
</div>
|
||||
{#if memories.length === 0}
|
||||
<button class="primary" style="margin-top:14px" onclick={() => adding = true}>
|
||||
+ Ersten Eintrag anlegen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
{#each filtered as m, i (m.id)}
|
||||
<div style="padding:14px 16px;{i < filtered.length - 1 ? 'border-bottom:1px solid var(--brd, rgba(255,255,255,.07))' : ''}">
|
||||
{#if editId === m.id}
|
||||
<textarea
|
||||
rows="3"
|
||||
style="width:100%;box-sizing:border-box;resize:vertical;margin-bottom:8px"
|
||||
bind:value={editContent}
|
||||
></textarea>
|
||||
<select bind:value={editCategory} style="margin-bottom:10px">
|
||||
<option value="user">Über mich</option>
|
||||
<option value="instruction">Verhalten</option>
|
||||
<option value="stable">Stabil</option>
|
||||
<option value="versioned">Versioniert</option>
|
||||
<option value="ephemeral">Temporär</option>
|
||||
</select>
|
||||
<div class="btn-row">
|
||||
<button class="primary" onclick={() => saveMem(m)}>Speichern</button>
|
||||
<button class="ghost" onclick={() => editId = null}>Abbrechen</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div style="display:flex;gap:10px;align-items:flex-start">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:13.5px;line-height:1.55;word-break:break-word">{m.content}</div>
|
||||
<div class="flex gap-2" style="margin-top:8px;flex-wrap:wrap;align-items:center">
|
||||
<span class="chip" style="border-color:{CAT[m.category]?.color || 'var(--mut)'};color:{CAT[m.category]?.color || 'var(--mut)'}">
|
||||
{CAT[m.category]?.label || m.category}
|
||||
</span>
|
||||
<span class="chip">{m.source}</span>
|
||||
<span style="font-size:11px;color:var(--mut)">{relTime(m.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2" style="flex-shrink:0;margin-top:2px">
|
||||
<button class="ghost" style="padding:4px 10px;font-size:12px"
|
||||
onclick={() => startEdit(m)}>Bearbeiten</button>
|
||||
<button class="ghost" style="padding:4px 10px;font-size:12px;color:var(--err)"
|
||||
onclick={() => delMem(m)}>Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Info-Box: wie KI-Tools das Gedächtnis nutzen -->
|
||||
<div class="card" style="background:rgba(45,212,191,.04);border-color:rgba(45,212,191,.2)">
|
||||
<div class="card-h"><h3 style="font-size:13px;font-weight:500">Wie KI-Tools dieses Gedächtnis nutzen</h3></div>
|
||||
<div class="card-sub" style="line-height:1.6">
|
||||
Cline, OpenCode und Claude Code greifen über den <b>Memory MCP-Server</b> auf diese Einträge zu.
|
||||
Beim Start einer Session lädt der Agent automatisch alle Einträge — er kennt dann deinen Stack,
|
||||
deine Präferenzen und frühere Entscheidungen. Den Setup-Guide findest du unter
|
||||
<a href="#"
|
||||
onclick={e => { e.preventDefault(); (document.querySelector(".nav-item[data-view='connect']") as HTMLElement)?.click() }}
|
||||
style="color:var(--accent)">Verbinden → Gedächtnis-MCP</a>.
|
||||
</div>
|
||||
<div class="card-sub" style="margin-top:8px;font-size:11.5px">
|
||||
<b style="color:#a78bfa">Über mich</b> = wer du bist, Präferenzen ·
|
||||
<b style="color:#fb923c">Verhalten</b> = Regeln für alle KI-Tools ·
|
||||
<b style="color:var(--accent)">Stabil</b> = Projektfakten ·
|
||||
<b style="color:#f59e0b">Versioniert</b> = Tech-Versionen ·
|
||||
<b style="color:var(--mut)">Temporär</b> = 7 Tage, dann weg
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,272 @@
|
||||
<script lang="ts">
|
||||
import { statusStore } from '../stores/status.svelte.js'
|
||||
import { api } from '@core/api.js'
|
||||
import { esc, toast, confirmModal, badge, infoDot } from '@core/ui.js'
|
||||
|
||||
const CTX_HELP = 'Kontext = das Kurzzeitgedächtnis des Modells. Größer = merkt sich mehr (längere Dateien/Chats), braucht aber mehr Speicher und wird etwas langsamer.'
|
||||
const ROLE_HELP = 'Die Rolle ist ein zusätzlicher, sprechender Name (z.B. coder). Du kannst in deinen Tools entweder den echten Modellnamen ODER die Rolle angeben — beides führt zum selben Modell.'
|
||||
const ROLES = [
|
||||
{ id: 'coder', label: 'Coder', desc: 'Programmieren & Code' },
|
||||
{ id: 'vision', label: 'Vision', desc: 'Bilder verstehen' },
|
||||
{ id: 'scout', label: 'Scout', desc: 'Schneller Allrounder' },
|
||||
{ id: 'reviewer', label: 'Reviewer', desc: 'Code/Texte prüfen' },
|
||||
{ id: 'manager', label: 'Manager', desc: 'Planen & koordinieren' },
|
||||
]
|
||||
|
||||
const allModels = $derived(((statusStore.value?.models) || []) as any[])
|
||||
function refresh() { document.dispatchEvent(new Event('mc:refresh')) }
|
||||
|
||||
// ---- Chat ----
|
||||
let chatModel = $state('')
|
||||
let chatMsg = $state('')
|
||||
let chatReply = $state('')
|
||||
let chatLoading = $state(false)
|
||||
|
||||
const chatModels = $derived(allModels.filter((m: any) => !m.incomplete))
|
||||
|
||||
async function sendChat() {
|
||||
const model = chatModel || chatModels[0]?.name
|
||||
if (!model || !chatMsg.trim()) return
|
||||
chatLoading = true; chatReply = '(wecke Modell, kann beim Laden kurz dauern…)'
|
||||
try {
|
||||
const r = await api('/api/chat', { method: 'POST', body: JSON.stringify({ model, message: chatMsg }) })
|
||||
chatReply = r.reply
|
||||
} catch (e: any) { chatReply = 'Fehler: ' + e.message }
|
||||
chatLoading = false; refresh()
|
||||
}
|
||||
|
||||
const DEFAULT_TTL = 300
|
||||
|
||||
// ---- Config Modal ----
|
||||
let cfgOpen = $state(false)
|
||||
let cfgModel = $state<any>(null)
|
||||
let cfgCtx = $state(8192)
|
||||
let cfgAlwaysActive = $state(false)
|
||||
|
||||
function openConfig(name: string) {
|
||||
cfgModel = allModels.find((m: any) => m.name === name)
|
||||
if (!cfgModel) return
|
||||
cfgCtx = cfgModel.meta?.ctx || 8192
|
||||
cfgAlwaysActive = (cfgModel.ttl ?? DEFAULT_TTL) >= 99999
|
||||
cfgOpen = true
|
||||
}
|
||||
async function saveConfig() {
|
||||
const ttl = cfgAlwaysActive ? 99999 : DEFAULT_TTL
|
||||
try {
|
||||
await api('/api/update_model', { method: 'POST', body: JSON.stringify({ alias: cfgModel.name, ctx: cfgCtx, ttl }) })
|
||||
toast('Gespeichert — aktiv beim nächsten Modell-Start.')
|
||||
cfgOpen = false; refresh()
|
||||
} catch (e: any) { toast(e.message, true) }
|
||||
}
|
||||
|
||||
// ---- Role Modal ----
|
||||
let roleOpen = $state(false)
|
||||
let roleModelName = $state('')
|
||||
let roleCustom = $state('')
|
||||
let roleCurrentRole = $state('')
|
||||
|
||||
function openRole(name: string) {
|
||||
const m = allModels.find((m: any) => m.name === name)
|
||||
roleModelName = name
|
||||
roleCurrentRole = m?.role || ''
|
||||
roleCustom = ''
|
||||
roleOpen = true
|
||||
}
|
||||
async function saveRole(role: string) {
|
||||
try {
|
||||
await api('/api/set_role', { method: 'POST', body: JSON.stringify({ alias: roleModelName, role }) })
|
||||
toast(role ? `Rolle gesetzt: ${role}` : 'Rolle entfernt.')
|
||||
roleOpen = false; refresh()
|
||||
} catch (e: any) { toast(e.message, true) }
|
||||
}
|
||||
|
||||
// ---- Actions ----
|
||||
async function unloadOne(name: string) {
|
||||
if (!await confirmModal({ title: `„${name}" entladen?`, body: 'Das Modell wird aus dem Grafikspeicher geworfen und lädt beim nächsten Aufruf automatisch neu.', confirmLabel: 'Entladen' })) return
|
||||
try { await api('/api/unload?model=' + encodeURIComponent(name), { method: 'POST' }); toast('Entladen: ' + name); setTimeout(refresh, 600) }
|
||||
catch (e: any) { toast(e.message, true) }
|
||||
}
|
||||
async function deleteModel(name: string) {
|
||||
if (!await confirmModal({ title: `„${name}" wirklich löschen?`, body: 'Das Modell wird aus der Konfiguration ausgetragen und seine Dateien werden von der Festplatte gelöscht, um Speicher freizugeben. <b>Das lässt sich nicht rückgängig machen</b> — später brauchst du dafür einen neuen Download.', confirmLabel: 'Endgültig löschen', danger: true })) return
|
||||
try {
|
||||
const r = await api('/api/delete_model', { method: 'POST', body: JSON.stringify({ alias: name }) })
|
||||
toast(r.note || 'Gelöscht: ' + name); setTimeout(refresh, 600)
|
||||
} catch (e: any) { toast(e.message, true) }
|
||||
}
|
||||
|
||||
function capChips(cap: any) {
|
||||
if (!cap) return '–'
|
||||
const chips: string[] = []
|
||||
if (cap.coder) chips.push('<span class="tag code">Code</span>')
|
||||
if (cap.vision) chips.push('<span class="tag img">Bild</span>')
|
||||
if (cap.reasoning) chips.push('<span class="tag" style="border-color:#a78bfa;color:#a78bfa">🧠 Reason</span>')
|
||||
if (cap.moe) chips.push(`<span class="tag" style="border-color:#f59e0b;color:#f59e0b">🧩 MoE${cap.active_b ? ' ·' + cap.active_b + 'B' : ''}</span>`)
|
||||
if (cap.tools === 'yes') chips.push('<span class="tag" style="border-color:var(--accent);color:var(--accent)" title="Tool-Calling vom Template/Flag unterstützt">🛠 Tools</span>')
|
||||
else if (cap.tools === 'likely') chips.push('<span class="tag" style="opacity:.55" title="laut Modell-Familie wahrscheinlich tool-fähig">🛠 Tools?</span>')
|
||||
if (cap.embedding) chips.push('<span class="tag" style="opacity:.7">🔢 Embed</span>')
|
||||
if (!cap.coder && !cap.vision && !cap.embedding) chips.unshift('<span class="tag text">Text</span>')
|
||||
return chips.length ? chips.join(' ') : '–'
|
||||
}
|
||||
|
||||
function details(meta: any) {
|
||||
if (!meta) return '–'
|
||||
const q = meta.quant || '?'
|
||||
const c = meta.ctx ? Math.round(meta.ctx / 1024) + 'K' : '?'
|
||||
const s = meta.size_bytes ? (meta.size_bytes / 1024 ** 3).toFixed(1) + ' GB' : '?'
|
||||
return `<span class="mono-sm">${q} · ${c} · ${s}</span>`
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard?.writeText(text); toast('Kopiert: ' + text)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="m-head">
|
||||
<div class="pagehead">
|
||||
<div>
|
||||
<h1>Modelle</h1>
|
||||
<div class="sub">Deine konfigurierten Modelle — testen, Kontext anpassen oder aus dem Speicher werfen.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Chat -->
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>Schnelltest</h3></div>
|
||||
<div class="card-sub">Schreib eine Nachricht — das gewählte Modell wird automatisch geweckt.</div>
|
||||
<label>Modell</label>
|
||||
<select bind:value={chatModel}>
|
||||
{#each chatModels as m}<option value={m.name}>{m.name}</option>{/each}
|
||||
</select>
|
||||
<label>Nachricht</label>
|
||||
<textarea bind:value={chatMsg} placeholder='z.B. „Erklär mir kurz, was du kannst."'></textarea>
|
||||
<button class="primary" disabled={chatLoading} onclick={sendChat}>{chatLoading ? '…' : 'Senden'}</button>
|
||||
{#if chatReply}
|
||||
<div class="reply" style="margin-top:12px">{chatReply}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Models Table -->
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>Modelle & Ports</h3><span class="meta">{allModels.length ? allModels.length + ' konfiguriert' : ''}</span></div>
|
||||
<div class="card-sub">Modelle laden automatisch, sobald eine Anfrage kommt — du musst nichts manuell starten.</div>
|
||||
|
||||
{#if !allModels.length}
|
||||
<div class="empty-c">
|
||||
<div class="e-t">Noch keine Modelle konfiguriert</div>
|
||||
<div class="e-s">Hol dir unter „Cookbook" ein passendes Modell.</div>
|
||||
</div>
|
||||
{:else}
|
||||
<table>
|
||||
<thead><tr><th>Modell</th><th>Kann</th><th>Details</th><th>Status</th><th>Port</th><th style="text-align:right">Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
{#each allModels as m}
|
||||
{#if m.incomplete}
|
||||
<tr style="opacity:.9">
|
||||
<td class="mid" style="font-weight:500">{m.name}<div class="li-sub mono-sm" style="color:var(--warn)">kein Modell hinterlegt</div></td>
|
||||
<td>—</td>
|
||||
<td><span class="mono-sm text-mut">leere Rolle</span></td>
|
||||
<td><span class="badge">leer</span></td>
|
||||
<td class="port">—</td>
|
||||
<td style="text-align:right;white-space:nowrap">
|
||||
<button class="ghost" onclick={() => { toast('Lade im Cookbook ein Modell und setze den Alias auf: ' + m.name); document.querySelector(".nav-item[data-view='cookbook']")?.dispatchEvent(new MouseEvent('click')) }}>Modell zuweisen</button>
|
||||
<button class="ghost del" onclick={() => deleteModel(m.name)}>Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td class="mid">
|
||||
<div style="font-weight:500;display:flex;align-items:center;gap:7px;flex-wrap:wrap">
|
||||
{m.name}
|
||||
{#if m.role}<span class="tag" style="background:rgba(45,212,191,.14);color:var(--accent);border-color:rgba(45,212,191,.3)">{m.role}</span>{/if}
|
||||
</div>
|
||||
{#if m.meta?.filename}<div class="li-sub mono-sm">{m.meta.filename}</div>{/if}
|
||||
<div class="li-sub mono-sm" style="margin-top:3px;display:flex;align-items:center;gap:6px;flex-wrap:wrap">
|
||||
<span class="text-mut">API-Name:</span>
|
||||
{#each (m.api_ids?.length ? m.api_ids : [m.name]) as id}
|
||||
<code style="cursor:pointer" title="Klicken zum Kopieren" onclick={() => copyToClipboard(id)}>{id}</code>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
<td>{@html capChips(m.meta?.capabilities)}</td>
|
||||
<td>{@html details(m.meta)}</td>
|
||||
<td>{@html badge(m.state, m.download_progress)}</td>
|
||||
<td class="port">{m.port ?? 'auto'}</td>
|
||||
<td style="text-align:right;white-space:nowrap">
|
||||
<button class="ghost" onclick={() => openRole(m.name)}>Rolle</button>
|
||||
<button class="ghost" onclick={() => openConfig(m.name)}>Konfigurieren</button>
|
||||
<button class="ghost" onclick={() => unloadOne(m.name)}>Entladen</button>
|
||||
<button class="ghost del" onclick={() => deleteModel(m.name)}>Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Config Modal -->
|
||||
{#if cfgOpen && cfgModel}
|
||||
<div class="modal-overlay" role="dialog" onclick={e => { if ((e.target as HTMLElement).classList.contains('modal-overlay')) cfgOpen = false }}>
|
||||
<div class="modal-card">
|
||||
<button class="ghost" style="position:absolute;top:14px;right:14px" onclick={() => cfgOpen = false}>Schließen</button>
|
||||
<h3>Modell konfigurieren</h3>
|
||||
<p class="mono-sm" style="margin:-4px 0 16px">{cfgModel.name}</p>
|
||||
<label>Kontext-Größe (Tokens) {@html infoDot(CTX_HELP)}</label>
|
||||
<input type="number" bind:value={cfgCtx}>
|
||||
{#if cfgModel.meta?.optimal_ctx}
|
||||
<div class="tile" style="display:flex;justify-content:space-between;align-items:center;margin:8px 0 10px">
|
||||
<div style="flex:1">
|
||||
<div style="font-size:13px">Empfohlen für deine Hardware</div>
|
||||
<div class="hint" style="margin:4px 0 0">
|
||||
Optimal ~{Math.round(cfgModel.meta.optimal_ctx / 1024)}k → ~{cfgModel.meta.peak_ram_optimal_gb} GB Spitzenbedarf.<br>
|
||||
Aktuell ~{Math.round((cfgModel.meta.ctx || 0) / 1024)}k → ~{cfgModel.meta.peak_ram_gb} GB.
|
||||
</div>
|
||||
</div>
|
||||
<button class="ghost" onclick={() => cfgCtx = cfgModel.meta.optimal_ctx}>Optimal übernehmen</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="hint" style="margin-bottom:14px">Es läuft immer nur <b>ein</b> Modell gleichzeitig — ein größerer Kontext hier beeinflusst deine anderen Modelle nicht.</div>
|
||||
|
||||
<div class="tile" style="display:flex;justify-content:space-between;align-items:center;margin:0 0 14px">
|
||||
<div style="flex:1">
|
||||
<div style="font-size:13px;font-weight:500">Immer aktiv halten</div>
|
||||
<div class="hint" style="margin:3px 0 0">Modell bleibt dauerhaft im RAM — nie automatisch entladen. Ideal für den kleinen Scout (~5 GB), der immer schnell antworten soll.</div>
|
||||
</div>
|
||||
<label style="display:flex;align-items:center;gap:8px;margin:0;cursor:pointer;flex:0 0 auto">
|
||||
<input type="checkbox" bind:checked={cfgAlwaysActive} style="width:18px;height:18px;accent-color:var(--accent);cursor:pointer">
|
||||
<span class="mono-sm" style="color:{cfgAlwaysActive ? 'var(--accent)' : 'var(--mut)'}">
|
||||
{cfgAlwaysActive ? 'An (TTL = ∞)' : 'Aus (TTL = 5 min)'}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="primary" style="width:100%;margin-top:6px" onclick={saveConfig}>Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Role Modal -->
|
||||
{#if roleOpen}
|
||||
<div class="modal-overlay" role="dialog" onclick={e => { if ((e.target as HTMLElement).classList.contains('modal-overlay')) roleOpen = false }}>
|
||||
<div class="modal-card">
|
||||
<button class="ghost" style="position:absolute;top:14px;right:14px" onclick={() => roleOpen = false}>Schließen</button>
|
||||
<h3>Rolle festlegen {@html infoDot(ROLE_HELP)}</h3>
|
||||
<p class="mono-sm" style="margin:-4px 0 16px">{roleModelName}</p>
|
||||
<div class="hint" style="margin-bottom:10px">Wähle eine Rolle als zweiten, sprechenden Namen. In deinen Tools funktionieren danach <b>beide</b>: der echte Modellname und die Rolle.</div>
|
||||
<div class="flex gap-2" style="flex-wrap:wrap;margin-bottom:12px">
|
||||
{#each ROLES as r}
|
||||
<button class="{roleCurrentRole === r.id ? 'primary' : 'ghost'}" title={r.desc} style="border-radius:999px"
|
||||
onclick={() => saveRole(r.id)}>{r.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<label>Eigene Rolle (optional)</label>
|
||||
<input bind:value={roleCustom} placeholder="z.B. uebersetzer">
|
||||
<div class="btn-row" style="margin-top:14px;display:flex;gap:8px">
|
||||
<button class="ghost" style="flex:1" onclick={() => saveRole('')}>Rolle entfernen</button>
|
||||
<button class="primary" style="flex:2" onclick={() => saveRole(roleCustom.trim().toLowerCase())}>Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { api, getHfToken } from '@core/api.js'
|
||||
|
||||
let items = $state<any[]>([])
|
||||
let upgrades = $state<any[]>([])
|
||||
let loadingNews = $state(true)
|
||||
let newsError = $state('')
|
||||
|
||||
const fitCls = (l: string) => l === 'perfect' ? 'ok' : l === 'marginal' ? 'warn' : 'bad'
|
||||
|
||||
function ago(ts: number) {
|
||||
const s = Math.max(0, Date.now() / 1000 - ts)
|
||||
if (s < 60) return s + 's'
|
||||
if (s < 3600) return Math.floor(s / 60) + 'm'
|
||||
if (s < 86400) return Math.floor(s / 3600) + 'h'
|
||||
return Math.floor(s / 86400) + 'd'
|
||||
}
|
||||
|
||||
async function loadNews() {
|
||||
loadingNews = true; newsError = ''
|
||||
try {
|
||||
items = (await api('/api/news')).items || []
|
||||
} catch (e: any) {
|
||||
newsError = e.message || 'Fehler beim Laden'
|
||||
} finally {
|
||||
loadingNews = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUpgrades() {
|
||||
try {
|
||||
upgrades = (await api('/api/cookbook/upgrades')).upgrades || []
|
||||
} catch {
|
||||
upgrades = []
|
||||
}
|
||||
}
|
||||
|
||||
async function installUpgrade(u: any, btn: HTMLButtonElement) {
|
||||
btn.disabled = true; btn.textContent = 'Starte…'
|
||||
try {
|
||||
await api('/api/cookbook/install-model', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ repo: u.repo, role: u.role, params_b: u.params_b, quant: u.quant, hf_token: getHfToken() })
|
||||
})
|
||||
document.querySelector(".nav-item[data-view='activity']")?.dispatchEvent(new MouseEvent('click'))
|
||||
} catch (e: any) {
|
||||
btn.disabled = false; btn.textContent = 'Installieren'
|
||||
alert('Fehler: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() { loadNews(); loadUpgrades() }
|
||||
|
||||
onMount(() => { loadNews(); loadUpgrades() })
|
||||
</script>
|
||||
|
||||
<div class="pagehead">
|
||||
<div>
|
||||
<h1>News</h1>
|
||||
<div class="sub">Aktuelles aus der Local-LLM-Welt — aus kuratierten Quellen.</div>
|
||||
</div>
|
||||
<button class="ghost" onclick={refresh}>Aktualisieren</button>
|
||||
</div>
|
||||
|
||||
{#if upgrades.length}
|
||||
<div class="card" style="margin-bottom:18px;border-color:rgba(45,212,191,.25)">
|
||||
<div class="card-h">
|
||||
<h3>Für dich: Upgrades</h3>
|
||||
<span class="meta">{upgrades.length}</span>
|
||||
</div>
|
||||
<div class="card-sub">Neuere Modelle, die ein installiertes ersetzen können.</div>
|
||||
<div class="list">
|
||||
{#each upgrades as u}
|
||||
<div class="li" style="align-items:flex-start">
|
||||
<div class="li-main">
|
||||
<div class="flex items-center gap-2" style="flex-wrap:wrap">
|
||||
<span class="li-id" style="font-family:var(--sans);font-weight:500">{u.name}</span>
|
||||
<span class="fit-badge {fitCls(u.fit.level)}">{u.fit.text}</span>
|
||||
</div>
|
||||
<div class="li-sub">Ersetzt dein „{u.old}". {u.why}</div>
|
||||
</div>
|
||||
<button class="primary" onclick={(e) => installUpgrade(u, e.currentTarget as HTMLButtonElement)}>
|
||||
Installieren
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loadingNews}
|
||||
<div class="empty" style="text-align:center;padding:40px">Lade News… (erstes Laden kann kurz dauern)</div>
|
||||
{:else if newsError}
|
||||
<div class="alert err" style="margin:0">{newsError}</div>
|
||||
{:else if !items.length}
|
||||
<div class="empty-c">
|
||||
<div class="e-t">Keine News geladen</div>
|
||||
<div class="e-s">Die Quellen sind gerade nicht erreichbar — später nochmal probieren.</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="news-grid">
|
||||
{#each items as n, i}
|
||||
<a href={n.link} target="_blank" rel="noopener" class="news-card{i === 0 ? ' featured' : ''}">
|
||||
<div class="news-meta">
|
||||
<span class="news-src">{n.source}</span>
|
||||
{#if n.relevant}<span class="news-rel">für dich</span>{/if}
|
||||
<span class="news-date">{n.ts ? ago(n.ts) : ''}</span>
|
||||
</div>
|
||||
<div class="news-title">{n.title}</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,277 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { statusStore } from '../stores/status.svelte.js'
|
||||
import { systemStore } from '../stores/system.svelte.js'
|
||||
import { api } from '@core/api.js'
|
||||
import { confirmModal, toast, icon, fmtBytes } from '@core/ui.js'
|
||||
|
||||
const s = $derived(statusStore.value)
|
||||
const sys = $derived(systemStore.value)
|
||||
const allModels = $derived((s?.models || []) as any[])
|
||||
const swapOk = $derived(s?.swap_ok ?? false)
|
||||
const LOADED = ['running', 'ready', 'loading', 'starting']
|
||||
const activeModel = $derived(allModels.find((m: any) => LOADED.includes(m.state)))
|
||||
const configuredModels = $derived(allModels.filter((m: any) => !m.incomplete))
|
||||
|
||||
const heroTitle = $derived(
|
||||
!s ? 'Verbinde…'
|
||||
: !swapOk ? 'LLM-Engine offline'
|
||||
: sys && sys.ram.percent >= 90 ? 'Achtung: Speicher wird knapp.'
|
||||
: 'Alles läuft rund.'
|
||||
)
|
||||
const heroSub = $derived(
|
||||
!s ? ''
|
||||
: !swapOk ? 'Der llama-swap-Dienst antwortet gerade nicht.'
|
||||
: sys && sys.ram.percent >= 90 ? `Arbeitsspeicher bei ${Math.round(sys.ram.percent)} % — eventuell Speicher freigeben.`
|
||||
: `${configuredModels.length} ${configuredModels.length === 1 ? 'Modell' : 'Modelle'} konfiguriert · ${activeModel ? `„${activeModel.name}" geladen` : 'keines geladen'} · keine Warnungen.`
|
||||
)
|
||||
|
||||
const gpuPct = $derived(() => {
|
||||
const g = sys?.gpu
|
||||
if (g && (g.vram.total + g.gtt.total) > 0)
|
||||
return ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100
|
||||
return 0
|
||||
})
|
||||
|
||||
let news = $state<any[]>([])
|
||||
let preloadingModel = $state<string | null>(null)
|
||||
|
||||
onMount(async () => {
|
||||
try { news = ((await api('/api/news')).items || []).slice(0, 4) } catch {}
|
||||
})
|
||||
|
||||
async function freeMemory() {
|
||||
if (!await confirmModal({
|
||||
title: 'Speicher freigeben?',
|
||||
body: 'Alle aktuell geladenen Modelle werden aus dem Grafikspeicher geworfen. Sie laden beim nächsten Aufruf automatisch neu.',
|
||||
confirmLabel: 'Speicher freigeben',
|
||||
})) return
|
||||
await api('/api/unload', { method: 'POST' })
|
||||
refresh()
|
||||
}
|
||||
|
||||
async function preload(modelName: string) {
|
||||
preloadingModel = modelName
|
||||
try { await api('/api/preload?model=' + encodeURIComponent(modelName), { method: 'POST' }) }
|
||||
catch (e: any) { toast('Laden fehlgeschlagen: ' + (e.message || 'Engine nicht erreichbar?'), true) }
|
||||
preloadingModel = null
|
||||
refresh()
|
||||
}
|
||||
|
||||
function refresh() { document.dispatchEvent(new Event('mc:refresh')) }
|
||||
function go(view: string) {
|
||||
document.querySelector(`.nav-item[data-view="${view}"]`)?.dispatchEvent(new MouseEvent('click'))
|
||||
}
|
||||
|
||||
function ramColor(pct: number) { return pct >= 90 ? 'red' : pct >= 75 ? '' : 'green' }
|
||||
function tempColor(t: number | null) { return t == null ? '' : t >= 75 ? 'red' : t >= 55 ? '' : 'green' }
|
||||
function gpuColor(pct: number) { return pct >= 90 ? 'red' : pct >= 70 ? '' : 'green' }
|
||||
</script>
|
||||
|
||||
<!-- Hero -->
|
||||
<div class="pagehead" style="margin-bottom:10px">
|
||||
<div>
|
||||
<h1 style="display:flex;align-items:center;gap:10px">
|
||||
{heroTitle}
|
||||
{#if swapOk && !activeModel}
|
||||
<span style="font-size:13px;font-weight:400;color:var(--mut)">— bereit für Anfragen</span>
|
||||
{/if}
|
||||
</h1>
|
||||
<div class="sub">{heroSub}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI Tiles -->
|
||||
<div class="tiles">
|
||||
<div class="kpi {swapOk ? 'green' : 'red'}">
|
||||
<div class="k-h">
|
||||
<span class="k-t">LLM-Engine</span>
|
||||
<span class="k-ic">{@html icon('server')}</span>
|
||||
</div>
|
||||
<div class="k-v">{swapOk ? 'Online' : 'Offline'}</div>
|
||||
<div class="k-s">{s?.swap_url?.replace(/^https?:\/\//, '') ?? '—'}</div>
|
||||
</div>
|
||||
|
||||
<div class="kpi {activeModel ? 'blue' : 'muted'}">
|
||||
<div class="k-h">
|
||||
<span class="k-t">Aktives Modell</span>
|
||||
<span class="k-ic">{@html icon('cpu')}</span>
|
||||
</div>
|
||||
<div class="k-v" style="font-size:16px;word-break:break-word">{activeModel ? activeModel.name : 'Keins'}</div>
|
||||
<div class="k-s">
|
||||
{#if activeModel}
|
||||
{activeModel.meta?.ctx ? Math.round(activeModel.meta.ctx / 1024) + 'k ctx' : ''} · {activeModel.state}
|
||||
{:else}
|
||||
lädt bei erster Anfrage
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if sys}
|
||||
{@const t = sys.gpu_temp ?? sys.cpu?.temp ?? null}
|
||||
{@const tCol = tempColor(t)}
|
||||
<div class="kpi {tCol}">
|
||||
<div class="k-h"><span class="k-t">Temperatur (GPU)</span><span class="k-ic">{@html icon('thermo')}</span></div>
|
||||
<div class="k-v">{t != null ? `${Math.round(t)}°C` : '–'}</div>
|
||||
<div class="k-s">{t == null ? 'messe…' : t < 55 ? 'kühl' : t < 75 ? 'normal' : '⚠ heiß'}</div>
|
||||
</div>
|
||||
{@const free = sys.ram.total - sys.ram.used}
|
||||
{@const freePct = 100 - sys.ram.percent}
|
||||
<div class="kpi {freePct < 15 ? 'red' : freePct < 30 ? '' : 'green'}">
|
||||
<div class="k-h"><span class="k-t">Freier Speicher</span><span class="k-ic">{@html icon('database')}</span></div>
|
||||
<div class="k-v">{Math.round(free / 1024 ** 3)}<small> GB</small></div>
|
||||
<div class="k-s">von {Math.round(sys.ram.total / 1024 ** 3)} GB · {Math.round(freePct)} % frei</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="kpi muted"><div class="k-h"><span class="k-t">Temperatur</span></div><div class="k-v">–</div><div class="k-s">messe…</div></div>
|
||||
<div class="kpi muted"><div class="k-h"><span class="k-t">Freier Speicher</span></div><div class="k-v">–</div><div class="k-s">messe…</div></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Split: Health + Quickstart -->
|
||||
<div class="split">
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>System-Gesundheit</h3></div>
|
||||
<div class="card-sub">Auslastung in Echtzeit.</div>
|
||||
{#if sys}
|
||||
{@const gpu = gpuPct()}
|
||||
{#each [
|
||||
['Prozessor (CPU)', sys.cpu?.percent ?? 0, ''],
|
||||
['Arbeitsspeicher (RAM)', sys.ram.percent, ramColor(sys.ram.percent)],
|
||||
['Grafikspeicher (VRAM)', gpu, gpuColor(gpu)]
|
||||
] as [label, pct, cls]}
|
||||
{@const p = Math.max(0, Math.min(100, (pct as number) || 0))}
|
||||
<div class="meter">
|
||||
<div class="meter-h"><span class="mk">{label}</span><span class="mv">{Math.round(p)} %</span></div>
|
||||
<div class="bar {p >= 90 ? 'bad' : p >= 75 ? 'warn' : ''}"><i style="width:{Math.max(2, p)}%"></i></div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="empty">Warte auf Messwerte…</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>Schnellstart</h3></div>
|
||||
{#if !configuredModels.length}
|
||||
<!-- Onboarding-Pfad: noch keine Modelle -->
|
||||
<div class="card-sub">Noch kein Modell eingerichtet — starte hier:</div>
|
||||
<button class="qa" onclick={() => go('cookbook')}>
|
||||
<span class="qa-ic teal">{@html icon('search')}</span>
|
||||
<span class="qa-main"><span class="qa-t">Schritt 1 · Modell holen</span><span class="qa-s">Cookbook → passendes Setup installieren</span></span>
|
||||
<span class="qa-arrow">{@html icon('chevron')}</span>
|
||||
</button>
|
||||
<button class="qa" onclick={() => go('connect')} style="opacity:.55;pointer-events:none">
|
||||
<span class="qa-ic teal">{@html icon('swap')}</span>
|
||||
<span class="qa-main"><span class="qa-t">Schritt 2 · Tools verbinden</span><span class="qa-s">Erst nach dem Download verfügbar</span></span>
|
||||
<span class="qa-arrow">{@html icon('chevron')}</span>
|
||||
</button>
|
||||
<button class="qa" onclick={() => go('guides')}>
|
||||
<span class="qa-ic blue">{@html icon('file')}</span>
|
||||
<span class="qa-main"><span class="qa-t">Einsteiger-Guide lesen</span><span class="qa-s">Alle Begriffe erklärt, ohne Vorwissen</span></span>
|
||||
<span class="qa-arrow">{@html icon('chevron')}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Normalbetrieb -->
|
||||
<div class="card-sub">Die häufigsten Aufgaben — ein Klick.</div>
|
||||
<button class="qa" onclick={() => go('cookbook')}>
|
||||
<span class="qa-ic teal">{@html icon('search')}</span>
|
||||
<span class="qa-main"><span class="qa-t">Modell finden</span><span class="qa-s">Passend zu deiner Hardware</span></span>
|
||||
<span class="qa-arrow">{@html icon('chevron')}</span>
|
||||
</button>
|
||||
<button class="qa" onclick={freeMemory}>
|
||||
<span class="qa-ic amber">{@html icon('refresh')}</span>
|
||||
<span class="qa-main"><span class="qa-t">Speicher freigeben</span><span class="qa-s">Alle Modelle entladen</span></span>
|
||||
<span class="qa-arrow">{@html icon('chevron')}</span>
|
||||
</button>
|
||||
<button class="qa" onclick={() => go('server')}>
|
||||
<span class="qa-ic blue">{@html icon('file')}</span>
|
||||
<span class="qa-main"><span class="qa-t">Logs ansehen</span><span class="qa-s">Live mitlesen, was läuft</span></span>
|
||||
<span class="qa-arrow">{@html icon('chevron')}</span>
|
||||
</button>
|
||||
<button class="qa" onclick={() => go('connect')}>
|
||||
<span class="qa-ic teal">{@html icon('swap')}</span>
|
||||
<span class="qa-main"><span class="qa-t">Tools verbinden</span><span class="qa-s">Zed, OpenCode, Cline einrichten</span></span>
|
||||
<span class="qa-arrow">{@html icon('chevron')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stack + News -->
|
||||
<div class="grid grid-2">
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<h3>Dein Stack</h3>
|
||||
<span class="meta">{configuredModels.length ? configuredModels.length + ' konfiguriert' : ''}</span>
|
||||
</div>
|
||||
{#if configuredModels.length}
|
||||
<div class="list">
|
||||
{#each configuredModels as m}
|
||||
{@const isLoaded = LOADED.includes(m.state)}
|
||||
{@const downloading = m.state === 'downloading'}
|
||||
{@const dot = (m.state === 'loading' || m.state === 'starting' || downloading) ? 'load' : isLoaded ? 'on' : ''}
|
||||
{@const statusText = downloading
|
||||
? `↓ ${m.download_progress != null ? Math.round(m.download_progress) + '%' : '...'}`
|
||||
: m.state === 'loading' || m.state === 'starting' ? 'lädt…'
|
||||
: isLoaded ? 'geladen ✓' : 'bereit'}
|
||||
<div class="li" style="padding:10px 4px">
|
||||
<span class="li-dot {dot}"></span>
|
||||
<div class="li-main">
|
||||
<div class="flex items-center gap-2" style="flex-wrap:wrap">
|
||||
<span class="li-id" style="{isLoaded ? 'color:var(--accent)' : ''}">{m.name}</span>
|
||||
{#if m.meta?.caps?.includes('Bild')}<span class="tag img">Bild</span>
|
||||
{:else if m.meta?.caps?.includes('Code')}<span class="tag code">Code</span>
|
||||
{:else}<span class="tag text">Text</span>{/if}
|
||||
{#if m.ttl >= 99999}<span class="chip" style="padding:2px 7px;font-size:11px">∞ aktiv</span>{/if}
|
||||
</div>
|
||||
<div class="li-sub mono-sm">
|
||||
{m.meta?.ctx ? Math.round(m.meta.ctx / 1024) + 'k ctx' : ''}
|
||||
{m.meta?.quant ? ' · ' + m.meta.quant : ''}
|
||||
{m.meta?.size_bytes ? ' · ' + (m.meta.size_bytes / 1024 ** 3).toFixed(1) + ' GB' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="li-meta" style="white-space:nowrap;color:{isLoaded ? 'var(--on)' : 'var(--mut)'}">{statusText}</span>
|
||||
{#if !isLoaded && !downloading && swapOk}
|
||||
<button class="ghost" style="padding:3px 9px;font-size:12px;opacity:{preloadingModel === m.name ? '.5' : '1'}"
|
||||
disabled={preloadingModel === m.name}
|
||||
onclick={() => preload(m.name)}>
|
||||
{preloadingModel === m.name ? '…' : 'Laden'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-c">
|
||||
<div class="e-t">Noch keine Modelle</div>
|
||||
<div class="e-s">Hol dir unter „Cookbook" ein Modell, das auf deine Hardware passt.</div>
|
||||
<button class="primary" style="margin-top:12px" onclick={() => go('cookbook')}>Zum Cookbook →</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<h3>Top-News</h3>
|
||||
<span class="meta" style="cursor:pointer" role="button" tabindex="0"
|
||||
onclick={() => go('news')} onkeydown={e => e.key === 'Enter' && go('news')}>Alle →</span>
|
||||
</div>
|
||||
{#if news.length}
|
||||
<div class="list">
|
||||
{#each news as n}
|
||||
<a class="li" href={n.link} target="_blank" rel="noopener" style="text-decoration:none;color:inherit">
|
||||
<div class="li-main">
|
||||
<div style="font-size:13px;line-height:1.4">{n.title}</div>
|
||||
<div class="li-sub">{n.source}{n.relevant ? ' · für dich' : ''}</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty">News gerade nicht ladbar.</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,262 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { statusStore } from '../stores/status.svelte.js'
|
||||
import { jobsStore } from '../stores/jobs.svelte.js'
|
||||
import { api, getToken } from '@core/api.js'
|
||||
import { esc, toast, confirmModal, promptModal } from '@core/ui.js'
|
||||
|
||||
const status = $derived(statusStore.value)
|
||||
const jobs = $derived(jobsStore.value)
|
||||
|
||||
// Console
|
||||
let ws: WebSocket | null = null
|
||||
let consoleLogs = $state('Verbinde…\n')
|
||||
let selectedService = $state('llama-swap')
|
||||
let consoleEl: HTMLDivElement | undefined
|
||||
|
||||
// Active job tracking
|
||||
let activeJobId = $state<string | null>(null)
|
||||
let activeJobLabel = $state('')
|
||||
const notified = new Set<string>()
|
||||
|
||||
const activeJob = $derived(activeJobId ? jobs.find((j: any) => j.id === activeJobId) : null)
|
||||
|
||||
// Auto-scroll + job finish notification
|
||||
$effect(() => {
|
||||
if (!activeJob) return
|
||||
const j = activeJob as any
|
||||
const done = j.state === 'done', failed = j.state === 'failed', canceled = j.state === 'canceled'
|
||||
if ((done || failed || canceled) && !notified.has(j.id)) {
|
||||
notified.add(j.id)
|
||||
toast(done ? `✓ ${activeJobLabel} abgeschlossen.` : canceled ? `${activeJobLabel} abgebrochen.` : `✗ ${activeJobLabel} fehlgeschlagen.`, failed)
|
||||
if (done) setTimeout(() => document.dispatchEvent(new Event('mc:refresh')), 800)
|
||||
}
|
||||
})
|
||||
|
||||
function watchJob(id: string, label: string) {
|
||||
activeJobId = id
|
||||
activeJobLabel = label
|
||||
jobsStore.track(id)
|
||||
}
|
||||
|
||||
// Expose track for use from other panels (server panel tracks its own jobs)
|
||||
onMount(() => { connectConsole() })
|
||||
onDestroy(() => { ws?.close() })
|
||||
|
||||
function connectConsole() {
|
||||
ws?.close()
|
||||
const svc = selectedService
|
||||
consoleLogs = `Verbinde mit ${svc}…\n`
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
ws = new WebSocket(`${proto}//${location.host}/api/logs/${svc}?token=${encodeURIComponent(getToken())}`)
|
||||
ws.onmessage = e => {
|
||||
consoleLogs += e.data
|
||||
if (consoleEl) consoleEl.scrollTop = consoleEl.scrollHeight
|
||||
}
|
||||
ws.onclose = () => { consoleLogs += '\n— Verbindung getrennt —' }
|
||||
ws.onerror = () => ws?.close()
|
||||
}
|
||||
|
||||
async function restartService(name: string, title: string, body: string) {
|
||||
if (!await confirmModal({ title, body, confirmLabel: 'Neustarten' })) return
|
||||
try {
|
||||
await api(`/api/service/${name}/restart`, { method: 'POST' })
|
||||
toast('Neustart ausgelöst: ' + name)
|
||||
setTimeout(() => document.dispatchEvent(new Event('mc:refresh')), 2000)
|
||||
} catch (e: any) { toast(e.message, true) }
|
||||
}
|
||||
|
||||
async function selfUpdate() {
|
||||
if (!await confirmModal({ title: 'Mission Control aktualisieren?', body: 'Holt die neueste Dashboard-Version aus Gitea und startet automatisch neu. Die Seite trennt kurz und verbindet von selbst wieder (~10 Sek).' })) return
|
||||
try { const r = await api('/api/self-update', { method: 'POST' }); toast('Update läuft — Dashboard startet gleich neu.'); watchJob(r.job_id, 'Mission Control Update') }
|
||||
catch (e: any) { toast(e.message, true) }
|
||||
}
|
||||
|
||||
async function engineUpdate() {
|
||||
if (!await confirmModal({ title: 'LLM-Engine aktualisieren?', body: 'Lädt die neueste llama.cpp-ROCm-Version und installiert sie. Die Engine ist während des Updates kurz nicht erreichbar.' })) return
|
||||
const pwd = await promptModal({ title: 'sudo-Passwort', body: 'Für das Engine-Update wird einmalig dein sudo-Passwort gebraucht (für den Update-Befehl).', placeholder: 'sudo-Passwort', password: true, confirmLabel: 'Update starten' })
|
||||
if (pwd === null) return
|
||||
try { const r = await api('/api/update', { method: 'POST', body: JSON.stringify({ password: pwd }) }); toast('LLM-Engine-Update läuft.'); watchJob(r.job_id, 'LLM-Engine-Update') }
|
||||
catch (e: any) { toast(e.message, true) }
|
||||
}
|
||||
|
||||
async function unloadAll() {
|
||||
if (!await confirmModal({ title: 'Grafikspeicher leeren?', body: 'Alle geladenen Modelle werden entladen. Sie laden beim nächsten Aufruf automatisch neu — es geht nichts verloren.', confirmLabel: 'Leeren' })) return
|
||||
try { await api('/api/unload', { method: 'POST' }); toast('Grafikspeicher geleert.'); setTimeout(() => document.dispatchEvent(new Event('mc:refresh')), 600) }
|
||||
catch (e: any) { toast(e.message, true) }
|
||||
}
|
||||
|
||||
async function osUpdate() {
|
||||
if (!await confirmModal({ title: 'OS-Updates installieren?', body: 'Führt <code>apt update & upgrade</code> aus. Das kann ein paar Minuten dauern; der Fortschritt erscheint in der Aktivität.' })) return
|
||||
const pwd = await promptModal({ title: 'sudo-Passwort', body: 'Für die System-Updates wird einmalig dein sudo-Passwort gebraucht.', placeholder: 'sudo-Passwort', password: true, confirmLabel: 'Installieren' })
|
||||
if (!pwd) return
|
||||
try { const r = await api('/api/os-update', { method: 'POST', body: JSON.stringify({ password: pwd }) }); toast('OS-Update gestartet.'); watchJob(r.job_id, 'OS-Update') }
|
||||
catch (e: any) { toast(e.message, true) }
|
||||
}
|
||||
|
||||
async function rebootServer() {
|
||||
if (!await confirmModal({ title: 'Server wirklich neu starten?', body: 'Der ganze Bosgame startet physisch neu. Alles ist für ~1 Minute offline — auch dieses Dashboard.', confirmLabel: 'Reboot', danger: true })) return
|
||||
const pwd = await promptModal({ title: 'sudo-Passwort', body: 'Für den Reboot wird einmalig dein sudo-Passwort gebraucht.', placeholder: 'sudo-Passwort', password: true, confirmLabel: 'Jetzt neustarten', danger: true })
|
||||
if (!pwd) return
|
||||
try { await api('/api/reboot', { method: 'POST', body: JSON.stringify({ password: pwd }) }); toast('Reboot ausgelöst — bis gleich.') }
|
||||
catch (e: any) { toast(e.message, true) }
|
||||
}
|
||||
|
||||
async function checkSwap() {
|
||||
swapCheckResult = 'prüfe…'
|
||||
try {
|
||||
const r = await api('/api/integration/test')
|
||||
swapCheckResult = r.ok ? `✓ Engine antwortet — ${r.models.length} Modell(e): ${r.models.join(', ')}` : `✗ keine Verbindung: ${r.error}`
|
||||
swapCheckOk = r.ok
|
||||
} catch (e: any) { swapCheckResult = '✗ ' + e.message; swapCheckOk = false }
|
||||
}
|
||||
|
||||
let swapCheckResult = $state('')
|
||||
let swapCheckOk = $state(false)
|
||||
|
||||
const swapModels = $derived((status?.models || []) as any[])
|
||||
const LOADED = ['running', 'ready', 'loading', 'starting']
|
||||
const loadedModel = $derived(swapModels.find((m: any) => LOADED.includes(m.state)))
|
||||
|
||||
// Job progress helpers
|
||||
function jobPct(j: any) {
|
||||
if (!j || j.state !== 'running') return null
|
||||
if (typeof j.progress === 'number') return Math.min(100, j.progress)
|
||||
const last = (j.log || []).at(-1) || ''
|
||||
const m = last.match(/(\d+(?:\.\d+)?)\s*%/)
|
||||
return m ? Math.min(100, parseFloat(m[1])) : null
|
||||
}
|
||||
|
||||
function qa(tone: string, title: string, desc: string, fn: () => void, danger = false) {
|
||||
return { tone, title, desc, fn, danger }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="pagehead">
|
||||
<div>
|
||||
<h1>Server & Wartung</h1>
|
||||
<div class="sub">Dienste steuern, Updates einspielen und live mitlesen — ganz ohne SSH oder Terminal.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LLM-Engine Card -->
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<h3>LLM-Engine (llama-swap)</h3>
|
||||
<span class="chip" style="margin-left:auto;{status?.swap_ok
|
||||
? 'background:rgba(63,185,80,.12);border-color:rgba(63,185,80,.25);color:#7ee29a'
|
||||
: 'background:rgba(248,81,73,.12);border-color:rgba(248,81,73,.3);color:#ff9b95'}">
|
||||
{status?.swap_ok ? '● erreichbar' : '● offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-sub">Das Herzstück: lädt bei jeder Anfrage automatisch das passende Modell.</div>
|
||||
<div class="kv" style="margin-bottom:6px">
|
||||
<div class="kv-row"><span class="kv-k">Adresse (für deine Tools)</span><span class="kv-v mono-sm">{(status?.swap_url || '—') + '/v1'}</span></div>
|
||||
<div class="kv-row"><span class="kv-k">Aktuell geladen</span>
|
||||
<span class="kv-v">{loadedModel ? loadedModel.name + ` (${loadedModel.state})` : 'nichts geladen — lädt bei der nächsten Anfrage'}</span>
|
||||
</div>
|
||||
<div class="kv-row"><span class="kv-k">Konfigurierte Modelle</span>
|
||||
<span class="kv-v">{swapModels.filter((m: any) => !m.incomplete).length || 'keine'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-row" style="display:flex;align-items:center;gap:10px">
|
||||
<button class="ghost" onclick={checkSwap}>Verbindung prüfen</button>
|
||||
{#if swapCheckResult}
|
||||
<span class="mono-sm" style="color:{swapCheckOk ? '#7ee29a' : '#ff9b95'}">{swapCheckResult}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service Actions -->
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>Dienste & Applikation</h3></div>
|
||||
<div class="card-sub">Diese Aktionen sind harmlos und passwortlos — es geht nichts dabei verloren.</div>
|
||||
<button class="qa" onclick={() => restartService('llama-swap', 'LLM-Engine neustarten?', 'Die Engine startet neu. Geladene Modelle werden kurz entladen (~5 Sekunden), laden danach automatisch wieder.')}>
|
||||
<span class="qa-ic teal">↻</span>
|
||||
<span class="qa-main"><span class="qa-t">LLM-Engine neustarten</span><span class="qa-s">Startet llama-swap neu. Geladene Modelle laden danach automatisch wieder.</span></span>
|
||||
<span class="qa-arrow">›</span>
|
||||
</button>
|
||||
<button class="qa" onclick={() => restartService('mission-control', 'Dashboard neustarten?', 'Diese Oberfläche trennt sich kurz und verbindet automatisch wieder.')}>
|
||||
<span class="qa-ic blue">↻</span>
|
||||
<span class="qa-main"><span class="qa-t">Dashboard neustarten</span><span class="qa-s">Startet diese Oberfläche neu (~5 Sek).</span></span>
|
||||
<span class="qa-arrow">›</span>
|
||||
</button>
|
||||
<button class="qa" onclick={selfUpdate}>
|
||||
<span class="qa-ic teal">⬇</span>
|
||||
<span class="qa-main"><span class="qa-t">Mission Control aktualisieren</span><span class="qa-s">Holt die neueste Version aus Gitea und startet automatisch neu (~10 Sek).</span></span>
|
||||
<span class="qa-arrow">›</span>
|
||||
</button>
|
||||
<button class="qa" onclick={engineUpdate}>
|
||||
<span class="qa-ic blue">⚡</span>
|
||||
<span class="qa-main"><span class="qa-t">LLM-Engine aktualisieren</span><span class="qa-s">Lädt die neueste llama.cpp-Version (ROCm) für deine GPU.</span></span>
|
||||
<span class="qa-arrow">›</span>
|
||||
</button>
|
||||
<button class="qa" onclick={unloadAll}>
|
||||
<span class="qa-ic amber">🗄</span>
|
||||
<span class="qa-main"><span class="qa-t">Grafikspeicher leeren</span><span class="qa-s">Wirft alle aktuell geladenen Modelle aus dem VRAM. Fragt vorher nach.</span></span>
|
||||
<span class="qa-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- OS Actions -->
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>Betriebssystem (Bosgame)</h3></div>
|
||||
<div class="card-sub">Tiefe Eingriffe — das Dashboard fragt einmalig nach deinem sudo-Passwort.</div>
|
||||
<button class="qa" onclick={osUpdate}>
|
||||
<span class="qa-ic amber">↻</span>
|
||||
<span class="qa-main"><span class="qa-t">OS-Updates installieren</span><span class="qa-s">Führt apt update & upgrade aus. Kann ein paar Minuten dauern.</span></span>
|
||||
<span class="qa-arrow">›</span>
|
||||
</button>
|
||||
<button class="qa qa-danger" onclick={rebootServer}>
|
||||
<span class="qa-ic red">⚡</span>
|
||||
<span class="qa-main"><span class="qa-t">Server neustarten (Reboot)</span><span class="qa-s">Bootet den ganzen Bosgame neu. Alles ist für ~1 Minute offline.</span></span>
|
||||
<span class="qa-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Active Job Card -->
|
||||
{#if activeJob}
|
||||
{@const j = activeJob as any}
|
||||
{@const done = j.state === 'done'}
|
||||
{@const failed = j.state === 'failed'}
|
||||
{@const canceled = j.state === 'canceled'}
|
||||
{@const terminal = done || failed || canceled}
|
||||
{@const p = jobPct(j)}
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<h3>Aktueller Vorgang</h3>
|
||||
{#if !terminal}
|
||||
<button class="ghost" style="margin-left:auto;padding:3px 12px"
|
||||
onclick={async () => {
|
||||
if (await confirmModal({ title: activeJobLabel + ' abbrechen?', body: 'Der laufende Vorgang wird gestoppt.', confirmLabel: 'Abbrechen', danger: true }))
|
||||
api(`/api/jobs/${j.id}/cancel`, { method: 'POST' })
|
||||
}}>Abbrechen</button>
|
||||
{/if}
|
||||
<span style="margin-left:10px">
|
||||
{#if done}<span class="badge b-run">✓ fertig</span>
|
||||
{:else if failed}<span class="badge b-err">✗ fehlgeschlagen</span>
|
||||
{:else if canceled}<span class="badge">abgebrochen</span>
|
||||
{:else}<span class="badge b-load">läuft…</span>{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-sub">{activeJobLabel}{terminal ? (done ? ' — erfolgreich.' : canceled ? ' — abgebrochen.' : ' — fehlgeschlagen.') : ' läuft…'}</div>
|
||||
{#if p != null && !terminal}
|
||||
<div class="bar" style="margin-bottom:10px"><i style="width:{p}%"></i></div>
|
||||
{/if}
|
||||
<div class="log" style="max-height:280px">{(j.log || []).join('\n')}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Live Console -->
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<h3>Live-Konsole</h3>
|
||||
<select bind:value={selectedService} onchange={connectConsole} style="margin:0 0 0 auto;width:240px">
|
||||
<option value="llama-swap">LLM-Engine (llama-swap)</option>
|
||||
<option value="mission-control">Dashboard (mission-control)</option>
|
||||
<option value="system">System (ganzer Server)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="card-sub">Live mitlesen, was der Dienst gerade tut.</div>
|
||||
<div class="console" bind:this={consoleEl}>{consoleLogs}</div>
|
||||
</div>
|
||||
@@ -0,0 +1,656 @@
|
||||
export type GuideGroup = {
|
||||
label: string
|
||||
tutorials: [string, string][]
|
||||
}
|
||||
|
||||
export const GUIDE_GROUPS: GuideGroup[] = [
|
||||
{
|
||||
label: 'Einstieg',
|
||||
tutorials: [
|
||||
["Dein erster Workflow — in 3 Schritten", `
|
||||
<ol class="tut-steps">
|
||||
<li><b>Modell holen:</b> Tab <b>Cookbook</b> → Anwendungsfall wählen (z.B. „Coden & Programmieren") →
|
||||
<b>Komplettes Setup installieren</b>. Der Download läuft im Tab <b>Aktivität</b> — einfach warten bis der Job grün wird.</li>
|
||||
<li><b>Verbinden:</b> Tab <b>Verbinden</b> → Tool wählen → Config-Snippet kopieren und einfügen.
|
||||
<b>Verbindung testen</b> bestätigt, dass alles klappt.</li>
|
||||
<li><b>Arbeiten:</b> Im Tool das gewünschte Modell auswählen und loslegen.
|
||||
Das Modell wird <b>automatisch geladen</b> — du musst nichts manuell starten.</li>
|
||||
</ol>
|
||||
<p style="margin-top:10px"><b>Nicht sicher welches Tool du nehmen sollst?</b>
|
||||
Lies den Abschnitt „Welches Tool ist das richtige für mich?" weiter unten.</p>`],
|
||||
|
||||
["Kontext — das Kurzzeitgedächtnis des Modells", `
|
||||
<p>Der Kontext ist wie ein Blatt Papier: alles was du mit dem Modell besprochen hast,
|
||||
steht darauf. Wird es voll, fallen die ältesten Nachrichten raus.</p>
|
||||
<p><b>Was du meist nicht tun musst:</b> Tools wie OpenCode, Cline und OpenWebUI
|
||||
verwalten das Kontextfenster automatisch. Ein neuer Chat = leeres Blatt.</p>
|
||||
<p><b>Wenn der Kontext volläuft</b> (Fehler „context length exceeded" oder das Modell vergisst den Anfang):</p>
|
||||
<ol class="tut-steps">
|
||||
<li><b>Neuen Chat starten</b> — der einfachste Reset.</li>
|
||||
<li>Vorher: <em>„Fasse kurz zusammen was wir bisher erreicht haben"</em> — dann diesen Stand in den neuen Chat einfügen.</li>
|
||||
<li><b>Kontext vergrößern:</b> Tab <b>Modelle</b> → <b>Konfigurieren</b> → Empfehlung für deine Hardware übernehmen.</li>
|
||||
</ol>
|
||||
<p><b>Wichtig:</b> „Grafikspeicher leeren" (Tab <b>Server</b>) ist etwas anderes —
|
||||
das wirft das <em>Modell</em> aus dem Speicher, nicht den Gesprächsverlauf.</p>`],
|
||||
|
||||
["Auto-Swap — Modelle laden sich von selbst", `
|
||||
<p>llama-swap lädt automatisch das Modell, das gerade angefragt wird —
|
||||
und entlädt es nach einer Leerlaufzeit wieder. <b>Es läuft immer nur eines gleichzeitig.</b></p>
|
||||
<p>Du richtest beliebig viele Modelle ein und wählst im Tool das passende.
|
||||
Das Laden und Entladen passiert unsichtbar im Hintergrund.</p>
|
||||
<p><b>Warum dauert die erste Antwort manchmal 5–15 Sekunden?</b><br>
|
||||
Das Modell wird gerade geladen — danach geht es normal weiter.
|
||||
Die Topbar zeigt <em>„[Modellname] lädt…"</em> während das passiert.</p>
|
||||
<p><b>Scout immer sofort verfügbar halten:</b> Tab <b>Modelle</b> → Konfigurieren →
|
||||
<em>Immer aktiv halten</em> aktivieren (TTL = ∞). Das Modell bleibt dann dauerhaft im RAM
|
||||
und antwortet ohne Wartezeit — ideal für den kleinen, schnellen Allrounder.</p>`],
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Tools einrichten',
|
||||
tutorials: [
|
||||
["Welches Tool ist das richtige für mich?", `
|
||||
<p><b>Chat — ich will einfach reden, schreiben, fragen:</b></p>
|
||||
<div class="tut-table">
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">Einfachster Einstieg</div>
|
||||
<div class="tut-cell-bd">→ <b>Jan AI</b> — Desktop-App, Doppelklick-Installer, sofort loslegen.
|
||||
Vertraut wie ChatGPT, läuft komplett lokal. Kein Admin-Panel, kein Docker.</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">Teams / Power-User</div>
|
||||
<div class="tut-cell-bd">→ <b>OpenWebUI</b> — viele Features (RAG, Bilder, Plugins, Mehrbenutzer).
|
||||
Mehr Setup-Aufwand, aber der mächtigste Chat-Client.</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin-top:12px"><b>Coding — ich will KI beim Programmieren:</b></p>
|
||||
<div class="tut-table">
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">Simpler Einstieg in VS Code</div>
|
||||
<div class="tut-cell-bd">→ <b>Continue</b> — Chat + Autocomplete direkt im Editor, kein Agent-Overhead.
|
||||
Macht keinen Schritt ohne dich.</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">Voller Coding-Agent im Editor</div>
|
||||
<div class="tut-cell-bd">→ <b>Cline</b> — liest Dateien, schreibt Code, fragt vor jeder Änderung.
|
||||
VS Code, JetBrains, Zed u.a.</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">Agent-IDE mit VS Code-Feeling</div>
|
||||
<div class="tut-cell-bd">→ <b>Antigravity</b> — Googles agentic IDE (VS Code-Fork), kostenlos.
|
||||
Multi-Agent, planen + coden + testen in einem.</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">Autonomer Terminal-Agent</div>
|
||||
<div class="tut-cell-bd">→ <b>OpenCode</b> — mächtigster lokaler Agent, lebt im Terminal.
|
||||
Ideal für „bau mir Feature X komplett fertig".</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">Schneller Editor, minimalistisch</div>
|
||||
<div class="tut-cell-bd">→ <b>Zed</b> — nativer Editor (Rust), eingebautes KI-Panel.
|
||||
Für Leute die Performance über Features stellen.</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin-top:10px"><b>Faustregel:</b> Fang mit <b>Jan AI</b> zum Chatten an.
|
||||
Für Coding: <b>Continue</b> als sanfter Einstieg oder gleich <b>Cline/Antigravity</b>
|
||||
wenn du bereit bist, dem Agenten mehr Kontrolle zu geben.</p>`],
|
||||
|
||||
["OpenCode verbinden und die ersten Schritte", `
|
||||
<p>OpenCode ist installiert? Dann in vier Schritten verbinden:</p>
|
||||
<ol class="tut-steps">
|
||||
<li><b>Config-Snippet holen:</b> Tab <b>Verbinden</b> → <em>OpenCode</em> auswählen →
|
||||
Snippet kopieren und in <code>~/.config/opencode/opencode.json</code> einfügen.</li>
|
||||
<li><b>OpenCode starten:</b> Terminal öffnen, <code>opencode</code> eintippen —
|
||||
immer im Projektordner starten, damit der Agent deine Dateien sieht.</li>
|
||||
<li><b>Modell wählen:</b> <code>/models</code> eintippen → <em>llama-swap/coder</em> auswählen.</li>
|
||||
<li><b>Erste Aufgabe geben</b> — z.B.:
|
||||
<em>„Lies die Datei README.md und erkläre mir kurz was dieses Projekt macht."</em></li>
|
||||
</ol>
|
||||
<p><b>Die wichtigsten Slash-Commands:</b></p>
|
||||
<div class="tut-table">
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd"><code>/model</code></div>
|
||||
<div class="tut-cell-bd">Modell wechseln — z.B. mitten im Gespräch auf <em>scout</em> für kurze Fragen</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd"><code>/compact</code> · <code>/summarize</code></div>
|
||||
<div class="tut-cell-bd">Kontext komprimieren wenn er voll wird — fasst die Session zusammen und setzt fort</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd"><code>/clear</code> · <code>/new</code></div>
|
||||
<div class="tut-cell-bd">Neue Session starten — komplett leerer Kontext</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>Bevor OpenCode eine Datei ändert, fragt es nach — mit <b>y</b> bestätigen oder <b>n</b> ablehnen.</p>
|
||||
<p><b>Terminal oder Desktop-App?</b> Die Anleitung gilt für beide —
|
||||
der Config-Snippet landet in derselben Datei (<code>~/.config/opencode/opencode.json</code>),
|
||||
und alle Slash-Commands funktionieren in beiden Versionen gleich.
|
||||
Unterschied: Im Terminal startest du mit <code>cd ~/mein-projekt && opencode</code>;
|
||||
in der Desktop-App öffnest du einfach die App und wählst den Projektordner über die GUI.</p>
|
||||
<p><b>Multi-Agenten (2026):</b> Du kannst in OpenCode mehrere spezialisierte Agenten definieren
|
||||
— z.B. einen <em>review</em>-Agent und einen <em>coder</em>-Agent.
|
||||
Mit <b>Tab</b> wechselst du zwischen primären Agenten; mit <b>@review</b> rufst du einen Subagenten direkt im Chat auf.
|
||||
Agenten werden als Markdown-Dateien angelegt: eine Datei <code>review.md</code> im
|
||||
<code>~/.config/opencode/</code>-Ordner erstellt automatisch den Agenten <em>review</em>.</p>
|
||||
<p class="tut-sources">Weitere Infos: <a href="https://opencode.ai/docs" target="_blank" rel="noopener">opencode.ai/docs</a> · <a href="https://opencode.ai/docs/agents/" target="_blank" rel="noopener">Agenten-Docs</a></p>`],
|
||||
|
||||
["Cline verbinden und nutzen", `
|
||||
<p>Cline (v3.81, Stand Juni 2026) läuft in: <b>VS Code, JetBrains, Cursor, Windsurf, Zed, Neovim</b>
|
||||
und als CLI auf macOS/Linux. Verbinden funktioniert überall gleich:</p>
|
||||
<ol class="tut-steps">
|
||||
<li>Das Cline-Panel öffnen (Roboter-Icon in der Seitenleiste).</li>
|
||||
<li>Provider <b>„OpenAI Compatible"</b> wählen.</li>
|
||||
<li>Tab <b>Verbinden</b> → <em>Cline / Cursor</em> → Basis-URL und Modell-ID kopieren und eintragen.</li>
|
||||
<li>API-Key: irgendeinen beliebigen Text eingeben (z.B. <code>local</code>) — wird nicht geprüft.</li>
|
||||
</ol>
|
||||
<p><b>Plan-Modus vs. Act-Modus:</b></p>
|
||||
<div class="tut-table">
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">Plan</div>
|
||||
<div class="tut-cell-bd">Cline liest und denkt nach — erklärt den Plan bevor es handelt.
|
||||
Billiger (weniger Tokens) und ideal für Anfänger.</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">Act</div>
|
||||
<div class="tut-cell-bd">Cline handelt sofort, fragt aber vor jeder Dateiänderung um Erlaubnis.
|
||||
Schneller für klare Aufgaben.</div>
|
||||
</div>
|
||||
</div>
|
||||
<p><b>Memory Bank (empfohlen):</b> Lege eine Datei <code>projectbrief.md</code> im Projektordner an —
|
||||
Cline liest sie zu Beginn jeder Aufgabe automatisch. Sie ist das Fundament: Projektziel, Tech-Stack,
|
||||
wichtigste Regeln. Ergänzend: <code>activeContext.md</code> für den aktuellen Fokus,
|
||||
<code>progress.md</code> für den Stand.</p>
|
||||
<p><b>.clinerules:</b> Für größere Projekte kannst du statt einer einzelnen Datei ein ganzes
|
||||
Verzeichnis <code>.clinerules/</code> anlegen — jede Datei darin gilt als separate Regel.
|
||||
Achtung: Jedes Zeichen in .clinerules verbraucht Kontext — kurze, präzise Regeln schlagen lange Listen.</p>
|
||||
<p><b>Ausgaben-Limit (seit April 2026):</b> In Settings → Spending Limits kannst du ein
|
||||
Tages- oder Monats-Budget setzen — verhindert dass ein laufender Agent unbemerkt viel Token verbraucht.</p>
|
||||
<p class="tut-sources">Weitere Infos: <a href="https://cline.bot" target="_blank" rel="noopener">cline.bot</a> · <a href="https://docs.cline.bot/best-practices/memory-bank" target="_blank" rel="noopener">Memory Bank Docs</a></p>`],
|
||||
|
||||
["Jan AI — einfachstes Chat-Interface", `
|
||||
<p><b>Jan AI</b> ist die einfachste Art, lokal mit KI zu chatten —
|
||||
Desktop-App, kein Terminal, kein Docker, kein Admin-Panel.</p>
|
||||
<ol class="tut-steps">
|
||||
<li><b>Jan AI installieren:</b> <a href="https://jan.ai" target="_blank" rel="noopener">jan.ai</a>
|
||||
→ Download → Doppelklick-Installer (Windows/macOS/Linux).</li>
|
||||
<li><b>Remote-Modell hinzufügen:</b> In der linken Leiste auf das Hub-Icon →
|
||||
oben rechts <b>+ Remote-Modell</b>.</li>
|
||||
<li><b>Verbindung eintragen:</b> Tab <b>Verbinden</b> → <em>Jan AI</em> auswählen →
|
||||
Basis-URL und Modell-ID kopieren und in Jan einfügen.</li>
|
||||
<li>API-Key: irgendeinen Text (z.B. <code>local</code>). Speichern.</li>
|
||||
</ol>
|
||||
<p>Danach erscheint das Modell in der Modell-Liste — auswählen und loslegen.</p>
|
||||
<p><b>Gut für:</b> Chatten, Texte schreiben, übersetzen, Fragen stellen —
|
||||
genauso wie ChatGPT, aber 100% lokal und privat. Bilder mitschicken klappt auch
|
||||
sobald ein Vision-Modell eingetragen ist.</p>
|
||||
<p class="tut-sources">Download & Docs: <a href="https://jan.ai" target="_blank" rel="noopener">jan.ai</a> · <a href="https://www.jan.ai/docs" target="_blank" rel="noopener">jan.ai/docs</a></p>`],
|
||||
|
||||
["Continue — Coding-Hilfe in VS Code ohne Agent-Overhead", `
|
||||
<p><b>Continue</b> ist die einfachste Coding-Erweiterung für VS Code und JetBrains —
|
||||
kein autonomer Agent, sondern ein KI-Assistent der nur auf Anfrage handelt.</p>
|
||||
<ol class="tut-steps">
|
||||
<li><b>Extension installieren:</b> VS Code → Extensions → „Continue" suchen → Installieren.<br>
|
||||
Oder: <a href="https://continue.dev" target="_blank" rel="noopener">continue.dev</a></li>
|
||||
<li><b>Config holen:</b> Tab <b>Verbinden</b> → <em>Continue</em> auswählen →
|
||||
Snippet kopieren und in <code>~/.continue/config.json</code> einfügen.</li>
|
||||
<li>Continue in VS Code neu starten → im Chat-Panel Modell wählen → loslegen.</li>
|
||||
</ol>
|
||||
<p><b>Was Continue kann:</b></p>
|
||||
<ul class="tut-steps" style="list-style:disc">
|
||||
<li><b>Chat:</b> Frage direkt im Editor — markiere Code und drücke <code>Strg+L</code></li>
|
||||
<li><b>Autocomplete:</b> Ergänzt Code während du tippst (wie Copilot)</li>
|
||||
<li><b>Inline-Edit:</b> Markieren → <code>Strg+I</code> → „Refactore das zu TypeScript"</li>
|
||||
</ul>
|
||||
<p><b>Unterschied zu Cline:</b> Continue macht nichts von alleine — du steuerst jeden Schritt.
|
||||
Ideal als erster Einstieg bevor man mit vollen Agenten arbeitet.</p>
|
||||
<p class="tut-sources">Weitere Infos: <a href="https://continue.dev" target="_blank" rel="noopener">continue.dev</a>
|
||||
— Hinweis: 2026 von Cursor akquiriert, weiterhin kostenlos & open source.</p>`],
|
||||
|
||||
["Antigravity — Googles agentic IDE", `
|
||||
<p><b>Antigravity</b> ist Googles agent-first IDE — ein Fork von VS Code, der Planung,
|
||||
Coding, Testing und Deployment in einem Workflow zusammenführt. Kostenlos für Einzelpersonen.</p>
|
||||
<ol class="tut-steps">
|
||||
<li><b>Antigravity installieren:</b>
|
||||
<a href="https://antigravityide.net" target="_blank" rel="noopener">antigravityide.net</a>
|
||||
→ Download → läuft wie VS Code.</li>
|
||||
<li><b>Eigenen Endpoint einrichten:</b> Settings → Models → <b>Custom Endpoint</b> → + hinzufügen.</li>
|
||||
<li><b>Verbindung eintragen:</b> Tab <b>Verbinden</b> → <em>Antigravity</em> auswählen →
|
||||
Basis-URL und Modell-ID kopieren und eintragen.</li>
|
||||
<li>API-Key: beliebiger Text (z.B. <code>local</code>). Danach das Modell aus der Liste wählen.</li>
|
||||
</ol>
|
||||
<p><b>Was Antigravity besonders macht:</b></p>
|
||||
<ul class="tut-steps" style="list-style:disc">
|
||||
<li><b>Agent-First:</b> Statt Code-Completion steht der Agent im Mittelpunkt —
|
||||
du beschreibst, er plant und setzt um</li>
|
||||
<li><b>Multi-Agent:</b> Mehrere Agenten arbeiten parallel an verschiedenen Teilen</li>
|
||||
<li><b>Manager-Oberfläche:</b> Übersicht über alle laufenden Agenten-Sessions</li>
|
||||
<li><b>VS Code-kompatibel:</b> Extensions und Keybindings funktionieren wie gewohnt</li>
|
||||
</ul>
|
||||
<p><b>Wann Antigravity statt OpenCode?</b> Wenn du lieber eine grafische IDE als ein Terminal magst
|
||||
und trotzdem volle Agenten-Kontrolle willst. OpenCode ist mächtiger im Terminal-Flow;
|
||||
Antigravity ist komfortabler für Umsteiger aus VS Code.</p>
|
||||
<p class="tut-sources">Weitere Infos: <a href="https://antigravityide.net" target="_blank" rel="noopener">antigravityide.net</a> ·
|
||||
<a href="https://developers.googleblog.com/build-with-google-antigravity-our-new-agentic-development-platform/" target="_blank" rel="noopener">Google Dev Blog</a></p>`],
|
||||
|
||||
["OpenWebUI — für Teams und Power-User", `
|
||||
<p>OpenWebUI ist der mächtigste lokale Chat-Client — aber auch der komplexeste.
|
||||
Empfohlen wenn du mehr als ein einfaches Chat-Interface brauchst.</p>
|
||||
<p><b>Was OpenWebUI besonders kann (gegenüber Jan AI):</b></p>
|
||||
<ul class="tut-steps" style="list-style:disc">
|
||||
<li>RAG: eigene Dokumente hochladen, KI liest und beantwortet Fragen dazu</li>
|
||||
<li>Mehrbenutzer: eigene Accounts für verschiedene Personen im Heimnetz</li>
|
||||
<li>Plugins und Agenten: erweiterbar mit Tools und Custom-Pipelines</li>
|
||||
<li>Bilder generieren (mit Stable Diffusion), Sprachausgabe, Web-Suche</li>
|
||||
</ul>
|
||||
<p><b>Verbinden</b> (braucht Docker oder eine lokale Installation):</p>
|
||||
<ol class="tut-steps">
|
||||
<li>OpenWebUI im Browser öffnen → <b>Admin Panel</b> → <b>Connections</b>.</li>
|
||||
<li><b>+ OpenAI-Verbindung</b> → Tab <b>Verbinden</b> → <em>OpenWebUI</em> → Basis-URL kopieren.</li>
|
||||
<li>API-Key: <code>dummy-key</code>. Speichern.</li>
|
||||
</ol>
|
||||
<p>Danach Modell auswählen und loslegen. Für einfaches Chatten: <b>Jan AI</b> ist einfacher.
|
||||
OpenWebUI lohnt sich ab dem Moment, wo du mehr als nur Chat willst.</p>
|
||||
<p class="tut-sources">Weitere Infos: <a href="https://openwebui.com" target="_blank" rel="noopener">openwebui.com</a></p>`],
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Agenten meistern',
|
||||
tutorials: [
|
||||
["Was ein Agent tut — und was er nicht kann", `
|
||||
<p>Ein nacktes Modell antwortet auf Text — das war's.
|
||||
Ein <b>Agent</b> ist das Programm drumherum, das das Modell in eine <b>Handlungsschleife</b> bringt:</p>
|
||||
<p style="text-align:center;padding:8px 0;font-family:monospace;font-size:12.5px;color:var(--accent)">
|
||||
Planen → Werkzeug nutzen → Ergebnis prüfen → nächsten Schritt planen → …
|
||||
</p>
|
||||
<p>Konkret: Datei lesen → Code verstehen → Änderung schreiben → Test ausführen → Fehler beheben —
|
||||
ohne dass du jeden Schritt anstoßen musst.</p>
|
||||
<p><b>Was Agenten gut können:</b></p>
|
||||
<ul class="tut-steps" style="list-style:disc">
|
||||
<li>Features über mehrere Dateien implementieren</li>
|
||||
<li>Bugs finden und in ihrem Kontext beheben</li>
|
||||
<li>Code refactoren, Dokumentation schreiben, Tests ergänzen</li>
|
||||
<li>Recherchieren und das Ergebnis direkt umsetzen (mit MCP)</li>
|
||||
<li>Parallele Teilaufgaben an Subagenten delegieren (OpenCode 2026)</li>
|
||||
</ul>
|
||||
<p><b>Was Agenten schlecht können:</b></p>
|
||||
<ul class="tut-steps" style="list-style:disc">
|
||||
<li>Kreative Richtungsentscheidungen ohne klare Vorgabe treffen</li>
|
||||
<li>Sehr komplexe, undokumentierte Systeme ohne Kontext verstehen</li>
|
||||
<li>Fehler in externer Hardware oder fremden APIs beheben</li>
|
||||
</ul>
|
||||
<p><b>Faustregel:</b> Je klarer die Aufgabe, desto besser das Ergebnis. Unklarer Auftrag → unklares Ergebnis.</p>`],
|
||||
|
||||
["Einen Agenten richtig briefen", `
|
||||
<p>Der Unterschied zwischen „es passiert nichts Sinnvolles" und „es funktioniert auf Anhieb"
|
||||
ist meistens die Qualität des Auftrags.</p>
|
||||
<p><b>Schlecht → Gut:</b></p>
|
||||
<div class="tut-table">
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd bad">„Mach meine App besser."</div>
|
||||
<div class="tut-cell-bd">→ <em>„Die Login-Seite in <code>src/pages/Login.tsx</code> zeigt
|
||||
bei falschem Passwort gar keinen Fehler an. Füge unter dem Passwortfeld eine rote Fehlermeldung
|
||||
ein wenn die API 401 zurückgibt."</em></div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd bad">„Erkläre mir Python."</div>
|
||||
<div class="tut-cell-bd">→ <em>„Ich bin Anfänger. Erkläre mir in 5 Schritten wie ich eine CSV-Datei
|
||||
in Python einlese und die Summe einer Spalte berechne — mit echtem Code-Beispiel."</em></div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd bad">„Mach das schneller."</div>
|
||||
<div class="tut-cell-bd">→ <em>„Die Funktion <code>loadUsers()</code> in <code>src/api/users.ts</code>
|
||||
braucht 3 Sekunden. Die Daten kommen vom Backend. Prüfe ob Caching helfen würde und schlage eine
|
||||
konkrete Lösung vor."</em></div>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin-top:12px"><b>Die 3 Zutaten für einen guten Auftrag:</b></p>
|
||||
<ol class="tut-steps">
|
||||
<li><b>WO:</b> Welche Datei / welcher Ordner / welches Feature</li>
|
||||
<li><b>WAS:</b> Was ist das genaue Problem oder Ziel</li>
|
||||
<li><b>WIE:</b> Welches Ergebnis erwartest du</li>
|
||||
</ol>
|
||||
<p><b>Trick:</b> Wenn du nicht sicher bist ob der Auftrag klar genug ist, frag den Agenten:
|
||||
<em>„Hast du alles was du brauchst, oder fehlt dir etwas?"</em></p>`],
|
||||
|
||||
["MCP — deinem Agenten echte Werkzeuge geben", `
|
||||
<p><b>MCP</b> (Model Context Protocol) ist ein einheitlicher „Stecker", über den ein Agent
|
||||
zusätzliche <b>Werkzeuge</b> bekommt. Ohne MCP kann er nur Text lesen und schreiben.
|
||||
Mit MCP kann er z.B.:</p>
|
||||
<ul class="tut-steps" style="list-style:disc">
|
||||
<li>Im Web suchen (Brave Search, Tavily)</li>
|
||||
<li>GitHub Issues lesen und kommentieren</li>
|
||||
<li>Deine Datenbank abfragen</li>
|
||||
<li>Screenshots machen, E-Mails lesen, Kalender verwalten</li>
|
||||
</ul>
|
||||
<p><b>Wie MCPs eingebunden werden</b> (Beispiel für OpenCode):</p>
|
||||
<p>In der Konfigurationsdatei (<code>~/.config/opencode/opencode.json</code>) einen
|
||||
<code>"mcp"</code>-Block hinzufügen:</p>
|
||||
<pre class="tut-code">{
|
||||
"mcp": {
|
||||
"brave-search": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
|
||||
"env": { "BRAVE_API_KEY": "dein-key" }
|
||||
}
|
||||
}
|
||||
}</pre>
|
||||
<p>Danach OpenCode neu starten — der Agent nutzt das Werkzeug automatisch wenn er es braucht.</p>
|
||||
<p><b>Empfohlene MCPs 2026 — nach Anwendungsfall:</b></p>
|
||||
<div class="tut-table">
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">Web-Suche</div>
|
||||
<div class="tut-cell-bd"><b>brave-search</b> oder <b>tavily</b> — recherchieren, aktuelle Infos holen</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">GitHub</div>
|
||||
<div class="tut-cell-bd"><b>github</b> — meistinstallierter MCP überhaupt. Issues, PRs, Commits, Repos direkt im Chat verwalten</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">Lokale Dateien</div>
|
||||
<div class="tut-cell-bd"><b>filesystem</b> — meistgenutzter MCP überhaupt. Sichere Dateizugriffe auf beliebige Ordner</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">Browser</div>
|
||||
<div class="tut-cell-bd"><b>playwright</b> — Browser-Automatisierung und UI-Tests. Agent öffnet Seiten, klickt, prüft</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">Gedächtnis</div>
|
||||
<div class="tut-cell-bd"><b>memory / mem0</b> — persistentes Gedächtnis über Sessions hinweg. Agent erinnert sich an deine Präferenzen und Projekt-Kontext auch in neuen Chats</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin-top:8px">Über <b>36.000 MCPs</b> sind mittlerweile verfügbar (Stand Juni 2026).
|
||||
Fang mit filesystem + github an — die decken 80% der täglichen Arbeit ab.</p>
|
||||
<p class="tut-sources"><b>Wo MCPs finden:</b>
|
||||
<a href="https://mcp.run" target="_blank" rel="noopener">mcp.run</a> ·
|
||||
<a href="https://smithery.ai" target="_blank" rel="noopener">smithery.ai</a> ·
|
||||
<a href="https://modelcontextprotocol.io/servers" target="_blank" rel="noopener">modelcontextprotocol.io/servers</a>
|
||||
— oder fragen: <em>„Welche MCPs gibt es für [dein Werkzeug]?"</em></p>
|
||||
<p><b>Merksatz:</b> Fehlt dem Agenten eine <em>Fähigkeit</em> → MCP.
|
||||
Soll er etwas nur <em>besser machen</em> → Skill (nächster Abschnitt).</p>`],
|
||||
|
||||
["Skills & Rules — Agenten Gewohnheiten beibringen", `
|
||||
<p><b>Skills</b> (auch <em>Rules</em>, <em>AGENTS.md</em> oder <em>Instructions</em> genannt)
|
||||
sind kurze Texte, die dem Agenten beibringen wie er in deinem Projekt arbeiten soll —
|
||||
immer, ohne dass du es jedes Mal neu sagen musst.</p>
|
||||
<p><b>Beispiel</b> — eine einfache AGENTS.md:</p>
|
||||
<pre class="tut-code">Schreib alle Variablen in camelCase.
|
||||
Kommentare immer auf Deutsch.
|
||||
Tests immer mit describe/it-Blöcken (Jest).
|
||||
Importiere nichts was nicht schon in package.json steht.
|
||||
Bevor du Dateien änderst: erkläre kurz was du vorhast.</pre>
|
||||
<p>Der Agent liest das vor jeder Aufgabe und hält sich daran.</p>
|
||||
<p><b>Wo die Datei ablegen:</b></p>
|
||||
<div class="tut-table">
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">OpenCode</div>
|
||||
<div class="tut-cell-bd"><code>AGENTS.md</code> im Projektordner (pro Projekt)<br>
|
||||
<code>~/.config/opencode/AGENTS.md</code> (global, gilt überall)</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">Cline</div>
|
||||
<div class="tut-cell-bd"><code>.clinerules</code> im Projektordner<br>
|
||||
<code>~/.clinerules</code> (global)</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">Zed</div>
|
||||
<div class="tut-cell-bd">Zed Settings → Assistant → System Prompt</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin-top:10px"><b>Fertige Skills finden:</b></p>
|
||||
<div class="tut-table">
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd"><a href="https://skills.sh" target="_blank" rel="noopener">skills.sh</a></div>
|
||||
<div class="tut-cell-bd">Von Vercel gestartet (Jan 2026) — das npm für Agent-Skills. Funktioniert mit Claude Code, OpenCode, Cursor u.v.m. One-Command-Installer.
|
||||
<b>Hinweis:</b> Community-Inhalte ohne formale Prüfung — Skill vor dem Einsatz kurz lesen.</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd"><a href="https://skillhub.ai" target="_blank" rel="noopener">SkillHub</a></div>
|
||||
<div class="tut-cell-bd">KI-bewertete Skills (S/A/B-Ranking nach Qualität, Klarheit, Wirkung). Kleinerer Katalog, höhere Signal-Qualität.</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd"><a href="https://cursor.directory" target="_blank" rel="noopener">cursor.directory</a></div>
|
||||
<div class="tut-cell-bd">Vorgefertigte Rules für React, Vue, Python, Go etc. — direkt als .clinerules oder AGENTS.md nutzbar.</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin-top:8px"><b>Sicherheitshinweis:</b> Ein Snyk-Audit (2026) fand in 13,4% der Community-Skills
|
||||
kritische Probleme (Prompt Injection, exponierte Secrets). Regel: Skill-Datei kurz lesen bevor du sie einsetzt.</p>
|
||||
<p style="margin-top:8px">Oder einfach fragen: <em>„Schreib mir eine AGENTS.md für ein React + TypeScript Projekt mit Tailwind."</em></p>`],
|
||||
|
||||
["Die Persona — Rolle, Beziehung & Onboarding", `
|
||||
<p>Eine Persona hat <b>zwei Dimensionen</b> — beide sind wichtig:</p>
|
||||
<div class="tut-table">
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">Was der Agent tut</div>
|
||||
<div class="tut-cell-bd">Fokus, Fachgebiet, Grenzen — die klassische Rollenbeschreibung.</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">Wie er mit dir umgeht</div>
|
||||
<div class="tut-cell-bd">Ton, Proaktivität, Feedback-Stil — antwortet er kurz oder ausführlich?
|
||||
Hakt er nach oder liefert er einfach? Spricht er Fehler direkt an oder verpackt er sie?</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin-top:10px">Ein Agent der technisch gleich konfiguriert ist, verhält sich komplett anders
|
||||
wenn er weiß: <em>Du willst kurze Antworten ohne Erklärung.</em> Oder: <em>Du lernst noch —
|
||||
ermutige mich und erkläre immer warum.</em></p>
|
||||
|
||||
<p><b>Das vollständige Persona-Template:</b></p>
|
||||
<pre class="tut-code">Du bist [Name], [Rolle].
|
||||
|
||||
Fokus: [Was du hauptsächlich tust]
|
||||
Wie du kommunizierst: [Stil — direkt/erklärend/ermutigend, kurze oder ausführliche Antworten]
|
||||
Wie du mit mir umgehst: [Proaktivität, Rückfragen, Feedback-Stil, Fehlerkultur]
|
||||
Sprache: Immer auf Deutsch. Code-Kommentare auf Englisch.
|
||||
Grenzen: [Was du nicht tust, was du nachfragst statt zu raten]</pre>
|
||||
|
||||
<p><b>Beispiel 1 — Coding-Assistent (direkt, erfahren):</b></p>
|
||||
<pre class="tut-code">Du bist Alex, ein erfahrener Senior-Entwickler spezialisiert auf TypeScript und React.
|
||||
|
||||
Fokus: Code schreiben, reviewen, erklären — immer mit konkreten Beispielen.
|
||||
Wie du kommunizierst: direkt, pragmatisch. Keine Erklärung wenn der Code für sich spricht.
|
||||
Wie du mit mir umgehst: Du fragst nach bevor du loslegst wenn die Aufgabe unklar ist.
|
||||
Fehler sprichst du klar an — aber ohne Schuldzuweisungen. Du machst Gegenvorschläge
|
||||
wenn du eine bessere Lösung siehst, aber respektierst meine Entscheidung.
|
||||
Sprache: Deutsch. Code-Kommentare auf Englisch.
|
||||
Grenzen: Kein Code ohne Datei-Kontext. Keine neuen Dependencies ohne Rückfrage.</pre>
|
||||
|
||||
<p><b>Beispiel 2 — Lernbegleiter (geduldig, ermutigend):</b></p>
|
||||
<pre class="tut-code">Du bist Sam, eine geduldige Lernbegleiterin für Programmier-Anfänger.
|
||||
|
||||
Fokus: Konzepte erklären, Fehler korrigieren, Schritt für Schritt führen.
|
||||
Wie du kommunizierst: freundlich, mit Analogien aus dem Alltag. Fachbegriffe sofort erklären.
|
||||
Wie du mit mir umgehst: Du lobst Fortschritte, auch kleine. Fehler sind Lernchancen —
|
||||
du zeigst was falsch war und warum, ohne zu urteilen. Du fragst ob ich es verstanden
|
||||
habe bevor du weitermachst.
|
||||
Sprache: Immer auf Deutsch. Niemals Englisch ohne Übersetzung.</pre>
|
||||
|
||||
<hr style="border:none;border-top:1px solid var(--line);margin:18px 0">
|
||||
<p><b>Der schnellste Weg: Onboarding-Interview</b></p>
|
||||
<p>Statt die Persona selbst auszufüllen — lass den Agenten <em>dich</em> befragen.
|
||||
Starte einen frischen Chat und gib ihm diesen Prompt:</p>
|
||||
<pre class="tut-code">Ich möchte dich in mein Projekt und meinen Arbeitsstil einarbeiten.
|
||||
Führe mit mir ein kurzes Interview durch — stelle mir 6–8 Fragen,
|
||||
eine nach der anderen — um zu verstehen:
|
||||
|
||||
1. Wer ich bin und wie ich am liebsten kommuniziere
|
||||
2. Was unser Projekt ist und welches Ziel es hat
|
||||
3. Was ich gut kann und wobei ich Unterstützung brauche
|
||||
4. Meine Arbeitsgewohnheiten und Präferenzen
|
||||
5. Was mir wichtig ist und was mich stört
|
||||
|
||||
Wenn du alle Antworten hast, erstelle daraus:
|
||||
- Eine vollständige AGENTS.md als dauerhaftes Fundament
|
||||
- Einen Satz der zusammenfasst wer ich für dich bin</pre>
|
||||
<p>Das Ergebnis: eine maßgeschneiderte Persona die <em>wirklich</em> zu dir passt —
|
||||
weil du sie nicht selbst formulieren musstest, sondern im Gespräch entstanden ist.</p>
|
||||
|
||||
<p><b>Wo eintragen:</b></p>
|
||||
<div class="tut-table">
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">Für ein Projekt</div>
|
||||
<div class="tut-cell-bd">Erste Zeilen der <code>AGENTS.md</code> im Projektordner.</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">Global (überall)</div>
|
||||
<div class="tut-cell-bd"><code>~/.config/opencode/AGENTS.md</code> (OpenCode) ·
|
||||
Settings → System Prompt (OpenWebUI)</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">Nur dieser Chat</div>
|
||||
<div class="tut-cell-bd"><em>„Ab jetzt bist du …"</em> direkt eintippen.</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="tut-sources" style="margin-top:10px">Fertige Personas: <a href="https://skills.sh" target="_blank" rel="noopener">skills.sh</a> · <a href="https://cursor.directory" target="_blank" rel="noopener">cursor.directory</a></p>`],
|
||||
|
||||
["AGENTS.MD & SAVEPOINT.MD — Gedächtnis für den Agenten", `
|
||||
<p>Jedes Mal wenn du einen neuen Chat startest, weiß der Agent <b>nichts</b> über dein Projekt.
|
||||
Diese zwei Dateien lösen das Problem.</p>
|
||||
<p><b>AGENTS.md ist 2026 der plattformübergreifende Standard</b> — nativ gelesen von:
|
||||
OpenCode, Claude Code, Codex CLI, Cursor, Aider, Devin, GitHub Copilot, Gemini CLI, Windsurf, Amazon Q.
|
||||
Eine Datei, alle Tools.</p>
|
||||
<div class="tut-table">
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">AGENTS.md</div>
|
||||
<div class="tut-cell-bd"><b>Was das Projekt ist</b> — Tech-Stack, Konventionen,
|
||||
Struktur. Wird vor jeder Aufgabe gelesen. Einmal schreiben, immer wirksam.</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">SAVEPOINT.md</div>
|
||||
<div class="tut-cell-bd"><b>Wo wir gerade stehen</b> — aktueller Stand, offene Punkte,
|
||||
nächste Schritte. Wird am Ende einer Session aktualisiert und zu Beginn der nächsten gelesen.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="margin-top:14px"><b>AGENTS.MD — Vorlage:</b></p>
|
||||
<pre class="tut-code"># Projekt: [Name]
|
||||
|
||||
## Persona
|
||||
Du bist [Name], [Rolle]. [Stil, Sprache, Grenzen]
|
||||
|
||||
## Tech-Stack
|
||||
- Frontend: [z.B. React 18, TypeScript, Tailwind CSS]
|
||||
- Backend: [z.B. FastAPI, Python 3.12]
|
||||
- Datenbank:[z.B. PostgreSQL via Prisma]
|
||||
|
||||
## Konventionen
|
||||
- Variablen: camelCase (JS) / snake_case (Python)
|
||||
- Kommentare: auf Deutsch
|
||||
- Commits: konventionell (feat/fix/chore/docs)
|
||||
- Tests: immer mit describe/it-Blöcken
|
||||
|
||||
## Projektstruktur
|
||||
src/
|
||||
components/ → UI-Komponenten (eine Datei = eine Komponente)
|
||||
pages/ → Seiten / Routes
|
||||
api/ → Backend-Calls und Typen
|
||||
|
||||
## Regeln
|
||||
- Vor jeder Dateiänderung kurz erklären was geplant ist
|
||||
- Keine neuen Dependencies ohne Rückfrage hinzufügen
|
||||
- Bei Unklarheiten fragen, nicht raten
|
||||
|
||||
<!-- Ziel: 200–500 Zeilen. AGENTS.md wird in jeden Kontext geladen —
|
||||
kürzer = mehr Platz für Code und Gespräch. --></pre>
|
||||
<p style="color:var(--mut);font-size:12px;margin:4px 0 8px">
|
||||
<b>Größe:</b> 200–500 Zeilen sind der empfohlene Sweet Spot (Stand 2026).
|
||||
AGENTS.md wird in jeden Kontext geladen — je kürzer, desto mehr Platz bleibt für Code und Gespräch.</p>
|
||||
|
||||
<p><b>SAVEPOINT.MD — Vorlage:</b></p>
|
||||
<pre class="tut-code"># Savepoint — [Datum / Uhrzeit]
|
||||
|
||||
## Aktueller Stand
|
||||
[1–2 Sätze: wo steht das Projekt gerade?]
|
||||
|
||||
## Zuletzt erledigt
|
||||
- [Aufgabe] ✓
|
||||
- [Aufgabe] ✓
|
||||
|
||||
## In Arbeit / halbfertig
|
||||
- [Was noch offen ist — welche Datei, welcher Schritt fehlt noch]
|
||||
|
||||
## Als nächstes
|
||||
1. [Konkrete nächste Aufgabe]
|
||||
2. [Übernächste Aufgabe]
|
||||
|
||||
## Wichtige Entscheidungen
|
||||
- [Was entschieden wurde]: [Warum]
|
||||
|
||||
## Bekannte Probleme
|
||||
- [Problem]: [Was bekannt ist, noch nicht gelöst]</pre>
|
||||
|
||||
<p><b>Workflow:</b></p>
|
||||
<ol class="tut-steps">
|
||||
<li><b>Session starten:</b> <em>„Lies AGENTS.md und SAVEPOINT.md und mach weiter."</em></li>
|
||||
<li><b>Session beenden:</b> <em>„Aktualisiere SAVEPOINT.md mit dem aktuellen Stand."</em></li>
|
||||
</ol>
|
||||
<p><b>Tipp:</b> Lass den Agenten die SAVEPOINT.md selbst schreiben — er sieht was er getan hat.
|
||||
Du prüfst nur ob der Stand stimmt und ergänzt bei Bedarf.</p>
|
||||
<p class="tut-sources">Vorlagen & Inspiration: <a href="https://skills.sh" target="_blank" rel="noopener">skills.sh</a> · <a href="https://opencode.ai/docs" target="_blank" rel="noopener">opencode.ai/docs</a></p>`],
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Wenn etwas nicht klappt',
|
||||
tutorials: [
|
||||
["Häufige Probleme & Lösungen", `
|
||||
<div class="tut-table">
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">🔴 Engine offline, rotes Licht in der Topbar</div>
|
||||
<div class="tut-cell-bd">Tab <b>Server</b> → LLM-Engine-Karte → <b>Neustart</b>.
|
||||
Warte 10–15 Sekunden — danach sollte die Verbindung wieder grün sein.</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">⏳ Modell lädt länger als 5 Minuten</div>
|
||||
<div class="tut-cell-bd">Tab <b>Aktivität</b> prüfen: läuft ein Download-Job?
|
||||
Der muss erst abgeschlossen sein bevor das Modell ladbar ist.
|
||||
Bei Fehler → Job-Zeile aufklappen, Log lesen.</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">💬 „context length exceeded" Fehler im Tool</div>
|
||||
<div class="tut-cell-bd">Neuen Chat starten. Vorher: <em>„Fasse kurz zusammen was wir
|
||||
erreicht haben"</em> eingeben und diesen Text in den neuen Chat mitnehmen.</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">🌐 Modell antwortet immer auf Englisch</div>
|
||||
<div class="tut-cell-bd">Am Anfang der Unterhaltung schreiben: <em>„Antworte immer auf Deutsch."</em>
|
||||
Für dauerhaft: eine <code>AGENTS.md</code> / <code>.clinerules</code> mit dieser Anweisung anlegen
|
||||
(siehe Abschnitt Skills & Rules).</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">🔌 Tool findet keine Modelle / Verbindung schlägt fehl</div>
|
||||
<div class="tut-cell-bd">Tab <b>Verbinden</b> → <b>Verbindung testen</b>. Wenn Fehler:
|
||||
läuft llama-swap? (Tab Server). URL korrekt? Muss <code>http://</code> (nicht https) sein,
|
||||
Port <b>8080</b>. Über Proxy? → LAN-IP direkt nutzen: <code>http://192.168.178.151:8080/v1</code></div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">⬇ Download schlägt fehl / bricht ab</div>
|
||||
<div class="tut-cell-bd">Tab <b>Aktivität</b> → Job-Zeile aufklappen → Log lesen.
|
||||
Häufigste Ursache: HuggingFace braucht einen Token für dieses Modell (gated model).
|
||||
→ Einstellungen (Zahnrad-Icon oben) → <b>HuggingFace-Token</b> eintragen.</div>
|
||||
</div>
|
||||
<div class="tut-row">
|
||||
<div class="tut-cell-hd">🔄 Seite lädt / Dashboard hängt komplett</div>
|
||||
<div class="tut-cell-bd">Strg+Shift+R (Hard Reload). Wenn das nicht hilft:
|
||||
Tab <b>Server</b> → Mission Control → <b>Neu starten</b>.</div>
|
||||
</div>
|
||||
</div>`],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const CONCEPTS: [string, string][] = [
|
||||
["LLM / Modell", "Die KI selbst — versteht und schreibt Text (wie ChatGPT, nur lokal auf deinem PC). Ein Modell ist eine konkrete Datei, oft auf etwas spezialisiert (Code, Bilder, Chat)."],
|
||||
["GGUF", "Das lokal lauffähige Dateiformat. llama-swap lädt nur GGUF — Repos ohne GGUF kann die Engine nicht nutzen."],
|
||||
["Quantisierung (Q4_K_M)", "Verkleinert ein Modell (16-Bit → 4-Bit), damit es in den Speicher passt — bei kaum Qualitätsverlust. Q4_K_M ist der bewährte Sweetspot. Für Coding/Reasoning: Q8_0 (nahezu verlustfrei). Wenn Q4_K_M 1 GB zu groß ist: IQ4_XS als Alternative (ähnliche Qualität, etwas kleiner)."],
|
||||
["Kontextfenster", "Das Kurzzeitgedächtnis: wie viel Text sich das Modell gleichzeitig merken kann. Größer = mehr Überblick, mehr Speicher, etwas langsamer."],
|
||||
["Token", "Die kleinste Einheit die ein Modell verarbeitet — meist 3–4 Zeichen oder ein kurzes Wort. 1 000 Tokens ≈ 750 Wörter. Kontextfenster, Geschwindigkeit und Kosten werden in Tokens gemessen."],
|
||||
["Auto-Swap", "llama-swap lädt/entlädt Modelle automatisch — immer nur eins gleichzeitig. Du managst nichts."],
|
||||
["TTL (Time to Live)", "Wie lange ein Modell nach der letzten Anfrage im RAM bleibt bevor es entladen wird. Standard: 5 Minuten. ∞ = bleibt dauerhaft geladen (sinnvoll für den Scout)."],
|
||||
["Agent", "Ein Programm (OpenCode, Cline, Continue), das das Modell in einer Handlungsschleife laufen lässt — Dateien lesen/ändern, Befehle ausführen, planen, bis die Aufgabe erledigt ist."],
|
||||
["MCP", "Model Context Protocol — der Stecker, über den ein Agent zusätzliche Werkzeuge bekommt (Web, Datenbank, GitHub …). MCP = neue Fähigkeiten."],
|
||||
["Skills / Rules", "Kurze Textdatei (AGENTS.md, .clinerules …) mit Anweisungen wie der Agent in deinem Projekt arbeiten soll — einmal geschrieben, immer befolgt. Seit 2026 plattformübergreifender Standard: AGENTS.md wird von über 10 Tools nativ gelesen."],
|
||||
["Memory Bank", "Clines Konzept für dauerhaften Projekt-Kontext: projectbrief.md (Ziel & Scope), activeContext.md (aktueller Fokus), progress.md (Stand). Wird zu Beginn jeder Aufgabe automatisch gelesen — ähnlich wie AGENTS.md, aber aufgeteilt nach Zweck."],
|
||||
["System-Prompt", "Eine unsichtbare Anweisung die vor deiner Nachricht steht und das Verhalten des Modells grundlegend prägt (Sprache, Ton, Rolle). Skills nutzen oft den System-Prompt."],
|
||||
["Vision", "Modelle mit Vision-Fähigkeit können Bilder verstehen — Screenshots, Diagramme, Fehlerfotos. Erkennbar am mmproj-Projektor der mitgeladen wird."],
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
let _value = $state<any[]>([])
|
||||
let _tracked = $state<string[]>([])
|
||||
|
||||
export const jobsStore = {
|
||||
get value() { return _value },
|
||||
set(jobs: any[]) { _value = jobs || [] },
|
||||
get tracked() { return _tracked },
|
||||
track(id: string) { if (!_tracked.includes(id)) _tracked = [..._tracked, id] },
|
||||
toggle(id: string) {
|
||||
_tracked = _tracked.includes(id)
|
||||
? _tracked.filter(x => x !== id)
|
||||
: [..._tracked, id]
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
let _value = $state<any>(null)
|
||||
|
||||
export const statusStore = {
|
||||
get value() { return _value },
|
||||
set(s: any) { _value = s },
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
let _value = $state<any>(null)
|
||||
|
||||
export const systemStore = {
|
||||
get value() { return _value },
|
||||
set(sys: any) { _value = sys },
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
compilerOptions: {
|
||||
runes: true,
|
||||
},
|
||||
onwarn: (warning, handler) => {
|
||||
// a11y-Warnungen nicht als Build-Fehler behandeln
|
||||
if (warning.code.startsWith('a11y')) return
|
||||
handler(warning)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"strict": false,
|
||||
"noEmit": true,
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
base: '/static/dist/',
|
||||
build: {
|
||||
outDir: resolve(__dirname, '../static/dist'),
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: resolve(__dirname, 'src/main.ts'),
|
||||
output: {
|
||||
entryFileNames: 'main.js',
|
||||
chunkFileNames: '[name].js',
|
||||
assetFileNames: '[name][extname]',
|
||||
// Alles in eine Datei — kein dynamisches Chunking für einfaches Deploy
|
||||
manualChunks: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@core': resolve(__dirname, '../static/js/core'),
|
||||
'@panels': resolve(__dirname, '../static/js/panels'),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
Hermes-Agent Control-Plane — read-only Einblicke fuer das Dashboard (Phase 4).
|
||||
|
||||
Macht das "geschenkte v9" sichtbar: Agent-Status, Cron-Scheduler und Skills des
|
||||
Nous-Hermes-Agent-Frameworks. Quellen (MC laeuft als hitonabi auf dem Bosgame,
|
||||
also direkter Zugriff):
|
||||
|
||||
- Status : ~/.hermes/gateway_state.json + cron-Heartbeat-Datei (sauberes JSON,
|
||||
kein Parsing). Liveness kommt aus dem Heartbeat, nicht aus dem evtl.
|
||||
beim Start eingefrorenen gateway_state.json.
|
||||
- Cron : `hermes cron list --all` (kanonisch; kein stabiles Storage-Schema).
|
||||
- Skills : `hermes skills list` (kanonisch) + ~/.hermes/skills/.usage.json
|
||||
(Nutzungszaehler). Rich-Tabellen werden mit COLUMNS=400 ohne
|
||||
Truncation erzeugt und ueber die `│`/`┃`-Spalten geparst.
|
||||
|
||||
Alles mit kurzem TTL-Cache, damit Dashboard-Polling die CLI nicht haemmert.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from config import HERMES_HOME, HERMES_BIN
|
||||
|
||||
_CACHE_TTL = 10.0 # Sekunden
|
||||
_cache: dict[str, tuple[float, object]] = {}
|
||||
|
||||
|
||||
def _cached(key: str, fn: Callable[[], object]) -> object:
|
||||
now = time.monotonic()
|
||||
hit = _cache.get(key)
|
||||
if hit and now - hit[0] < _CACHE_TTL:
|
||||
return hit[1]
|
||||
val = fn()
|
||||
_cache[key] = (now, val)
|
||||
return val
|
||||
|
||||
|
||||
def _run_cli(args: list[str], timeout: float = 45.0) -> str:
|
||||
"""`hermes <args>` ausfuehren. COLUMNS=400 verhindert Rich-Truncation."""
|
||||
env = dict(os.environ, COLUMNS="400", NO_COLOR="1")
|
||||
try:
|
||||
p = subprocess.run(
|
||||
[HERMES_BIN, *args],
|
||||
capture_output=True, text=True, timeout=timeout, env=env,
|
||||
)
|
||||
return p.stdout or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_rich_table(text: str) -> list[dict]:
|
||||
"""Eine Rich-Box-Tabelle in list[dict] verwandeln.
|
||||
|
||||
Erste Zeile mit Spaltentrennern (`│` oder `┃`) ist der Header, der Rest sind
|
||||
Datenzeilen. Trenn-/Rahmenzeilen (┏━┓ usw.) enthalten keine Trenner -> ignoriert.
|
||||
"""
|
||||
header: list[str] | None = None
|
||||
rows: list[list[str]] = []
|
||||
for ln in text.splitlines():
|
||||
if "│" not in ln and "┃" not in ln:
|
||||
continue
|
||||
cells = [c.strip() for c in re.split(r"[│┃]", ln)]
|
||||
# fuehrende/abschliessende Rahmen-Leerzellen entfernen
|
||||
while cells and cells[0] == "":
|
||||
cells.pop(0)
|
||||
while cells and cells[-1] == "":
|
||||
cells.pop()
|
||||
if not cells:
|
||||
continue
|
||||
if header is None:
|
||||
header = [c.lower() for c in cells]
|
||||
else:
|
||||
rows.append(cells)
|
||||
if not header:
|
||||
return []
|
||||
out = []
|
||||
for r in rows:
|
||||
if len(r) != len(header):
|
||||
continue
|
||||
out.append(dict(zip(header, r)))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Oeffentliche Reads
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def agent_status() -> dict:
|
||||
"""Live-Status des Hermes-Gateways + Cron-Tickers."""
|
||||
def _load() -> dict:
|
||||
st = {
|
||||
"gateway_state": "unknown",
|
||||
"pid": None,
|
||||
"active_agents": None,
|
||||
"api_server": "unknown",
|
||||
"cron_running": False,
|
||||
"heartbeat_age_s": None,
|
||||
"version": None,
|
||||
}
|
||||
try:
|
||||
d = json.loads((HERMES_HOME / "gateway_state.json").read_text())
|
||||
st["gateway_state"] = d.get("gateway_state", "unknown")
|
||||
st["pid"] = d.get("pid")
|
||||
st["active_agents"] = d.get("active_agents")
|
||||
api = (d.get("platforms") or {}).get("api_server") or {}
|
||||
st["api_server"] = api.get("state", "unknown")
|
||||
except Exception:
|
||||
pass
|
||||
# Liveness: Cron-Ticker-Heartbeat (Unix-Timestamp). < 120s = laeuft.
|
||||
try:
|
||||
ts = float((HERMES_HOME / "cron" / "ticker_heartbeat").read_text().strip())
|
||||
age = max(0, int(time.time() - ts))
|
||||
st["heartbeat_age_s"] = age
|
||||
st["cron_running"] = age < 120
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
st["version"] = (HERMES_HOME / "PINNED_VERSION").read_text().strip() or None
|
||||
except Exception:
|
||||
pass
|
||||
return st
|
||||
|
||||
return _cached("agent", _load)
|
||||
|
||||
|
||||
def _parse_cron(text: str) -> list[dict]:
|
||||
"""`hermes cron list` parsen. Format pro Job (KEINE Rich-Tabelle):
|
||||
|
||||
7ac183295470 [active]
|
||||
Name: Memory-Kurator
|
||||
Schedule: 0 4 * * 0
|
||||
Next run: 2026-06-28T04:00:00+00:00
|
||||
Deliver: local
|
||||
"""
|
||||
jobs: list[dict] = []
|
||||
cur: dict | None = None
|
||||
for ln in text.splitlines():
|
||||
m = re.match(r"\s*([0-9a-fA-F]{6,})\s+\[(\w+)\]\s*$", ln)
|
||||
if m:
|
||||
cur = {"id": m.group(1), "status": m.group(2)}
|
||||
jobs.append(cur)
|
||||
continue
|
||||
if cur is not None:
|
||||
kv = re.match(r"\s+([A-Za-z][\w ]*?):\s+(.+?)\s*$", ln)
|
||||
if kv:
|
||||
key = kv.group(1).strip().lower().replace(" ", "_")
|
||||
cur[key] = kv.group(2).strip()
|
||||
return jobs
|
||||
|
||||
|
||||
def cron_jobs() -> list[dict]:
|
||||
"""Geplante Jobs (inkl. pausierter)."""
|
||||
return _cached("cron", lambda: _parse_cron(_run_cli(["cron", "list", "--all"]))) # type: ignore[return-value]
|
||||
|
||||
|
||||
def skills() -> list[dict]:
|
||||
"""Installierte Skills, angereichert um Nutzungszaehler aus .usage.json."""
|
||||
def _load() -> list[dict]:
|
||||
rows = _parse_rich_table(_run_cli(["skills", "list"]))
|
||||
usage = {}
|
||||
try:
|
||||
usage = json.loads((HERMES_HOME / "skills" / ".usage.json").read_text())
|
||||
except Exception:
|
||||
pass
|
||||
for r in rows:
|
||||
u = usage.get(r.get("name", ""), {})
|
||||
r["use_count"] = u.get("use_count", 0)
|
||||
r["last_used_at"] = u.get("last_used_at")
|
||||
r["pinned"] = bool(u.get("pinned", False))
|
||||
return rows
|
||||
|
||||
return _cached("skills", _load) # type: ignore[return-value]
|
||||
|
||||
|
||||
def learned_profile() -> dict:
|
||||
"""Was Hermes ueber den Nutzer gelernt hat (Phase 8).
|
||||
|
||||
`~/.hermes/memories/USER.md` ist das vom Curator destillierte Nutzerprofil
|
||||
(Eintraege mit `§` getrennt), `MEMORY.md` allgemeine gelernte Fakten. Direkter
|
||||
Beleg, dass der Agent automatisch mitlernt.
|
||||
"""
|
||||
def _read(name: str) -> str:
|
||||
try:
|
||||
return (HERMES_HOME / "memories" / name).read_text(errors="replace").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _load() -> dict:
|
||||
user_raw = _read("USER.md")
|
||||
facts = [s.strip() for s in user_raw.split("§") if s.strip()]
|
||||
return {"user_facts": facts, "memory": _read("MEMORY.md")}
|
||||
|
||||
return _cached("learned", _load) # type: ignore[return-value]
|
||||
|
||||
|
||||
def insights() -> dict:
|
||||
"""Aktivitaets-Kennzahlen via `hermes insights` (Phase 8).
|
||||
|
||||
Die CLI rendert ein Box-Art-Layout (kein --json) -> Headline-Zahlen + Top-Tools
|
||||
per Regex herausziehen (best effort; faellt leer aus, wenn sich das Format aendert).
|
||||
"""
|
||||
def _num(pat: str, text: str):
|
||||
m = re.search(pat, text)
|
||||
return m.group(1).strip() if m else None
|
||||
|
||||
def _load() -> dict:
|
||||
txt = _run_cli(["insights", "--days", "30"])
|
||||
out: dict = {
|
||||
"sessions": _num(r"Sessions:\s+([\d,]+)", txt),
|
||||
"messages": _num(r"Messages:\s+([\d,]+)", txt),
|
||||
"tool_calls": _num(r"Tool calls:\s+([\d,]+)", txt),
|
||||
"total_tokens": _num(r"Total tokens:\s+([\d,]+)", txt),
|
||||
"active_time": _num(r"Active time:\s+(~?[\dhm ]+?)\s{2,}", txt),
|
||||
"top_tools": [],
|
||||
}
|
||||
# "Top Tools"-Sektion: Zeilen <name> <calls> <pct>%
|
||||
sec = txt.split("Top Tools", 1)
|
||||
if len(sec) == 2:
|
||||
for ln in sec[1].splitlines():
|
||||
m = re.match(r"\s*(\S.*?)\s+(\d+)\s+([\d.]+)%\s*$", ln)
|
||||
if m:
|
||||
out["top_tools"].append(
|
||||
{"name": m.group(1), "calls": int(m.group(2)), "pct": float(m.group(3))}
|
||||
)
|
||||
return out
|
||||
|
||||
return _cached("insights", _load) # type: ignore[return-value]
|
||||
+66
-8
@@ -3,6 +3,8 @@ Extrahierte Mathematik aus dem Odysseus Projekt zur VRAM/RAM Berechnung.
|
||||
Abgestimmt auf APUs mit Unified Memory (Bosgame M5 / Strix Halo).
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# Annahme: Bytes per Parameter für GGUF Quants
|
||||
QUANT_BYTES_PER_PARAM = {
|
||||
"Q2_K": 0.35,
|
||||
@@ -34,21 +36,36 @@ def estimate_memory_gb(params_b: float, quant: str, ctx: int) -> float:
|
||||
|
||||
return weights + context_vram
|
||||
|
||||
def estimate_speed(req_gb: float, sys_ram_gb: float) -> float:
|
||||
"""Berechnet die geschätzte Tokens/s basierend auf der 273 GB/s Bandbreite der APU."""
|
||||
# Strix Halo hat ca 273 GB/s Unified Memory Bandbreite.
|
||||
def extract_active_params_b(name: str) -> float | None:
|
||||
"""Aktive Parameter bei MoE-Modellen aus dem Namen (z.B. '30B-A3B' → 3.0).
|
||||
Gibt None zurück für Dense-Modelle (kein MoE-Suffix erkannt)."""
|
||||
m = re.search(r'(?<![a-zA-Z])a(\d+(?:\.\d+)?)b\b', name.lower())
|
||||
return float(m.group(1)) if m else None
|
||||
|
||||
|
||||
def estimate_speed(req_gb: float, sys_ram_gb: float, moe_active_ratio: float = 1.0) -> float:
|
||||
"""Berechnet die geschätzte Tokens/s basierend auf der 273 GB/s Bandbreite der APU.
|
||||
moe_active_ratio = aktive_params / gesamt_params; < 1 bei MoE (z.B. 0.1 für 30B-A3B)."""
|
||||
bw = 273 if sys_ram_gb > 8 else 70
|
||||
if req_gb <= 0:
|
||||
return 0.0
|
||||
|
||||
# (Bandbreite / Modellgröße) * Effizienz (0.55)
|
||||
raw_tps = (bw / req_gb) * 0.55
|
||||
# MoE-Boost: nur ein Bruchteil der Gewichte wird pro Token aktiviert →
|
||||
# effektiv höhere Durchsatzrate als ein Dense-Modell gleicher Gesamtgröße.
|
||||
# sqrt-Dämpfung: Routing-Overhead + VRAM-Traffic für alle Experten bleibt.
|
||||
if moe_active_ratio < 0.8:
|
||||
raw_tps *= (1.0 / moe_active_ratio) ** 0.5
|
||||
return raw_tps
|
||||
|
||||
def evaluate_fit(params_b: float, quant: str, ctx: int, sys_ram_gb: float) -> dict:
|
||||
"""Berechnet den Fit für ein System mit Shared Memory (APU)."""
|
||||
|
||||
def evaluate_fit(params_b: float, quant: str, ctx: int, sys_ram_gb: float,
|
||||
name: str = "") -> dict:
|
||||
"""Berechnet den Fit für ein System mit Shared Memory (APU).
|
||||
name wird für MoE-Erkennung genutzt (optional)."""
|
||||
req_gb = estimate_memory_gb(params_b, quant, ctx)
|
||||
tps = estimate_speed(req_gb, sys_ram_gb)
|
||||
active_b = extract_active_params_b(name) if name else None
|
||||
moe_ratio = (active_b / params_b) if (active_b and params_b > 0) else 1.0
|
||||
tps = estimate_speed(req_gb, sys_ram_gb, moe_ratio)
|
||||
|
||||
# Das OS und andere Prozesse brauchen RAM. Wir lassen 4GB Puffer.
|
||||
usable_ram = max(sys_ram_gb - 4.0, 0)
|
||||
@@ -69,3 +86,44 @@ def evaluate_fit(params_b: float, quant: str, ctx: int, sys_ram_gb: float) -> di
|
||||
"req_gb": round(req_gb, 1),
|
||||
"tps": round(tps, 0)
|
||||
}
|
||||
|
||||
|
||||
def extract_params_b(name: str) -> float:
|
||||
"""Parametergröße (Mrd.) aus Repo-/Dateiname. 8x7B (MoE) -> 56."""
|
||||
moe = re.search(r"(\d+)x(\d+(?:\.\d+)?)[bB]", name)
|
||||
if moe:
|
||||
return float(moe.group(1)) * float(moe.group(2))
|
||||
m = re.search(r"(\d+(?:\.\d+)?)[bB](?![a-zA-Z])", name)
|
||||
if m:
|
||||
return float(m.group(1))
|
||||
return 7.0
|
||||
|
||||
|
||||
# "Schöne" Kontextstufen, die UIs/Modelle gern mögen.
|
||||
_NICE_CTX = [2048, 4096, 8192, 16384, 32768, 49152, 65536, 98304, 131072]
|
||||
|
||||
|
||||
def max_ctx_for(params_b: float, quant: str, sys_ram_gb: float) -> int:
|
||||
"""Größter 'schöner' Kontext, der komfortabel passt (80% des nutzbaren RAM, 4 GB OS-Puffer).
|
||||
Umkehrung von estimate_memory_gb: löst die Kontext-Heuristik nach ctx auf."""
|
||||
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.65)
|
||||
weights = params_b * bpp
|
||||
usable = max(sys_ram_gb - 4.0, 0) * 0.8 # gleicher Komfort wie evaluate_fit "perfect"
|
||||
ctx_budget = usable - weights # GB, die für den KV-Cache übrig sind
|
||||
if ctx_budget <= 0:
|
||||
return 2048 # Modell selbst schon knapp -> Minimal-Kontext
|
||||
per_8k = (max(params_b, 7) / 7) * 0.8 # GB pro 8k Kontext (aus der Heuristik)
|
||||
raw_ctx = (ctx_budget / per_8k) * 8192
|
||||
best = _NICE_CTX[0]
|
||||
for c in _NICE_CTX:
|
||||
if c <= raw_ctx:
|
||||
best = c
|
||||
return best
|
||||
|
||||
|
||||
def recommend_ctx(params_b: float, quant: str, sys_ram_gb: float) -> dict:
|
||||
"""Empfohlener Kontext + Klartext-Begründung (für die UI)."""
|
||||
ctx = max_ctx_for(params_b, quant, sys_ram_gb)
|
||||
k = ctx // 1024
|
||||
return {"ctx": ctx, "k": k,
|
||||
"note": f"Bis ~{k}k Kontext passt komfortabel auf deine Hardware ({round(sys_ram_gb)} GB)."}
|
||||
|
||||
+162
-13
@@ -6,6 +6,7 @@ zeilenweisem Log-Capture. Keine Persistenz, kein Broker. Genutzt von allen
|
||||
Routern, die laenger laufende Shell-Befehle anstossen (Download, Update, ...).
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
@@ -14,45 +15,193 @@ import time
|
||||
import uuid
|
||||
|
||||
JOBS: dict[str, dict] = {}
|
||||
_PROCS: dict[str, subprocess.Popen] = {} # laufende Prozesse je Job-ID (fuer Abbruch)
|
||||
_LOG_CAP = 400
|
||||
|
||||
|
||||
def _run_job(job_id: str, args: list[str], env: dict | None = None):
|
||||
def _append_log(job: dict, line: str) -> None:
|
||||
job["log"].append(line)
|
||||
if len(job["log"]) > _LOG_CAP:
|
||||
del job["log"][0]
|
||||
|
||||
|
||||
def _pump_output(job: dict, stream) -> None:
|
||||
"""Liest die Ausgabe byteweise und behandelt `\\r` (Fortschrittsbalken wie bei
|
||||
`hf download`/tqdm) als Ueberschreiben der letzten Zeile statt als neue Zeile.
|
||||
So erscheint der Download-Fortschritt live, ohne das Log mit tausenden Frames zu
|
||||
fluten. Bewusst BINAER gelesen: im Text-Modus wuerde Python ein einzelnes `\\r`
|
||||
zu `\\n` uebersetzen (universal newlines) und das Ueberschreiben unmoeglich machen."""
|
||||
buf = b""
|
||||
overwrite = False # soll die naechste committete Zeile die letzte ueberschreiben?
|
||||
pending_cr = False # haben wir gerade ein `\r` gesehen und warten auf das naechste Byte?
|
||||
|
||||
def commit():
|
||||
line = buf.decode("utf-8", "replace")
|
||||
if overwrite and job["log"]:
|
||||
job["log"][-1] = line
|
||||
else:
|
||||
_append_log(job, line)
|
||||
|
||||
while True:
|
||||
ch = stream.read(1)
|
||||
if not ch:
|
||||
break
|
||||
if pending_cr:
|
||||
pending_cr = False
|
||||
if ch == b"\n": # `\r\n` zusammen = echte neue Zeile
|
||||
commit(); overwrite = False; buf = b""
|
||||
continue
|
||||
commit(); overwrite = True; buf = b"" # einzelnes `\r` = Fortschritt (ueberschreiben)
|
||||
# ch faellt unten normal durch
|
||||
if ch == b"\r":
|
||||
pending_cr = True
|
||||
elif ch == b"\n":
|
||||
commit(); overwrite = False; buf = b""
|
||||
else:
|
||||
buf += ch
|
||||
if pending_cr:
|
||||
commit(); overwrite = True; buf = b""
|
||||
if buf:
|
||||
commit()
|
||||
|
||||
|
||||
def _run_job(job_id: str, args: list[str], env: dict | None = None, stdin_data: str | None = None):
|
||||
job = JOBS[job_id]
|
||||
job["state"] = "running"
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
args,
|
||||
stdin=subprocess.PIPE if stdin_data is not None else None,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
bufsize=0, # binaer + ungepuffert -> Fortschritt (\r) kommt live an
|
||||
env={**os.environ, **(env or {})},
|
||||
)
|
||||
for line in proc.stdout: # type: ignore[union-attr]
|
||||
job["log"].append(line.rstrip("\n"))
|
||||
if len(job["log"]) > _LOG_CAP:
|
||||
del job["log"][0]
|
||||
_PROCS[job_id] = proc
|
||||
if stdin_data is not None:
|
||||
# Secret (z.B. sudo-Passwort) ueber stdin fuettern — landet NICHT im Log/ps.
|
||||
try:
|
||||
proc.stdin.write(stdin_data.encode()) # type: ignore[union-attr]
|
||||
proc.stdin.close() # type: ignore[union-attr]
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
_pump_output(job, proc.stdout)
|
||||
proc.wait()
|
||||
job["returncode"] = proc.returncode
|
||||
job["state"] = "done" if proc.returncode == 0 else "failed"
|
||||
if job.get("canceled"):
|
||||
job["state"] = "canceled"
|
||||
job["returncode"] = proc.returncode
|
||||
_append_log(job, "[mission-control] Vorgang abgebrochen.")
|
||||
else:
|
||||
job["returncode"] = proc.returncode
|
||||
job["state"] = "done" if proc.returncode == 0 else "failed"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
job["log"].append(f"[mission-control] Fehler: {exc}")
|
||||
_append_log(job, f"[mission-control] Fehler: {exc}")
|
||||
job["state"] = "failed"
|
||||
job["returncode"] = -1
|
||||
finally:
|
||||
_PROCS.pop(job_id, None)
|
||||
job["finished_at"] = time.time()
|
||||
|
||||
|
||||
def start_job(args: list[str], label: str, env: dict | None = None) -> str:
|
||||
def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> None:
|
||||
"""Echten Download-Fortschritt (in %) auf den Job legen. `hf` gibt im Nicht-TTY-
|
||||
Modus keinen Fortschritt aus, schreibt aber in <local_dir>/.cache/huggingface/
|
||||
download/*.incomplete (waechst). Wir vergleichen dessen Groesse mit total_bytes
|
||||
(aus der HF-Tree-API). Ein Daemon-Thread aktualisiert job["progress"]."""
|
||||
if not total_bytes or total_bytes <= 0:
|
||||
return
|
||||
job = JOBS.get(job_id)
|
||||
if job is not None:
|
||||
job["progress"] = 0
|
||||
job["total_bytes"] = total_bytes
|
||||
|
||||
def _watch():
|
||||
pat = os.path.join(local_dir, ".cache", "huggingface", "download", "*.incomplete")
|
||||
prev_t, prev_b = None, None
|
||||
rate = 0.0 # geglaettete Download-Rate (Bytes/s), EMA gegen Zappeln
|
||||
while True:
|
||||
j = JOBS.get(job_id)
|
||||
if not j or j["state"] in ("done", "failed", "canceled"):
|
||||
break
|
||||
try:
|
||||
inc = glob.glob(pat)
|
||||
cur = sum(os.path.getsize(f) for f in inc) if inc else 0
|
||||
if cur:
|
||||
j["progress"] = min(99, int(cur * 100 / total_bytes))
|
||||
j["done_bytes"] = cur
|
||||
now = time.time()
|
||||
if prev_t is not None and now > prev_t and cur >= prev_b:
|
||||
inst = (cur - prev_b) / (now - prev_t)
|
||||
rate = inst if rate == 0 else 0.3 * inst + 0.7 * rate
|
||||
if rate > 0:
|
||||
j["rate_bps"] = rate
|
||||
j["eta_s"] = int((total_bytes - cur) / rate)
|
||||
prev_t, prev_b = now, cur
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
time.sleep(1.0)
|
||||
j = JOBS.get(job_id)
|
||||
if j and j["state"] == "done":
|
||||
j["progress"] = 100
|
||||
j.pop("eta_s", None)
|
||||
|
||||
threading.Thread(target=_watch, daemon=True).start()
|
||||
|
||||
|
||||
def cancel_job(job_id: str) -> bool:
|
||||
"""Laufenden Job abbrechen: Prozess terminieren. Liefert False, wenn der Job
|
||||
nicht (mehr) laeuft oder unbekannt ist."""
|
||||
job = JOBS.get(job_id)
|
||||
if not job or job["state"] in ("done", "failed", "canceled"):
|
||||
return False
|
||||
job["canceled"] = True
|
||||
_append_log(job, "[mission-control] Abbruch angefordert…")
|
||||
proc = _PROCS.get(job_id)
|
||||
if proc is not None:
|
||||
try:
|
||||
proc.terminate()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
else:
|
||||
# Prozess noch nicht gestartet -> direkt als abgebrochen markieren.
|
||||
job["state"] = "canceled"
|
||||
job["finished_at"] = time.time()
|
||||
return True
|
||||
|
||||
|
||||
def delete_job(job_id: str) -> bool:
|
||||
"""Einen abgeschlossenen Job aus dem Verlauf entfernen. Laufende Jobs werden
|
||||
NICHT geloescht (erst abbrechen). Liefert False, wenn unbekannt oder noch aktiv."""
|
||||
job = JOBS.get(job_id)
|
||||
if not job or job["state"] not in ("done", "failed", "canceled"):
|
||||
return False
|
||||
JOBS.pop(job_id, None)
|
||||
return True
|
||||
|
||||
|
||||
def clear_finished() -> int:
|
||||
"""Alle abgeschlossenen Jobs (fertig/fehler/abgebrochen) aus dem Verlauf raeumen.
|
||||
Laufende bleiben. Liefert die Anzahl entfernter Eintraege."""
|
||||
done = [jid for jid, j in JOBS.items() if j["state"] in ("done", "failed", "canceled")]
|
||||
for jid in done:
|
||||
JOBS.pop(jid, None)
|
||||
return len(done)
|
||||
|
||||
|
||||
def start_job(args: list[str], label: str, env: dict | None = None,
|
||||
stdin_data: str | None = None, log_cmd: str | None = None) -> str:
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
# log_cmd erlaubt eine sanitisierte Befehlszeile (kein Secret im Log), wenn stdin_data ein
|
||||
# Geheimnis (sudo-Passwort) traegt. Sonst die echten Argumente.
|
||||
first_line = log_cmd if log_cmd is not None else "$ " + " ".join(shlex.quote(a) for a in args)
|
||||
JOBS[job_id] = {
|
||||
"id": job_id,
|
||||
"label": label,
|
||||
"state": "queued",
|
||||
"log": [f"$ {' '.join(shlex.quote(a) for a in args)}"],
|
||||
"log": [first_line],
|
||||
"returncode": None,
|
||||
"started_at": time.time(),
|
||||
"finished_at": None,
|
||||
}
|
||||
threading.Thread(target=_run_job, args=(job_id, args, env), daemon=True).start()
|
||||
threading.Thread(target=_run_job, args=(job_id, args, env, stdin_data), daemon=True).start()
|
||||
return job_id
|
||||
|
||||
+57
-3
@@ -6,10 +6,53 @@ Helfer rund um llama-swap und dessen config.yaml.
|
||||
sodass Kommentare und Quotes erhalten bleiben.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
from config import CONFIG_PATH, LLAMA_SWAP_URL, yaml
|
||||
|
||||
# Kanonische Rollen (Tags auf den Modellen). Bewusst eine kleine, feste Palette fuer die
|
||||
# UI-Schnellwahl — frei waehlbare Rollen sind trotzdem erlaubt.
|
||||
ROLE_IDS = {"vision", "coder", "scout", "reviewer", "manager"}
|
||||
|
||||
|
||||
def model_id_from_path(model_path: str) -> str:
|
||||
"""Sprechende Modell-ID (= llama-swap-Modellname, den man in der API angibt) aus dem
|
||||
GGUF-Pfad ableiten: der Repo-Ordnername ohne '-GGUF'. Fallback: Dateiname ohne
|
||||
Quant-Suffix/.gguf. So steht in der Modell-Liste der echte Name statt 'coder'."""
|
||||
d = os.path.basename(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 llama-swap-`aliases` auf ein Modell legen (damit der Rollenname in der API
|
||||
ebenfalls funktioniert). Haelt den Alias EINDEUTIG: derselbe Rollen-Alias wird vorher
|
||||
bei allen anderen Modellen entfernt. 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 _swap_get(path: str):
|
||||
with httpx.Client(timeout=5.0) as c:
|
||||
@@ -29,6 +72,17 @@ def read_config() -> dict:
|
||||
|
||||
|
||||
def write_config(cfg: dict) -> None:
|
||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with CONFIG_PATH.open("w", encoding="utf-8") as f:
|
||||
yaml.dump(cfg, f)
|
||||
"""Schreibt die config.yaml atomar (tmp-Datei + os.replace), damit llama-swap
|
||||
mit -watch-config nie eine halb geschriebene Datei sieht. Fehlende Schreibrechte
|
||||
werden in eine klare Meldung uebersetzt statt als roher Traceback zu landen."""
|
||||
try:
|
||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = CONFIG_PATH.with_name(CONFIG_PATH.name + ".tmp")
|
||||
with tmp.open("w", encoding="utf-8") as f:
|
||||
yaml.dump(cfg, f)
|
||||
os.replace(tmp, CONFIG_PATH)
|
||||
except PermissionError as exc:
|
||||
raise PermissionError(
|
||||
f"Mission Control darf '{CONFIG_PATH}' nicht schreiben. "
|
||||
f"Einmalig Besitz uebergeben: sudo chown -R hitonabi:hitonabi {CONFIG_PATH.parent}"
|
||||
) from exc
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Mission Control — Memory MCP Server
|
||||
|
||||
Stdio-MCP-Server fuer persistentes Gedaechtnis.
|
||||
Laeuft als Subprocess von Cline / Claude Code / OpenCode auf dem Client-Rechner.
|
||||
Ruft die /api/memory-Endpunkte von Mission Control via HTTP auf.
|
||||
|
||||
Konfiguration via Env-Vars:
|
||||
MC_URL Mission Control URL (default: http://192.168.178.151:9000)
|
||||
MC_TOKEN Auth-Token (default: leer)
|
||||
|
||||
Einmalig installieren (auf dem Rechner wo das KI-Tool laeuft):
|
||||
pip install mcp httpx
|
||||
"""
|
||||
|
||||
import os
|
||||
import httpx
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
MC_URL = os.environ.get("MC_URL", "http://192.168.178.151:9000").rstrip("/")
|
||||
MC_TOKEN = os.environ.get("MC_TOKEN", "")
|
||||
|
||||
mcp = FastMCP("mission-control-memory")
|
||||
|
||||
|
||||
def _headers() -> dict:
|
||||
return {"X-MC-Token": MC_TOKEN} if MC_TOKEN else {}
|
||||
|
||||
|
||||
def _get(path: str, **params) -> list | dict:
|
||||
r = httpx.get(f"{MC_URL}{path}", headers=_headers(), params=params, timeout=10)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _post(path: str, data: dict) -> dict:
|
||||
r = httpx.post(f"{MC_URL}{path}", headers=_headers(), json=data, timeout=10)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _put(path: str, data: dict) -> dict:
|
||||
r = httpx.put(f"{MC_URL}{path}", headers=_headers(), json=data, timeout=10)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _delete(path: str) -> dict:
|
||||
r = httpx.delete(f"{MC_URL}{path}", headers=_headers(), timeout=10)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_memories(category: str = "") -> str:
|
||||
"""Laedt gespeicherte Fakten und Entscheidungen aus dem geteilten Gedaechtnis.
|
||||
RUFE DAS ZU BEGINN JEDER SESSION AUF, um vorhandenen Kontext (Konventionen,
|
||||
Entscheidungen, Nutzer-Praeferenzen) zu laden, BEVOR du handelst oder nachfragst.
|
||||
category: user | instruction | stable | versioned | ephemeral | (leer = alle)"""
|
||||
params = {"category": category} if category else {}
|
||||
items = _get("/api/memory", **params)
|
||||
if not items:
|
||||
return "Keine Memories gespeichert."
|
||||
cat_icon = {"stable": "🔵", "versioned": "🟡", "ephemeral": "⏱"}
|
||||
return "\n".join(
|
||||
f"{cat_icon.get(m['category'], '·')} [{m['category']}] {m['content']}"
|
||||
f" (Quelle: {m['source']}, ID: {m['id'][:8]})"
|
||||
for m in items
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def add_memory(content: str, category: str = "stable", source: str = "agent") -> str:
|
||||
"""Speichert einen Fakt/eine Entscheidung dauerhaft im GETEILTEN Gedaechtnis (alle Tools sehen es).
|
||||
RUFE DAS PROAKTIV AUF, sobald waehrend der Arbeit etwas Dauerhaftes entsteht — eine
|
||||
Konvention, Architektur-Entscheidung, Tech-Version, Nutzer-Praeferenz oder ein Projektfakt.
|
||||
Warte NICHT, bis man dich bittet.
|
||||
Mache VORHER `search_memories` mit Stichworten, um Dubletten zu vermeiden; existiert ein
|
||||
passender Eintrag, nutze `update_memory` statt neu anzulegen. Halte Eintraege knapp & atomar.
|
||||
category: user=Fakten ueber den User (Praeferenzen/Arbeitsweise), instruction=Verhaltensregeln fuer alle Tools, stable=Projektfakten, versioned=Tech-Versionen, ephemeral=temporaer/Session-Notiz (7 Tage, dann weg).
|
||||
"""
|
||||
m = _post("/api/memory", {"content": content, "category": category, "source": source})
|
||||
return f"Gespeichert (ID: {m['id'][:8]}): {content}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def search_memories(q: str) -> str:
|
||||
"""Sucht per Stichwort in allen gespeicherten Fakten. Nutze das VOR `add_memory`
|
||||
(Dubletten-Check) und immer dann, wenn du fruehere Entscheidungen/Kontext brauchst."""
|
||||
items = _get("/api/memory", q=q)
|
||||
if not items:
|
||||
return f"Keine Treffer fuer '{q}'."
|
||||
return "\n".join(
|
||||
f"[{m['category']}] {m['content']} (ID: {m['id'][:8]})"
|
||||
for m in items
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def update_memory(memory_id: str, content: str = "", category: str = "") -> str:
|
||||
"""Aktualisiert einen bestehenden Eintrag (z.B. nach Tech-Update). Nur gesetzte Felder werden geaendert."""
|
||||
data: dict = {}
|
||||
if content:
|
||||
data["content"] = content
|
||||
if category:
|
||||
data["category"] = category
|
||||
if not data:
|
||||
return "Nichts zu aktualisieren (content und category sind leer)."
|
||||
m = _put(f"/api/memory/{memory_id}", data)
|
||||
return f"Aktualisiert: {m['content']}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def delete_memory(memory_id: str) -> str:
|
||||
"""Loescht einen veralteten oder falschen Eintrag anhand seiner ID."""
|
||||
_delete(f"/api/memory/{memory_id}")
|
||||
return f"Eintrag {memory_id[:8]} geloescht."
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
@@ -13,7 +13,9 @@ Environment=MC_CONFIG_PATH=/etc/llama-swap/config.yaml
|
||||
Environment=MC_MODELS_DIR=/srv/models
|
||||
Environment=MC_DEFAULT_TTL=300
|
||||
# Befehl zum Starten eines Modells (in config.yaml geschrieben). ${PORT} bleibt stehen!
|
||||
Environment=MC_CMD_TEMPLATE=llama-server -m {model} --host 127.0.0.1 --port ${PORT} -c {ctx} -ngl 999 -fa 1 --no-mmap
|
||||
# WICHTIG: Wert MUSS gequotet sein — sonst splittet systemd an Leerzeichen und es kommt
|
||||
# nur "llama-server" an (Mission Control faengt das zur Sicherheit auch im Code ab).
|
||||
Environment="MC_CMD_TEMPLATE=llama-server -m {model} --host 127.0.0.1 --port ${PORT} -c {ctx} -ngl 999 -fa 1 --no-mmap"
|
||||
# Update-Befehl (z.B. kyuz0 refresh oder podman pull):
|
||||
Environment=MC_UPDATE_CMD=/opt/amd-strix-halo-toolboxes/refresh-toolboxes.sh all
|
||||
# Optionales Token fuer minimale Absicherung im LAN:
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
Modell-Capabilities (Phase 10) — EINE Quelle der Wahrheit fuer Modell-Eigenschaften.
|
||||
|
||||
Statt Einzel-Label ("Code"/"Text") ein Satz unabhaengiger Tags, die ein Modell
|
||||
gleichzeitig tragen kann (MoE + Tools + Reasoning + Coder + Long-Context …).
|
||||
|
||||
Quellen, geschichtet (sicher → Fallback):
|
||||
1) GGUF-Metadaten der lokalen Datei (architecture, expert_count, context_length,
|
||||
parameter_count) — authoritativ & **offline** (kein Netz, passt zu 100%-lokal).
|
||||
2) cmd-Flags der llama-swap-Config: `--jinja` = Tool-Template aktiv, `--mmproj` = Vision.
|
||||
3) duenner Namens-/Familien-Fallback, wenn Metadaten fehlen.
|
||||
4) optional `hf`-Block (HF-API: tags + gguf.chat_template) fuer die Profi-Suche (Schritt 2).
|
||||
|
||||
Tool-Faehigkeit kommt NICHT aus dem Dateinamen, sondern aus `--jinja` (bestaetigt) /
|
||||
dem Chat-Template (HF) / der Familie (vermutet) → drei Stufen: yes | likely | no.
|
||||
"""
|
||||
|
||||
import re
|
||||
import struct
|
||||
|
||||
from hw_math import extract_params_b, extract_active_params_b
|
||||
|
||||
# GGUF-Skalar-Typen → Bytebreite (fuer Skip)
|
||||
_GGUF_FIXED = {0: 1, 1: 1, 2: 2, 3: 2, 4: 4, 5: 4, 6: 4, 7: 1, 10: 8, 11: 8, 12: 8}
|
||||
|
||||
|
||||
def _read_gguf_meta(path: str) -> dict:
|
||||
"""Liest nur den GGUF-Metadaten-Header (architecture/context_length/expert_count/
|
||||
parameter_count). Bricht vor dem riesigen Tokenizer-Array ab → schnell, laedt NICHT
|
||||
das Modell. Robust: gibt {} bei jedem Fehler."""
|
||||
out: dict = {}
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
if f.read(4) != b"GGUF":
|
||||
return {}
|
||||
struct.unpack("<I", f.read(4))[0] # version
|
||||
f.read(8) # tensor_count (u64)
|
||||
kv = struct.unpack("<Q", f.read(8))[0]
|
||||
|
||||
def ru32() -> int: return struct.unpack("<I", f.read(4))[0]
|
||||
def ru64() -> int: return struct.unpack("<Q", f.read(8))[0]
|
||||
def rstr() -> str: return f.read(ru64()).decode("utf-8", "replace")
|
||||
|
||||
def rval(t: int):
|
||||
if t == 8: return rstr()
|
||||
if t == 0: return struct.unpack("<B", f.read(1))[0]
|
||||
if t == 1: return struct.unpack("<b", f.read(1))[0]
|
||||
if t == 2: return struct.unpack("<H", f.read(2))[0]
|
||||
if t == 3: return struct.unpack("<h", f.read(2))[0]
|
||||
if t == 4: return struct.unpack("<I", f.read(4))[0]
|
||||
if t == 5: return struct.unpack("<i", f.read(4))[0]
|
||||
if t == 6: return struct.unpack("<f", f.read(4))[0]
|
||||
if t == 7: return f.read(1) != b"\x00"
|
||||
if t == 10: return struct.unpack("<Q", f.read(8))[0]
|
||||
if t == 11: return struct.unpack("<q", f.read(8))[0]
|
||||
if t == 12: return struct.unpack("<d", f.read(8))[0]
|
||||
if t == 9: # Array: nur ueberlesen (brauchen wir nicht)
|
||||
et = ru32(); cnt = ru64()
|
||||
if et == 8:
|
||||
for _ in range(cnt):
|
||||
f.seek(ru64(), 1)
|
||||
elif et == 9:
|
||||
for _ in range(cnt):
|
||||
rval(9)
|
||||
else:
|
||||
f.seek(cnt * _GGUF_FIXED.get(et, 0), 1)
|
||||
return None
|
||||
raise ValueError(f"unbekannter GGUF-Typ {t}")
|
||||
|
||||
want = {"architecture", "context_length", "expert_count", "parameter_count"}
|
||||
for _ in range(kv):
|
||||
key = rstr()
|
||||
t = ru32()
|
||||
if key == "tokenizer.ggml.tokens": # ab hier nur noch riesige Arrays
|
||||
break
|
||||
v = rval(t)
|
||||
short = key.split(".")[-1]
|
||||
if short in want and short not in out:
|
||||
out[short] = v
|
||||
except Exception:
|
||||
return out
|
||||
return out
|
||||
|
||||
|
||||
# Familien-Fallback (nur wenn Metadaten schweigen). Bewusst kurz gehalten.
|
||||
_TOOL_FAMILIES = (
|
||||
"qwen2.5", "qwen3", "qwen2", "hermes", "mistral", "mixtral", "devstral",
|
||||
"command-r", "command_r", "llama-3.1", "llama3.1", "llama-3.3", "llama-4", "llama4",
|
||||
"functionary", "watt", "firefunction", "granite", "glm-4", "ministral",
|
||||
)
|
||||
_REASON_KW = (
|
||||
"-r1", "deepseek-r1", "qwq", "magistral", "-think", "thinking", "-o1",
|
||||
"gpt-oss", "reasoning", "exaone-deep", "phi-4-reasoning", "phi-4-mini-reasoning",
|
||||
)
|
||||
_CODE_KW = ("coder", "-code", "code-", "codestral", "starcoder", "deepseek-coder")
|
||||
_VISION_KW = ("-vl", "vision", "llava", "pixtral", "multimodal", "-mm-", "qwen3vl", "qwen2-vl")
|
||||
_EMBED_KW = ("bge", "e5-", "gte-", "nomic-embed", "embed")
|
||||
_MOE_ARCH = ("moe", "mixtral", "deepseek2", "deepseek3", "llama4", "qwen3moe", "grok")
|
||||
|
||||
|
||||
def capabilities(name: str = "", cmd: str = "", gguf_path: str = "", hf: dict | None = None) -> dict:
|
||||
"""Capability-Tag-Set fuer ein Modell. Alle Quellen optional — nutzt, was da ist."""
|
||||
low = (name or "").lower()
|
||||
cmdl = (cmd or "").lower()
|
||||
hf = hf or {}
|
||||
|
||||
meta = _read_gguf_meta(gguf_path) if gguf_path else {}
|
||||
arch = str(meta.get("architecture") or hf.get("architecture") or "").lower()
|
||||
tags = [str(t).lower() for t in (hf.get("tags") or [])]
|
||||
chat_tpl = str(hf.get("chat_template") or "")
|
||||
|
||||
# --- MoE ---
|
||||
expert_count = int(meta.get("expert_count") or 0)
|
||||
moe = (
|
||||
expert_count > 1
|
||||
or any(a in arch for a in _MOE_ARCH)
|
||||
or bool(re.search(r"\d+x\d+\.?\d*b", low)) # 8x7B
|
||||
or bool(re.search(r"a\d+\.?\d*b", low)) # 30B-A3B
|
||||
)
|
||||
active_b = extract_active_params_b(name)
|
||||
|
||||
# --- Params / Kontext ---
|
||||
pcount = int(meta.get("parameter_count") or 0)
|
||||
params_b = round(pcount / 1e9, 1) if pcount else extract_params_b(name)
|
||||
ctx = meta.get("context_length")
|
||||
if not ctx:
|
||||
m = re.search(r"-(?:c|-ctx-size)\s+(\d+)", cmdl)
|
||||
ctx = int(m.group(1)) if m else None
|
||||
|
||||
# --- Tools: yes (bestaetigt) | likely (Familie) | no ---
|
||||
tool_confirmed = "--jinja" in cmdl or "tool_call" in chat_tpl or "<tools>" in chat_tpl
|
||||
tool_family = any(fam in low for fam in _TOOL_FAMILIES) or "function-calling" in tags
|
||||
tools = "yes" if tool_confirmed else ("likely" if tool_family else "no")
|
||||
|
||||
vision = "--mmproj" in cmdl or "vl" in arch or "clip" in arch or any(k in low for k in _VISION_KW)
|
||||
coder = any(k in low for k in _CODE_KW)
|
||||
reasoning = any(k in low for k in _REASON_KW) or "reasoning" in tags
|
||||
embedding = "bert" in arch or any(k in low for k in _EMBED_KW)
|
||||
|
||||
return {
|
||||
"moe": moe,
|
||||
"active_b": active_b,
|
||||
"tools": tools, # yes | likely | no
|
||||
"vision": vision,
|
||||
"coder": coder,
|
||||
"reasoning": reasoning,
|
||||
"embedding": embedding,
|
||||
"ctx": ctx,
|
||||
"params_b": params_b or None,
|
||||
"arch": arch or None,
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Kuratierte Use-Case-„Setups" (Stacks) fuers Cookbook 2.0. Stand: Juni 2026.
|
||||
|
||||
Best-in-class je Use-Case — bewusst herstelleruebergreifend (Qwen, Google Gemma, Mistral, DeepSeek),
|
||||
nicht alles Qwen. Pro Modell nur Repo + Groesse/Quant; die GGUF-Datei wird beim Installieren dynamisch
|
||||
aufgeloest (robust). Fit + optimaler Kontext kommen zur Laufzeit aus hw_math.
|
||||
"""
|
||||
|
||||
RECIPES = [
|
||||
{
|
||||
"id": "elite", "title": "Elite-Lineup (für deine APU)", "icon": "bolt",
|
||||
"desc": "Das Stärkste, das auf deinem Strix Halo wirklich flott läuft: bevorzugt MoE-Modelle (viel Wissen, wenige aktive Parameter). Ein Spitzenmodell je Rolle — sie wechseln sich automatisch ab, es läuft immer nur eins.",
|
||||
"models": [
|
||||
{"role": "coder", "name": "Qwen3-Coder 30B-A3B", "repo": "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF",
|
||||
"params_b": 30, "quant": "Q4_K_M", "why": "Bestes lokales Coder-Modell — MoE (nur ~3B aktiv) → 30B-Wissen bei hohem Tempo."},
|
||||
{"role": "manager", "name": "Qwen3 30B-A3B", "repo": "unsloth/Qwen3-30B-A3B-Instruct-2507-GGUF",
|
||||
"params_b": 30, "quant": "Q4_K_M", "why": "Starker Allrounder & Orchestrator (MoE) — plant, delegiert, fasst zusammen."},
|
||||
{"role": "reviewer", "name": "Gemma 3 27B", "repo": "unsloth/gemma-3-27b-it-GGUF",
|
||||
"params_b": 27, "quant": "Q4_K_M", "why": "Googles Modell mit feinem Sprachgefühl — ideal zum Prüfen & Gegenlesen von Code/Texten."},
|
||||
{"role": "vision", "name": "Qwen3-VL 8B", "repo": "Qwen/Qwen3-VL-8B-Instruct-GGUF",
|
||||
"params_b": 8, "quant": "Q4_K_M", "why": "Liest Screenshots, Diagramme & Fehlerbilder — der Blick fürs Visuelle."},
|
||||
{"role": "scout", "name": "Qwen3 8B", "repo": "unsloth/Qwen3-8B-GGUF",
|
||||
"params_b": 8, "quant": "Q4_K_M", "why": "Flinker Kundschafter für schnelle Fragen & einfache Aufgaben."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "coding", "title": "Coden & Programmieren", "icon": "code",
|
||||
"desc": "Aktueller Top-Coder (MoE, nur 3B aktiv → schnell), ein flinker Helfer und Vision für Screenshots.",
|
||||
"models": [
|
||||
{"role": "coder", "name": "Qwen3-Coder 30B-A3B", "repo": "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF",
|
||||
"params_b": 30, "quant": "Q4_K_M", "why": "Bestes lokales Coder-Modell — versteht große Codebasen, läuft dank MoE flott."},
|
||||
{"role": "coder-fast", "name": "Qwen3 8B", "repo": "unsloth/Qwen3-8B-GGUF",
|
||||
"params_b": 8, "quant": "Q4_K_M", "why": "Schneller Helfer für einfache Edits & Autovervollständigung."},
|
||||
{"role": "vision", "name": "Qwen3-VL 8B", "repo": "Qwen/Qwen3-VL-8B-Instruct-GGUF",
|
||||
"params_b": 8, "quant": "Q4_K_M", "why": "Liest Screenshots & Fehlerbilder für die Analyse."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "vision", "title": "Bilder verstehen", "icon": "eye",
|
||||
"desc": "Ein multimodales Modell, das Bilder und Text gemeinsam versteht.",
|
||||
"models": [
|
||||
{"role": "vision", "name": "Qwen3-VL 8B", "repo": "Qwen/Qwen3-VL-8B-Instruct-GGUF",
|
||||
"params_b": 8, "quant": "Q4_K_M", "why": "Aktuelles Vision-Modell — beschreibt Bilder, liest Diagramme & Screenshots."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "chat", "title": "Allrounder & Chat", "icon": "compass",
|
||||
"desc": "Vielseitig für Alltag, Texte und Brainstorming — Googles Gemma als starke Alternative zu Qwen.",
|
||||
"models": [
|
||||
{"role": "scout", "name": "Gemma 3 27B", "repo": "unsloth/gemma-3-27b-it-GGUF",
|
||||
"params_b": 27, "quant": "Q4_K_M", "why": "Googles Allrounder — exzellent bei Sprache, Wissen und langem Kontext."},
|
||||
{"role": "scout-fast", "name": "Qwen3 8B", "repo": "unsloth/Qwen3-8B-GGUF",
|
||||
"params_b": 8, "quant": "Q4_K_M", "why": "Schnelle Alternative für kurze Fragen."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "reasoning", "title": "Nachdenken & Logik", "icon": "pulse",
|
||||
"desc": "Ein Reasoning-Modell, das Schritt für Schritt denkt — für Mathe, Logik, knifflige Probleme.",
|
||||
"models": [
|
||||
{"role": "thinker", "name": "DeepSeek-R1 Distill 7B", "repo": "unsloth/DeepSeek-R1-Distill-Qwen-7B-GGUF",
|
||||
"params_b": 7, "quant": "Q4_K_M", "why": "DeepSeeks Reasoning-Ansatz — denkt laut mit, stark bei Logik & Mathe."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "longdoc", "title": "Lange Dokumente", "icon": "file",
|
||||
"desc": "Großes Modell mit großem Kontext — für lange Texte, Verträge, Bücher.",
|
||||
"models": [
|
||||
{"role": "reader", "name": "Gemma 3 27B", "repo": "unsloth/gemma-3-27b-it-GGUF",
|
||||
"params_b": 27, "quant": "Q4_K_M", "why": "Starke Qualität bei sehr großem Kontextfenster (auto-optimiert)."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "agents", "title": "Agenten & Tool-Use", "icon": "layers",
|
||||
"desc": "Modelle, die gut mit Werkzeugen/Funktionen umgehen (MCP) — Mistral als Tool-Use-Spezialist.",
|
||||
"models": [
|
||||
{"role": "agent", "name": "Mistral Small 3.2 24B", "repo": "unsloth/Mistral-Small-3.2-24B-Instruct-2506-GGUF",
|
||||
"params_b": 24, "quant": "Q4_K_M", "why": "Mistrals Stärke: zuverlässiges Function-Calling für Agenten."},
|
||||
{"role": "agent-think", "name": "Qwen3 30B-A3B", "repo": "unsloth/Qwen3-30B-A3B-Instruct-2507-GGUF",
|
||||
"params_b": 30, "quant": "Q4_K_M", "why": "Starkes mehrstufiges Denken (MoE) als Backup-Hirn."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "fast", "title": "Schnell & sparsam", "icon": "bolt",
|
||||
"desc": "Kleine, flinke Modelle — ideal bei wenig Speicher oder für schnelle Antworten.",
|
||||
"models": [
|
||||
{"role": "mini", "name": "Qwen3 4B", "repo": "Qwen/Qwen3-4B-GGUF",
|
||||
"params_b": 4, "quant": "Q4_K_M", "why": "Winzig & sehr schnell für einfache Aufgaben."},
|
||||
{"role": "scout-fast", "name": "Qwen3 8B", "repo": "unsloth/Qwen3-8B-GGUF",
|
||||
"params_b": 8, "quant": "Q4_K_M", "why": "Etwas mehr Können, immer noch flott."},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dynamische Setups: KONZEPT bleibt kuratiert (Rollen-Slots), die Modelle werden
|
||||
# zur Laufzeit aus `discover` (live HF + Hardware-Fit) gefuellt → Setups
|
||||
# aktualisieren sich von selbst und passen sich der Hardware an.
|
||||
# slots = Rollen aus sources.CATEGORIES (vision/coder/reasoning/agent/scout).
|
||||
# ---------------------------------------------------------------------------
|
||||
RECIPE_TEMPLATES = [
|
||||
{"id": "auto-elite", "title": "Elite-Lineup (für deine APU)", "icon": "bolt",
|
||||
"desc": "Das Stärkste, das auf deiner Hardware flott läuft — bevorzugt MoE (viel Wissen, "
|
||||
"wenige aktive Parameter). Ein Spitzenmodell je Rolle, automatisch aktuell gehalten.",
|
||||
"slots": ["coder", "reasoning", "agent", "vision", "scout"]},
|
||||
{"id": "auto-coding", "title": "Coden & Programmieren", "icon": "code",
|
||||
"desc": "Bestes Coder-Modell für deine Hardware + ein Reasoning-Modell zum Gegenlesen.",
|
||||
"slots": ["coder", "reasoning"]},
|
||||
{"id": "auto-chat", "title": "Alltag & Chat", "icon": "compass",
|
||||
"desc": "Vielseitiger Allrounder + Bildverständnis für den täglichen Gebrauch.",
|
||||
"slots": ["scout", "vision"]},
|
||||
{"id": "auto-agent", "title": "Agenten & Tools", "icon": "layers",
|
||||
"desc": "Tool-fähige Modelle für autonome Aufgaben, ergänzt um einen Coder.",
|
||||
"slots": ["agent", "coder"]},
|
||||
]
|
||||
|
||||
# Kurzbegruendung je Rolle (ersetzt die handgeschriebenen „why"-Texte bei Auto-Setups).
|
||||
ROLE_WHY = {
|
||||
"coder": "Bestes lokales Coder-Modell für deine Hardware.",
|
||||
"reasoning": "Reasoning-Modell für Mathe, Logik & knifflige Aufgaben.",
|
||||
"agent": "Tool-fähig — plant, ruft Werkzeuge auf, arbeitet mehrschrittig.",
|
||||
"vision": "Versteht Screenshots, Diagramme & Fehlerbilder.",
|
||||
"scout": "Flinker Allrounder für schnelle Fragen & Chat.",
|
||||
}
|
||||
|
||||
# Upgrade-Map: hat der Nutzer ein „altes" Modell, schlagen wir das aktuelle vor.
|
||||
UPGRADES = [
|
||||
{"match": ["qwen2.5-coder", "qwen2_5-coder"], "name": "Qwen3-Coder 30B-A3B",
|
||||
"repo": "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF", "params_b": 30, "quant": "Q4_K_M",
|
||||
"why": "Nachfolger deines Coders: MoE mit nur 3B aktiven Parametern → schneller und stärker auf SWE-bench."},
|
||||
{"match": ["qwen2.5-7b", "qwen2_5-7b", "qwen2.5-instruct", "llama-3.1-8b", "llama-3-8b"], "name": "Qwen3 8B",
|
||||
"repo": "unsloth/Qwen3-8B-GGUF", "params_b": 8, "quant": "Q4_K_M",
|
||||
"why": "Neuere Generation deines Allrounders — besser im Reasoning und bei Tool-Use."},
|
||||
{"match": ["llama-3.2-11b-vision", "llama-3.2-vision", "qwen2.5-vl"], "name": "Qwen3-VL 8B",
|
||||
"repo": "Qwen/Qwen3-VL-8B-Instruct-GGUF", "params_b": 8, "quant": "Q4_K_M",
|
||||
"why": "Modernes Vision-Modell — kleiner und treffsicherer beim Bildverständnis."},
|
||||
]
|
||||
@@ -3,3 +3,8 @@ uvicorn[standard]>=0.29
|
||||
httpx>=0.27
|
||||
ruamel.yaml>=0.18
|
||||
psutil>=5.9.0
|
||||
huggingface_hub>=0.34 # liefert die `hf`-CLI fuer Modell-Downloads
|
||||
mcp>=1.0 # MCP Python SDK fuer mcp_memory.py stdio-Server
|
||||
faster-whisper>=1.0 # Lokale Sprach-zu-Text Transkription (Hermes Voice)
|
||||
paramiko>=3.0 # SSH-Client fuer Hermes → Windows-PC Zugriff
|
||||
websockets>=12.0 # WebSocket-Client fuer den Hermes-Dashboard-Reverse-Proxy
|
||||
|
||||
+537
-13
@@ -3,16 +3,121 @@ Cookbook Router: Verbindet die HuggingFace API mit der Odysseus-Hardware-Berechn
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import psutil
|
||||
|
||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||
|
||||
from auth import auth
|
||||
from hw_math import evaluate_fit
|
||||
from hw_math import evaluate_fit, max_ctx_for, extract_params_b, extract_active_params_b
|
||||
from config import (MODELS_DIR, CMD_TEMPLATE, DEFAULT_TTL, HF_DOWNLOAD_ENV, USER_RECIPES_PATH,
|
||||
DISCOVER_CACHE_PATH, DISCOVER_TTL, hf_bin)
|
||||
from llamaswap import read_config, write_config, model_id_from_path, set_role_alias
|
||||
from jobengine import start_job, JOBS, attach_download_progress
|
||||
from recipes import RECIPES, UPGRADES, RECIPE_TEMPLATES, ROLE_WHY
|
||||
from sources import TRUSTED_AUTHORS, CATEGORIES, SKIP_TOKENS
|
||||
|
||||
router = APIRouter(prefix="/api/cookbook", dependencies=[Depends(auth)])
|
||||
|
||||
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Eigene Setups (vom Nutzer) — persistente JSON-Datei, gleiches Schema wie RECIPES.
|
||||
# ---------------------------------------------------------------------------
|
||||
def load_user_recipes() -> list:
|
||||
try:
|
||||
if USER_RECIPES_PATH.exists():
|
||||
return json.loads(USER_RECIPES_PATH.read_text(encoding="utf-8")) or []
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def save_user_recipes(items: list) -> None:
|
||||
USER_RECIPES_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = USER_RECIPES_PATH.with_name(USER_RECIPES_PATH.name + ".tmp")
|
||||
tmp.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(tmp, USER_RECIPES_PATH)
|
||||
|
||||
|
||||
def _is_moe(name: str) -> bool:
|
||||
low = (name or "").lower()
|
||||
return (extract_active_params_b(name) is not None
|
||||
or "mixtral" in low or bool(re.search(r"\d+x\d+\.?\d*b", low)))
|
||||
|
||||
|
||||
def _tool_capable(m: dict) -> bool:
|
||||
"""Tool-Fähigkeit eines (noch nicht installierten) Modells aus Name + HF-Tags."""
|
||||
from model_caps import capabilities
|
||||
c = capabilities(name=m.get("repo", ""), hf={"tags": m.get("tags", [])})
|
||||
return c["tools"] != "no"
|
||||
|
||||
|
||||
def _safe_discover(ram_gb: float) -> dict | None:
|
||||
"""discover-Daten aus Cache (oder live), ohne zu werfen — 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: # noqa: BLE001
|
||||
return cached
|
||||
|
||||
|
||||
def build_dynamic_recipes(disc: dict) -> list:
|
||||
"""Fuellt die kuratierten Rollen-Slot-Templates LIVE aus discover.
|
||||
Pro Slot das beste passende Modell (perfect>marginal, MoE bevorzugt fuer APU,
|
||||
dann Beliebtheit); dedupe ueber Slots; leere Slots werden still ausgelassen."""
|
||||
cats = {c["role"]: c for c in (disc.get("categories") or [])}
|
||||
all_models = [m for c in cats.values() for m in (c.get("models") or [])]
|
||||
out = []
|
||||
for tpl in RECIPE_TEMPLATES:
|
||||
used: set[str] = set()
|
||||
models = []
|
||||
for role in tpl["slots"]:
|
||||
# "agent" = bestes TOOL-FÄHIGES Modell (kategorieübergreifend, via caps),
|
||||
# nicht die dünne Name-Keyword-Kategorie. Sonst: Modelle der Rolle.
|
||||
if role == "agent":
|
||||
pool = [m for m in all_models if _tool_capable(m)]
|
||||
else:
|
||||
cat = cats.get(role)
|
||||
pool = (cat.get("models") if cat else []) or []
|
||||
cands = [m for m in pool
|
||||
if m["fit"]["level"] != "too_tight" and m["repo"] not in used]
|
||||
if not cands:
|
||||
continue
|
||||
cands.sort(key=lambda m: (_FIT_ORDER[m["fit"]["level"]],
|
||||
0 if _is_moe(m["repo"]) else 1,
|
||||
-int(m.get("downloads") or 0)))
|
||||
pick = cands[0]
|
||||
used.add(pick["repo"])
|
||||
models.append({
|
||||
"role": role, "name": pick["name"], "repo": pick["repo"],
|
||||
"params_b": pick["params_b"], "quant": pick.get("quant", "Q4_K_M"),
|
||||
"tags": pick.get("tags", []), "why": ROLE_WHY.get(role, ""),
|
||||
})
|
||||
if models:
|
||||
out.append({"id": tpl["id"], "title": tpl["title"], "icon": tpl["icon"],
|
||||
"desc": tpl["desc"], "models": models, "auto": True})
|
||||
return out
|
||||
|
||||
|
||||
def all_recipes() -> list:
|
||||
"""Auto-komponierte Setups (live aus discover, Hardware-aware) + eigene Setups.
|
||||
Fallback auf die statischen RECIPES, wenn discover gerade nichts liefert."""
|
||||
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
||||
disc = _safe_discover(ram_gb)
|
||||
base = build_dynamic_recipes(disc) if disc else []
|
||||
if not base:
|
||||
base = list(RECIPES)
|
||||
return base + load_user_recipes()
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
repo_id: str
|
||||
ctx: int = 8192
|
||||
@@ -22,17 +127,33 @@ class EvaluateRequest(BaseModel):
|
||||
quant: str
|
||||
ctx: int
|
||||
|
||||
def extract_params_b(repo_id: str) -> float:
|
||||
"""Extrahiert die Parametergröße (in Milliarden) aus dem Repo-Namen."""
|
||||
# z.B. Qwen2.5-Coder-32B -> 32
|
||||
# 8x7B -> 56 (MoE)
|
||||
moe = re.search(r"(\d+)x(\d+(?:\.\d+)?)[bB]", repo_id)
|
||||
if moe:
|
||||
return float(moe.group(1)) * float(moe.group(2))
|
||||
m = re.search(r"(\d+(?:\.\d+)?)[bB](?![a-zA-Z])", repo_id)
|
||||
if m:
|
||||
return float(m.group(1))
|
||||
return 7.0 # Fallback
|
||||
class InstallRecipeReq(BaseModel):
|
||||
recipe_id: str
|
||||
hf_token: str | None = None
|
||||
|
||||
class InstallModelReq(BaseModel):
|
||||
repo: str
|
||||
role: str
|
||||
params_b: float
|
||||
quant: str = "Q4_K_M"
|
||||
file: str | None = None # konkrete GGUF-Datei; None → _pick_gguf wählt automatisch
|
||||
ctx: int | None = None # None → max_ctx_for (Hardware-Optimum)
|
||||
hf_token: str | None = None
|
||||
|
||||
|
||||
class UserRecipeModelReq(BaseModel):
|
||||
repo: str
|
||||
role: str
|
||||
quant: str = "Q4_K_M"
|
||||
name: str | None = None
|
||||
why: str | None = None
|
||||
|
||||
|
||||
class UserRecipeReq(BaseModel):
|
||||
title: str
|
||||
desc: str = ""
|
||||
icon: str = "box"
|
||||
models: list[UserRecipeModelReq]
|
||||
|
||||
def extract_quant(filename: str) -> str:
|
||||
m = re.search(r"(Q\d_[A-Z0-9_]+|IQ\d_[A-Z0-9_]+|FP16|BF16)", filename, re.IGNORECASE)
|
||||
@@ -64,7 +185,7 @@ async def analyze_repo(req: AnalyzeRequest):
|
||||
results = []
|
||||
for f in gguf_files:
|
||||
quant = extract_quant(f)
|
||||
fit = evaluate_fit(params_b, quant, req.ctx, ram_gb)
|
||||
fit = evaluate_fit(params_b, quant, req.ctx, ram_gb, name=req.repo_id)
|
||||
|
||||
# Priority-Score, um den besten Fit an oberste Stelle zu setzen.
|
||||
# "Q4_K_M" ist oft der Sweetspot.
|
||||
@@ -79,6 +200,7 @@ async def analyze_repo(req: AnalyzeRequest):
|
||||
"filename": f,
|
||||
"quant": quant,
|
||||
"fit": fit,
|
||||
"optimal_ctx": max_ctx_for(params_b, quant, ram_gb),
|
||||
"priority": priority
|
||||
})
|
||||
|
||||
@@ -96,5 +218,407 @@ async def analyze_repo(req: AnalyzeRequest):
|
||||
def evaluate_single(req: EvaluateRequest):
|
||||
ram_gb = psutil.virtual_memory().total / (1024**3)
|
||||
fit = evaluate_fit(req.params_b, req.quant, req.ctx, ram_gb)
|
||||
fit["optimal_ctx"] = max_ctx_for(req.params_b, req.quant, ram_gb)
|
||||
return fit
|
||||
|
||||
|
||||
@router.get("/recipes")
|
||||
def recipes():
|
||||
"""Use-Case-Setups mit Hardware-Fit pro Modell + Stack-Gesamturteil. Da llama-swap nur EIN
|
||||
Modell gleichzeitig lädt, ist das Stack-Urteil der schlechteste (= größte) Einzel-Fit."""
|
||||
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
||||
out = []
|
||||
for r in all_recipes():
|
||||
models, worst = [], "perfect"
|
||||
for m in r["models"]:
|
||||
fit = evaluate_fit(m["params_b"], m["quant"], 8192, ram_gb, name=m.get("name", ""))
|
||||
models.append({**m, "fit": fit, "optimal_ctx": max_ctx_for(m["params_b"], m["quant"], ram_gb)})
|
||||
if _FIT_ORDER[fit["level"]] > _FIT_ORDER[worst]:
|
||||
worst = fit["level"]
|
||||
out.append({**r, "models": models, "fit_level": worst, "user": bool(r.get("user"))})
|
||||
# „Beste Wahl": das reichste KURATIERTE Setup, das komplett auf die Hardware passt.
|
||||
fitting = [r for r in out if not r["user"] and r["fit_level"] != "too_tight"]
|
||||
rec = max(fitting, key=lambda r: len(r["models"]), default=None) if fitting else None
|
||||
return {"recipes": out, "sys_ram_gb": round(ram_gb, 1), "recommended_id": rec["id"] if rec else None}
|
||||
|
||||
|
||||
@router.post("/user-recipe")
|
||||
def create_user_recipe(req: UserRecipeReq):
|
||||
"""Eigenes Cookbook-Setup anlegen. params_b wird aus dem Repo-Namen abgeleitet, damit der
|
||||
Nutzer nur Repo + Rolle + Quant angeben muss; Fit/Kontext kommen wie immer zur Laufzeit."""
|
||||
if not req.title.strip():
|
||||
raise HTTPException(400, "Bitte einen Titel angeben.")
|
||||
models = []
|
||||
for m in req.models:
|
||||
if not m.repo.strip():
|
||||
continue
|
||||
models.append({
|
||||
"role": (m.role or "modell").strip(),
|
||||
"name": (m.name or m.repo.split("/")[-1]).strip(),
|
||||
"repo": m.repo.strip(),
|
||||
"params_b": extract_params_b(m.repo),
|
||||
"quant": (m.quant or "Q4_K_M").strip(),
|
||||
"why": (m.why or "").strip(),
|
||||
})
|
||||
if not models:
|
||||
raise HTTPException(400, "Mindestens ein Modell (Repo) angeben.")
|
||||
items = load_user_recipes()
|
||||
base = "user-" + (re.sub(r"[^a-z0-9]+", "-", req.title.lower()).strip("-") or "setup")
|
||||
rid, n = base, 2
|
||||
existing = {r.get("id") for r in items} | {r["id"] for r in RECIPES}
|
||||
while rid in existing:
|
||||
rid, n = f"{base}-{n}", n + 1
|
||||
items.append({"id": rid, "title": req.title.strip(), "icon": (req.icon or "box").strip(),
|
||||
"desc": req.desc.strip(), "models": models, "user": True})
|
||||
save_user_recipes(items)
|
||||
return {"ok": True, "id": rid}
|
||||
|
||||
|
||||
@router.put("/user-recipe/{recipe_id}")
|
||||
def update_user_recipe(recipe_id: str, req: UserRecipeReq):
|
||||
"""Bestehendes eigenes Setup aktualisieren (Titel, Beschreibung, Modelle)."""
|
||||
items = load_user_recipes()
|
||||
idx = next((i for i, r in enumerate(items) if r.get("id") == recipe_id), None)
|
||||
if idx is None:
|
||||
raise HTTPException(404, "Eigenes Setup nicht gefunden.")
|
||||
if not req.title.strip():
|
||||
raise HTTPException(400, "Bitte einen Titel angeben.")
|
||||
models = []
|
||||
for m in req.models:
|
||||
if not m.repo.strip():
|
||||
continue
|
||||
models.append({
|
||||
"role": (m.role or "modell").strip(),
|
||||
"name": (m.name or m.repo.split("/")[-1]).strip(),
|
||||
"repo": m.repo.strip(),
|
||||
"params_b": extract_params_b(m.repo),
|
||||
"quant": (m.quant or "Q4_K_M").strip(),
|
||||
"why": (m.why or "").strip(),
|
||||
})
|
||||
if not models:
|
||||
raise HTTPException(400, "Mindestens ein Modell (Repo) angeben.")
|
||||
items[idx] = {**items[idx], "title": req.title.strip(), "icon": (req.icon or "box").strip(),
|
||||
"desc": req.desc.strip(), "models": models}
|
||||
save_user_recipes(items)
|
||||
return {"ok": True, "id": recipe_id}
|
||||
|
||||
|
||||
@router.delete("/user-recipe/{recipe_id}")
|
||||
def delete_user_recipe(recipe_id: str):
|
||||
items = load_user_recipes()
|
||||
kept = [r for r in items if r.get("id") != recipe_id]
|
||||
if len(kept) == len(items):
|
||||
raise HTTPException(404, "Eigenes Setup nicht gefunden.")
|
||||
save_user_recipes(kept)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/install-recipe")
|
||||
def install_recipe(req: InstallRecipeReq):
|
||||
"""Komplettes Setup installieren: jedes Modell als Download-Job starten UND sofort mit
|
||||
optimalem (gedeckeltem) Kontext in die config.yaml eintragen. llama-swap (-watch-config)
|
||||
übernimmt es, sobald die Datei da ist."""
|
||||
recipe = next((r for r in all_recipes() if r["id"] == req.recipe_id), None)
|
||||
if not recipe:
|
||||
raise HTTPException(404, "Setup nicht gefunden.")
|
||||
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
||||
env = dict(HF_DOWNLOAD_ENV)
|
||||
if req.hf_token:
|
||||
env["HF_TOKEN"] = req.hf_token
|
||||
cfg = read_config()
|
||||
job_ids = []
|
||||
for m in recipe["models"]:
|
||||
file = _pick_gguf(m["repo"], m.get("quant", "Q4_K_M"))
|
||||
if not file:
|
||||
continue # kein GGUF im Repo gefunden -> Modell ueberspringen (Rest installiert trotzdem)
|
||||
# Vision-Modelle: mmproj-Projektor mitholen (nötig für --mmproj in llama.cpp).
|
||||
mmproj = _pick_mmproj(m["repo"])
|
||||
target = MODELS_DIR / m["repo"].split("/")[-1]
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
args = [hf_bin(), "download", m["repo"], file]
|
||||
if mmproj:
|
||||
args.append(mmproj) # hf-cli unterstützt mehrere Dateien in einem Aufruf
|
||||
args += ["--local-dir", str(target)]
|
||||
jid = start_job(args, f"download {m['name']}", env=env)
|
||||
JOBS[jid]["result_path"] = str(target / file)
|
||||
attach_download_progress(jid, str(target), hf_file_size(m["repo"], file))
|
||||
job_ids.append(jid)
|
||||
# Eintrag jetzt schon schreiben — optimaler Kontext für die Hardware.
|
||||
ctx = max_ctx_for(m["params_b"], m["quant"], ram_gb)
|
||||
path = str(target / file)
|
||||
cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx))
|
||||
if mmproj:
|
||||
cmd = cmd.rstrip() + f" --mmproj {target / mmproj} --jinja"
|
||||
# Schluessel = sprechender Modellname; Rolle (aus dem Rezept) als Alias.
|
||||
mid = model_id_from_path(path)
|
||||
cfg["models"][mid] = {"cmd": LiteralScalarString(cmd + "\n"), "ttl": DEFAULT_TTL}
|
||||
set_role_alias(cfg, mid, m["role"])
|
||||
write_config(cfg)
|
||||
return {"job_ids": job_ids, "count": len(job_ids)}
|
||||
|
||||
|
||||
def hf_file_size(repo: str, file: str) -> int:
|
||||
"""Groesse einer Datei im HF-Repo (Bytes) fuer die Fortschrittsanzeige. 0 wenn unbekannt.
|
||||
GGUFs sind LFS -> ggf. unter 'lfs.size'."""
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as c:
|
||||
tree = c.get(f"https://huggingface.co/api/models/{repo}/tree/main").json()
|
||||
for f in tree:
|
||||
if isinstance(f, dict) and f.get("path") == file:
|
||||
return int(f.get("size") or (f.get("lfs") or {}).get("size") or 0)
|
||||
except Exception: # noqa: BLE001
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def _pick_gguf(repo: str, quant: str = "Q4_K_M") -> str | None:
|
||||
"""Beste GGUF-Datei eines Repos auflösen: bevorzugt gewünschten Quant, keine Split-Teile."""
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as c:
|
||||
tree = c.get(f"https://huggingface.co/api/models/{repo}/tree/main").json()
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
ggufs = [f["path"] for f in tree if isinstance(f, dict) and str(f.get("path", "")).endswith(".gguf")]
|
||||
if not ggufs:
|
||||
return None
|
||||
pref = [g for g in ggufs if quant.lower() in g.lower() and "-of-" not in g]
|
||||
nosplit = [g for g in ggufs if "-of-" not in g]
|
||||
return (pref or nosplit or ggufs)[0]
|
||||
|
||||
|
||||
def _pick_mmproj(repo: str) -> str | None:
|
||||
"""Sucht den mmproj-Projektor (Vision-Modelle) im Repo — F16/BF16 bevorzugt.
|
||||
Gibt None zurueck, wenn kein Projektor vorhanden (= kein Vision-Modell)."""
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as c:
|
||||
tree = c.get(f"https://huggingface.co/api/models/{repo}/tree/main").json()
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
candidates = [
|
||||
f["path"] for f in tree
|
||||
if isinstance(f, dict) and "mmproj" in str(f.get("path", "")).lower()
|
||||
and str(f.get("path", "")).endswith(".gguf")
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
# F16/BF16 hat die beste Projektor-Qualität; nimm das erste wenn keins passt.
|
||||
pref = [c for c in candidates if "f16" in c.lower() or "bf16" in c.lower()]
|
||||
return (pref or candidates)[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Automatische Modell-Entdeckung ("aktuell beste Modelle", live von HF + Cache).
|
||||
# ---------------------------------------------------------------------------
|
||||
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" # Fallback: Allrounder
|
||||
|
||||
|
||||
def _installed_index() -> dict:
|
||||
"""alias -> kleingeschriebene cmd-Zeile der installierten Modelle (fuer Bestandsabgleich)."""
|
||||
return {alias: str(spec.get("cmd", "")).lower()
|
||||
for alias, spec in (read_config().get("models") or {}).items()}
|
||||
|
||||
|
||||
def _model_installed(repo: str, idx: dict) -> str | None:
|
||||
"""Liefert den Alias, unter dem dieses Repo schon installiert ist — sonst None."""
|
||||
base = repo.split("/")[-1].lower()
|
||||
# GGUF-Repos heissen meist '<modell>-GGUF' -> auch ohne Suffix vergleichen
|
||||
stem = base[:-5] if base.endswith("-gguf") else base
|
||||
for alias, cmd in idx.items():
|
||||
if base in cmd or (stem and stem in cmd):
|
||||
return alias
|
||||
return None
|
||||
|
||||
|
||||
def _model_requirements(role: str, quant: str, fit: dict) -> list[str]:
|
||||
"""Ehrliche Voraussetzungen/Hinweise pro Modell (Klartext fuer Anfaenger)."""
|
||||
reqs = []
|
||||
if role == "vision":
|
||||
reqs.append("Bild-Modell: Projektor (mmproj-F16) wird automatisch mitgeladen und --jinja gesetzt.")
|
||||
if quant.upper() in ("FP16", "BF16", "F16", "F32"):
|
||||
reqs.append("Unquantisiert — sehr groß. Eine Q4/Q5-Variante ist meist die bessere Wahl.")
|
||||
if fit["level"] == "too_tight":
|
||||
reqs.append("Größer als dein Speicher — nur mit kleinerem Quant realistisch.")
|
||||
elif fit["level"] == "marginal":
|
||||
reqs.append("Läuft, wird aber knapp — andere Programme währenddessen besser schließen.")
|
||||
return reqs
|
||||
|
||||
|
||||
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: # noqa: BLE001
|
||||
return []
|
||||
|
||||
|
||||
def refresh_discover(ram_gb: float) -> dict:
|
||||
"""Quellen live abfragen, kategorisieren, nach Hardware-Fit + Beliebtheit ranken
|
||||
und cachen. Wirft nur, wenn KEINE einzige 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:
|
||||
raise RuntimeError("Keine Quelle erreichbar.")
|
||||
|
||||
by_cat = {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 fuer die Fit-Einschaetzung
|
||||
fit = evaluate_fit(params_b, quant, 8192, ram_gb, name=rid)
|
||||
by_cat[role].append({
|
||||
"name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid,
|
||||
"role": role, "params_b": params_b, "quant": quant,
|
||||
"tags": [str(t) for t in (m.get("tags") or [])], # HF-Tags für Capability-Erkennung (Phase 10)
|
||||
"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),
|
||||
})
|
||||
|
||||
cats = []
|
||||
for c in CATEGORIES:
|
||||
items = by_cat[c["role"]]
|
||||
# Passendes zuerst (perfect/marginal vor too_tight), dann nach Downloads.
|
||||
items.sort(key=lambda x: (x["fit"]["level"] != "too_tight", x["downloads"]), reverse=True)
|
||||
top = items[:4]
|
||||
if top:
|
||||
cats.append({"role": c["role"], "title": c["title"], "icon": c["icon"], "models": top})
|
||||
|
||||
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: # noqa: BLE001
|
||||
pass # Cache ist nur Beschleunigung — Entdeckung funktioniert auch ohne Schreibrecht
|
||||
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: # noqa: BLE001
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/discover")
|
||||
def discover(force: bool = False):
|
||||
"""Aktuell beste Modelle je Kategorie — live aus den Quellen (gecacht, TTL).
|
||||
Markiert pro Modell, ob es schon installiert ist, und nennt Voraussetzungen."""
|
||||
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
||||
cached = _load_discover()
|
||||
fresh = bool(cached) and (time.time() - cached.get("updated", 0) < DISCOVER_TTL)
|
||||
if fresh and not force:
|
||||
data = cached
|
||||
else:
|
||||
try:
|
||||
data = refresh_discover(ram_gb)
|
||||
except Exception: # noqa: BLE001
|
||||
if cached:
|
||||
data = cached # Quellen down -> alter Cache ist besser als nichts
|
||||
else:
|
||||
raise HTTPException(502, "Modell-Quellen gerade nicht erreichbar — später erneut versuchen.")
|
||||
# Bestand + Voraussetzungen frisch dazurechnen (aendern sich unabhaengig vom Cache).
|
||||
idx = _installed_index()
|
||||
out = []
|
||||
for c in data["categories"]:
|
||||
ms = [{**m, "installed": _model_installed(m["repo"], idx),
|
||||
"requirements": _model_requirements(m["role"], m.get("quant", "Q4_K_M"), m["fit"])}
|
||||
for m in c["models"]]
|
||||
# „Beste Wahl" je Kategorie: das lauffähige Modell mit dem besten Fit (perfect vor
|
||||
# marginal), bei Gleichstand das meistgeladene. Zu große Modelle kommen nie in Frage.
|
||||
ranked = sorted([m for m in ms if m["fit"]["level"] != "too_tight"],
|
||||
key=lambda m: (_FIT_ORDER[m["fit"]["level"]], -int(m.get("downloads") or 0)))
|
||||
out.append({**c, "models": ms, "recommended": ranked[0]["repo"] if ranked else None})
|
||||
return {"updated": data["updated"], "categories": out,
|
||||
"sys_ram_gb": round(ram_gb, 1), "stale": not fresh}
|
||||
|
||||
|
||||
def compute_upgrades(ram_gb):
|
||||
"""Liste relevanter Modell-Upgrades für die installierten Modelle (UPGRADES-Map)."""
|
||||
installed = (read_config().get("models") or {})
|
||||
out, seen = [], set()
|
||||
for up in UPGRADES:
|
||||
new_base = up["repo"].split("/")[-1].lower()
|
||||
if any(new_base in str(s.get("cmd", "")).lower() for s in installed.values()):
|
||||
continue # neueres Modell schon installiert
|
||||
old = None
|
||||
for alias, spec in installed.items():
|
||||
hay = (alias + " " + str(spec.get("cmd", ""))).lower()
|
||||
if any(k in hay for k in up["match"]):
|
||||
old = alias
|
||||
break
|
||||
if not old or up["repo"] in seen:
|
||||
continue
|
||||
seen.add(up["repo"])
|
||||
out.append({
|
||||
"name": up["name"], "repo": up["repo"], "params_b": up["params_b"], "quant": up["quant"],
|
||||
"why": up["why"], "old": old, "role": old,
|
||||
"fit": evaluate_fit(up["params_b"], up["quant"], 8192, ram_gb, name=up.get("name", "")),
|
||||
"optimal_ctx": max_ctx_for(up["params_b"], up["quant"], ram_gb),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/upgrades")
|
||||
def upgrades():
|
||||
return {"upgrades": compute_upgrades(psutil.virtual_memory().total / (1024 ** 3))}
|
||||
|
||||
|
||||
@router.post("/install-model")
|
||||
def install_model(req: InstallModelReq):
|
||||
"""Ein einzelnes Modell installieren (Download + Einpflegen unter 'role', optimaler ctx).
|
||||
Wenn 'file' übergeben wird, wird genau diese GGUF-Datei geladen (Profi-Suche-Auswahl);
|
||||
andernfalls wählt _pick_gguf automatisch die beste Variante für den gewünschten Quant."""
|
||||
file = req.file or _pick_gguf(req.repo, req.quant)
|
||||
if not file:
|
||||
raise HTTPException(404, "Keine GGUF-Datei im Repo gefunden.")
|
||||
mmproj = _pick_mmproj(req.repo)
|
||||
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
||||
env = dict(HF_DOWNLOAD_ENV)
|
||||
if req.hf_token:
|
||||
env["HF_TOKEN"] = req.hf_token
|
||||
target = MODELS_DIR / req.repo.split("/")[-1]
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
args = [hf_bin(), "download", req.repo, file]
|
||||
if mmproj:
|
||||
args.append(mmproj)
|
||||
args += ["--local-dir", str(target)]
|
||||
jid = start_job(args, f"download {req.repo.split('/')[-1]}", env=env)
|
||||
JOBS[jid]["result_path"] = str(target / file)
|
||||
attach_download_progress(jid, str(target), hf_file_size(req.repo, file))
|
||||
cfg = read_config()
|
||||
ctx = req.ctx if req.ctx else max_ctx_for(req.params_b, req.quant, ram_gb)
|
||||
path = str(target / file)
|
||||
cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx))
|
||||
if mmproj:
|
||||
cmd = cmd.rstrip() + f" --mmproj {target / mmproj} --jinja"
|
||||
mid = model_id_from_path(path)
|
||||
cfg["models"][mid] = {"cmd": LiteralScalarString(cmd + "\n"), "ttl": DEFAULT_TTL}
|
||||
set_role_alias(cfg, mid, req.role)
|
||||
write_config(cfg)
|
||||
return {"job_id": jid, "model_id": mid}
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Hermes Router — Voice-Endpoints, Status & Cockpit-Reads.
|
||||
|
||||
Der Chat selbst ist in das eingebettete Hermes-Web-Dashboard umgezogen
|
||||
(siehe `routers/hermes_ui.py`); der fruehere Eigenbau-Chat-WS/Proxy entfiel.
|
||||
|
||||
Endpunkte:
|
||||
POST /api/hermes/transcribe — Audio → Text (Whisper)
|
||||
POST /api/hermes/tts — Text → Audio WAV (Piper)
|
||||
GET /api/hermes/status — Komponentenstatus (Hermes, Whisper, Piper, SSH)
|
||||
GET /api/hermes/{agent,cron,skills} — Cockpit-Reads (Phase 4)
|
||||
GET /api/hermes/pubkey — Bosgame SSH-Public-Key fuer Windows-Setup
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, UploadFile, File
|
||||
from fastapi.responses import Response, JSONResponse
|
||||
|
||||
from auth import auth
|
||||
from config import (
|
||||
PIPER_BIN, PIPER_VOICE, WHISPER_MODEL_SIZE,
|
||||
HERMES_WINDOWS_HOST, HERMES_WINDOWS_USER, HERMES_SSH_KEY,
|
||||
HERMES_API_URL,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Whisper (lazy-loaded, einmalig in RAM)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_whisper_model = None
|
||||
_whisper_loading = False
|
||||
_whisper_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_whisper():
|
||||
global _whisper_model, _whisper_loading
|
||||
with _whisper_lock:
|
||||
if _whisper_model is not None:
|
||||
return _whisper_model
|
||||
if _whisper_loading:
|
||||
return None
|
||||
_whisper_loading = True
|
||||
try:
|
||||
from faster_whisper import WhisperModel
|
||||
model = WhisperModel(WHISPER_MODEL_SIZE, device="cpu", compute_type="int8")
|
||||
with _whisper_lock:
|
||||
_whisper_model = model
|
||||
return model
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
with _whisper_lock:
|
||||
_whisper_loading = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TTS-Cache (in-memory, max 64 Eintraege)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_tts_cache: dict[str, bytes] = {}
|
||||
|
||||
|
||||
def _tts(text: str) -> Optional[bytes]:
|
||||
key = hashlib.md5(text.encode()).hexdigest()
|
||||
if key in _tts_cache:
|
||||
return _tts_cache[key]
|
||||
if not PIPER_BIN.exists() or not PIPER_VOICE.exists():
|
||||
return None
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
|
||||
wav_path = f.name
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(PIPER_BIN), "--model", str(PIPER_VOICE), "--output_file", wav_path],
|
||||
input=text, capture_output=True, text=True, timeout=30
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"[Piper] Fehler: {result.stderr or 'exit '+str(result.returncode)}")
|
||||
return None
|
||||
audio = Path(wav_path).read_bytes()
|
||||
if len(_tts_cache) >= 64:
|
||||
_tts_cache.pop(next(iter(_tts_cache)))
|
||||
_tts_cache[key] = audio
|
||||
return audio
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
Path(wav_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/hermes/transcribe", dependencies=[Depends(auth)])
|
||||
async def transcribe_audio(audio: UploadFile = File(...)):
|
||||
"""Audio-Datei (webm/ogg/wav) per Whisper transkribieren."""
|
||||
model = _get_whisper()
|
||||
if model is None:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"error": "Whisper nicht verfuegbar. Bitte faster-whisper installieren."}
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(suffix=".webm", delete=False) as f:
|
||||
tmp = Path(f.name)
|
||||
tmp.write_bytes(await audio.read())
|
||||
try:
|
||||
segments, _ = await asyncio.get_running_loop().run_in_executor(
|
||||
None, lambda: model.transcribe(str(tmp), language="de")
|
||||
)
|
||||
text = " ".join(s.text.strip() for s in segments).strip()
|
||||
return {"text": text}
|
||||
except Exception as exc:
|
||||
return JSONResponse(status_code=500, content={"error": str(exc)})
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@router.post("/hermes/tts", dependencies=[Depends(auth)])
|
||||
async def text_to_speech(body: dict):
|
||||
"""Text zu WAV-Audio per Piper TTS konvertieren."""
|
||||
text = (body.get("text") or "").strip()
|
||||
if not text:
|
||||
return JSONResponse(status_code=400, content={"error": "Kein Text angegeben."})
|
||||
audio = await asyncio.get_running_loop().run_in_executor(None, lambda: _tts(text))
|
||||
if audio is None:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"error": "Piper TTS nicht verfuegbar. Bitte Piper Binary + Stimme installieren."}
|
||||
)
|
||||
return Response(content=audio, media_type="audio/wav")
|
||||
|
||||
|
||||
@router.get("/hermes/status", dependencies=[Depends(auth)])
|
||||
def hermes_status():
|
||||
"""Status aller Hermes-Komponenten."""
|
||||
# Whisper
|
||||
try:
|
||||
from faster_whisper import WhisperModel # noqa
|
||||
whisper = "ready" if _whisper_model else "installed"
|
||||
except ImportError:
|
||||
whisper = "not_installed"
|
||||
|
||||
# Piper
|
||||
piper = "ready" if (PIPER_BIN.exists() and PIPER_VOICE.exists()) else "not_installed"
|
||||
|
||||
# SSH
|
||||
ssh_configured = bool(HERMES_WINDOWS_HOST) and HERMES_SSH_KEY.exists()
|
||||
ssh_ok = False
|
||||
if ssh_configured:
|
||||
try:
|
||||
import paramiko
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
c.connect(
|
||||
HERMES_WINDOWS_HOST,
|
||||
username=HERMES_WINDOWS_USER,
|
||||
key_filename=str(HERMES_SSH_KEY),
|
||||
timeout=3,
|
||||
)
|
||||
c.close()
|
||||
ssh_ok = True
|
||||
except Exception:
|
||||
ssh_ok = False
|
||||
|
||||
# Hermes-Agent-Server (:8642) — erreichbar?
|
||||
try:
|
||||
httpx.get(HERMES_API_URL.rsplit("/v1", 1)[0] + "/", timeout=2)
|
||||
hermes = "ready"
|
||||
except Exception:
|
||||
hermes = "offline"
|
||||
|
||||
return {
|
||||
"hermes": hermes,
|
||||
"whisper": whisper,
|
||||
"piper": piper,
|
||||
"ssh_configured": ssh_configured,
|
||||
"ssh_ok": ssh_ok,
|
||||
"windows_host": HERMES_WINDOWS_HOST or None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/hermes/agent", dependencies=[Depends(auth)])
|
||||
def hermes_agent_status():
|
||||
"""Phase 4 — Live-Status des Hermes-Agent-Gateways + Cron-Tickers."""
|
||||
from hermes_control import agent_status
|
||||
return agent_status()
|
||||
|
||||
|
||||
@router.get("/hermes/cron", dependencies=[Depends(auth)])
|
||||
def hermes_cron():
|
||||
"""Phase 4 — geplante Cron-Jobs des Hermes-Agenten (inkl. pausierter)."""
|
||||
from hermes_control import cron_jobs
|
||||
return {"jobs": cron_jobs()}
|
||||
|
||||
|
||||
@router.get("/hermes/skills", dependencies=[Depends(auth)])
|
||||
def hermes_skills():
|
||||
"""Phase 4 — installierte Skills des Hermes-Agenten + Nutzungszaehler."""
|
||||
from hermes_control import skills
|
||||
return {"skills": skills()}
|
||||
|
||||
|
||||
@router.get("/hermes/learned", dependencies=[Depends(auth)])
|
||||
def hermes_learned():
|
||||
"""Phase 8 — was Hermes ueber den Nutzer gelernt hat (USER.md/MEMORY.md)."""
|
||||
from hermes_control import learned_profile
|
||||
return learned_profile()
|
||||
|
||||
|
||||
@router.get("/hermes/insights", dependencies=[Depends(auth)])
|
||||
def hermes_insights():
|
||||
"""Phase 8 — Aktivitaets-Kennzahlen (Sessions/Tokens/Top-Tools)."""
|
||||
from hermes_control import insights
|
||||
return insights()
|
||||
|
||||
|
||||
@router.get("/hermes/pubkey", dependencies=[Depends(auth)])
|
||||
def hermes_pubkey():
|
||||
"""Gibt den SSH-Public-Key des Hermes Agent zurueck (fuer Windows authorized_keys)."""
|
||||
pub = Path(str(HERMES_SSH_KEY) + ".pub")
|
||||
if not pub.exists():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"error": "Kein SSH-Key gefunden. Bitte auf dem Bosgame generieren: "
|
||||
"ssh-keygen -t ed25519 -C 'hermes-agent@bosgame' "
|
||||
f"-f {HERMES_SSH_KEY} -N ''"}
|
||||
)
|
||||
return {"pubkey": pub.read_text().strip()}
|
||||
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Hermes-UI Reverse-Proxy — bettet das Hermes-Web-Dashboard in Mission Control ein.
|
||||
|
||||
Das Hermes-Agent-Framework bringt eine fertige Web-UI mit (Chat mit Live-Tool-
|
||||
Aktivitaet, Approval-Prompts, Settings, Sessions). Sie laeuft als eigener Dienst
|
||||
lokal auf der Box (`hermes dashboard`, Port 9119, an 127.0.0.1 gebunden). MC
|
||||
proxyt sie unter `/hermes-ui/` und bettet sie per iframe ein:
|
||||
|
||||
- Das Dashboard ist explizit fuer Prefix-Reverse-Proxy gebaut: wir setzen
|
||||
`X-Forwarded-Prefix: /hermes-ui`, dann rewritet es index.html/CSS/Asset-URLs
|
||||
und seinen SPA-Base-Path selbst.
|
||||
- Auf Loopback injiziert das Dashboard seinen eigenen Session-Token in die SPA
|
||||
-> kein zweiter Login. MC bleibt das eine Gateway (LAN-only).
|
||||
|
||||
Damit faellt der fruehere Eigenbau-Chat (Proxy auf :8642) weg.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from fastapi import APIRouter, Request, WebSocket
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
from config import HERMES_DASHBOARD_URL
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
PREFIX = "/hermes-ui"
|
||||
_WS_BASE = HERMES_DASHBOARD_URL.replace("http://", "ws://").replace("https://", "wss://")
|
||||
|
||||
# Hop-by-hop-Header, die ein Proxy nicht weiterreichen darf (RFC 7230) plus solche,
|
||||
# die httpx/Starlette selbst neu berechnen (Laenge/Encoding).
|
||||
_HOP = {
|
||||
"host", "content-length", "connection", "keep-alive", "transfer-encoding",
|
||||
"content-encoding", "upgrade", "proxy-authenticate", "proxy-authorization",
|
||||
"te", "trailers",
|
||||
}
|
||||
|
||||
_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
|
||||
|
||||
|
||||
@router.get(PREFIX)
|
||||
def _hermes_ui_root_redirect():
|
||||
"""Bare /hermes-ui -> /hermes-ui/ (sonst greift das Asset-Prefix-Rewriting nicht)."""
|
||||
return RedirectResponse(url=PREFIX + "/")
|
||||
|
||||
|
||||
@router.api_route(PREFIX + "/{path:path}", methods=_METHODS)
|
||||
async def hermes_ui_proxy(request: Request, path: str):
|
||||
"""HTTP-Reverse-Proxy auf das Hermes-Dashboard mit Prefix-Header."""
|
||||
url = f"{HERMES_DASHBOARD_URL}/{path}"
|
||||
headers = {k: v for k, v in request.headers.items() if k.lower() not in _HOP}
|
||||
headers["X-Forwarded-Prefix"] = PREFIX
|
||||
body = await request.body()
|
||||
timeout = httpx.Timeout(60.0, connect=10.0)
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
|
||||
r = await client.request(
|
||||
request.method, url,
|
||||
params=request.query_params, headers=headers, content=body,
|
||||
)
|
||||
resp_headers = {k: v for k, v in r.headers.items() if k.lower() not in _HOP}
|
||||
return Response(
|
||||
content=r.content, status_code=r.status_code,
|
||||
headers=resp_headers, media_type=r.headers.get("content-type"),
|
||||
)
|
||||
|
||||
|
||||
@router.websocket(PREFIX + "/api/{name}")
|
||||
async def hermes_ui_ws(ws: WebSocket, name: str):
|
||||
"""WebSocket-Bruecke fuer die Chat-/Event-WS des Dashboards (pty/ws/pub/events)."""
|
||||
await ws.accept()
|
||||
qs = ws.url.query
|
||||
target = f"{_WS_BASE}/api/{name}" + (f"?{qs}" if qs else "")
|
||||
try:
|
||||
async with websockets.connect(
|
||||
target,
|
||||
additional_headers={"X-Forwarded-Prefix": PREFIX},
|
||||
max_size=None, open_timeout=10, ping_interval=None,
|
||||
) as up:
|
||||
|
||||
async def client_to_upstream():
|
||||
try:
|
||||
while True:
|
||||
msg = await ws.receive()
|
||||
if msg.get("type") == "websocket.disconnect":
|
||||
break
|
||||
if msg.get("text") is not None:
|
||||
await up.send(msg["text"])
|
||||
elif msg.get("bytes") is not None:
|
||||
await up.send(msg["bytes"])
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
await up.close()
|
||||
|
||||
async def upstream_to_client():
|
||||
try:
|
||||
async for m in up:
|
||||
if isinstance(m, (bytes, bytearray)):
|
||||
await ws.send_bytes(m)
|
||||
else:
|
||||
await ws.send_text(m)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if ws.application_state != WebSocketState.DISCONNECTED:
|
||||
await ws.close()
|
||||
|
||||
await asyncio.gather(client_to_upstream(), upstream_to_client())
|
||||
except Exception:
|
||||
if ws.application_state != WebSocketState.DISCONNECTED:
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Integrations-Router: testet die OpenAI-kompatible Verbindung zur LLM-Engine.
|
||||
Damit der „Verbinden"-Assistent dem Nutzer sagen kann: funktioniert ✓ + welche Modelle.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from auth import auth
|
||||
from config import LLAMA_SWAP_URL
|
||||
from llamaswap import _swap_get
|
||||
|
||||
router = APIRouter(prefix="/api/integration", dependencies=[Depends(auth)])
|
||||
|
||||
|
||||
@router.get("/test")
|
||||
def test_connection():
|
||||
"""Ruft die OpenAI-kompatible Modell-Liste der Engine ab (das, was externe Tools nutzen)."""
|
||||
try:
|
||||
data = _swap_get("/v1/models")
|
||||
items = data.get("data", data) if isinstance(data, dict) else data
|
||||
models = [m.get("id") for m in (items or []) if isinstance(m, dict) and m.get("id")]
|
||||
return {"ok": True, "models": models, "url": LLAMA_SWAP_URL}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"ok": False, "error": str(exc), "url": LLAMA_SWAP_URL}
|
||||
+21
-1
@@ -6,7 +6,7 @@ Liefert die Daten fuer das Aktivitaets-Panel mit Live-Log.
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from auth import auth
|
||||
from jobengine import JOBS
|
||||
from jobengine import JOBS, cancel_job, clear_finished, delete_job
|
||||
|
||||
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
|
||||
|
||||
@@ -19,6 +19,26 @@ def job_status(job_id: str):
|
||||
return job
|
||||
|
||||
|
||||
@router.post("/jobs/{job_id}/cancel")
|
||||
def job_cancel(job_id: str):
|
||||
if not cancel_job(job_id):
|
||||
raise HTTPException(409, "Job laeuft nicht (mehr) oder ist unbekannt.")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/jobs/clear")
|
||||
def jobs_clear():
|
||||
"""Alle abgeschlossenen Jobs aus dem Verlauf entfernen (laufende bleiben)."""
|
||||
return {"ok": True, "removed": clear_finished()}
|
||||
|
||||
|
||||
@router.delete("/jobs/{job_id}")
|
||||
def job_delete(job_id: str):
|
||||
if not delete_job(job_id):
|
||||
raise HTTPException(409, "Job laeuft noch oder ist unbekannt — laufende erst abbrechen.")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/jobs")
|
||||
def jobs_list():
|
||||
return sorted(JOBS.values(), key=lambda j: j["started_at"], reverse=True)[:20]
|
||||
|
||||
+101
-10
@@ -6,6 +6,7 @@ laeuft als Hintergrund-Job mit Live-Log. Spaeter wandert hier ggf. mehr
|
||||
Server-Wartung hinein (siehe Roadmap: Server-Management).
|
||||
"""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import asyncio
|
||||
@@ -14,7 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisco
|
||||
from pydantic import BaseModel
|
||||
|
||||
from auth import auth
|
||||
from config import UPDATE_CMD
|
||||
from config import UPDATE_CMD, SOURCE_DIR, PROD_DIR
|
||||
from jobengine import start_job
|
||||
|
||||
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
|
||||
@@ -22,17 +23,47 @@ router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
|
||||
class PwdReq(BaseModel):
|
||||
password: str
|
||||
|
||||
class UpdateReq(BaseModel):
|
||||
password: str = ""
|
||||
|
||||
@router.post("/update")
|
||||
def update():
|
||||
def update(req: UpdateReq = None):
|
||||
"""LLM-Engine (llama.cpp) aktualisieren — Befehl aus MC_UPDATE_CMD.
|
||||
Optionales Passwort fuer sudo-Befehle (via stdin, nicht in argv)."""
|
||||
if not UPDATE_CMD:
|
||||
raise HTTPException(400, "Kein Update-Befehl gesetzt (MC_UPDATE_CMD).")
|
||||
job_id = start_job(shlex.split(UPDATE_CMD), "update containers")
|
||||
pwd = (req.password if req else "").strip()
|
||||
if pwd:
|
||||
args = ["sudo", "-S", "bash", "-c", UPDATE_CMD]
|
||||
job_id = start_job(args, "llama.cpp-Update", stdin_data=pwd + "\n",
|
||||
log_cmd=f"$ sudo {UPDATE_CMD}")
|
||||
else:
|
||||
job_id = start_job(shlex.split(UPDATE_CMD), "llama.cpp-Update")
|
||||
return {"job_id": job_id}
|
||||
|
||||
@router.post("/self-update")
|
||||
def self_update():
|
||||
"""Mission Control selbst aktualisieren: Quelle pullen -> nach Prod rsyncen -> Dienst neu starten.
|
||||
git pull liest aus Gitea; der Restart laeuft ueber die NOPASSWD-sudoers-Whitelist. Die einzelnen
|
||||
Schritte sind mit && verkettet -> bei einem Fehler (z.B. Gitea offline) bricht es sauber ab."""
|
||||
cmd = (
|
||||
f"cd {shlex.quote(SOURCE_DIR)} && git pull --ff-only && "
|
||||
f"rsync -a --exclude=.git --exclude=.venv --exclude=__pycache__ --exclude='*.pyc' "
|
||||
f"--exclude='frontend/node_modules' "
|
||||
f"{shlex.quote(SOURCE_DIR)}/ {shlex.quote(PROD_DIR)}/ && "
|
||||
f"sudo systemctl restart mission-control"
|
||||
)
|
||||
job_id = start_job(["bash", "-c", cmd], "Mission Control Update",
|
||||
log_cmd="$ git pull && rsync -> /opt && systemctl restart mission-control")
|
||||
return {"job_id": job_id}
|
||||
|
||||
@router.post("/os-update")
|
||||
def os_update(req: PwdReq):
|
||||
cmd = f"echo {shlex.quote(req.password)} | sudo -S bash -c 'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y'"
|
||||
job_id = start_job(["bash", "-c", cmd], "os update")
|
||||
# Passwort ueber stdin an sudo -S — NICHT als Klartext-Arg (sonst im Job-Log/ps sichtbar).
|
||||
args = ["sudo", "-S", "bash", "-c",
|
||||
"apt-get update && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y"]
|
||||
job_id = start_job(args, "OS-Update (apt)", stdin_data=req.password + "\n",
|
||||
log_cmd="$ sudo apt-get update && apt-get upgrade -y")
|
||||
return {"job_id": job_id}
|
||||
|
||||
@router.post("/service/{name}/restart")
|
||||
@@ -44,19 +75,79 @@ def service_restart(name: str):
|
||||
|
||||
@router.post("/reboot")
|
||||
def reboot(req: PwdReq):
|
||||
cmd = f"echo {shlex.quote(req.password)} | sudo -S reboot"
|
||||
subprocess.Popen(["bash", "-c", cmd])
|
||||
# Passwort ueber stdin (nicht in argv -> nicht in ps sichtbar).
|
||||
proc = subprocess.Popen(["sudo", "-S", "reboot"], stdin=subprocess.PIPE, text=True)
|
||||
try:
|
||||
proc.stdin.write(req.password + "\n")
|
||||
proc.stdin.flush()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return {"ok": True, "note": "Reboot getriggert."}
|
||||
|
||||
_engine_cache = {"ts": 0.0, "avail": False}
|
||||
|
||||
|
||||
def _engine_update_available():
|
||||
"""Heuristik: ist die neueste llama.cpp-ROCm-Release neuer als die installierte? (1h gecacht)"""
|
||||
import time
|
||||
now = time.time()
|
||||
if now - _engine_cache["ts"] < 3600:
|
||||
return _engine_cache["avail"]
|
||||
avail = False
|
||||
try:
|
||||
import os as _os
|
||||
import httpx
|
||||
from datetime import datetime
|
||||
rel = httpx.get("https://api.github.com/repos/lemonade-sdk/llamacpp-rocm/releases/latest",
|
||||
timeout=6, headers={"User-Agent": "MissionControl"}).json()
|
||||
pub = datetime.fromisoformat(rel["published_at"].replace("Z", "+00:00")).timestamp()
|
||||
inst = _os.path.getmtime("/opt/llamacpp")
|
||||
avail = pub > inst + 86400 # neuer als Installation (1 Tag Puffer)
|
||||
except Exception: # noqa: BLE001
|
||||
avail = False
|
||||
_engine_cache.update(ts=now, avail=avail)
|
||||
return avail
|
||||
|
||||
|
||||
@router.get("/updates")
|
||||
def updates():
|
||||
"""Ausstehende Updates für die Toolbar: OS-Pakete (apt), AI-Engine (llama.cpp), Modell-Upgrades."""
|
||||
import psutil
|
||||
from routers.cookbook import compute_upgrades
|
||||
os_count = 0
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["bash", "-c", "apt list --upgradable 2>/dev/null | grep -c upgradable || true"],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
os_count = int((out.stdout or "0").strip() or 0)
|
||||
except Exception: # noqa: BLE001
|
||||
os_count = 0
|
||||
try:
|
||||
models = len(compute_upgrades(psutil.virtual_memory().total / (1024 ** 3)))
|
||||
except Exception: # noqa: BLE001
|
||||
models = 0
|
||||
apt_cache_age_h = None
|
||||
try:
|
||||
import time as _time
|
||||
apt_cache_age_h = round((_time.time() - os.path.getmtime("/var/lib/apt/lists")) / 3600, 1) # noqa: PTH
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return {"os": os_count, "models": models, "engine": 1 if _engine_update_available() else 0,
|
||||
"apt_cache_age_h": apt_cache_age_h}
|
||||
|
||||
@router.websocket("/logs/{service}")
|
||||
async def stream_logs(websocket: WebSocket, service: str):
|
||||
await websocket.accept()
|
||||
if service not in ("llama-swap", "mission-control"):
|
||||
if service not in ("llama-swap", "mission-control", "system"):
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
|
||||
if service == "system":
|
||||
cmd = ["sudo", "journalctl", "-n", "100", "-f"] # ganzer Server (alle Units)
|
||||
else:
|
||||
cmd = ["sudo", "journalctl", "-u", service, "-n", "100", "-f"]
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"sudo", "journalctl", "-u", service, "-n", "100", "-f",
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
Memory Layer fuer Mission Control.
|
||||
|
||||
Persistentes Gedaechtnis fuer lokale LLM-Agenten (Cline, Claude Code, OpenCode).
|
||||
SQLite aus stdlib — kein Vektor-Overhead, keine neue Abhaengigkeit.
|
||||
Alle MCP-Tools teilen denselben Speicher via mcp_memory.py-Wrapper.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from difflib import SequenceMatcher
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from auth import auth
|
||||
from config import MEMORY_DB
|
||||
|
||||
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
|
||||
|
||||
_db_conn: Optional[sqlite3.Connection] = None
|
||||
|
||||
|
||||
def _db() -> sqlite3.Connection:
|
||||
global _db_conn
|
||||
if _db_conn is None:
|
||||
MEMORY_DB.parent.mkdir(parents=True, exist_ok=True)
|
||||
_db_conn = sqlite3.connect(str(MEMORY_DB), check_same_thread=False)
|
||||
_db_conn.row_factory = sqlite3.Row
|
||||
_db_conn.execute("PRAGMA journal_mode=WAL") # sicherer bei concurrent FastAPI-Threads
|
||||
_db_conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'stable',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
# Temporaere Eintraege nach 7 Tagen automatisch loeschen
|
||||
_db_conn.execute(
|
||||
"DELETE FROM memories WHERE category = 'ephemeral'"
|
||||
" AND datetime(created_at) < datetime('now', '-7 days')"
|
||||
)
|
||||
_db_conn.commit()
|
||||
return _db_conn
|
||||
|
||||
|
||||
class _MemCategory(str, Enum):
|
||||
user = "user"
|
||||
instruction = "instruction"
|
||||
stable = "stable"
|
||||
versioned = "versioned"
|
||||
ephemeral = "ephemeral"
|
||||
|
||||
|
||||
class _MemIn(BaseModel):
|
||||
content: str
|
||||
category: _MemCategory = _MemCategory.stable
|
||||
source: str = "manual"
|
||||
|
||||
|
||||
class _MemUp(BaseModel):
|
||||
content: Optional[str] = None
|
||||
category: Optional[_MemCategory] = None
|
||||
|
||||
|
||||
def _row(r: sqlite3.Row) -> dict:
|
||||
return dict(r)
|
||||
|
||||
|
||||
# Export muss vor /{mid} stehen, sonst wuerde FastAPI "export" als ID behandeln
|
||||
@router.get("/memory/export")
|
||||
def export_memories():
|
||||
"""Alle Eintraege als strukturierter Plain-Text — direkt als System-Prompt-Kontext nutzbar."""
|
||||
db = _db()
|
||||
rows = db.execute(
|
||||
"SELECT * FROM memories ORDER BY category, updated_at DESC"
|
||||
).fetchall()
|
||||
cat_names = {
|
||||
"stable": "Stabile Fakten",
|
||||
"versioned": "Versionierte Infos",
|
||||
"ephemeral": "Temporaerer Kontext",
|
||||
}
|
||||
lines = ["# Mission Control — Gedaechtnis\n"]
|
||||
current = ""
|
||||
for r in rows:
|
||||
cat = cat_names.get(r["category"], r["category"])
|
||||
if cat != current:
|
||||
lines.append(f"\n## {cat}\n")
|
||||
current = cat
|
||||
lines.append(
|
||||
f"- {r['content']} _(Quelle: {r['source']}, {r['updated_at'][:10]})_"
|
||||
)
|
||||
return {"text": "\n".join(lines), "count": len(rows)}
|
||||
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
"""Normalisiert Text fuer Dubletten-Vergleich: klein, ohne Satzzeichen, Whitespace kollabiert."""
|
||||
s = re.sub(r"[^\w\s]", " ", s.lower(), flags=re.UNICODE)
|
||||
return re.sub(r"\s+", " ", s).strip()
|
||||
|
||||
|
||||
class _DedupeIn(BaseModel):
|
||||
apply: bool = False # False = Dry-Run (nur Vorschau), True = wirklich loeschen
|
||||
threshold: float = 0.85 # Aehnlichkeits-Schwelle (SequenceMatcher)
|
||||
|
||||
|
||||
@router.post("/memory/dedupe")
|
||||
def dedupe_memories(body: _DedupeIn):
|
||||
"""Deterministischer Kurator: findet Dubletten (exakt / enthalten / aehnlich) je Kategorie,
|
||||
behaelt den vollstaendigsten (laengsten) Eintrag, entfernt die uebrigen. KEIN LLM.
|
||||
Konservativ: nur innerhalb derselben Kategorie, Containment braucht vollstaendige Teilstring-Deckung."""
|
||||
db = _db()
|
||||
# laengste zuerst -> der vollstaendigste Eintrag einer Gruppe wird zum Kanon
|
||||
rows = [dict(r) for r in db.execute(
|
||||
"SELECT * FROM memories ORDER BY length(content) DESC, created_at ASC"
|
||||
).fetchall()]
|
||||
used: set[str] = set()
|
||||
groups: list[dict] = []
|
||||
for i, a in enumerate(rows):
|
||||
if a["id"] in used:
|
||||
continue
|
||||
na = _norm(a["content"])
|
||||
if not na:
|
||||
continue
|
||||
dups = []
|
||||
for b in rows[i + 1:]:
|
||||
if b["id"] in used or b["category"] != a["category"]:
|
||||
continue
|
||||
nb = _norm(b["content"])
|
||||
if not nb:
|
||||
continue
|
||||
contained = nb in na or na in nb
|
||||
ratio = SequenceMatcher(None, na, nb).ratio()
|
||||
if contained or ratio >= body.threshold:
|
||||
dups.append(b)
|
||||
used.add(b["id"])
|
||||
if dups:
|
||||
used.add(a["id"])
|
||||
groups.append({
|
||||
"keep": {"id": a["id"], "content": a["content"], "category": a["category"]},
|
||||
"remove": [{"id": d["id"], "content": d["content"]} for d in dups],
|
||||
})
|
||||
dup_count = sum(len(g["remove"]) for g in groups)
|
||||
removed = 0
|
||||
if body.apply:
|
||||
for g in groups:
|
||||
for d in g["remove"]:
|
||||
db.execute("DELETE FROM memories WHERE id = ?", (d["id"],))
|
||||
removed += 1
|
||||
if removed:
|
||||
db.commit()
|
||||
return {
|
||||
"groups": groups,
|
||||
"duplicate_count": dup_count,
|
||||
"removed": removed,
|
||||
"applied": body.apply,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/memory")
|
||||
def list_memories(q: str = "", category: str = ""):
|
||||
db = _db()
|
||||
sql = "SELECT * FROM memories"
|
||||
params: list = []
|
||||
conds: list = []
|
||||
if q:
|
||||
conds.append("content LIKE ?")
|
||||
params.append(f"%{q}%")
|
||||
if category:
|
||||
conds.append("category = ?")
|
||||
params.append(category)
|
||||
if conds:
|
||||
sql += " WHERE " + " AND ".join(conds)
|
||||
sql += " ORDER BY created_at DESC"
|
||||
return [_row(r) for r in db.execute(sql, params).fetchall()]
|
||||
|
||||
|
||||
@router.post("/memory", status_code=201)
|
||||
def add_memory(body: _MemIn):
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
mid = str(uuid.uuid4())
|
||||
db = _db()
|
||||
db.execute(
|
||||
"INSERT INTO memories (id, content, category, source, created_at, updated_at)"
|
||||
" VALUES (?,?,?,?,?,?)",
|
||||
(mid, body.content.strip(), body.category, body.source, now, now),
|
||||
)
|
||||
db.commit()
|
||||
return {
|
||||
"id": mid, "content": body.content.strip(),
|
||||
"category": body.category, "source": body.source,
|
||||
"created_at": now, "updated_at": now,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/memory/{mid}")
|
||||
def update_memory(mid: str, body: _MemUp):
|
||||
db = _db()
|
||||
row = db.execute("SELECT * FROM memories WHERE id = ?", (mid,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
content = body.content.strip() if body.content is not None else row["content"]
|
||||
category = body.category if body.category is not None else row["category"]
|
||||
db.execute(
|
||||
"UPDATE memories SET content=?, category=?, updated_at=? WHERE id=?",
|
||||
(content, category, now, mid),
|
||||
)
|
||||
db.commit()
|
||||
return {
|
||||
"id": mid, "content": content, "category": category,
|
||||
"source": row["source"], "created_at": row["created_at"], "updated_at": now,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/memory/{mid}")
|
||||
def delete_memory(mid: str):
|
||||
db = _db()
|
||||
if not db.execute("SELECT id FROM memories WHERE id = ?", (mid,)).fetchone():
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
db.execute("DELETE FROM memories WHERE id = ?", (mid,))
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
+222
-27
@@ -13,11 +13,18 @@ from pydantic import BaseModel
|
||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||
|
||||
from auth import auth
|
||||
from config import CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, LLAMA_SWAP_URL, MODELS_DIR
|
||||
from jobengine import JOBS, start_job
|
||||
from llamaswap import _swap_get, read_config, write_config
|
||||
from config import (CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, HF_DOWNLOAD_ENV, LLAMA_SWAP_URL,
|
||||
MODELS_DIR, TOKEN, hf_bin)
|
||||
from jobengine import JOBS, start_job, attach_download_progress
|
||||
from routers.cookbook import hf_file_size
|
||||
from llamaswap import (_swap_get, read_config, write_config,
|
||||
model_id_from_path, set_role_alias, ROLE_IDS)
|
||||
from hw_math import extract_params_b, max_ctx_for, estimate_memory_gb
|
||||
from model_caps import capabilities
|
||||
import re
|
||||
import os
|
||||
import shutil
|
||||
import psutil
|
||||
|
||||
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
|
||||
|
||||
@@ -29,15 +36,22 @@ class DownloadReq(BaseModel):
|
||||
repo: str
|
||||
file: str
|
||||
subdir: str | None = None
|
||||
hf_token: str | None = None
|
||||
|
||||
|
||||
class RegisterReq(BaseModel):
|
||||
alias: str
|
||||
alias: str = "" # rueckwaertskompatibel: wird als Rolle interpretiert, wenn 'role' fehlt
|
||||
role: str | None = None # Rollen-Tag (vision/coder/scout/reviewer/manager o.ae.)
|
||||
model_path: str
|
||||
ctx: int = 8192
|
||||
ctx: int | None = None # None → optimal fuer die Hardware (max_ctx_for)
|
||||
ttl: int | None = None
|
||||
|
||||
|
||||
class RoleReq(BaseModel):
|
||||
alias: str # die Modell-ID (config-Key)
|
||||
role: str = "" # leer = Rolle entfernen
|
||||
|
||||
|
||||
class ChatReq(BaseModel):
|
||||
model: str
|
||||
message: str
|
||||
@@ -45,7 +59,13 @@ class ChatReq(BaseModel):
|
||||
|
||||
class UpdateReq(BaseModel):
|
||||
alias: str
|
||||
ctx: int
|
||||
ctx: int | None = None
|
||||
ttl: int | None = None
|
||||
|
||||
|
||||
class DeleteReq(BaseModel):
|
||||
alias: str
|
||||
delete_files: bool = True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -54,6 +74,10 @@ class UpdateReq(BaseModel):
|
||||
@router.get("/status")
|
||||
def status():
|
||||
cfg = read_config()
|
||||
try:
|
||||
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
||||
except Exception: # noqa: BLE001
|
||||
ram_gb = 0
|
||||
configured = {}
|
||||
for name, spec in (cfg.get("models") or {}).items():
|
||||
spec = spec or {}
|
||||
@@ -67,6 +91,7 @@ def status():
|
||||
size_bytes = None
|
||||
quant = ""
|
||||
filename = ""
|
||||
path = ""
|
||||
m_path = re.search(r'-(?:m|-model)\s+([^\s]+)', cmd)
|
||||
if m_path:
|
||||
path = m_path.group(1).replace("'", "").replace('"', '')
|
||||
@@ -77,26 +102,63 @@ def status():
|
||||
if q_match:
|
||||
quant = q_match.group(1).upper()
|
||||
|
||||
# Rolle aus dem llama-swap-Alias ableiten; Legacy-Eintraege ohne Alias, deren Key
|
||||
# selbst eine Rolle ist (coder/vision/...), behalten diese als Rolle.
|
||||
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)
|
||||
|
||||
caps = ["Text"]
|
||||
if "coder" in name.lower() or (m_path and "code" in m_path.group(1).lower()):
|
||||
if role == "coder" or "coder" in name.lower() or (m_path and "code" in m_path.group(1).lower()):
|
||||
caps = ["Code"]
|
||||
if "--mmproj" in cmd:
|
||||
caps.append("Bild")
|
||||
|
||||
|
||||
# "incomplete" = Rolle existiert in der config, hat aber kein Modell (-m) hinterlegt
|
||||
# (z.B. von provision.sh angelegter Platzhalter). Ehrlich kennzeichnen statt "bereit".
|
||||
incomplete = not (m_path and m_path.group(1))
|
||||
configured[name] = {
|
||||
"name": name,
|
||||
"role": role,
|
||||
"aliases": aliases,
|
||||
"api_ids": [name] + aliases,
|
||||
"ttl": spec.get("ttl", cfg.get("globalTTL", 0)),
|
||||
"cmd": cmd,
|
||||
"state": "idle",
|
||||
"incomplete": incomplete,
|
||||
"port": None,
|
||||
"meta": {
|
||||
"ctx": ctx,
|
||||
"size_bytes": size_bytes,
|
||||
"quant": quant,
|
||||
"caps": caps,
|
||||
"filename": filename
|
||||
"filename": filename,
|
||||
"params_b": (_pb := extract_params_b(filename or name)),
|
||||
"optimal_ctx": (_oc := (max_ctx_for(_pb, quant or "Q4_K_M", ram_gb) if ram_gb else None)),
|
||||
"peak_ram_gb": round(estimate_memory_gb(_pb, quant or "Q4_K_M", ctx), 1),
|
||||
"peak_ram_optimal_gb": (round(estimate_memory_gb(_pb, quant or "Q4_K_M", _oc), 1) if _oc else None),
|
||||
"capabilities": capabilities(
|
||||
name=filename or name, cmd=cmd,
|
||||
gguf_path=(path if (path and os.path.exists(path)) else ""),
|
||||
),
|
||||
}
|
||||
}
|
||||
# Laufende Download-Jobs erkennnen: Modell bekommt state "downloading" + Fortschritt.
|
||||
for j in JOBS.values():
|
||||
if j.get("state") not in ("running", "queued"):
|
||||
continue
|
||||
rp = j.get("result_path", "")
|
||||
if not rp:
|
||||
continue
|
||||
for mconf in configured.values():
|
||||
m_p = re.search(r'-(?:m|-model)\s+(\S+)', mconf.get("cmd", ""))
|
||||
if m_p and m_p.group(1).strip("'\"") == rp:
|
||||
mconf["state"] = "downloading"
|
||||
mconf["download_progress"] = j.get("progress")
|
||||
break
|
||||
|
||||
swap_ok = True
|
||||
try:
|
||||
running = _swap_get("/running")
|
||||
@@ -118,6 +180,7 @@ def status():
|
||||
"swap_url": LLAMA_SWAP_URL,
|
||||
"config_path": str(CONFIG_PATH),
|
||||
"models_dir": str(MODELS_DIR),
|
||||
"secured": bool(TOKEN),
|
||||
"models": list(configured.values()),
|
||||
}
|
||||
|
||||
@@ -127,48 +190,180 @@ def download(req: DownloadReq):
|
||||
sub = req.subdir or req.repo.split("/")[-1]
|
||||
target = MODELS_DIR / sub
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
args = ["hf", "download", req.repo, req.file, "--local-dir", str(target)]
|
||||
job_id = start_job(args, f"download {req.repo}/{req.file}",
|
||||
env={"HF_XET_HIGH_PERFORMANCE": "1"})
|
||||
args = [hf_bin(), "download", req.repo, req.file, "--local-dir", str(target)]
|
||||
env = dict(HF_DOWNLOAD_ENV)
|
||||
if req.hf_token:
|
||||
env["HF_TOKEN"] = req.hf_token
|
||||
job_id = start_job(args, f"download {req.repo}/{req.file}", env=env)
|
||||
JOBS[job_id]["result_path"] = str(target / req.file)
|
||||
attach_download_progress(job_id, str(target), hf_file_size(req.repo, req.file))
|
||||
return {"job_id": job_id, "expected_path": str(target / req.file)}
|
||||
|
||||
|
||||
def _augment_vision(cmd: str, model_path: str) -> str:
|
||||
"""Liegt im selben Ordner ein mmproj-Projektor, --mmproj + --jinja ergaenzen —
|
||||
Vision-Modelle brauchen das in llama.cpp (siehe CLAUDE.md). No-op, wenn schon gesetzt
|
||||
oder kein Projektor da."""
|
||||
if "--mmproj" in cmd:
|
||||
return cmd
|
||||
try:
|
||||
d = os.path.dirname(model_path)
|
||||
for f in sorted(os.listdir(d)):
|
||||
if "mmproj" in f.lower() and f.lower().endswith(".gguf"):
|
||||
return cmd.rstrip() + f" --mmproj {os.path.join(d, f)} --jinja"
|
||||
except OSError:
|
||||
pass
|
||||
return cmd
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
def register(req: RegisterReq):
|
||||
if not Path(req.model_path).exists():
|
||||
raise HTTPException(404, f"Datei nicht gefunden: {req.model_path}")
|
||||
# Bewusst KEIN exists()-Check: beim frischen Download läuft der hf-Job noch, die Datei kommt
|
||||
# erst gleich. Eintrag jetzt schon schreiben → llama-swap (-watch-config) lädt, sobald sie da ist.
|
||||
cfg = read_config()
|
||||
cmd = CMD_TEMPLATE.replace("{model}", req.model_path).replace("{ctx}", str(req.ctx))
|
||||
cfg["models"][req.alias] = {
|
||||
ctx = req.ctx
|
||||
if ctx is None:
|
||||
params_b = extract_params_b(req.model_path)
|
||||
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
||||
ctx = max_ctx_for(params_b, "Q4_K_M", ram_gb)
|
||||
cmd = CMD_TEMPLATE.replace("{model}", req.model_path).replace("{ctx}", str(ctx))
|
||||
cmd = _augment_vision(cmd, req.model_path)
|
||||
# Neues Schema: Schluessel = sprechender Modellname (steht so in der Modell-Liste und ist
|
||||
# der API-Name), die Rolle kommt als llama-swap-Alias obendrauf (beide Namen funktionieren).
|
||||
role = (req.role or req.alias or "").strip().lower()
|
||||
model_id = model_id_from_path(req.model_path)
|
||||
cfg["models"][model_id] = {
|
||||
"cmd": LiteralScalarString(cmd + "\n"),
|
||||
"ttl": req.ttl if req.ttl is not None else DEFAULT_TTL,
|
||||
}
|
||||
set_role_alias(cfg, model_id, role)
|
||||
write_config(cfg)
|
||||
return {"ok": True, "alias": req.alias,
|
||||
return {"ok": True, "alias": model_id, "model_id": model_id, "role": role,
|
||||
"note": "In config.yaml geschrieben. llama-swap mit -watch-config laedt automatisch neu."}
|
||||
|
||||
|
||||
@router.post("/set_role")
|
||||
def set_role(req: RoleReq):
|
||||
"""Rollen-Tag eines Modells setzen/aendern (als eindeutiger llama-swap-Alias)."""
|
||||
cfg = read_config()
|
||||
if req.alias not in cfg.get("models", {}):
|
||||
raise HTTPException(404, "Modell nicht gefunden.")
|
||||
set_role_alias(cfg, req.alias, req.role.strip().lower() or None)
|
||||
write_config(cfg)
|
||||
return {"ok": True, "alias": req.alias, "role": req.role.strip().lower()}
|
||||
|
||||
|
||||
@router.post("/update_model")
|
||||
def update_model(req: UpdateReq):
|
||||
cfg = read_config()
|
||||
if req.alias not in cfg.get("models", {}):
|
||||
raise HTTPException(404, "Modell nicht gefunden")
|
||||
|
||||
|
||||
spec = cfg["models"][req.alias]
|
||||
cmd = str(spec.get("cmd", ""))
|
||||
|
||||
# Replace or add context size
|
||||
if re.search(r'-(?:c|-ctx-size)\s+\d+', cmd):
|
||||
cmd = re.sub(r'-(?:c|-ctx-size)\s+\d+', f'-c {req.ctx}', cmd)
|
||||
else:
|
||||
cmd = cmd.strip() + f" -c {req.ctx}\n"
|
||||
|
||||
cfg["models"][req.alias]["cmd"] = LiteralScalarString(cmd)
|
||||
|
||||
if req.ctx is not None:
|
||||
cmd = str(spec.get("cmd", ""))
|
||||
if re.search(r'-(?:c|-ctx-size)\s+\d+', cmd):
|
||||
cmd = re.sub(r'-(?:c|-ctx-size)\s+\d+', f'-c {req.ctx}', cmd)
|
||||
else:
|
||||
cmd = cmd.strip() + f" -c {req.ctx}\n"
|
||||
cfg["models"][req.alias]["cmd"] = LiteralScalarString(cmd)
|
||||
|
||||
if req.ttl is not None:
|
||||
cfg["models"][req.alias]["ttl"] = req.ttl
|
||||
|
||||
write_config(cfg)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _dir_size(p: Path) -> int:
|
||||
if p.is_file():
|
||||
try:
|
||||
return p.stat().st_size
|
||||
except OSError:
|
||||
return 0
|
||||
total = 0
|
||||
for root, _dirs, files in os.walk(p):
|
||||
for f in files:
|
||||
try:
|
||||
total += os.path.getsize(os.path.join(root, f))
|
||||
except OSError:
|
||||
pass
|
||||
return total
|
||||
|
||||
|
||||
def _delete_model_files(model_path: str, cfg: dict) -> tuple[str | None, int]:
|
||||
"""Loescht die Dateien eines Modells — STRENG auf MODELS_DIR begrenzt. Loescht den
|
||||
ganzen Repo-Unterordner (inkl. .cache), wenn dieser ein direktes Kind von MODELS_DIR
|
||||
ist und kein anderes (verbleibendes) Modell eine Datei darin nutzt; sonst nur die
|
||||
GGUF-Datei selbst. Gibt (geloeschter_pfad|None, freigegebene_bytes) zurueck."""
|
||||
root = MODELS_DIR.resolve()
|
||||
p = Path(model_path).resolve()
|
||||
if root not in p.parents: # Sicherheit: niemals ausserhalb des Modell-Ordners loeschen
|
||||
return None, 0
|
||||
parent = p.parent
|
||||
others = " ".join(str(s.get("cmd", "")) for s in (cfg.get("models") or {}).values())
|
||||
parent_shared = parent != root and str(parent) in others
|
||||
target = parent if (parent != root and not parent_shared) else p
|
||||
if not target.exists():
|
||||
return None, 0
|
||||
freed = _dir_size(target)
|
||||
if target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
return str(target), freed
|
||||
|
||||
|
||||
@router.post("/delete_model")
|
||||
def delete_model(req: DeleteReq):
|
||||
"""Modell komplett entfernen: aus der config.yaml austragen (+ entladen) und optional
|
||||
die GGUF-Dateien loeschen, um Speicher freizugeben. Unwiderruflich."""
|
||||
cfg = read_config()
|
||||
models = cfg.get("models", {})
|
||||
if req.alias not in models:
|
||||
raise HTTPException(404, "Modell nicht gefunden.")
|
||||
cmd = str(models[req.alias].get("cmd", ""))
|
||||
m = re.search(r'-(?:m|-model)\s+(\S+)', cmd)
|
||||
model_path = m.group(1).strip('"\'') if m else None
|
||||
|
||||
# Erst entladen (falls geladen) — Fehler ignorieren, das Modell soll trotzdem weg.
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as c:
|
||||
c.post(f"{LLAMA_SWAP_URL}/api/models/unload/{req.alias}")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
del models[req.alias]
|
||||
write_config(cfg) # cfg enthaelt das Modell jetzt nicht mehr -> _delete prueft die Restmenge
|
||||
|
||||
note, freed = "", 0
|
||||
if req.delete_files and model_path:
|
||||
try:
|
||||
deleted, freed = _delete_model_files(model_path, cfg)
|
||||
note = (f"Dateien gelöscht ({round(freed / 1024 ** 3, 1)} GB frei)."
|
||||
if deleted else "Dateien liegen außerhalb des Modell-Ordners — nur ausgetragen.")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
note = f"Aus der Konfiguration entfernt, aber Dateien konnten nicht gelöscht werden: {exc}"
|
||||
return {"ok": True, "alias": req.alias, "freed_bytes": freed, "note": note or "Modell entfernt."}
|
||||
|
||||
|
||||
@router.post("/preload")
|
||||
def preload(model: str):
|
||||
"""Modell vorwaermen: sendet eine Minimal-Anfrage damit llama-swap es laedt.
|
||||
max_tokens=1 haelt die Antwort kurz; der Token wird verworfen."""
|
||||
try:
|
||||
with httpx.Client(timeout=60.0) as c:
|
||||
c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": "!"}],
|
||||
"max_tokens": 1,
|
||||
})
|
||||
return {"ok": True}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(502, f"llama-swap nicht erreichbar: {exc}")
|
||||
|
||||
|
||||
@router.post("/unload")
|
||||
def unload(model: str | None = None):
|
||||
path = f"/api/models/unload/{model}" if model else "/api/models/unload"
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
News-Router: aggregiert kuratierte RSS/Atom-Feeds rund um lokale LLMs.
|
||||
Serverseitig geholt (httpx) + mit stdlib geparst (kein neuer Dep), In-memory-Cache (30 min).
|
||||
Degradiert sauber: einzelne Feeds dürfen fehlschlagen, der Rest kommt trotzdem.
|
||||
"""
|
||||
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from auth import auth
|
||||
|
||||
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
|
||||
|
||||
FEEDS = [
|
||||
("HuggingFace", "https://huggingface.co/blog/feed.xml"),
|
||||
("r/LocalLLaMA", "https://www.reddit.com/r/LocalLLaMA/.rss"),
|
||||
("Simon Willison", "https://simonwillison.net/atom/everything/"),
|
||||
("Latent Space", "https://www.latent.space/feed"),
|
||||
("Ahead of AI", "https://magazine.sebastianraschka.com/feed"),
|
||||
("The Batch", "https://www.deeplearning.ai/the-batch/rss.xml"),
|
||||
]
|
||||
_UA = {"User-Agent": "Mozilla/5.0 (MissionControl NewsBot)"}
|
||||
_TTL = 1800 # 30 min
|
||||
_CACHE = {"ts": 0.0, "items": []}
|
||||
_RELEVANT = ("rocm", "gfx1151", "strix", "amd", "vulkan", "llama.cpp", "gguf", "quant")
|
||||
|
||||
|
||||
def _tag(t):
|
||||
return t.split("}")[-1]
|
||||
|
||||
|
||||
def _epoch(s):
|
||||
if not s:
|
||||
return 0.0
|
||||
try:
|
||||
return parsedate_to_datetime(s).timestamp()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
|
||||
except Exception: # noqa: BLE001
|
||||
return 0.0
|
||||
|
||||
|
||||
def _parse(source, xml_text):
|
||||
out = []
|
||||
try:
|
||||
root = ET.fromstring(xml_text)
|
||||
except Exception: # noqa: BLE001
|
||||
return out
|
||||
for el in root.iter():
|
||||
if _tag(el.tag) not in ("item", "entry"):
|
||||
continue
|
||||
title = link = date = ""
|
||||
for ch in el:
|
||||
t = _tag(ch.tag)
|
||||
if t == "title":
|
||||
title = (ch.text or "").strip()
|
||||
elif t == "link":
|
||||
link = ch.get("href") or (ch.text or "").strip() or link
|
||||
elif t in ("pubDate", "published", "updated") and not date:
|
||||
date = (ch.text or "").strip()
|
||||
if title:
|
||||
low = title.lower()
|
||||
out.append({
|
||||
"source": source, "title": title, "link": link,
|
||||
"ts": _epoch(date),
|
||||
"relevant": any(k in low for k in _RELEVANT),
|
||||
})
|
||||
return out[:8]
|
||||
|
||||
|
||||
def get_news():
|
||||
now = time.time()
|
||||
if now - _CACHE["ts"] < _TTL and _CACHE["items"]:
|
||||
return _CACHE["items"]
|
||||
items = []
|
||||
with httpx.Client(timeout=8.0, headers=_UA, follow_redirects=True) as c:
|
||||
for source, url in FEEDS:
|
||||
try:
|
||||
r = c.get(url)
|
||||
r.raise_for_status()
|
||||
items += _parse(source, r.text)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
items.sort(key=lambda x: x["ts"], reverse=True)
|
||||
items = items[:24]
|
||||
_CACHE.update(ts=now, items=items)
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/news")
|
||||
def news():
|
||||
return {"items": get_news()}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Vertrauenswuerdige Quellen + Kategorien fuer die AUTOMATISCHE Modell-Entdeckung
|
||||
(Cookbook „aktuell beste Modelle"). Bewusst nur Daten, kein Code.
|
||||
|
||||
Idee: Statt Modelle hartzukodieren, fragt MC diese HF-Orgs live ab (sie pflegen
|
||||
zuverlaessig hochwertige GGUF-Quants), ordnet die Treffer per Stichwort einer
|
||||
Kategorie zu, filtert per hw_math auf „passt zu deiner Hardware" und cached das
|
||||
Ergebnis (siehe DISCOVER_CACHE_PATH/_TTL in config). So bleibt das Cookbook von
|
||||
selbst aktuell, ohne Code-Aenderung.
|
||||
"""
|
||||
|
||||
# HF-Orgs, die zuverlaessig aktuelle, hochwertige GGUF-Quants veroeffentlichen.
|
||||
TRUSTED_AUTHORS = ["unsloth", "bartowski", "ggml-org", "lmstudio-community"]
|
||||
|
||||
# Kategorien (Reihenfolge = Anzeige + Zuordnungs-Prioritaet). Ein Modell wird der
|
||||
# ERSTEN Kategorie zugeordnet, deren Stichwort im Repo-Namen vorkommt; bleibt nichts
|
||||
# uebrig, faellt es in „scout" (Allrounder). Die `role` ist zugleich der Alias-Vorschlag.
|
||||
CATEGORIES = [
|
||||
{"role": "vision", "title": "Bilder verstehen", "icon": "eye",
|
||||
"kw": ["-vl-", "-vl", "vision", "llava", "multimodal", "-mm-", "pixtral"]},
|
||||
{"role": "coder", "title": "Coden & Programmieren", "icon": "code",
|
||||
"kw": ["coder", "-code-", "code-", "codestral", "starcoder"]},
|
||||
{"role": "reasoning", "title": "Nachdenken & Logik", "icon": "pulse",
|
||||
"kw": ["-r1", "deepseek-r1", "reasoning", "qwq", "magistral", "-think", "thinking", "-o1"]},
|
||||
{"role": "agent", "title": "Agenten & Tool-Use", "icon": "layers",
|
||||
"kw": ["hermes", "-tool", "command-r", "watt", "-fc-", "function"]},
|
||||
{"role": "scout", "title": "Allrounder & Chat", "icon": "compass",
|
||||
"kw": []}, # Fallback: instruct/chat-Modelle
|
||||
]
|
||||
|
||||
# Repo-Namensteile, die wir bei der Entdeckung ueberspringen (Roh-/Spezialformate,
|
||||
# die fuer Anfaenger nicht die richtige Wahl sind).
|
||||
SKIP_TOKENS = ["-base", "-bnb-", "-gptq", "-awq", "-fp8", "draft", "tokenizer"]
|
||||
+125
-48
@@ -1,31 +1,35 @@
|
||||
/* =========================================================================
|
||||
base.css — Design-Tokens, Reset, App-Layout (Sidebar + Topbar + Content)
|
||||
Optik orientiert an docs/mission-control-overview.png (GitHub-Dark-Familie).
|
||||
v3: dichtes Control-Plane-Design, EINE Akzentfarbe (Teal), Klartext-tauglich.
|
||||
Orientierung: docs/mission-control-overview.png
|
||||
========================================================================= */
|
||||
|
||||
:root{
|
||||
/* Flächen */
|
||||
--bg:#0a0d12; --bg2:#0d1117; --panel:#10151c; --panel2:#151b23; --inset:#0a0e13;
|
||||
--line:rgba(255,255,255,.07); --line2:rgba(255,255,255,.13);
|
||||
--bg:#0b0e13; --bg2:#0d1117; --panel:#141a24; --tile:#11161e; --panel2:#11161e; --inset:#0a0e13;
|
||||
--line:rgba(255,255,255,.08); --line2:rgba(255,255,255,.14);
|
||||
/* Text */
|
||||
--tx:#d7dee7; --mut:#8b97a5; --dim:#5c6773;
|
||||
/* Akzente */
|
||||
--on:#3fb950; --act:#4493e0; --purple:#a371f7; --warn:#e0a32e; --err:#e5534b;
|
||||
--teal:#2dd4bf;
|
||||
/* Tints (für getönte Karten) */
|
||||
--tx:#e6edf3; --mut:#8b97a5; --dim:#5d6b79;
|
||||
/* EINE Akzentfarbe für alles Klickbare/Aktive */
|
||||
--accent:#2dd4bf; --accent-ink:#06231f; --teal:#2dd4bf;
|
||||
/* Status-/Datenfarben (NUR für Bedeutung, nicht für Buttons) */
|
||||
--on:#3fb950; --act:#4493e0; --purple:#a371f7; --warn:#e0a32e; --err:#f0573f;
|
||||
/* Kompat-Aliase (von Phase-B-Panels referenziert) */
|
||||
--hi:var(--accent); --red:var(--err); --red-dim:rgba(240,87,63,.12);
|
||||
/* Tints (getönte Karten, Legacy) */
|
||||
--t-green:rgba(63,185,80,.07); --b-green:rgba(63,185,80,.22);
|
||||
--t-blue:rgba(68,147,224,.07); --b-blue:rgba(68,147,224,.22);
|
||||
--t-purple:rgba(163,113,247,.08);--b-purple:rgba(163,113,247,.24);
|
||||
--t-red:rgba(229,83,75,.08); --b-red:rgba(229,83,75,.24);
|
||||
--t-red:rgba(240,87,63,.08); --b-red:rgba(240,87,63,.24);
|
||||
/* Schrift */
|
||||
--mono:ui-monospace,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
|
||||
--sans:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
/* Typografie Skala */
|
||||
--text-xs: 11.5px; --text-sm: 13px; --text-base: 14px; --text-lg: 16px; --text-xl: 20px; --text-2xl: 24px;
|
||||
/* Spacing Skala */
|
||||
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 20px; --sp-6: 24px; --sp-8: 32px; --sp-10: 40px;
|
||||
/* Typografie-Skala */
|
||||
--text-xs:11.5px; --text-sm:13px; --text-base:14px; --text-lg:16px; --text-xl:20px; --text-2xl:24px;
|
||||
/* Spacing-Skala */
|
||||
--sp-1:4px; --sp-2:8px; --sp-3:12px; --sp-4:16px; --sp-5:20px; --sp-6:24px; --sp-8:32px; --sp-10:40px;
|
||||
/* Maße */
|
||||
--side:62px; --radius:6px;
|
||||
--side:212px; --radius:12px; --radius-sm:8px;
|
||||
}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
@@ -35,66 +39,85 @@ body{
|
||||
font-family:var(--sans);font-size:var(--text-base);line-height:1.5;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
a{color:var(--act);text-decoration:none}
|
||||
::selection{background:rgba(68,147,224,.32)}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
::selection{background:rgba(45,212,191,.28)}
|
||||
|
||||
/* ---- App-Shell: feste Sidebar + scrollender Main ---- */
|
||||
/* ---- App-Shell: feste, beschriftete Sidebar + scrollender Main ---- */
|
||||
#app{display:flex;min-height:100vh}
|
||||
|
||||
.sidebar{
|
||||
width:var(--side);flex:0 0 var(--side);
|
||||
background:var(--bg2);border-right:1px solid var(--line);
|
||||
display:flex;flex-direction:column;align-items:center;
|
||||
padding:var(--sp-4) 0;gap:var(--sp-2);position:sticky;top:0;height:100vh;
|
||||
display:flex;flex-direction:column;
|
||||
padding:var(--sp-4) var(--sp-3);gap:2px;position:sticky;top:0;height:100vh;
|
||||
}
|
||||
.side-logo{
|
||||
width:34px;height:34px;border-radius:9px;margin-bottom:10px;
|
||||
display:grid;place-items:center;color:var(--teal);
|
||||
background:rgba(45,212,191,.10);border:1px solid rgba(45,212,191,.22);
|
||||
.brand{display:flex;align-items:center;gap:10px;padding:4px 8px 16px}
|
||||
.brand-logo{
|
||||
width:30px;height:30px;border-radius:9px;flex:0 0 auto;
|
||||
display:grid;place-items:center;color:var(--accent-ink);background:var(--accent);
|
||||
}
|
||||
.side-nav{display:flex;flex-direction:column;gap:4px;flex:1}
|
||||
.side-foot{margin-top:auto}
|
||||
.brand-logo svg{width:18px;height:18px}
|
||||
.brand-tx{font-size:15px;font-weight:500;letter-spacing:.2px}
|
||||
.side-nav{display:flex;flex-direction:column;gap:2px;flex:1}
|
||||
.side-foot{margin-top:auto;padding-top:var(--sp-2);border-top:1px solid var(--line)}
|
||||
.nav-cap{font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);padding:8px 10px 6px}
|
||||
.nav-item{
|
||||
width:40px;height:40px;border-radius:var(--radius);display:grid;place-items:center;
|
||||
color:var(--mut);cursor:pointer;border:1px solid transparent;transition:.15s;
|
||||
display:flex;align-items:center;gap:11px;
|
||||
padding:9px 11px;border-radius:10px;color:var(--mut);cursor:pointer;
|
||||
border:1px solid transparent;transition:.13s;font-size:13.5px;user-select:none;
|
||||
}
|
||||
.nav-item:hover{color:var(--tx);background:var(--panel)}
|
||||
.nav-item.active{
|
||||
color:var(--teal);background:rgba(45,212,191,.12);border-color:rgba(45,212,191,.22);
|
||||
}
|
||||
.nav-item.active{color:var(--tx);background:rgba(45,212,191,.12);border-color:rgba(45,212,191,.20);font-weight:500}
|
||||
.nav-item.active .ni-ic{color:var(--accent)}
|
||||
.ni-ic{display:grid;place-items:center;color:inherit;flex:0 0 auto}
|
||||
.ni-ic svg{width:18px;height:18px}
|
||||
.nav-item.disabled{opacity:.34;cursor:not-allowed}
|
||||
.nav-item.disabled:hover{background:transparent;color:var(--mut)}
|
||||
.nav-item svg{width:20px;height:20px}
|
||||
|
||||
/* ---- Main-Spalte ---- */
|
||||
.main{flex:1;min-width:0;display:flex;flex-direction:column}
|
||||
|
||||
.topbar{
|
||||
position:sticky;top:0;z-index:20;
|
||||
display:flex;align-items:center;gap:var(--sp-4);flex-wrap:wrap;
|
||||
padding:var(--sp-3) var(--sp-6);background:rgba(10,13,18,.86);backdrop-filter:blur(8px);
|
||||
display:flex;align-items:center;gap:var(--sp-3);flex-wrap:wrap;
|
||||
padding:var(--sp-3) var(--sp-6);background:rgba(11,14,19,.86);backdrop-filter:blur(8px);
|
||||
border-bottom:1px solid var(--line);
|
||||
}
|
||||
.spacer{flex:1}
|
||||
.status-pill{
|
||||
display:inline-flex;align-items:center;gap:var(--sp-2);font-family:var(--mono);font-size:var(--text-xs);
|
||||
padding:var(--sp-1) var(--sp-3);border:1px solid var(--line);border-radius:999px;color:var(--mut);background:var(--panel);
|
||||
padding:5px 12px;border:1px solid var(--line);border-radius:999px;color:var(--mut);background:var(--panel);
|
||||
}
|
||||
.dot{width:8px;height:8px;border-radius:50%;background:var(--mut);flex:0 0 auto}
|
||||
.dot.on{background:var(--on);box-shadow:0 0 0 0 rgba(63,185,80,.5);animation:pulse 2.2s infinite}
|
||||
.dot.off{background:var(--err)}
|
||||
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(63,185,80,.45)}70%{box-shadow:0 0 0 7px rgba(63,185,80,0)}100%{box-shadow:0 0 0 0 rgba(63,185,80,0)}}
|
||||
.top-stat{font-size:var(--text-sm);color:var(--mut)}
|
||||
.top-stat b{color:var(--tx);font-family:var(--mono);font-weight:600;margin-left:4px}
|
||||
.top-clock{font-family:var(--mono);font-size:var(--text-sm);color:var(--act)}
|
||||
.tokin{
|
||||
font-family:var(--mono);font-size:var(--text-sm);background:var(--panel);border:1px solid var(--line);
|
||||
color:var(--tx);border-radius:var(--radius);padding:var(--sp-2) var(--sp-3);width:128px;
|
||||
.top-stat{font-size:var(--text-sm);color:var(--mut);display:flex;align-items:center;gap:5px}
|
||||
.top-stat b{color:var(--accent);font-family:var(--mono);font-weight:600;letter-spacing:.01em}
|
||||
/* Aktives Modell in Topbar — klarer sichtbar wenn geladen */
|
||||
#top-active-text{
|
||||
font-family:var(--mono);font-size:var(--text-sm);
|
||||
padding:4px 10px;border-radius:999px;
|
||||
border:1px solid transparent;transition:border-color .3s,background .3s;
|
||||
}
|
||||
.tokin:focus{outline:none;border-color:var(--act)}
|
||||
#top-active-text b{font-weight:600}
|
||||
#top-active-text:has(b){border-color:rgba(45,212,191,.25);background:rgba(45,212,191,.07)}
|
||||
.top-clock{font-family:var(--mono);font-size:var(--text-sm);color:var(--accent)}
|
||||
|
||||
/* Security-Chip (zeigt LAN/Token-Status) */
|
||||
.sec-chip{
|
||||
display:inline-flex;align-items:center;gap:7px;font-size:var(--text-xs);
|
||||
padding:5px 11px;border-radius:999px;border:1px solid var(--line);
|
||||
background:var(--panel);color:var(--mut);
|
||||
}
|
||||
.sec-chip.ok{background:rgba(63,185,80,.12);border-color:rgba(63,185,80,.25);color:#7ee29a}
|
||||
.sec-chip .ti, .sec-chip svg{width:14px;height:14px}
|
||||
|
||||
.icon-btn{color:var(--dim);cursor:pointer;display:grid;place-items:center;background:none;border:0;padding:6px}
|
||||
.icon-btn:hover{color:var(--tx);border-color:transparent}
|
||||
.icon-btn svg{width:18px;height:18px}
|
||||
|
||||
/* ---- Content-Bereich ---- */
|
||||
.content{padding:var(--sp-5) var(--sp-6) var(--sp-10);max-width:1300px;margin:0 auto;width:100%}
|
||||
.content{padding:var(--sp-5) var(--sp-6) var(--sp-10);max-width:1320px;margin:0 auto;width:100%}
|
||||
.view[hidden]{display:none}
|
||||
.view{display:flex;flex-direction:column;gap:var(--sp-4)}
|
||||
|
||||
@@ -103,18 +126,72 @@ a{color:var(--act);text-decoration:none}
|
||||
.grid-3{grid-template-columns:repeat(auto-fit, minmax(300px, 1fr))}
|
||||
.grid-2{grid-template-columns:repeat(auto-fit, minmax(400px, 1fr))}
|
||||
.kpis{grid-template-columns:repeat(5,1fr)}
|
||||
.tiles{display:grid;gap:var(--sp-3);grid-template-columns:repeat(auto-fit, minmax(165px, 1fr))}
|
||||
.split{display:grid;gap:var(--sp-4);grid-template-columns:1.05fr 1fr}
|
||||
@media(max-width:1180px){.grid-3{grid-template-columns:1fr 1fr}.kpis{grid-template-columns:repeat(2,1fr)}}
|
||||
@media(max-width:760px){.grid-3,.grid-2,.kpis{grid-template-columns:1fr}}
|
||||
@media(max-width:900px){.split{grid-template-columns:1fr}}
|
||||
@media(max-width:760px){
|
||||
.grid-3,.grid-2,.kpis{grid-template-columns:1fr}
|
||||
.sidebar{width:60px;flex-basis:60px}
|
||||
.ni-tx,.brand-tx{display:none}
|
||||
.nav-item{justify-content:center}.brand{justify-content:center}
|
||||
}
|
||||
|
||||
/* ---- Mobile (≤ 520px): Bottom-Nav, kompaktes Layout ---- */
|
||||
@media(max-width:520px){
|
||||
:root{ --side:0px }
|
||||
|
||||
/* Sidebar → unsichtbar, Navigation nach unten */
|
||||
.sidebar{
|
||||
position:fixed;bottom:0;left:0;right:0;top:auto;
|
||||
width:100%;height:56px;flex-basis:auto;z-index:30;
|
||||
flex-direction:row;padding:0;
|
||||
border-right:none;border-top:1px solid var(--line);
|
||||
background:var(--bg2);
|
||||
overflow-x:auto;gap:0;
|
||||
}
|
||||
.brand{display:none}
|
||||
.side-nav{flex-direction:row;gap:0;flex:1;overflow-x:auto}
|
||||
.nav-cap,.side-foot{display:none}
|
||||
.nav-item{
|
||||
flex-direction:column;gap:2px;padding:6px 8px;
|
||||
font-size:9px;min-width:52px;flex:1;border-radius:0;
|
||||
justify-content:center;align-items:center;border:none;
|
||||
}
|
||||
.ni-tx{display:block;font-size:9px;line-height:1;color:inherit}
|
||||
.ni-ic svg{width:20px;height:20px}
|
||||
|
||||
/* Main-Bereich: Bottom-Padding für die Nav */
|
||||
.main{ padding-bottom:56px }
|
||||
|
||||
/* Topbar kompakter */
|
||||
.topbar{padding:var(--sp-2) var(--sp-3);gap:var(--sp-2)}
|
||||
.top-clock,.sec-chip{display:none}
|
||||
#update-badges .upd-badge{font-size:10px;padding:3px 7px}
|
||||
|
||||
/* Content-Padding reduzieren */
|
||||
.content{padding:var(--sp-3) var(--sp-3) var(--sp-6)}
|
||||
|
||||
/* Karten kompakter */
|
||||
.card{padding:var(--sp-3) var(--sp-3)}
|
||||
.modal-card{padding:var(--sp-4)}
|
||||
.modal-overlay{padding:12px}
|
||||
|
||||
/* Buttons: mindestens 44px Höhe für Touch */
|
||||
button{min-height:40px}
|
||||
button.ghost{min-height:34px}
|
||||
|
||||
/* Tabs in Guides horizontal scrollbar */
|
||||
.guide-tabs{flex-wrap:nowrap;overflow-x:auto;padding-bottom:2px}
|
||||
}
|
||||
|
||||
/* Alert-Banner */
|
||||
.alert{
|
||||
margin:var(--sp-4) var(--sp-6) 0;padding:var(--sp-3) var(--sp-4);border-radius:var(--radius);
|
||||
background:rgba(229,83,75,.08);
|
||||
border:1px solid rgba(229,83,75,.2);color:#ffcdc8;
|
||||
background:rgba(240,87,63,.10);border:1px solid rgba(240,87,63,.26);color:#ffcdc8;
|
||||
display:flex;align-items:center;gap:var(--sp-3);font-size:var(--text-sm);
|
||||
}
|
||||
.alert .a-dot{width:8px;height:8px;border-radius:50%;background:var(--err);flex:0 0 auto}
|
||||
.alert b{color:#ffe2de}
|
||||
.alert.warn{background:rgba(224,163,46,.08);
|
||||
border-color:rgba(224,163,46,.2);color:#f3dca6}
|
||||
.alert.warn{background:rgba(224,163,46,.10);border-color:rgba(224,163,46,.26);color:#f3dca6}
|
||||
.alert.warn .a-dot{background:var(--warn)}
|
||||
|
||||
+175
-109
@@ -1,73 +1,89 @@
|
||||
/* =========================================================================
|
||||
components.css — wiederverwendbare Bausteine (Karten, KPIs, Listen, Forms)
|
||||
components.css — wiederverwendbare Bausteine.
|
||||
v3: Teal-Primary, Metrik-Kacheln, Fit-Ampel, Modal, Quickstart.
|
||||
Legacy-Klassen bleiben erhalten, bis alle Panels migriert sind.
|
||||
========================================================================= */
|
||||
|
||||
/* ---- Live-Swap-Flash (Topbar-Highlight wenn Modell wechselt) ---- */
|
||||
@keyframes swap-flash {
|
||||
0% { color:var(--tx); }
|
||||
25% { color:var(--accent); }
|
||||
100% { color:var(--tx); }
|
||||
}
|
||||
.swap-flash { animation: swap-flash 0.9s ease-out forwards; }
|
||||
|
||||
/* ---- Karte (Grundbaustein) ---- */
|
||||
.card{
|
||||
background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);
|
||||
padding:var(--sp-3) var(--sp-4);
|
||||
padding:var(--sp-4) var(--sp-5);
|
||||
}
|
||||
.card-h{display:flex;align-items:center;gap:10px;margin:0 0 14px}
|
||||
.card-h h3{font-size:15px;font-weight:600;margin:0;flex:1;color:var(--tx)}
|
||||
.card-h{display:flex;align-items:center;gap:10px;margin:0 0 4px}
|
||||
.card-h h3{font-size:15px;font-weight:500;margin:0;flex:1;color:var(--tx)}
|
||||
.card-h .meta{font-family:var(--mono);font-size:12px;color:var(--mut)}
|
||||
.card-h .meta.ok{color:var(--on)}
|
||||
/* Klartext-Einzeiler unter einer Sektionsüberschrift */
|
||||
.card-sub{font-size:var(--text-xs);color:var(--mut);margin:0 0 14px}
|
||||
|
||||
/* ---- Hero (Overview-Kopf) ---- */
|
||||
.hero{
|
||||
background:var(--panel);
|
||||
border:1px solid var(--line);border-radius:var(--radius);
|
||||
padding:var(--sp-4) var(--sp-5);display:flex;justify-content:space-between;gap:var(--sp-6);flex-wrap:wrap;
|
||||
}
|
||||
.hero .eyebrow{font-size:11.5px;letter-spacing:.22em;text-transform:uppercase;color:var(--mut)}
|
||||
.hero h1{font-size:26px;font-weight:650;margin:8px 0 6px}
|
||||
.hero p{margin:0;color:var(--mut);font-size:14px;max-width:60ch}
|
||||
.hero-stats{display:grid;grid-template-columns:1fr 1fr;gap:12px;align-content:start}
|
||||
.mini{
|
||||
min-width:128px;border:1px solid var(--line);border-radius:10px;
|
||||
padding:11px 14px;background:var(--bg2);
|
||||
}
|
||||
.mini .l{font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--mut)}
|
||||
.mini .v{font-family:var(--mono);font-size:15px;margin-top:4px;color:var(--tx)}
|
||||
.mini .v b{color:var(--on);font-weight:600}
|
||||
/* ---- Seiten-Kopf (Hero, Klartext-Urteil) ---- */
|
||||
.pagehead{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:2px}
|
||||
.pagehead h1{font-size:var(--text-xl);font-weight:500;margin:0;letter-spacing:.2px}
|
||||
.pagehead .sub{font-size:var(--text-sm);color:var(--mut);margin-top:4px}
|
||||
|
||||
/* ---- KPI-Kacheln ---- */
|
||||
.kpi{
|
||||
position:relative;border-radius:var(--radius);padding:var(--sp-3) var(--sp-4);
|
||||
border:1px solid var(--line);background:var(--panel);overflow:hidden;
|
||||
}
|
||||
/* Legacy-Hero (Overview alt) — bleibt für nicht migrierte Nutzung */
|
||||
.hero{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);
|
||||
padding:var(--sp-4) var(--sp-5);display:flex;justify-content:space-between;gap:var(--sp-6);flex-wrap:wrap}
|
||||
.hero .eyebrow{font-size:11.5px;letter-spacing:.18em;text-transform:uppercase;color:var(--mut)}
|
||||
.hero h1{font-size:22px;font-weight:500;margin:8px 0 6px}
|
||||
.hero p{margin:0;color:var(--mut);font-size:14px;max-width:64ch}
|
||||
|
||||
/* ---- Metrik-Kachel ---- */
|
||||
.tile{background:var(--tile);border:1px solid var(--line);border-radius:var(--radius);padding:14px 15px}
|
||||
.tile .t-l{font-size:12px;color:var(--mut)}
|
||||
.tile .t-v{font-family:var(--mono);font-size:20px;margin-top:6px;color:var(--tx);line-height:1.1}
|
||||
.tile .t-v small{font-size:13px;color:var(--mut)}
|
||||
.tile .t-s{font-size:11.5px;color:var(--mut);margin-top:3px}
|
||||
.tile .t-v.ok{color:var(--on)} .tile .t-v.warn{color:var(--warn)} .tile .t-v.bad{color:var(--err)}
|
||||
.tile .t-s.ok{color:#7ee29a}
|
||||
|
||||
/* ---- Chip (Status/Kontext) ---- */
|
||||
.chip{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--mut);
|
||||
background:var(--tile);border:1px solid var(--line);border-radius:999px;padding:6px 12px;white-space:nowrap}
|
||||
.chip svg{width:14px;height:14px}
|
||||
|
||||
/* ---- KPI-Kacheln (Legacy, Activity-Panel) ---- */
|
||||
.kpi{position:relative;border-radius:var(--radius);padding:var(--sp-3) var(--sp-4);
|
||||
border:1px solid var(--line);background:var(--panel);overflow:hidden}
|
||||
.kpi .k-h{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--sp-2)}
|
||||
.kpi .k-t{font-size:var(--text-sm);color:var(--mut)}
|
||||
.kpi .k-ic{color:var(--mut);opacity:.85}
|
||||
.kpi .k-ic svg{width:18px;height:18px}
|
||||
.kpi .k-v{font-family:var(--mono);font-size:var(--text-2xl);font-weight:600;line-height:1.1;margin:var(--sp-3) 0 var(--sp-1);color:var(--tx)}
|
||||
.kpi .k-v{font-family:var(--mono);font-size:var(--text-2xl);font-weight:500;line-height:1.1;margin:var(--sp-3) 0 var(--sp-1);color:var(--tx)}
|
||||
.kpi .k-v small{font-size:var(--text-base);color:var(--mut);font-weight:400}
|
||||
.kpi .k-s{font-size:var(--text-xs);color:var(--mut)}
|
||||
/* Farb-Varianten (flach) */
|
||||
.kpi.green {border-top:2px solid var(--on)}
|
||||
.kpi.green .k-v,.kpi.green .k-ic{color:var(--on)}
|
||||
.kpi.blue {border-top:2px solid var(--act)}
|
||||
.kpi.blue .k-v,.kpi.blue .k-ic{color:var(--act)}
|
||||
.kpi.purple{border-top:2px solid var(--purple)}
|
||||
.kpi.purple .k-v,.kpi.purple .k-ic{color:var(--purple)}
|
||||
.kpi.red {border-top:2px solid var(--err)}
|
||||
.kpi.red .k-v,.kpi.red .k-ic{color:var(--err)}
|
||||
.kpi.green{border-top:2px solid var(--on)} .kpi.green .k-v,.kpi.green .k-ic{color:var(--on)}
|
||||
.kpi.blue{border-top:2px solid var(--act)} .kpi.blue .k-v,.kpi.blue .k-ic{color:var(--act)}
|
||||
.kpi.purple{border-top:2px solid var(--purple)} .kpi.purple .k-v,.kpi.purple .k-ic{color:var(--purple)}
|
||||
.kpi.red{border-top:2px solid var(--err)} .kpi.red .k-v,.kpi.red .k-ic{color:var(--err)}
|
||||
.kpi.muted .k-v{color:var(--dim)}
|
||||
|
||||
/* ---- Key-Value-Liste (Health-Signale) ---- */
|
||||
/* ---- Key-Value-Liste + Balken (System-Gesundheit) ---- */
|
||||
.kv{display:flex;flex-direction:column}
|
||||
.kv-row{display:flex;align-items:center;justify-content:space-between;gap:12px;
|
||||
padding:11px 2px;border-top:1px solid var(--line)}
|
||||
.kv-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 2px;border-top:1px solid var(--line)}
|
||||
.kv-row:first-child{border-top:0}
|
||||
.kv-row .kv-k{color:var(--mut);font-size:13.5px}
|
||||
.kv-row .kv-v{font-family:var(--mono);font-size:13.5px;color:var(--tx)}
|
||||
.kv-v.ok{color:var(--on)} .kv-v.bad{color:var(--err)} .kv-v.na{color:var(--dim)}
|
||||
.bar{height:4px;border-radius:3px;background:var(--panel2);margin-top:8px;overflow:hidden}
|
||||
.bar > i{display:block;height:100%;background:var(--on);border-radius:3px;transition:width .4s}
|
||||
.bar.blue > i{background:var(--act)} .bar.warn > i{background:var(--warn)}
|
||||
.bar{height:6px;border-radius:4px;background:var(--inset);margin-top:8px;overflow:hidden}
|
||||
.bar > i{display:block;height:100%;background:var(--accent);border-radius:4px;transition:width .4s}
|
||||
.bar.blue > i{background:var(--act)} .bar.warn > i{background:var(--warn)} .bar.bad > i{background:var(--err)}
|
||||
/* benannte Mess-Zeile mit Label + Wert + Balken */
|
||||
.meter{margin-top:14px} .meter:first-child{margin-top:0}
|
||||
.meter-h{display:flex;justify-content:space-between;font-size:12.5px}
|
||||
.meter-h .mk{color:#aeb9c4} .meter-h .mv{font-family:var(--mono);color:var(--mut)}
|
||||
|
||||
/* ---- Listen-Items (Modelle / "Session Router") ---- */
|
||||
/* ---- Listen-Items (Modelle / Stack) ---- */
|
||||
.list{display:flex;flex-direction:column}
|
||||
.li{display:flex;align-items:center;gap:12px;padding:12px 4px;border-top:1px solid var(--line)}
|
||||
.li{display:flex;align-items:center;gap:12px;padding:11px 4px;border-top:1px solid var(--line)}
|
||||
.li:first-child{border-top:0}
|
||||
.li .li-dot{width:9px;height:9px;border-radius:50%;background:var(--dim);flex:0 0 auto}
|
||||
.li .li-dot.on{background:var(--on)} .li .li-dot.load{background:var(--warn)}
|
||||
@@ -78,10 +94,26 @@
|
||||
.li .li-meta{font-family:var(--mono);font-size:12px;color:var(--mut)}
|
||||
.li .li-time{font-family:var(--mono);font-size:11.5px;color:var(--dim);margin-top:2px}
|
||||
|
||||
/* ---- Quickstart (geführte Aktionen) ---- */
|
||||
.qa{display:flex;align-items:center;gap:11px;padding:11px 0;border-top:1px solid var(--line);
|
||||
width:100%;background:none;text-align:left;cursor:pointer;color:var(--tx);font-family:var(--sans)}
|
||||
.qa:first-of-type{border-top:0}
|
||||
.qa:hover{border-color:var(--line)} .qa:hover .qa-t{color:var(--accent)}
|
||||
.qa-ic{width:32px;height:32px;border-radius:9px;display:grid;place-items:center;flex:0 0 auto}
|
||||
.qa-ic svg{width:17px;height:17px}
|
||||
.qa-ic.teal{background:rgba(45,212,191,.13);color:var(--accent)}
|
||||
.qa-ic.amber{background:rgba(224,163,46,.13);color:var(--warn)}
|
||||
.qa-ic.blue{background:rgba(68,147,224,.13);color:var(--act)}
|
||||
.qa-ic.red{background:rgba(240,87,63,.13);color:var(--err)}
|
||||
.qa-danger:hover .qa-t{color:var(--err)}
|
||||
.qa-danger:hover{border-color:rgba(240,87,63,.2)}
|
||||
.qa-main{flex:1;min-width:0;display:flex;flex-direction:column}
|
||||
.qa-t{font-size:13.5px} .qa-s{font-size:11.5px;color:var(--mut);margin-top:2px}
|
||||
.qa-arrow{color:var(--dim)} .qa-arrow svg{width:16px;height:16px}
|
||||
|
||||
/* ---- Tabelle ---- */
|
||||
table{width:100%;border-collapse:collapse;font-size:14px}
|
||||
th{text-align:left;font-weight:500;color:var(--mut);font-size:11.5px;text-transform:uppercase;
|
||||
letter-spacing:.06em;padding:0 10px 10px}
|
||||
th{text-align:left;font-weight:400;color:var(--mut);font-size:11.5px;text-transform:uppercase;letter-spacing:.06em;padding:0 10px 10px}
|
||||
td{padding:12px 10px;border-top:1px solid var(--line)}
|
||||
.mid{font-family:var(--mono);font-size:13px;color:#e8eef5}
|
||||
.port{font-family:var(--mono);color:var(--mut);font-size:13px}
|
||||
@@ -91,100 +123,134 @@ td{padding:12px 10px;border-top:1px solid var(--line)}
|
||||
.b-run{background:rgba(63,185,80,.14);color:var(--on)}
|
||||
.b-idle{background:rgba(139,151,165,.14);color:var(--mut)}
|
||||
.b-load{background:rgba(224,163,46,.16);color:var(--warn)}
|
||||
.b-err{background:rgba(229,83,75,.16);color:var(--err)}
|
||||
.b-err{background:rgba(240,87,63,.16);color:var(--err)}
|
||||
.b-ok{background:rgba(63,185,80,.14);color:var(--on)}
|
||||
/* Capability-Tags */
|
||||
.tag{font-size:11px;border-radius:6px;padding:2px 8px}
|
||||
.tag.code{color:#86b9ff;background:rgba(68,147,224,.13)}
|
||||
.tag.text{color:#9aa7b4;background:rgba(139,151,165,.13)}
|
||||
.tag.img{color:#c9a6f5;background:rgba(163,113,247,.14)}
|
||||
|
||||
/* ---- Fit-Ampel (Cookbook) ---- */
|
||||
.fit-badge{font-size:11.5px;border-radius:7px;padding:3px 9px;white-space:nowrap}
|
||||
.fit-badge.ok{background:rgba(63,185,80,.14);color:#7ee29a}
|
||||
.fit-badge.warn{background:rgba(224,163,46,.15);color:#f0c570}
|
||||
.fit-badge.bad{background:rgba(240,87,63,.14);color:#f3a08f}
|
||||
.legend{display:flex;gap:14px;flex-wrap:wrap;font-size:11.5px;color:var(--mut)}
|
||||
.legend span{display:flex;align-items:center;gap:6px}
|
||||
.legend i{width:8px;height:8px;border-radius:50%;display:inline-block}
|
||||
|
||||
/* ---- Empty-States ---- */
|
||||
.empty{color:var(--mut);font-size:13.5px;padding:14px 4px}
|
||||
.empty-c{display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
text-align:center;padding:44px 16px;color:var(--mut)}
|
||||
.empty-c .e-t{font-size:14px}
|
||||
.empty-c .e-s{font-size:12.5px;color:var(--dim);margin-top:6px}
|
||||
.empty-c{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:44px 16px;color:var(--mut)}
|
||||
.empty-c .e-t{font-size:14px} .empty-c .e-s{font-size:12.5px;color:var(--dim);margin-top:6px}
|
||||
|
||||
/* ---- Forms ---- */
|
||||
label{display:block;font-size:12px;color:var(--mut);margin:0 0 5px}
|
||||
input,textarea,select{width:100%;background:var(--panel2);border:1px solid var(--line);color:var(--tx);
|
||||
border-radius:8px;padding:9px 11px;font-family:var(--mono);font-size:13px;margin-bottom:12px}
|
||||
input:focus,textarea:focus,select:focus{outline:none;border-color:var(--act)}
|
||||
border-radius:var(--radius-sm);padding:9px 11px;font-family:var(--mono);font-size:13px;margin-bottom:12px}
|
||||
input:focus,textarea:focus,select:focus{outline:none;border-color:var(--accent)}
|
||||
textarea{resize:vertical;min-height:64px;font-family:var(--sans)}
|
||||
.row{display:flex;gap:10px}.row>div{flex:1}
|
||||
.hint{font-size:12px;color:var(--mut);margin:-4px 0 12px}
|
||||
.info-dot{display:inline-flex;vertical-align:-3px;color:var(--dim);cursor:help;margin-left:5px}
|
||||
.info-dot svg{width:14px;height:14px}
|
||||
.info-dot:hover{color:var(--accent)}
|
||||
.upd-badge{font-size:11.5px;padding:4px 10px;border-radius:999px;cursor:pointer;white-space:nowrap;
|
||||
background:rgba(68,147,224,.14);color:#86b9ff;border:1px solid rgba(68,147,224,.25)}
|
||||
.upd-badge.warn{background:rgba(224,163,46,.14);color:#f0c570;border-color:rgba(224,163,46,.25)}
|
||||
.upd-badge.ok{background:rgba(63,185,80,.12);color:#7ee29a;border-color:rgba(63,185,80,.22)}
|
||||
.upd-badge:hover{filter:brightness(1.12)}
|
||||
.mono-sm{font-family:var(--mono);font-size:11.5px;color:var(--mut)}
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
button{font-family:var(--sans);font-size:13.5px;font-weight:500;border:1px solid var(--line2);
|
||||
background:var(--panel2);color:var(--tx);border-radius:8px;padding:9px 15px;cursor:pointer;transition:.15s}
|
||||
button:hover{border-color:var(--act)}
|
||||
button.primary{background:var(--act);border-color:var(--act);color:#fff}
|
||||
button.primary:hover{filter:brightness(1.08)}
|
||||
background:var(--panel2);color:var(--tx);border-radius:var(--radius-sm);padding:9px 15px;cursor:pointer;transition:.15s}
|
||||
button:hover{border-color:var(--accent)}
|
||||
button.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-ink)}
|
||||
button.primary:hover{filter:brightness(1.06)}
|
||||
button.danger{background:var(--err);border-color:var(--err);color:#fff}
|
||||
button.danger:hover{filter:brightness(1.08)}
|
||||
button.ghost{padding:6px 11px;font-size:12.5px}
|
||||
button.danger:hover{filter:brightness(1.06)}
|
||||
button.warn{background:var(--warn);border-color:var(--warn);color:#3a2a05}
|
||||
button.ghost{padding:6px 11px;font-size:12.5px;background:none}
|
||||
button.ghost.del{color:var(--err)}
|
||||
button.ghost.del:hover{border-color:var(--err);background:var(--red-dim)}
|
||||
button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.btn-row{display:flex;gap:10px;flex-wrap:wrap}
|
||||
|
||||
/* ---- Reply / Log ---- */
|
||||
.reply{margin-top:12px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;
|
||||
/* ---- Reply / Log / Konsole ---- */
|
||||
.reply{margin-top:12px;background:var(--panel2);border:1px solid var(--line);border-radius:var(--radius-sm);
|
||||
padding:12px;white-space:pre-wrap;font-size:14px;min-height:20px;color:var(--tx)}
|
||||
.log{font-family:var(--mono);font-size:12px;line-height:1.65;background:var(--inset);border:1px solid var(--line);
|
||||
border-radius:8px;padding:12px;max-height:240px;overflow:auto;white-space:pre-wrap;color:#aeb9c4}
|
||||
border-radius:var(--radius-sm);padding:12px;max-height:240px;overflow:auto;white-space:pre-wrap;color:#aeb9c4}
|
||||
.console{background:var(--inset);border:1px solid var(--line);border-radius:var(--radius-sm);
|
||||
font-family:var(--mono);font-size:12px;line-height:1.6;color:#9fe6c4;padding:13px;height:400px;overflow:auto;white-space:pre-wrap}
|
||||
.job{border:1px solid var(--line);border-radius:10px;margin-bottom:8px;overflow:hidden;background:var(--bg2)}
|
||||
.job-h{display:flex;align-items:center;gap:10px;padding:11px 13px;cursor:pointer}
|
||||
.job-h .mid{flex:1}
|
||||
|
||||
/* ---- Modal ---- */
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.62);z-index:100;display:flex;align-items:center;justify-content:center;padding:20px}
|
||||
.modal-card{background:var(--panel);border:1px solid var(--line2);border-radius:var(--radius);
|
||||
width:100%;max-width:440px;padding:var(--sp-5) var(--sp-5);position:relative}
|
||||
.modal-card h3{margin:0 0 8px;font-size:17px;font-weight:500}
|
||||
.modal-card p{margin:0 0 18px;color:var(--mut);font-size:13.5px;line-height:1.55}
|
||||
.modal-actions{display:flex;gap:10px;justify-content:flex-end}
|
||||
|
||||
/* ---- News (Magazin) ---- */
|
||||
.news-grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}
|
||||
.news-card{display:flex;flex-direction:column;gap:10px;background:var(--panel);border:1px solid var(--line);
|
||||
border-radius:var(--radius);padding:15px 17px;text-decoration:none;color:inherit;transition:.15s}
|
||||
.news-card:hover{border-color:var(--accent);background:var(--bg2)}
|
||||
.news-card.featured{grid-column:span 2}
|
||||
.news-meta{display:flex;align-items:center;gap:8px;font-size:11.5px}
|
||||
.news-src{color:var(--accent);font-weight:500}
|
||||
.news-rel{color:#7ee29a;background:rgba(63,185,80,.13);border-radius:5px;padding:1px 7px}
|
||||
.news-date{margin-left:auto;color:var(--dim);font-family:var(--mono)}
|
||||
.news-title{font-size:14px;line-height:1.45;color:var(--tx)}
|
||||
.news-card.featured .news-title{font-size:18px;font-weight:500;line-height:1.35}
|
||||
@media(max-width:620px){.news-card.featured{grid-column:span 1}}
|
||||
|
||||
/* ---- Guide (Konzept-Karten) ---- */
|
||||
.concept-grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}
|
||||
.concept{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:15px 17px}
|
||||
.concept h4{margin:0 0 6px;font-size:14px;font-weight:500;color:var(--tx)}
|
||||
.concept p{margin:0;font-size:13px;color:var(--mut);line-height:1.55}
|
||||
.tut-steps{padding-left:20px;line-height:1.85;margin:6px 0 0}
|
||||
.tut-steps li{margin-bottom:4px}
|
||||
|
||||
/* ---- Toast ---- */
|
||||
.toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:var(--panel2);
|
||||
border:1px solid var(--line2);border-radius:10px;padding:11px 16px;font-size:13.5px;
|
||||
opacity:0;transition:.25s;pointer-events:none;max-width:90vw;z-index:50}
|
||||
opacity:0;transition:.25s;pointer-events:none;max-width:90vw;z-index:120}
|
||||
.toast.show{opacity:1}
|
||||
.toast.err{border-color:var(--err);color:#ffb4ae}
|
||||
|
||||
/* ---- Accordion (Guides) ---- */
|
||||
.guide-acc { border-bottom: 1px solid var(--line); }
|
||||
.guide-acc:last-child { border-bottom: none; }
|
||||
.guide-acc summary {
|
||||
padding: 16px 20px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.guide-acc summary::-webkit-details-marker { display: none; }
|
||||
.guide-acc summary::after { content: "▼"; font-size: 10px; color: var(--mut); transition: transform 0.2s; }
|
||||
.guide-acc[open] summary::after { transform: rotate(180deg); }
|
||||
.guide-acc .acc-body { padding: 0 20px 20px 20px; }
|
||||
.guide-acc{border-bottom:1px solid var(--line)}
|
||||
.guide-acc:last-child{border-bottom:none}
|
||||
.guide-acc summary{padding:16px 20px;font-weight:500;cursor:pointer;list-style:none;display:flex;justify-content:space-between;align-items:center}
|
||||
.guide-acc summary::-webkit-details-marker{display:none}
|
||||
.guide-acc summary::after{content:"▼";font-size:10px;color:var(--mut);transition:transform .2s}
|
||||
.guide-acc[open] summary::after{transform:rotate(180deg)}
|
||||
.guide-acc .acc-body{padding:0 20px 20px 20px}
|
||||
|
||||
/* ---- Utilities & Layout Helpers ---- */
|
||||
.flex { display: flex; }
|
||||
.flex-col { display: flex; flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-2 { gap: var(--sp-2); }
|
||||
.gap-3 { gap: var(--sp-3); }
|
||||
.mt-2 { margin-top: var(--sp-2); }
|
||||
.mt-3 { margin-top: var(--sp-3); }
|
||||
.mt-4 { margin-top: var(--sp-4); }
|
||||
.mt-6 { margin-top: var(--sp-6); }
|
||||
.mb-2 { margin-bottom: var(--sp-2); }
|
||||
.mb-4 { margin-bottom: var(--sp-4); }
|
||||
.text-sm { font-size: var(--text-sm); }
|
||||
.text-xs { font-size: var(--text-xs); }
|
||||
.text-mut { color: var(--mut); }
|
||||
.text-act { color: var(--act); }
|
||||
/* ---- Utilities ---- */
|
||||
.flex{display:flex}.flex-col{display:flex;flex-direction:column}
|
||||
.items-center{align-items:center}.justify-between{justify-content:space-between}
|
||||
.gap-2{gap:var(--sp-2)}.gap-3{gap:var(--sp-3)}
|
||||
.mt-2{margin-top:var(--sp-2)}.mt-3{margin-top:var(--sp-3)}.mt-4{margin-top:var(--sp-4)}.mt-6{margin-top:var(--sp-6)}
|
||||
.mb-2{margin-bottom:var(--sp-2)}.mb-4{margin-bottom:var(--sp-4)}
|
||||
.text-sm{font-size:var(--text-sm)}.text-xs{font-size:var(--text-xs)}
|
||||
.text-mut{color:var(--mut)}.text-act{color:var(--act)}.text-accent{color:var(--accent)}
|
||||
|
||||
/* ---- Interaktive Karten (A11y) ---- */
|
||||
.card-btn {
|
||||
display: block; width: 100%; text-align: left;
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
|
||||
padding: var(--sp-3) var(--sp-4); cursor: pointer; transition: border-color 0.15s, background 0.15s;
|
||||
color: var(--tx); font-family: var(--sans);
|
||||
}
|
||||
.card-btn:hover, .card-btn:focus {
|
||||
border-color: var(--act);
|
||||
background: var(--bg2);
|
||||
outline: none;
|
||||
}
|
||||
.card-btn h3 { margin: 0; font-size: var(--text-lg); font-weight: 600; }
|
||||
.card-btn p { margin: var(--sp-2) 0 0; font-size: var(--text-sm); color: var(--mut); }
|
||||
/* ---- Interaktive Karten (Legacy Quick-Actions) ---- */
|
||||
.card-btn{display:block;width:100%;text-align:left;background:var(--panel);border:1px solid var(--line);
|
||||
border-radius:var(--radius);padding:var(--sp-3) var(--sp-4);cursor:pointer;transition:border-color .15s,background .15s;
|
||||
color:var(--tx);font-family:var(--sans)}
|
||||
.card-btn:hover,.card-btn:focus{border-color:var(--accent);background:var(--bg2);outline:none}
|
||||
.card-btn.cb-best{border:2px solid var(--accent);background:rgba(45,212,191,.05)}
|
||||
.card.res-best{border:2px solid var(--accent);background:rgba(45,212,191,.05)}
|
||||
.cb-best-tag{display:inline-block;font-size:11px;color:var(--accent-ink);background:var(--accent);border-radius:6px;padding:2px 9px;margin-bottom:8px;font-weight:500}
|
||||
.card-btn h3{margin:0;font-size:var(--text-lg);font-weight:500}
|
||||
.card-btn p{margin:var(--sp-2) 0 0;font-size:var(--text-sm);color:var(--mut)}
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
.guide-tabs.svelte-112sh7i{display:flex;gap:6px;flex-wrap:wrap;margin:-6px 0 2px}.guide-tab.svelte-112sh7i{font-size:12px;padding:5px 13px;border-radius:999px;background:var(--tile);border:1px solid var(--line);color:var(--mut);cursor:pointer;transition:.13s;font-family:var(--sans)}.guide-tab.svelte-112sh7i:hover{color:var(--tx);border-color:var(--line2)}.guide-tab.active.svelte-112sh7i{background:#2dd4bf1f;border-color:#2dd4bf4d;color:var(--accent)}.guide-card.svelte-112sh7i{padding:0;overflow:hidden;margin-bottom:0}.guide-group-hd.svelte-112sh7i{padding:9px 18px 7px;font-size:10.5px;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:var(--accent);background:#2dd4bf0f;border-bottom:1px solid var(--line)}.tut-table{display:flex;flex-direction:column;border:1px solid var(--line);border-radius:6px;overflow:hidden;margin-top:8px;font-size:12.5px}.tut-row{display:grid;grid-template-columns:1fr 2fr;border-bottom:1px solid var(--line)}.tut-row:last-child{border-bottom:none}.tut-cell-hd{padding:10px 14px;font-weight:500;border-right:1px solid var(--line);background:#ffffff06;line-height:1.5}.tut-cell-hd.bad{color:var(--mut);text-decoration:line-through;font-style:italic}.tut-cell-bd{padding:10px 14px;line-height:1.6;color:var(--fg)}.tut-sources{margin-top:14px;padding-top:10px;border-top:1px solid var(--line);font-size:11.5px;color:var(--mut)}.tut-sources a{color:var(--accent);text-decoration:none}.tut-sources a:hover{text-decoration:underline}.tut-code{display:block;background:var(--bg, #0d0d0d);border:1px solid var(--line);border-radius:6px;padding:12px 14px;font-family:monospace;font-size:11.5px;line-height:1.65;white-space:pre;overflow-x:auto;margin:10px 0;color:var(--fg)}
|
||||
Vendored
+665
File diff suppressed because one or more lines are too long
+40
-67
@@ -4,104 +4,77 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Mission Control</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230f1720'/%3E%3Ccircle cx='16' cy='16' r='8' fill='none' stroke='%232dd4bf' stroke-width='3'/%3E%3Ccircle cx='16' cy='16' r='2.5' fill='%232dd4bf'/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/static/css/base.css">
|
||||
<link rel="stylesheet" href="/static/css/components.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
|
||||
<!-- Sidebar: Bereichs-Navigation. Platzhalter-Items sind die kommenden Roadmap-Bereiche. -->
|
||||
<aside class="sidebar">
|
||||
<div class="side-logo" id="logo"></div>
|
||||
<div class="brand"><span class="brand-logo" id="logo"></span><span class="brand-tx">Mission Control</span></div>
|
||||
<nav class="side-nav">
|
||||
<a class="nav-item" data-view="overview" title="Übersicht" data-ic="grid"></a>
|
||||
<a class="nav-item" data-view="models" title="Modelle" data-ic="cpu"></a>
|
||||
<a class="nav-item" data-view="activity" title="Aktivität" data-ic="pulse"></a>
|
||||
<a class="nav-item" data-view="server" title="Server" data-ic="server"></a>
|
||||
<a class="nav-item" data-view="cookbook" title="Cookbook" data-ic="book"></a>
|
||||
<a class="nav-item" data-view="guides" title="Guides" data-ic="help"></a>
|
||||
<a class="nav-item" data-view="overview"><span class="ni-ic" data-ic="grid"></span><span class="ni-tx">Übersicht</span></a>
|
||||
<a class="nav-item" data-view="models"><span class="ni-ic" data-ic="cpu"></span><span class="ni-tx">Modelle</span></a>
|
||||
<a class="nav-item" data-view="activity"><span class="ni-ic" data-ic="pulse"></span><span class="ni-tx">Aktivität</span></a>
|
||||
<a class="nav-item" data-view="server"><span class="ni-ic" data-ic="server"></span><span class="ni-tx">Server</span></a>
|
||||
<a class="nav-item" data-view="cookbook"><span class="ni-ic" data-ic="book"></span><span class="ni-tx">Cookbook</span></a>
|
||||
<a class="nav-item" data-view="connect"><span class="ni-ic" data-ic="swap"></span><span class="ni-tx">Verbinden</span></a>
|
||||
<a class="nav-item" data-view="news"><span class="ni-ic" data-ic="info"></span><span class="ni-tx">News</span></a>
|
||||
<a class="nav-item" data-view="guides"><span class="ni-ic" data-ic="help"></span><span class="ni-tx">Guides</span></a>
|
||||
<a class="nav-item" data-view="memory"><span class="ni-ic" data-ic="database"></span><span class="ni-tx">Gedächtnis</span></a>
|
||||
<a class="nav-item" data-view="hermes"><span class="ni-ic" data-ic="bolt"></span><span class="ni-tx">Hermes</span></a>
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
<span class="nav-item" title="Einstellungen" data-ic="settings" id="nav-settings"></span>
|
||||
<span class="nav-item" id="nav-settings"><span class="ni-ic" data-ic="settings"></span><span class="ni-tx">Einstellungen</span></span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="main">
|
||||
<!-- Topbar -->
|
||||
<header class="topbar">
|
||||
<span class="status-pill"><span id="swdot" class="dot"></span><span id="swlabel">verbinde…</span></span>
|
||||
<span class="spacer"></span>
|
||||
<span class="top-stat" id="top-active-text" style="color:var(--teal)">Kein Modell geladen</span>
|
||||
<span id="update-badges" class="flex" style="gap:6px"></span>
|
||||
<span class="top-stat" id="top-active-text">Kein Modell geladen</span>
|
||||
<span class="sec-chip" id="sec-chip"><span data-ic="shield"></span><span id="sec-chip-tx">Nur im Heimnetz</span></span>
|
||||
</header>
|
||||
|
||||
<!-- Alert-Banner (wird per JS ein-/ausgeblendet) -->
|
||||
<div id="alert" class="alert" style="display:none"></div>
|
||||
|
||||
<!-- Content: genau eine .view ist sichtbar (Hash-Routing) -->
|
||||
<main class="content">
|
||||
|
||||
<section class="view" data-view="overview">
|
||||
<div id="hero"></div>
|
||||
|
||||
<div class="grid grid-3 mt-6" id="ov-quick"></div>
|
||||
|
||||
<div class="grid grid-2 mt-6" style="align-items: start;">
|
||||
<div class="card" id="ov-models"></div>
|
||||
<div class="card" id="ov-recent-jobs"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="activity" hidden>
|
||||
<div class="grid kpis" id="act-kpis"></div>
|
||||
<div class="card" id="act-sys"></div>
|
||||
<div class="card" id="v-activity"></div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="server" hidden>
|
||||
<div class="card" id="wartung"></div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="models" hidden>
|
||||
<div class="grid grid-2" id="m-top-grid">
|
||||
<div class="card" id="m-chat" style="grid-column: span 2;"></div>
|
||||
</div>
|
||||
<div class="card" id="m-table"></div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="cookbook" hidden>
|
||||
<!-- Wird von cookbook.js gerendert -->
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="guides" hidden>
|
||||
<!-- Wird von guides.js gerendert -->
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="overview"></section>
|
||||
<section class="view" data-view="activity" hidden></section>
|
||||
<section class="view" data-view="server" hidden></section>
|
||||
<section class="view" data-view="models" hidden></section>
|
||||
<section class="view" data-view="cookbook" hidden></section>
|
||||
<section class="view" data-view="connect" hidden></section>
|
||||
<section class="view" data-view="news" hidden></section>
|
||||
<section class="view" data-view="guides" hidden></section>
|
||||
<section class="view" data-view="memory" hidden></section>
|
||||
<section class="view" data-view="hermes" hidden></section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div id="settings-modal" style="display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.8); z-index:100; align-items:center; justify-content:center;">
|
||||
<div class="card" style="width:100%; max-width:400px; position:relative">
|
||||
<div id="settings-modal" style="display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.62); z-index:100; align-items:center; justify-content:center;">
|
||||
<div class="card" style="width:100%; max-width:420px; position:relative">
|
||||
<button id="sm-close" class="ghost" style="position:absolute; top:12px; right:12px;">Schließen</button>
|
||||
<h2 style="margin-top:0">Einstellungen</h2>
|
||||
|
||||
<div class="mt-6">
|
||||
<label>API Token (Authentifizierung)</label>
|
||||
<input id="token" class="tokin" placeholder="Optionales API Token..." autocomplete="off">
|
||||
<div class="meta text-xs mt-2">Wird für die WebSockets und API Calls genutzt, falls der Server geschützt ist.</div>
|
||||
<h3 style="margin-top:0;font-weight:500">Einstellungen</h3>
|
||||
<div class="mt-4">
|
||||
<label>Zugangs-Token (optional)</label>
|
||||
<input id="token" class="tokin" placeholder="Nur nötig, wenn der Server geschützt ist…" autocomplete="off">
|
||||
<div class="hint mt-2">Schützt den Zugriff. Wird für API-Aufrufe und Live-Verbindungen mitgeschickt.</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label>HuggingFace-Token (optional)</label>
|
||||
<input id="hf-token" class="tokin" placeholder="hf_…" autocomplete="off">
|
||||
<div class="hint mt-2">Nicht nötig für öffentliche Modelle — vermeidet aber Rate-Limits und erlaubt zugangsbeschränkte Modelle.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<!-- Icons in die Nav/Logo einsetzen, bevor das Haupt-Modul laedt -->
|
||||
<script type="module">
|
||||
import { ICON } from "/static/js/core/ui.js";
|
||||
document.getElementById("logo").innerHTML = ICON.logo;
|
||||
document.querySelectorAll(".nav-item[data-ic]").forEach(n => (n.innerHTML = ICON[n.dataset.ic] || ""));
|
||||
</script>
|
||||
<script type="module" src="/static/js/main.js"></script>
|
||||
<link rel="stylesheet" href="/static/dist/main.css">
|
||||
<script type="module" src="/static/dist/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -16,6 +16,10 @@ export function hdr() {
|
||||
return h;
|
||||
}
|
||||
|
||||
// HuggingFace-Token (optional) — wird bei Downloads als HF_TOKEN mitgesendet.
|
||||
export function getHfToken() { return localStorage.getItem("mc_hf_token") || ""; }
|
||||
export function setHfToken(t) { localStorage.setItem("mc_hf_token", (t || "").trim()); }
|
||||
|
||||
export async function api(path, opts = {}) {
|
||||
const r = await fetch(path, { headers: hdr(), ...opts });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
|
||||
+89
-2
@@ -19,9 +19,13 @@ export function toast(msg, err = false) {
|
||||
}
|
||||
|
||||
// Modell-Status -> Badge-HTML
|
||||
export function badge(state) {
|
||||
if (state === "running" || state === "ready") return '<span class="badge b-run">geladen</span>';
|
||||
export function badge(state, progress) {
|
||||
if (state === "running" || state === "ready") return '<span class="badge b-run">geladen</span>';
|
||||
if (state === "loading" || state === "starting") return '<span class="badge b-load">lädt…</span>';
|
||||
if (state === "downloading") {
|
||||
const pct = progress != null ? ` ${Math.round(progress)}%` : "";
|
||||
return `<span class="badge b-load">↓ Download${pct}</span>`;
|
||||
}
|
||||
return '<span class="badge b-run">bereit</span>';
|
||||
}
|
||||
|
||||
@@ -58,6 +62,89 @@ export const ICON = {
|
||||
compass: _svg('<circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/>'),
|
||||
code: _svg('<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>'),
|
||||
eye: _svg('<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>'),
|
||||
refresh: _svg('<path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/><path d="M3 21v-5h5"/>'),
|
||||
file: _svg('<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 17h5"/>'),
|
||||
database: _svg('<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5"/><path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/>'),
|
||||
thermo: _svg('<path d="M14 14.76V5a2 2 0 0 0-4 0v9.76a4 4 0 1 0 4 0z"/>'),
|
||||
shield: _svg('<path d="M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7z"/><path d="m9 12 2 2 4-4"/>'),
|
||||
info: _svg('<circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/>'),
|
||||
chevron: _svg('<path d="m9 6 6 6-6 6"/>'),
|
||||
bolt: _svg('<path d="M13 2 3 14h7l-1 8 10-12h-7z"/>'),
|
||||
x: _svg('<path d="M18 6 6 18M6 6l12 12"/>'),
|
||||
check: _svg('<path d="M20 6 9 17l-5-5"/>'),
|
||||
download: _svg('<path d="M12 3v12M7 10l5 5 5-5"/><path d="M5 21h14"/>'),
|
||||
clock: _svg('<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>'),
|
||||
};
|
||||
|
||||
export function icon(name) { return ICON[name] || ""; }
|
||||
|
||||
// kleines Info-ⓘ mit Klartext-Tooltip (native title) — für Erklärungen direkt am Feld.
|
||||
export function infoDot(text) {
|
||||
return `<span class="info-dot" tabindex="0" title="${esc(text)}" aria-label="${esc(text)}">${ICON.info}</span>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Format-Helfer (Klartext-Zahlen)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function fmtBytes(b) {
|
||||
if (!b && b !== 0) return "–";
|
||||
const gb = b / 1024 / 1024 / 1024;
|
||||
if (gb >= 1) return gb.toFixed(1) + " GB";
|
||||
return Math.round(b / 1024 / 1024) + " MB";
|
||||
}
|
||||
export function fmtPct(n) { return Math.round(n || 0) + " %"; }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bestätigungs-Dialog (ersetzt nacktes window.confirm) — gibt Promise<boolean>.
|
||||
// Für heikle Aktionen mit Klartext-Konsequenz.
|
||||
// ---------------------------------------------------------------------------
|
||||
export function confirmModal({ title, body, confirmLabel = "Bestätigen", cancelLabel = "Abbrechen", danger = false }) {
|
||||
return new Promise(resolve => {
|
||||
const ov = document.createElement("div");
|
||||
ov.className = "modal-overlay";
|
||||
ov.innerHTML = `<div class="modal-card" role="dialog" aria-modal="true">
|
||||
<h3>${esc(title)}</h3>
|
||||
<p>${body}</p>
|
||||
<div class="modal-actions">
|
||||
<button data-x="0">${esc(cancelLabel)}</button>
|
||||
<button class="${danger ? "danger" : "primary"}" data-x="1">${esc(confirmLabel)}</button>
|
||||
</div></div>`;
|
||||
const done = v => { ov.remove(); resolve(v); };
|
||||
ov.addEventListener("click", e => {
|
||||
if (e.target === ov) return done(false);
|
||||
const b = e.target.closest("[data-x]");
|
||||
if (b) done(b.getAttribute("data-x") === "1");
|
||||
});
|
||||
document.body.appendChild(ov);
|
||||
ov.querySelector('[data-x="1"]').focus();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Eingabe-Dialog (ersetzt window.prompt) — gibt Promise<string|null>.
|
||||
// password:true blendet die Eingabe aus (für sudo-Passwort).
|
||||
// ---------------------------------------------------------------------------
|
||||
export function promptModal({ title, body = "", placeholder = "", password = false, confirmLabel = "Weiter", danger = false }) {
|
||||
return new Promise(resolve => {
|
||||
const ov = document.createElement("div");
|
||||
ov.className = "modal-overlay";
|
||||
ov.innerHTML = `<div class="modal-card" role="dialog" aria-modal="true">
|
||||
<h3>${esc(title)}</h3>
|
||||
${body ? `<p>${body}</p>` : ""}
|
||||
<input type="${password ? "password" : "text"}" placeholder="${esc(placeholder)}" autocomplete="off" style="margin-bottom:18px">
|
||||
<div class="modal-actions">
|
||||
<button data-x="0">Abbrechen</button>
|
||||
<button class="${danger ? "danger" : "primary"}" data-x="1">${esc(confirmLabel)}</button>
|
||||
</div></div>`;
|
||||
const inp = ov.querySelector("input");
|
||||
const done = v => { ov.remove(); resolve(v); };
|
||||
ov.addEventListener("click", e => {
|
||||
if (e.target === ov) return done(null);
|
||||
const b = e.target.closest("[data-x]");
|
||||
if (b) done(b.getAttribute("data-x") === "1" ? (inp.value || "") : null);
|
||||
});
|
||||
inp.addEventListener("keydown", e => { if (e.key === "Enter") done(inp.value || ""); if (e.key === "Escape") done(null); });
|
||||
document.body.appendChild(ov);
|
||||
inp.focus();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
// main.js — App-Boot: Panels mounten, Nav starten, Topbar/Alert pflegen, Polling fahren.
|
||||
// Panel-Vertrag: { id, mount?(), onStatus?(s), onJobs?(jobs) }.
|
||||
|
||||
import { api, getToken, setToken } from "./core/api.js";
|
||||
import { $ } from "./core/ui.js";
|
||||
import { initNav } from "./core/nav.js";
|
||||
|
||||
import overview from "./panels/overview.js";
|
||||
import models from "./panels/models.js";
|
||||
import server from "./panels/server.js";
|
||||
import jobs from "./panels/jobs.js";
|
||||
import cookbook from "./panels/cookbook.js";
|
||||
import guides from "./panels/guides.js";
|
||||
|
||||
const panels = [overview, models, server, jobs, cookbook, guides];
|
||||
|
||||
let lastJobs = [];
|
||||
let lastSystem = null;
|
||||
|
||||
// ---- Topbar / Alert aus dem Status ableiten ----
|
||||
function applyStatus(s) {
|
||||
const dot = $("#swdot"), label = $("#swlabel"), alert = $("#alert");
|
||||
|
||||
if (!s) {
|
||||
dot.className = "dot off";
|
||||
label.textContent = "Backend nicht erreichbar";
|
||||
$("#top-active-text").textContent = "Backend offline";
|
||||
showAlert("Backend nicht erreichbar – läuft uvicorn?", false);
|
||||
} else {
|
||||
const host = s.swap_url.replace(/^https?:\/\//, "");
|
||||
dot.className = "dot " + (s.swap_ok ? "on" : "off");
|
||||
label.textContent = (s.swap_ok ? "LLM-Engine: Online – " : "LLM-Engine: Offline – ") + host;
|
||||
|
||||
const active = (s.models || []).find(m => m.state === "running");
|
||||
if (active) {
|
||||
$("#top-active-text").innerHTML = `Geladen: <b style="color:var(--teal)">${active.name}</b>`;
|
||||
} else {
|
||||
$("#top-active-text").innerHTML = "Kein Modell im VRAM";
|
||||
}
|
||||
|
||||
if (s.swap_ok) hideAlert();
|
||||
else showAlert(`LLM-Engine nicht erreichbar unter <b>${host}</b> – läuft der llama-swap Dienst?`, true);
|
||||
}
|
||||
for (const p of panels) p.onStatus?.(s);
|
||||
}
|
||||
|
||||
function applyJobs(jobs) {
|
||||
lastJobs = jobs || [];
|
||||
for (const p of panels) p.onJobs?.(lastJobs);
|
||||
}
|
||||
|
||||
function applySystem(sys) {
|
||||
lastSystem = sys;
|
||||
for (const p of panels) p.onSystem?.(sys);
|
||||
}
|
||||
|
||||
function showAlert(html, warn) {
|
||||
const a = $("#alert");
|
||||
a.className = "alert" + (warn ? " warn" : "");
|
||||
a.innerHTML = `<span class="a-dot"></span><span>${html}</span>`;
|
||||
a.style.display = "flex";
|
||||
}
|
||||
function hideAlert() { $("#alert").style.display = "none"; }
|
||||
|
||||
// ---- Polling ----
|
||||
async function pollStatus() {
|
||||
try { applyStatus(await api("/api/status")); }
|
||||
catch { applyStatus(null); }
|
||||
}
|
||||
async function pollJobs() {
|
||||
try { applyJobs(await api("/api/jobs")); }
|
||||
catch { /* still */ }
|
||||
}
|
||||
|
||||
function connectSystemStream() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/system/stream`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
applySystem(data);
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
// Bei Verbindungsabbruch nach 3 Sekunden erneut versuchen
|
||||
setTimeout(connectSystemStream, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
|
||||
// ---- Boot ----
|
||||
function bootToken() {
|
||||
const i = $("#token");
|
||||
if (i) {
|
||||
i.value = getToken();
|
||||
i.addEventListener("change", e => { setToken(e.target.value); pollStatus(); });
|
||||
}
|
||||
|
||||
const sbtn = $("#nav-settings");
|
||||
const smod = $("#settings-modal");
|
||||
const scls = $("#sm-close");
|
||||
if (sbtn && smod && scls) {
|
||||
sbtn.addEventListener("click", () => smod.style.display = "flex");
|
||||
scls.addEventListener("click", () => smod.style.display = "none");
|
||||
}
|
||||
}
|
||||
function tickClock() {
|
||||
const c = $("#clock");
|
||||
if (c) c.textContent = new Date().toTimeString().slice(0, 5);
|
||||
}
|
||||
|
||||
for (const p of panels) p.mount?.();
|
||||
initNav("overview");
|
||||
bootToken();
|
||||
tickClock();
|
||||
document.addEventListener("mc:refresh", pollStatus);
|
||||
|
||||
pollStatus();
|
||||
pollJobs();
|
||||
connectSystemStream();
|
||||
|
||||
setInterval(tickClock, 1000);
|
||||
setInterval(pollStatus, 3000);
|
||||
setInterval(pollJobs, 1500);
|
||||
@@ -1,453 +0,0 @@
|
||||
import { api } from "../core/api.js";
|
||||
import { $, esc, icon, toast } from "../core/ui.js";
|
||||
import { track } from "./jobs.js";
|
||||
|
||||
const CURATED_MODELS = [
|
||||
{
|
||||
id: "qwen-coder-32b",
|
||||
name: "Qwen 2.5 Coder 32B",
|
||||
repo: "unsloth/Qwen2.5-Coder-32B-Instruct-GGUF",
|
||||
file: "Qwen2.5-Coder-32B-Instruct-Q4_K_M.gguf",
|
||||
desc: "Top-Tier lokales Coder-Modell. Braucht ca. 24GB VRAM.",
|
||||
params_b: 32, quant: "Q4_K_M", ctx: 32768, alias: "coder"
|
||||
},
|
||||
{
|
||||
id: "qwen-coder-7b",
|
||||
name: "Qwen 2.5 Coder 7B",
|
||||
repo: "unsloth/Qwen2.5-Coder-7B-Instruct-GGUF",
|
||||
file: "Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf",
|
||||
desc: "Schneller Coder. Perfekte Balance aus Speed und Qualität.",
|
||||
params_b: 7, quant: "Q4_K_M", ctx: 32768, alias: "coder-fast"
|
||||
},
|
||||
{
|
||||
id: "llama3-vision-11b",
|
||||
name: "Llama 3.2 Vision 11B",
|
||||
repo: "unsloth/Llama-3.2-11B-Vision-Instruct-GGUF",
|
||||
file: "Llama-3.2-11B-Vision-Instruct-Q4_K_M.gguf",
|
||||
desc: "Modell für Bilderkennung und multimodale Tasks.",
|
||||
params_b: 11, quant: "Q4_K_M", ctx: 8192, alias: "vision"
|
||||
},
|
||||
{
|
||||
id: "qwen-general-7b",
|
||||
name: "Qwen 2.5 7B",
|
||||
repo: "unsloth/Qwen2.5-7B-Instruct-GGUF",
|
||||
file: "Qwen2.5-7B-Instruct-Q4_K_M.gguf",
|
||||
desc: "Hervorragendes Generalist/Scout Modell.",
|
||||
params_b: 7, quant: "Q4_K_M", ctx: 8192, alias: "scout"
|
||||
}
|
||||
];
|
||||
|
||||
// Lokale Mathe entfernt. Wir nutzen jetzt das Backend.
|
||||
|
||||
let lastSys = null;
|
||||
let currentResults = [];
|
||||
let currentAnalysis = null;
|
||||
let activeFilter = "";
|
||||
|
||||
const FILTERS = [
|
||||
{ id: "", label: "Alle" },
|
||||
{ id: "vision", label: "Vision" },
|
||||
{ id: "scout", label: "Scout" },
|
||||
{ id: "coder", label: "Coder" },
|
||||
{ id: "manager", label: "Manager" },
|
||||
{ id: "reviewer", label: "Reviewer" }
|
||||
];
|
||||
|
||||
function mount() {
|
||||
const c = $(".view[data-view='cookbook']");
|
||||
|
||||
const filtersHtml = FILTERS.map(f =>
|
||||
`<button class="ghost filter-btn ${activeFilter === f.id ? 'active' : ''}" style="margin-right:8px; border-radius:16px; padding:6px 16px; ${activeFilter === f.id ? 'background:var(--hi); color:var(--bg)' : 'background:var(--bg); border:1px solid var(--line)'}" onclick="window.setFilter('${f.id}')">${f.label}</button>`
|
||||
).join("");
|
||||
|
||||
c.innerHTML = `
|
||||
<div class="card" style="padding-bottom:32px">
|
||||
<div style="max-width:600px; margin:0 auto; text-align:center;">
|
||||
<h2 style="margin-top:20px; margin-bottom:8px">App Store für Modelle</h2>
|
||||
<p class="meta" style="margin-bottom:24px">Entdecke Modelle live auf HuggingFace, Hardware-Fit inklusive.</p>
|
||||
|
||||
<div style="display:flex; gap:12px; position:relative">
|
||||
<input id="cb-search" class="tokin" style="flex:1; padding:12px 18px; font-size:16px; border-radius:12px;" placeholder="Nach Modellen suchen (z.B. Llama 3)...">
|
||||
<button class="primary" id="cb-btn-search" style="border-radius:12px; padding:0 24px;">Suchen</button>
|
||||
</div>
|
||||
|
||||
<div id="cb-filters" style="margin-top:20px; display:flex; justify-content:center; flex-wrap:wrap; gap:8px">
|
||||
${filtersHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-h" style="margin-top:24px"><h3 id="cb-section-title">Kuratierte Empfehlungen</h3></div>
|
||||
<div class="grid grid-3" id="cb-grid"></div>
|
||||
|
||||
<!-- Modal für HuggingFace Modell Details -->
|
||||
<div id="cb-modal" style="display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.8); z-index:100; align-items:center; justify-content:center;">
|
||||
<div class="card" style="width:100%; max-width:600px; max-height:80vh; overflow-y:auto; position:relative">
|
||||
<button id="cb-modal-close" class="ghost" style="position:absolute; top:12px; right:12px;">Schließen</button>
|
||||
<h2 id="cb-m-title" style="margin-top:0">Modell</h2>
|
||||
<p class="meta" id="cb-m-repo">repo/name</p>
|
||||
|
||||
<div style="margin-top:24px">
|
||||
<label>Wähle eine Quantisierung (GGUF-Datei)</label>
|
||||
<select id="cb-m-files" class="tokin" style="width:100%; margin-top:8px; padding:12px"></select>
|
||||
<div id="cb-m-loading" class="meta" style="margin-top:8px; display:none">Lade Dateien...</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:24px; display:flex; gap:12px">
|
||||
<div style="flex:1">
|
||||
<label>Alias (Rolle)</label>
|
||||
<input id="cb-m-alias" class="tokin" style="width:100%; margin-top:8px" placeholder="z.B. coder">
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label>Context-Size</label>
|
||||
<input id="cb-m-ctx" class="tokin" style="width:100%; margin-top:8px" value="8192" type="number">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cb-m-fit-container" style="margin-top:24px; padding:16px; background:var(--bg); border:1px solid var(--line); border-radius:8px; display:flex; justify-content:space-between; align-items:center;">
|
||||
<div>
|
||||
<div style="font-weight:600; font-size:14px; margin-bottom:4px;">Ressourcen-Check</div>
|
||||
<div class="meta" id="cb-m-fit-text">Berechne...</div>
|
||||
</div>
|
||||
<div id="cb-m-fit-badge"></div>
|
||||
</div>
|
||||
|
||||
<button class="primary" id="cb-m-download" style="width:100%; margin-top:24px; padding:12px">Herunterladen & Einpflegen</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
$("#cb-btn-search").addEventListener("click", doSearch);
|
||||
$("#cb-search").addEventListener("keydown", e => { if (e.key === "Enter") doSearch(); });
|
||||
$("#cb-modal-close").addEventListener("click", () => $("#cb-modal").style.display = "none");
|
||||
$("#cb-m-download").addEventListener("click", doDownload);
|
||||
|
||||
$("#cb-m-files").addEventListener("change", updateLiveFit);
|
||||
$("#cb-m-ctx").addEventListener("change", reanalyzeCtx);
|
||||
|
||||
renderCurated();
|
||||
}
|
||||
|
||||
// Aktuelle Analyse-Daten vom Backend
|
||||
|
||||
function updateLiveFit() {
|
||||
const file = $("#cb-m-files").value;
|
||||
if (!currentAnalysis || !file) {
|
||||
$("#cb-m-fit-container").style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const fData = currentAnalysis.files.find(f => f.filename === file);
|
||||
if (!fData) return;
|
||||
|
||||
const fit = fData.fit;
|
||||
const cls = fit.level === "perfect" ? "b-run" : (fit.level === "marginal" ? "b-load" : "b-err");
|
||||
|
||||
$("#cb-m-fit-container").style.display = "flex";
|
||||
$("#cb-m-fit-text").innerHTML = `Geschätzter Bedarf: <b>~${fit.req_gb.toFixed(1)} GB RAM/VRAM</b> <br><small class="meta">${currentAnalysis.params_b}B Params · ${fData.quant} · ~${Math.round(fit.tps)} t/s</small>`;
|
||||
$("#cb-m-fit-badge").innerHTML = `<span class="badge ${cls}">${fit.text}</span>`;
|
||||
|
||||
// Wenn "too_tight", machen wir den Download-Button gelb zur Warnung, erlauben ihn aber
|
||||
const btn = $("#cb-m-download");
|
||||
if (fit.level === "too_tight") {
|
||||
btn.className = "primary warn";
|
||||
btn.innerHTML = "Trotzdem herunterladen (OOM Risiko!)";
|
||||
} else {
|
||||
btn.className = "primary";
|
||||
btn.innerHTML = "Herunterladen & Einpflegen";
|
||||
}
|
||||
}
|
||||
|
||||
async function reanalyzeCtx() {
|
||||
if (!currentAnalysis) return;
|
||||
const ctx = parseInt($("#cb-m-ctx").value) || 8192;
|
||||
const repo = currentAnalysis.repo;
|
||||
const file = $("#cb-m-files").value;
|
||||
|
||||
$("#cb-m-fit-text").innerHTML = "Berechne neues Context-Limit...";
|
||||
try {
|
||||
const res = await api("/api/cookbook/analyze", {
|
||||
method: "POST", body: JSON.stringify({ repo_id: repo, ctx })
|
||||
});
|
||||
currentAnalysis = res;
|
||||
// Auswahl beibehalten
|
||||
$("#cb-m-files").value = file;
|
||||
updateLiveFit();
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
window.setFilter = (filterId) => {
|
||||
activeFilter = filterId;
|
||||
const buttons = document.querySelectorAll('.filter-btn');
|
||||
buttons.forEach(b => {
|
||||
b.style.background = 'var(--bg)';
|
||||
b.style.color = '';
|
||||
b.style.border = '1px solid var(--line)';
|
||||
});
|
||||
|
||||
const idx = FILTERS.findIndex(f => f.id === filterId);
|
||||
if (idx !== -1 && buttons[idx]) {
|
||||
buttons[idx].style.background = 'var(--hi)';
|
||||
buttons[idx].style.color = 'var(--bg)';
|
||||
buttons[idx].style.border = 'none';
|
||||
}
|
||||
|
||||
doSearch();
|
||||
};
|
||||
|
||||
async function doSearch() {
|
||||
let q = $("#cb-search").value.trim();
|
||||
|
||||
if (activeFilter) {
|
||||
q = q ? (q + " " + activeFilter) : activeFilter;
|
||||
}
|
||||
|
||||
if (!q) return renderCurated();
|
||||
|
||||
const btn = $("#cb-btn-search");
|
||||
btn.disabled = true; btn.textContent = "Lade...";
|
||||
$("#cb-section-title").textContent = "Suchergebnisse für: " + esc(q);
|
||||
$("#cb-grid").innerHTML = `<div class="meta" style="grid-column:1/-1; text-align:center; padding:40px">Suche auf HuggingFace...</div>`;
|
||||
|
||||
try {
|
||||
const url = `https://huggingface.co/api/models?search=${encodeURIComponent(q)}&filter=gguf&sort=downloads&direction=-1&limit=12`;
|
||||
const r = await fetch(url);
|
||||
const data = await r.json();
|
||||
currentResults = data;
|
||||
renderResults(data);
|
||||
} catch (e) {
|
||||
$("#cb-grid").innerHTML = `<div class="alert err" style="grid-column:1/-1">${esc(e.message)}</div>`;
|
||||
}
|
||||
btn.disabled = false; btn.textContent = "Suchen";
|
||||
}
|
||||
|
||||
function renderResults(results) {
|
||||
const grid = $("#cb-grid");
|
||||
if (!results || results.length === 0) {
|
||||
grid.innerHTML = `<div class="meta" style="grid-column:1/-1; text-align:center; padding:40px">Keine GGUF-Modelle gefunden.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = results.map((m, i) => `
|
||||
<div class="card" style="display:flex; flex-direction:column; cursor:pointer" onclick="window.openModelModal(${i})">
|
||||
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:8px;">
|
||||
<div>
|
||||
<h3 style="margin:0; font-size:16px; word-break:break-all">${esc(m.id.split('/').pop())}</h3>
|
||||
<div class="meta" style="font-size:12px; margin-top:4px">${esc(m.author)}</div>
|
||||
</div>
|
||||
<span id="cb-s-badge-${i}" class="badge b-load" style="white-space:nowrap;">Analysiere...</span>
|
||||
</div>
|
||||
<div style="flex:1; margin-top:16px;"></div>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:16px; font-size:12px" class="meta">
|
||||
<span id="cb-s-metrics-${i}">Berechne Hardware-Fit...</span>
|
||||
<span style="display:flex; gap:8px; align-items:center">
|
||||
<span>⬇ ${(m.downloads||0).toLocaleString()}</span>
|
||||
<span id="cb-s-quant-${i}" class="badge b-idle" style="font-size:11px">GGUF</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
// Lade Metriken im Hintergrund
|
||||
results.forEach((m, i) => fetchAnalysisForCard(i, m.id));
|
||||
}
|
||||
|
||||
async function fetchAnalysisForCard(index, repo_id) {
|
||||
try {
|
||||
const res = await api("/api/cookbook/analyze", {
|
||||
method: "POST", body: JSON.stringify({ repo_id, ctx: 8192 })
|
||||
});
|
||||
|
||||
const badgeEl = document.getElementById(`cb-s-badge-${index}`);
|
||||
const metricsEl = document.getElementById(`cb-s-metrics-${index}`);
|
||||
const quantEl = document.getElementById(`cb-s-quant-${index}`);
|
||||
|
||||
if (!badgeEl || !metricsEl || !quantEl) return; // card might be gone
|
||||
|
||||
if (!res.files || res.files.length === 0) {
|
||||
badgeEl.className = "badge b-err";
|
||||
badgeEl.textContent = "Keine GGUFs";
|
||||
metricsEl.textContent = "Keine Dateien gefunden";
|
||||
return;
|
||||
}
|
||||
|
||||
let best = res.files.find(f => f.quant && f.quant.includes("Q4_K_M"));
|
||||
if (!best) best = res.files[0];
|
||||
|
||||
const fit = best.fit;
|
||||
const cls = fit.level === "perfect" ? "b-run" : (fit.level === "marginal" ? "b-load" : "b-err");
|
||||
|
||||
badgeEl.className = `badge ${cls}`;
|
||||
badgeEl.textContent = fit.text;
|
||||
metricsEl.innerHTML = `~${fit.req_gb.toFixed(1)} GB RAM/VRAM · ~${Math.round(fit.tps)} t/s`;
|
||||
quantEl.textContent = best.quant || "GGUF";
|
||||
|
||||
} catch(e) {
|
||||
const badgeEl = document.getElementById(`cb-s-badge-${index}`);
|
||||
const metricsEl = document.getElementById(`cb-s-metrics-${index}`);
|
||||
if (badgeEl) {
|
||||
badgeEl.className = "badge b-err";
|
||||
badgeEl.textContent = "Fehler";
|
||||
}
|
||||
if (metricsEl) {
|
||||
metricsEl.textContent = "Metriken konnten nicht geladen werden";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global hook for inline onclick
|
||||
window.openModelModal = async (index) => {
|
||||
const m = currentResults[index];
|
||||
if (!m) return;
|
||||
$("#cb-modal").style.display = "flex";
|
||||
$("#cb-m-title").textContent = m.id.split('/').pop();
|
||||
$("#cb-m-repo").textContent = m.id;
|
||||
$("#cb-m-files").innerHTML = "";
|
||||
$("#cb-m-alias").value = m.id.split('/').pop().toLowerCase().replace(/[^a-z0-9]/g, "-");
|
||||
|
||||
$("#cb-m-files").style.display = "none";
|
||||
$("#cb-m-loading").style.display = "block";
|
||||
$("#cb-m-download").disabled = true;
|
||||
|
||||
try {
|
||||
const ctx = parseInt($("#cb-m-ctx").value) || 8192;
|
||||
const res = await api("/api/cookbook/analyze", {
|
||||
method: "POST", body: JSON.stringify({ repo_id: m.id, ctx })
|
||||
});
|
||||
|
||||
currentAnalysis = res;
|
||||
|
||||
$("#cb-m-loading").style.display = "none";
|
||||
$("#cb-m-files").style.display = "block";
|
||||
|
||||
if (!res.files || res.files.length === 0) {
|
||||
$("#cb-m-files").innerHTML = "<option value=''>Keine GGUF-Dateien gefunden.</option>";
|
||||
$("#cb-m-fit-container").style.display = "none";
|
||||
} else {
|
||||
// Optische Indikatoren im Dropdown
|
||||
$("#cb-m-files").innerHTML = res.files.map(f => {
|
||||
let mark = "";
|
||||
if (f.fit.level === "perfect") mark = "🟢";
|
||||
else if (f.fit.level === "marginal") mark = "🟡";
|
||||
else mark = "🔴";
|
||||
return `<option value="${esc(f.filename)}">${mark} ${esc(f.filename)}</option>`;
|
||||
}).join("");
|
||||
|
||||
$("#cb-m-download").disabled = false;
|
||||
updateLiveFit();
|
||||
}
|
||||
} catch(e) {
|
||||
$("#cb-m-loading").textContent = "Fehler: " + e.message;
|
||||
}
|
||||
};
|
||||
|
||||
async function doDownload() {
|
||||
const repo = $("#cb-m-repo").textContent;
|
||||
const file = $("#cb-m-files").value;
|
||||
const alias = $("#cb-m-alias").value.trim();
|
||||
const ctx = parseInt($("#cb-m-ctx").value) || 8192;
|
||||
|
||||
if (!repo || !file || !alias) return toast("Bitte alle Felder ausfüllen.", true);
|
||||
|
||||
$("#cb-m-download").disabled = true;
|
||||
$("#cb-m-download").textContent = "Starte...";
|
||||
|
||||
try {
|
||||
const res = await api("/api/download", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ repo, file })
|
||||
});
|
||||
|
||||
// Register directly, jobengine will download it, MC will pick it up when done
|
||||
await api("/api/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ alias, model_path: res.expected_path, ctx })
|
||||
});
|
||||
|
||||
toast("Download gestartet! Siehe Aktivitäten.");
|
||||
$("#cb-modal").style.display = "none";
|
||||
track(res.job_id); // open in jobs
|
||||
// Switch to activity tab
|
||||
document.querySelector(".nav-item[data-view='activity']").click();
|
||||
|
||||
} catch (e) {
|
||||
toast("Fehler: " + e.message, true);
|
||||
}
|
||||
|
||||
$("#cb-m-download").disabled = false;
|
||||
$("#cb-m-download").textContent = "Herunterladen & Einpflegen";
|
||||
}
|
||||
|
||||
async function renderCurated() {
|
||||
$("#cb-section-title").textContent = "Kuratierte Empfehlungen";
|
||||
const grid = $("#cb-grid");
|
||||
if (!grid) return;
|
||||
|
||||
grid.innerHTML = "<div class='meta' style='grid-column:1/-1;text-align:center;padding:40px'>Berechne Hardware-Fit für Empfehlungen...</div>";
|
||||
|
||||
try {
|
||||
let html = "";
|
||||
for (let i = 0; i < CURATED_MODELS.length; i++) {
|
||||
const m = CURATED_MODELS[i];
|
||||
const fit = await api("/api/cookbook/evaluate", {
|
||||
method: "POST", body: JSON.stringify({ params_b: m.params_b, quant: m.quant, ctx: m.ctx })
|
||||
});
|
||||
|
||||
const cls = fit.level === "perfect" ? "b-run" : (fit.level === "marginal" ? "b-load" : "b-err");
|
||||
|
||||
html += `
|
||||
<div class="card" style="display:flex; flex-direction:column; cursor:pointer" onclick="window.openCuratedModal(${i})">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<h3 style="margin:0; font-size:16px">${esc(m.name)}</h3>
|
||||
<span class="badge ${cls}">${fit.text}</span>
|
||||
</div>
|
||||
<div style="font-size:13px; color:var(--mut); margin-top:12px; flex:1; line-height:1.5;">
|
||||
${m.desc}
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between; margin-top:16px; font-size:12px" class="meta">
|
||||
<span>~${fit.req_gb.toFixed(1)} GB RAM/VRAM · ~${Math.round(fit.tps)} t/s</span>
|
||||
<span>${m.quant}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
grid.innerHTML = html;
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<div class="alert err" style="grid-column:1/-1">Fehler beim Laden der Empfehlungen: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
window.openCuratedModal = async (index) => {
|
||||
const m = CURATED_MODELS[index];
|
||||
if (!m) return;
|
||||
$("#cb-modal").style.display = "flex";
|
||||
$("#cb-m-title").textContent = m.name;
|
||||
$("#cb-m-repo").textContent = m.repo;
|
||||
$("#cb-m-files").innerHTML = `<option value="${esc(m.file)}">${esc(m.file)}</option>`;
|
||||
$("#cb-m-files").style.display = "block";
|
||||
$("#cb-m-loading").style.display = "none";
|
||||
$("#cb-m-alias").value = m.alias;
|
||||
$("#cb-m-ctx").value = m.ctx;
|
||||
$("#cb-m-download").disabled = false;
|
||||
|
||||
// Wir nutzen die neue API Struktur auch für das simulierte Modal
|
||||
try {
|
||||
const fit = await api("/api/cookbook/evaluate", {
|
||||
method: "POST", body: JSON.stringify({ params_b: m.params_b, quant: m.quant, ctx: m.ctx })
|
||||
});
|
||||
currentAnalysis = {
|
||||
repo: m.repo,
|
||||
params_b: m.params_b,
|
||||
files: [{ filename: m.file, quant: m.quant, fit: fit }]
|
||||
};
|
||||
updateLiveFit();
|
||||
} catch(e) {}
|
||||
};
|
||||
|
||||
function onSystem(sys) {
|
||||
lastSys = sys;
|
||||
}
|
||||
|
||||
function unmount() {}
|
||||
|
||||
export default { id: "cookbook", mount, unmount, onSystem };
|
||||
@@ -1,102 +0,0 @@
|
||||
import { $ } from "../core/ui.js";
|
||||
|
||||
let currentUrl = "http://localhost:8000";
|
||||
|
||||
function render() {
|
||||
const c = document.querySelector(".view[data-view='guides']");
|
||||
if (!c) return;
|
||||
|
||||
c.innerHTML = `
|
||||
<div class="card-h"><h3>Guides & Integrationen</h3></div>
|
||||
<div class="card" style="padding:0; overflow:hidden;">
|
||||
|
||||
<details class="guide-acc">
|
||||
<summary>Cline / Cursor (Juni 2026)</summary>
|
||||
<div class="acc-body">
|
||||
<p>Nutze deine lokalen Modelle kostenlos in der Cursor IDE und Cline.</p>
|
||||
<label>Cursor: Settings -> Models -> Add Custom Model</label>
|
||||
<input class="tokin mono-sm" readonly value="coder">
|
||||
<label>Base URL (OpenAI API)</label>
|
||||
<input class="tokin mono-sm" readonly value="${currentUrl}/v1">
|
||||
<label>API Key</label>
|
||||
<input class="tokin mono-sm" readonly value="MissionControl-Token-oder-leer">
|
||||
<p style="margin-top:12px; font-size:12px; color:var(--mut);">
|
||||
In Cline: Wähle "OpenAI Compatible" als Provider.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="guide-acc">
|
||||
<summary>OpenWebUI (Pipes & Connections)</summary>
|
||||
<div class="acc-body">
|
||||
<p>Verbinde llama-swap nativ in OpenWebUI.</p>
|
||||
<label>Settings -> Admin Settings -> Connections</label>
|
||||
<input class="tokin mono-sm" readonly value="${currentUrl}/v1">
|
||||
<label>API Key</label>
|
||||
<input class="tokin mono-sm" readonly value="dummy-key">
|
||||
<p style="margin-top:12px; font-size:12px; color:var(--mut);">
|
||||
OpenWebUI erkennt nun vollautomatisch, wenn ein Modell ausgetauscht wird.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="guide-acc">
|
||||
<summary>Python / LangChain (OpenAI SDK)</summary>
|
||||
<div class="acc-body">
|
||||
<p>Nutze das offizielle <code>openai</code> Package, um mit llama-swap zu sprechen.</p>
|
||||
<pre style="background: var(--bg); padding: 12px; border-radius: 8px; border: 1px solid var(--line); overflow-x: auto; color: var(--tx); font-family: monospace; font-size: 13px;"><code>from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="${currentUrl}/v1",
|
||||
api_key="Dein_Token" # Optional
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="coder", # Alias aus deinem Cookbook
|
||||
messages=[{"role": "user", "content": "Hallo Modell!"}]
|
||||
)
|
||||
|
||||
print(response.choices[0].message.content)</code></pre>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="guide-acc">
|
||||
<summary>n8n (AI Agent Nodes)</summary>
|
||||
<div class="acc-body">
|
||||
<p>Nutze den "OpenAI Chat Model" Node in n8n Advanced AI.</p>
|
||||
<label>Credentials -> OpenAI API -> Custom URL</label>
|
||||
<input class="tokin mono-sm" readonly value="${currentUrl}/v1">
|
||||
<p style="margin-top:12px; font-size:12px; color:var(--mut);">
|
||||
Einfach den Node verbinden, "coder" als Modell-ID (Expression) eintippen und loslegen.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="guide-acc">
|
||||
<summary>Begrifflichkeiten (Glossar)</summary>
|
||||
<div class="acc-body">
|
||||
<p><b>LLM-Engine (llama-swap):</b> Der Server im Hintergrund, der die Sprachmodelle (LLMs) lädt und OpenAI-kompatible Schnittstellen bereitstellt.</p>
|
||||
<p><b>VRAM:</b> Der Grafikkarten-Speicher. KI-Modelle sind extrem groß und benötigen viel VRAM, um schnell zu laufen.</p>
|
||||
<p><b>Quantisierung (z.B. Q4_K_M):</b> Ein Verfahren, das die Genauigkeit der Modellgewichte leicht reduziert (von 16-Bit auf 4-Bit), damit sie in den VRAM passen, ohne signifikant dümmer zu werden.</p>
|
||||
<p><b>Context-Window:</b> Wie viel Text ("Tokens") sich das Modell gleichzeitig "merken" kann. Ein Buchstabe ist ~0.3 Tokens. Mehr Kontext braucht drastisch mehr VRAM.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function mount() {
|
||||
render();
|
||||
}
|
||||
|
||||
function onStatus(s) {
|
||||
if (s && s.swap_url) {
|
||||
if (currentUrl !== s.swap_url) {
|
||||
currentUrl = s.swap_url;
|
||||
render(); // Neu rendern, wenn sich die URL ändert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default { id: "guides", mount, onStatus };
|
||||
@@ -1,142 +0,0 @@
|
||||
// jobs.js (Aktivität) — System-Metriken & Hintergrund-Jobs
|
||||
|
||||
import { $, esc, icon } from "../core/ui.js";
|
||||
|
||||
const tracked = new Set();
|
||||
let JOBS = [];
|
||||
let SYS = null;
|
||||
|
||||
export function track(id) {
|
||||
tracked.add(id);
|
||||
renderJobs();
|
||||
}
|
||||
|
||||
function statusBadge(state) {
|
||||
if (state === "done") return '<span class="badge b-run">fertig</span>';
|
||||
if (state === "failed") return '<span class="badge b-err">fehler</span>';
|
||||
return '<span class="badge b-load">lädt…</span>';
|
||||
}
|
||||
function dotClass(state) {
|
||||
if (state === "done") return "on";
|
||||
if (state === "failed") return "";
|
||||
return "load";
|
||||
}
|
||||
|
||||
function kpi(cls, title, ic, value, sub) {
|
||||
return `<div class="kpi ${cls}">
|
||||
<div class="k-h"><span class="k-t">${title}</span><span class="k-ic">${icon(ic)}</span></div>
|
||||
<div class="k-v">${value}</div>
|
||||
<div class="k-s">${sub}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function kvRow(k, v, cls = "") {
|
||||
return `<div class="kv-row"><span class="kv-k">${k}</span><span class="kv-v ${cls}">${v}</span></div>`;
|
||||
}
|
||||
|
||||
// Chart-Daten (letzte 30 Sekunden, je 0.5s Stream = 60 Punkte)
|
||||
const hist = { cpu: [], ram: [], gpu: [] };
|
||||
const MAX_HIST = 60;
|
||||
|
||||
function renderSys() {
|
||||
if (!SYS) return;
|
||||
const sysV = `${SYS.cpu.percent.toFixed(0)}<small>% CPU</small>`;
|
||||
const sysS = `${SYS.ram.percent.toFixed(0)}% RAM, ${SYS.gpu_temp ? SYS.gpu_temp.toFixed(0)+'°C' : SYS.cpu.temp ? SYS.cpu.temp.toFixed(0)+'°C' : '–'}`;
|
||||
|
||||
// History aktualisieren
|
||||
hist.cpu.push(SYS.cpu.percent);
|
||||
hist.ram.push(SYS.ram.percent);
|
||||
let gpuP = 0;
|
||||
if (SYS.gpu && SYS.gpu.vram.total) {
|
||||
gpuP = ((SYS.gpu.vram.used + SYS.gpu.gtt.used) / (SYS.gpu.vram.total + SYS.gpu.gtt.total)) * 100;
|
||||
}
|
||||
hist.gpu.push(gpuP);
|
||||
if (hist.cpu.length > MAX_HIST) hist.cpu.shift();
|
||||
if (hist.ram.length > MAX_HIST) hist.ram.shift();
|
||||
if (hist.gpu.length > MAX_HIST) hist.gpu.shift();
|
||||
|
||||
// Mini-Sparklines generieren
|
||||
const makeBars = (arr, color) => {
|
||||
return '<div style="display:flex; align-items:flex-end; gap:2px; height:40px; margin-top:12px;">' +
|
||||
arr.map(v => `<div style="width:4px; background:var(--${color}); opacity:0.6; height:${Math.max(2, v)}%; border-radius:2px;"></div>`).join("") +
|
||||
'</div>';
|
||||
};
|
||||
|
||||
$("#act-kpis").innerHTML =
|
||||
kpi("blue", "CPU Last", "gauge", sysV, sysS) +
|
||||
kpi("purple", "RAM", "monitor", `${SYS.ram.percent.toFixed(0)}<small>%</small>`, "Arbeitsspeicher") +
|
||||
kpi("green", "GPU VRAM", "layers", `${gpuP.toFixed(0)}<small>%</small>`, "Grafikspeicher");
|
||||
|
||||
const gb = b => (b / 1024 / 1024 / 1024).toFixed(1);
|
||||
const ramStr = `${gb(SYS.ram.used)} GB / ${gb(SYS.ram.total)} GB`;
|
||||
const gpuStr = (SYS.gpu && SYS.gpu.vram.total) ? `${gb(SYS.gpu.vram.used + SYS.gpu.gtt.used)} GB / ${gb(SYS.gpu.vram.total + SYS.gpu.gtt.total)} GB` : "–";
|
||||
const diskStr = `${SYS.disk.percent.toFixed(0)}% belegt`;
|
||||
|
||||
$("#act-sys").innerHTML = `
|
||||
<div class="card-h"><h3>System-Metriken (Bosgame)</h3></div>
|
||||
<div class="kv" style="margin-bottom: 24px;">
|
||||
${kvRow("Arbeitsspeicher (RAM)", ramStr)}
|
||||
${kvRow("Grafikspeicher (VRAM+GTT)", gpuStr)}
|
||||
${kvRow("Speicherplatz (Disk)", diskStr)}
|
||||
${kvRow("Temperatur (GPU / CPU)", `${SYS.gpu_temp?.toFixed(1) || '–'}°C / ${SYS.cpu.temp?.toFixed(1) || '–'}°C`)}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-3">
|
||||
<div><div class="meta">CPU Historie</div>${makeBars(hist.cpu, "act")}</div>
|
||||
<div><div class="meta">RAM Historie</div>${makeBars(hist.ram, "purple")}</div>
|
||||
<div><div class="meta">VRAM Historie</div>${makeBars(hist.gpu, "on")}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function mount() {
|
||||
$("#v-activity").innerHTML = `
|
||||
<div class="card-h"><h3>Hintergrund-Aktivitäten</h3><span class="meta" id="job-count"></span></div>
|
||||
<div id="jobs"></div>
|
||||
<div id="jobs-empty" class="empty-c">
|
||||
<div class="e-t">Keine laufenden Jobs.</div>
|
||||
<div class="e-s">Downloads, Updates & Co. erscheinen hier mit Live-Log.</div>
|
||||
</div>`;
|
||||
|
||||
// Klicks auf Job-Kopf -> auf/zuklappen
|
||||
$("#v-activity").addEventListener("click", e => {
|
||||
const h = e.target.closest(".job-h");
|
||||
if (!h) return;
|
||||
const id = h.getAttribute("data-id");
|
||||
tracked.has(id) ? tracked.delete(id) : tracked.add(id);
|
||||
renderJobs();
|
||||
});
|
||||
|
||||
if (SYS) renderSys();
|
||||
}
|
||||
|
||||
function renderJobs() {
|
||||
const c = $("#jobs");
|
||||
if (!c) return;
|
||||
$("#jobs-empty").style.display = JOBS.length ? "none" : "flex";
|
||||
const failed = JOBS.filter(j => j.state === "failed").length;
|
||||
$("#job-count").textContent = JOBS.length ? (failed ? failed + " Fehler" : JOBS.length + " gesamt") : "";
|
||||
|
||||
c.innerHTML = JOBS.map(j => {
|
||||
const open = tracked.has(j.id);
|
||||
const log = open ? `<div class="log">${esc((j.log || []).join("\\n"))}</div>` : "";
|
||||
return `<div class="job">
|
||||
<div class="job-h" data-id="${esc(j.id)}">
|
||||
<span class="li-dot ${dotClass(j.state)}"></span>
|
||||
<span class="mid">${esc(j.label)}</span>${statusBadge(j.state)}
|
||||
</div>${log}</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function onJobs(jobs) {
|
||||
JOBS = jobs || [];
|
||||
renderJobs();
|
||||
}
|
||||
|
||||
function onSystem(sys) {
|
||||
SYS = sys;
|
||||
const c = $("#act-sys");
|
||||
if (c) renderSys();
|
||||
}
|
||||
|
||||
export default { id: "jobs", mount, onJobs, onSystem };
|
||||
@@ -1,160 +0,0 @@
|
||||
// models.js — "Modelle"-Ansicht: Schnelltest-Chat, Modell-Tabelle & Konfiguration.
|
||||
|
||||
import { api } from "../core/api.js";
|
||||
import { $, badge, esc, toast, icon } from "../core/ui.js";
|
||||
|
||||
let ALL_MODELS = [];
|
||||
|
||||
function refreshSoon() { document.dispatchEvent(new Event("mc:refresh")); }
|
||||
|
||||
function mount() {
|
||||
|
||||
$("#m-chat").innerHTML = `
|
||||
<div class="card-h"><h3>Schnelltest</h3></div>
|
||||
<label>Modell</label>
|
||||
<select id="chat-model"></select>
|
||||
<label>Nachricht</label>
|
||||
<textarea id="chat-msg" placeholder="Schreib was, um ein Modell zu wecken…"></textarea>
|
||||
<button class="primary" id="chat-btn" title="Sendet eine Chat-Anfrage an das Modell (weckt es auf, falls es schläft)">Senden</button>
|
||||
<div id="chat-reply" class="reply" style="display:none"></div>`;
|
||||
|
||||
$("#m-table").innerHTML = `
|
||||
<div class="card-h"><h3>Modelle & Ports</h3><span class="meta" id="m-count"></span></div>
|
||||
<div class="hint mb-4">
|
||||
💡 <b>Modelle werden automatisch geladen</b>, sobald eine Chat-Anfrage an sie gestellt wird. Du musst sie nicht manuell starten.
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Modell</th><th>Fähigkeiten</th><th>Details</th><th>Status</th><th>Port</th><th style="text-align:right">Aktionen</th></tr></thead>
|
||||
<tbody id="models"></tbody>
|
||||
</table>
|
||||
<div id="models-empty" class="empty" style="display:none">Noch keine Modelle konfiguriert — zieh dir oben eins rein. 👇</div>
|
||||
|
||||
<!-- Config Modal -->
|
||||
<div id="cfg-modal" style="display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.8); z-index:100; align-items:center; justify-content:center;">
|
||||
<div class="card" style="width:100%; max-width:400px; position:relative">
|
||||
<button id="cfg-close" class="ghost" style="position:absolute; top:12px; right:12px;">Schließen</button>
|
||||
<h2 style="margin-top:0">Modell konfigurieren</h2>
|
||||
<p class="meta" id="cfg-model-name"></p>
|
||||
|
||||
<div class="mt-6">
|
||||
<label>Context-Size (Tokens)</label>
|
||||
<input id="cfg-ctx" type="number" class="tokin" value="8192">
|
||||
<div class="meta text-xs mt-2">Höhere Werte erlauben längere Dokumente, brauchen aber mehr VRAM.</div>
|
||||
</div>
|
||||
|
||||
<button class="primary mt-6" id="cfg-save" style="width:100%; padding:var(--sp-3)">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
$("#chat-btn").addEventListener("click", sendChat);
|
||||
$("#cfg-close").addEventListener("click", () => $("#cfg-modal").style.display = "none");
|
||||
$("#cfg-save").addEventListener("click", saveConfig);
|
||||
}
|
||||
|
||||
function onStatus(s) {
|
||||
ALL_MODELS = s?.models || [];
|
||||
const tb = $("#models");
|
||||
if (!tb) return;
|
||||
tb.innerHTML = "";
|
||||
$("#models-empty").style.display = ALL_MODELS.length ? "none" : "block";
|
||||
$("#m-count").textContent = ALL_MODELS.length ? ALL_MODELS.length + " konfiguriert" : "";
|
||||
|
||||
const sel = $("#chat-model");
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = "";
|
||||
for (const m of ALL_MODELS) {
|
||||
const tr = document.createElement("tr");
|
||||
|
||||
let capsHtml = "–";
|
||||
if (m.meta && m.meta.caps) {
|
||||
capsHtml = m.meta.caps.map(c => {
|
||||
if (c === "Text") return `<span class="meta" title="Text" style="display:inline-block;width:18px">${icon("compass")}</span>`;
|
||||
if (c === "Code") return `<strong style="color:var(--blue);display:inline-block;width:18px" title="Code">{ }</strong>`;
|
||||
if (c === "Bild") return `<strong style="color:var(--purple);display:inline-block;width:18px" title="Vision">👁</strong>`;
|
||||
return "";
|
||||
}).join(" ");
|
||||
}
|
||||
|
||||
let detailsHtml = "–";
|
||||
if (m.meta) {
|
||||
const q = m.meta.quant || "?";
|
||||
const c = m.meta.ctx ? (m.meta.ctx / 1024).toFixed(0) + "K" : "?";
|
||||
const s = m.meta.size_bytes ? (m.meta.size_bytes / 1024 / 1024 / 1024).toFixed(1) + " GB" : "?";
|
||||
detailsHtml = `<span class="meta" style="font-size:0.9em">${q} · ${c} · ${s}</span>`;
|
||||
}
|
||||
|
||||
const perfHtml = m.state === "running" ? `<br><small class="meta" style="font-size:0.75em">n/a t/s</small>` : "";
|
||||
|
||||
const filenameHtml = m.meta?.filename ? `<br><small class="meta" style="font-size:0.8em">${esc(m.meta.filename)}</small>` : "";
|
||||
|
||||
tr.innerHTML = `<td class="mid" style="font-weight:500">${esc(m.name)}${filenameHtml}</td>
|
||||
<td>${capsHtml}</td>
|
||||
<td>${detailsHtml}</td>
|
||||
<td>${badge(m.state)}${perfHtml}</td>
|
||||
<td class="port">${m.port ?? "auto"}</td>
|
||||
<td style="text-align:right">
|
||||
<button class="ghost" data-cfg="${esc(m.name)}" title="Context-Size anpassen">Konfigurieren</button>
|
||||
<button class="ghost" data-unload="${esc(m.name)}" title="Beendet das Modell sofort und gibt den VRAM wieder frei">Aus VRAM entfernen</button>
|
||||
</td>`;
|
||||
tb.appendChild(tr);
|
||||
sel.insertAdjacentHTML("beforeend", `<option>${esc(m.name)}</option>`);
|
||||
}
|
||||
if (cur) sel.value = cur;
|
||||
|
||||
tb.querySelectorAll("[data-unload]").forEach(b =>
|
||||
b.addEventListener("click", () => unloadOne(b.getAttribute("data-unload")))
|
||||
);
|
||||
tb.querySelectorAll("[data-cfg]").forEach(b =>
|
||||
b.addEventListener("click", () => openConfig(b.getAttribute("data-cfg")))
|
||||
);
|
||||
}
|
||||
|
||||
function openConfig(alias) {
|
||||
const m = ALL_MODELS.find(x => x.name === alias);
|
||||
if (!m) return;
|
||||
$("#cfg-model-name").textContent = m.name;
|
||||
$("#cfg-ctx").value = m.meta?.ctx || 8192;
|
||||
$("#cfg-modal").style.display = "flex";
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
const alias = $("#cfg-model-name").textContent;
|
||||
const ctx = parseInt($("#cfg-ctx").value) || 8192;
|
||||
|
||||
$("#cfg-save").disabled = true;
|
||||
try {
|
||||
await api("/api/update_model", { method: "POST", body: JSON.stringify({ alias, ctx }) });
|
||||
toast("Gespeichert! Änderungen werden beim nächsten Modell-Start aktiv.");
|
||||
$("#cfg-modal").style.display = "none";
|
||||
refreshSoon();
|
||||
} catch (e) {
|
||||
toast(e.message, true);
|
||||
}
|
||||
$("#cfg-save").disabled = false;
|
||||
}
|
||||
|
||||
async function unloadOne(m) {
|
||||
try {
|
||||
await api("/api/unload?model=" + encodeURIComponent(m), { method: "POST" });
|
||||
toast("Entladen: " + m);
|
||||
setTimeout(refreshSoon, 600);
|
||||
} catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
async function sendChat() {
|
||||
const model = $("#chat-model").value, message = $("#chat-msg").value.trim();
|
||||
if (!model) return toast("Kein Modell vorhanden.", true);
|
||||
if (!message) return;
|
||||
const btn = $("#chat-btn"); btn.disabled = true; btn.textContent = "…";
|
||||
const box = $("#chat-reply"); box.style.display = "block";
|
||||
box.textContent = "(wecke Modell, kann beim Swap kurz dauern…)";
|
||||
try {
|
||||
const r = await api("/api/chat", { method: "POST", body: JSON.stringify({ model, message }) });
|
||||
box.textContent = r.reply;
|
||||
} catch (e) { box.textContent = "Fehler: " + e.message; }
|
||||
btn.disabled = false; btn.textContent = "Senden";
|
||||
refreshSoon();
|
||||
}
|
||||
|
||||
export default { id: "models", mount, onStatus };
|
||||
@@ -1,184 +0,0 @@
|
||||
// overview.js — Dashboard: Quick Actions, Modelle & Recent Jobs
|
||||
|
||||
import { api } from "../core/api.js";
|
||||
import { $, esc, icon, toast } from "../core/ui.js";
|
||||
|
||||
let S = null; // letzter Status
|
||||
let J = []; // letzte Job-Liste
|
||||
let SYS = null;
|
||||
|
||||
const RUNNING = new Set(["running", "ready", "loading", "starting"]);
|
||||
|
||||
function counts() {
|
||||
const models = S?.models || [];
|
||||
return {
|
||||
total: models.length,
|
||||
running: models.filter(m => RUNNING.has(m.state)).length,
|
||||
};
|
||||
}
|
||||
|
||||
function renderHero() {
|
||||
$("#hero").innerHTML = `<div class="hero">
|
||||
<div>
|
||||
<div class="eyebrow">Dashboard</div>
|
||||
<h1>Mission Control</h1>
|
||||
<p>Steuerzentrale für deinen lokalen llama-swap-Stack. Hier verwaltest du Modelle, Downloads und Server-Wartung.</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function triggerAction(action) {
|
||||
if (action === "restart_llama") {
|
||||
toast("Neustart ausgelöst...");
|
||||
try {
|
||||
await api("/api/service/llama-swap/restart", { method: "POST" });
|
||||
toast("llama-swap wird neugestartet.");
|
||||
} catch(e) {
|
||||
toast("Fehler: " + e.message, true);
|
||||
}
|
||||
} else if (action === "update_mc") {
|
||||
toast("Update gestartet! Siehe Aktivitäten.");
|
||||
try {
|
||||
await api("/api/update", { method: "POST" });
|
||||
document.querySelector('.nav-item[data-view="activity"]').click();
|
||||
} catch(e) {
|
||||
toast("Fehler: " + e.message, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global hook für onclick
|
||||
window.triggerAction = triggerAction;
|
||||
|
||||
function renderQuickActions() {
|
||||
let actionsHtml = "";
|
||||
|
||||
if (SYS) {
|
||||
const ram_percent = SYS.ram.percent || 0;
|
||||
// Wenn RAM über 90% ist, zeige Warnung.
|
||||
// Wir nehmen 90 für Produktion, aber für den Test könnte es angepasst werden.
|
||||
if (ram_percent >= 90) {
|
||||
actionsHtml += `
|
||||
<div class="card" style="background:var(--red-dim); border:1px solid var(--red); grid-column:1/-1;">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 style="color:var(--red); margin:0;">⚠️ Arbeitsspeicher kritisch (${ram_percent.toFixed(0)}%)</h3>
|
||||
<p style="color:var(--red); margin-top:4px;">Der RAM/VRAM ist fast voll. Dies kann zu Systeminstabilität führen.</p>
|
||||
</div>
|
||||
<button class="primary warn" onclick="window.triggerAction('restart_llama')">VRAM leeren (Neustart)</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Simulierter Update-Check (Idealerweise vom Backend, hier als permanenter Button wenn man manuell checken will,
|
||||
// oder wir blenden ihn ein wenn ein lokales flag gesetzt ist. Wir zeigen ihn hier als Feature-Highlight)
|
||||
// Da wir aktuell keinen echten Git-Check im Backend haben, zeigen wir einen "Update Prüfen" Button in den QuickActions.
|
||||
}
|
||||
|
||||
// 3 Standard Kacheln (Cookbook, Server-Status, Aktivität/Guides)
|
||||
$("#ov-quick").innerHTML = actionsHtml + `
|
||||
<button class="card-btn" onclick="document.querySelector('.nav-item[data-view=\\'cookbook\\']').click()">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3>Modell finden</h3>
|
||||
<span class="text-act">${icon("book")}</span>
|
||||
</div>
|
||||
<p>Durchsuche HuggingFace nach neuen Modellen im Cookbook.</p>
|
||||
</button>
|
||||
|
||||
<button class="card-btn" onclick="window.triggerAction('update_mc')">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3>Container Updates</h3>
|
||||
<span class="text-act">${icon("download")}</span>
|
||||
</div>
|
||||
<p>Prüfe auf Updates für Mission-Control und Llama.cpp.</p>
|
||||
</button>
|
||||
|
||||
<button class="card-btn" onclick="document.querySelector('.nav-item[data-view=\\'server\\']').click()">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3>Wartung</h3>
|
||||
<span class="text-act">${icon("server")}</span>
|
||||
</div>
|
||||
<p>Server neustarten, VRAM leeren oder OS aktualisieren.</p>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function modelRow(m) {
|
||||
const on = RUNNING.has(m.state);
|
||||
const dot = m.state === "loading" || m.state === "starting" ? "load" : (on || m.state === "bereit") ? "on" : "";
|
||||
const state = on ? (m.state === "loading" ? "lädt…" : "geladen") : "bereit";
|
||||
|
||||
let caps = "";
|
||||
if (m.meta && m.meta.caps) {
|
||||
caps = m.meta.caps.map(c => {
|
||||
if (c === "Code") return `<span title="Code" style="color:var(--blue);font-size:0.9em;margin-left:6px">{ }</span>`;
|
||||
if (c === "Bild") return `<span title="Vision" style="color:var(--purple);font-size:0.9em;margin-left:6px">👁</span>`;
|
||||
return "";
|
||||
}).join("");
|
||||
}
|
||||
|
||||
const filename = m.meta?.filename ? `<div class="li-sub" style="font-family:var(--mono); color:var(--mut);">${esc(m.meta.filename)}</div>` : '';
|
||||
|
||||
return `<div class="li">
|
||||
<span class="li-dot ${dot}"></span>
|
||||
<div class="li-main">
|
||||
<div class="li-id" style="font-weight:500">${esc(m.name)}${caps}</div>
|
||||
${filename}
|
||||
</div>
|
||||
<div class="li-right">
|
||||
<div class="li-time">${state}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderModels() {
|
||||
const models = S?.models || [];
|
||||
$("#ov-models").innerHTML = `
|
||||
<div class="card-h"><h3>Aktuelle Modelle im Stack</h3><span class="meta">${models.length || ""}</span></div>
|
||||
${models.length
|
||||
? `<div class="list">${models.map(modelRow).join("")}</div>`
|
||||
: `<div class="empty-c"><div class="e-t">Keine Modelle konfiguriert</div>
|
||||
<div class="e-s">Hol dir unter „Cookbook“ eins von HuggingFace.</div></div>`}`;
|
||||
}
|
||||
|
||||
function renderRecentJobs() {
|
||||
const latest = J.slice(0, 4);
|
||||
|
||||
const statusBadge = (s) => {
|
||||
if (s === "done") return '<span class="badge b-run" style="font-size:10px">fertig</span>';
|
||||
if (s === "failed") return '<span class="badge b-err" style="font-size:10px">fehler</span>';
|
||||
return '<span class="badge b-load" style="font-size:10px">lädt…</span>';
|
||||
};
|
||||
|
||||
$("#ov-recent-jobs").innerHTML = `
|
||||
<div class="card-h"><h3>Letzte Aktivitäten</h3><span class="meta" style="cursor:pointer" onclick="document.querySelector('.nav-item[data-view=\\'activity\\']').click()">Alle ansehen →</span></div>
|
||||
${latest.length
|
||||
? `<div class="list">
|
||||
${latest.map(j => `
|
||||
<div class="li">
|
||||
<div class="li-main">
|
||||
<div class="li-id text-sm">${esc(j.label)}</div>
|
||||
</div>
|
||||
<div class="li-right">${statusBadge(j.state)}</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>`
|
||||
: `<div class="empty-c"><div class="e-t">Keine Aktivitäten</div><div class="e-s">Alles läuft ruhig.</div></div>`
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
renderHero();
|
||||
renderQuickActions();
|
||||
renderModels();
|
||||
renderRecentJobs();
|
||||
}
|
||||
|
||||
function mount() { renderAll(); }
|
||||
function onStatus(s) { S = s; renderModels(); }
|
||||
function onJobs(jobs) { J = jobs || []; renderRecentJobs(); }
|
||||
function onSystem(sys) { SYS = sys; }
|
||||
|
||||
export default { id: "overview", mount, onStatus, onJobs, onSystem };
|
||||
@@ -1,120 +0,0 @@
|
||||
import { api, getToken } from "../core/api.js";
|
||||
import { $, toast } from "../core/ui.js";
|
||||
import { track } from "./jobs.js";
|
||||
|
||||
function refreshSoon() { document.dispatchEvent(new Event("mc:refresh")); }
|
||||
|
||||
function mount() {
|
||||
$("#wartung").innerHTML = `
|
||||
<div class="card-h"><h3>Dienste & Applikation</h3></div>
|
||||
<div class="btn-row">
|
||||
<button id="w-restart-swap" title="Startet die LLM-Engine neu. Dauer ca. 5 Sekunden.">llama-swap neustarten</button>
|
||||
<button id="w-restart-mc" title="Startet Mission Control (das Dashboard) neu.">Mission Control neustarten</button>
|
||||
<button id="w-update" title="Zieht den neuesten Code via Git und aktualisiert das Dashboard.">Mission Control System-Update</button>
|
||||
<button id="w-unload" class="ghost" title="Wirft alle derzeit in VRAM geladenen Modelle sofort raus.">Aus dem VRAM entfernen</button>
|
||||
</div>
|
||||
<div class="hint" style="margin-top:12px; margin-bottom:32px">
|
||||
Dienste starten via passwortlosem Sudo neu.
|
||||
</div>
|
||||
|
||||
<div class="card-h"><h3>Betriebssystem (Bosgame)</h3></div>
|
||||
<div class="btn-row">
|
||||
<button id="w-os-update" title="Führt apt update & upgrade aus, um das Betriebssystem (Bosgame) zu aktualisieren.">OS-Updates installieren (apt update)</button>
|
||||
<button id="w-reboot" class="danger" title="Startet den gesamten Server physikalisch neu. Alles ist kurz offline.">Server Reboot</button>
|
||||
</div>
|
||||
<div class="hint" style="margin-top:12px; margin-bottom:32px">
|
||||
Für tiefe Eingriffe fragt das Dashboard einmalig das sudo-Passwort ab.
|
||||
</div>
|
||||
|
||||
<div class="card-h">
|
||||
<h3>Live-Konsole</h3>
|
||||
<select id="w-console-sel" style="margin-left:auto; width:200px">
|
||||
<option value="llama-swap">llama-swap</option>
|
||||
<option value="mission-control">mission-control</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="w-console" style="background:#111; color:#0f0; font-family:monospace; font-size:12px; padding:12px; border-radius:8px; height:400px; overflow-y:auto; white-space:pre-wrap;">
|
||||
Verbinde...
|
||||
</div>`;
|
||||
|
||||
$("#w-restart-swap").addEventListener("click", () => restartService("llama-swap"));
|
||||
$("#w-restart-mc").addEventListener("click", () => restartService("mission-control"));
|
||||
$("#w-update").addEventListener("click", update);
|
||||
$("#w-unload").addEventListener("click", unloadAll);
|
||||
|
||||
$("#w-os-update").addEventListener("click", osUpdate);
|
||||
$("#w-reboot").addEventListener("click", rebootServer);
|
||||
|
||||
$("#w-console-sel").addEventListener("change", () => connectConsole());
|
||||
connectConsole();
|
||||
}
|
||||
|
||||
let ws = null;
|
||||
|
||||
function connectConsole() {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
const svc = $("#w-console-sel").value;
|
||||
const out = $("#w-console");
|
||||
out.innerHTML = "Verbinde mit " + svc + "...\n";
|
||||
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const url = `${proto}//${location.host}/api/logs/${svc}?token=${getToken()}`;
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
out.innerHTML += e.data;
|
||||
out.scrollTop = out.scrollHeight;
|
||||
};
|
||||
ws.onclose = () => {
|
||||
out.innerHTML += "\n--- Verbindung getrennt ---";
|
||||
};
|
||||
}
|
||||
|
||||
async function restartService(name) {
|
||||
try {
|
||||
await api("/api/service/" + name + "/restart", { method: "POST" });
|
||||
toast("Neustart ausgelöst: " + name);
|
||||
setTimeout(refreshSoon, 2000);
|
||||
} catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
async function osUpdate() {
|
||||
const pwd = window.prompt("Bitte sudo Passwort eingeben (für apt-get update & upgrade):");
|
||||
if (!pwd) return;
|
||||
try {
|
||||
const r = await api("/api/os-update", { method: "POST", body: JSON.stringify({ password: pwd }) });
|
||||
toast("OS-Update gestartet.");
|
||||
track(r.job_id);
|
||||
} catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
async function rebootServer() {
|
||||
if (!window.confirm("ACHTUNG: Server wird komplett neu gestartet. Fortfahren?")) return;
|
||||
const pwd = window.prompt("Bitte sudo Passwort eingeben (für reboot):");
|
||||
if (!pwd) return;
|
||||
try {
|
||||
await api("/api/reboot", { method: "POST", body: JSON.stringify({ password: pwd }) });
|
||||
toast("Reboot ausgelöst. UI ist gleich offline.");
|
||||
} catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
async function update() {
|
||||
try {
|
||||
const r = await api("/api/update", { method: "POST" });
|
||||
toast("Update läuft.");
|
||||
track(r.job_id);
|
||||
} catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
async function unloadAll() {
|
||||
try {
|
||||
await api("/api/unload", { method: "POST" });
|
||||
toast("Alle Modelle entladen.");
|
||||
setTimeout(refreshSoon, 600);
|
||||
} catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
export default { id: "server", mount };
|
||||
Reference in New Issue
Block a user