Compare commits
87 Commits
1aea0f558e
..
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 |
@@ -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__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.claude/launch.json
|
.claude/launch.json
|
||||||
|
frontend/node_modules/
|
||||||
|
|||||||
@@ -1,85 +1,142 @@
|
|||||||
# Mission Control
|
# Mission Control
|
||||||
|
|
||||||
Web-Dashboard zur Verwaltung eines **lokalen LLM-Stacks (`llama-swap`)** auf dem Bosgame M5.
|
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
|
## Architektur
|
||||||
|
|
||||||
**Backend** (Top-Level-Helfer + ein Router je Bereich):
|
**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.
|
- **`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.
|
- **`config.py`** — alle Env-Vars + die gemeinsame `ruamel.yaml`-Instanz. Auch `SOURCE_DIR`/`PROD_DIR` fürs Self-Update.
|
||||||
- **`auth.py`** — optionale Token-Auth (`X-MC-Token`).
|
- **`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; fährt Downloads/Updates.
|
- **`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 (`/running`, `/v1/models`, unload) und liest/schreibt dessen `config.yaml` per `ruamel.yaml` (Kommentare bleiben erhalten).
|
- **`llamaswap.py`** — spricht `llama-swap` an und liest/schreibt dessen `config.yaml` per `ruamel.yaml`.
|
||||||
- **`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/*`.
|
- **`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):
|
**Frontend** (`frontend/src/`, Svelte 5 + Vite, Build → `static/dist/`):
|
||||||
- **`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.
|
- **`index.html`** — Gerüst: Sidebar-Nav (10 Tabs), Topbar, Alert-Banner, View-Container je Bereich.
|
||||||
- **`css/base.css`** — Design-Tokens (`:root`), Reset, App-Layout (Sidebar/Topbar/Content). **`css/components.css`** — Karten, KPI-Kacheln, Listen, Forms, Log, Toast.
|
- **`frontend/src/main.ts`** — bootet alle Svelte-Panels, Topbar/Alert, WebSocket Live-Metriken, Polling.
|
||||||
- **`js/core/*`** — `api.js` (Fetch + Token), `ui.js` (DOM-Helfer, Toast, Icons), `nav.js` (View-Switch).
|
- **`frontend/src/panels/`** — je ein Panel: Overview, Models, Jobs(Aktivität), Server, Cookbook, Connect(Verbinden), News, Guides, Memory(Gedächtnis), Hermes.
|
||||||
- **`js/panels/*`** — ein Panel je Bereich (`overview`, `models`, `maintenance`, `jobs`). Panel-Vertrag: `{ id, mount?(), onStatus?(s), onJobs?(jobs) }`.
|
- **`frontend/src/stores/`** — Svelte 5 Runes: `status.svelte.ts`, `jobs.svelte.ts`, `system.svelte.ts`.
|
||||||
- **`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.
|
- **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).
|
## Der Stack drumherum
|
||||||
- **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 (Kontext)
|
- **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`).
|
||||||
- **Bosgame M5**: AMD Strix Halo (gfx1151), Ubuntu 26.04, Kernel 7.0, ~124 GB GTT-Speicher. LAN-IP `192.168.178.151`.
|
- **3 Modelle / 5 Rollen**: `coder` (Qwen3-30B-A3B), `scout` (Qwen3-8B), `vision` (Qwen3-VL).
|
||||||
- **Inferenz**: vorgebaute llama.cpp-ROCm-Binaries → `llama-swap` auf `:8080` (läuft mit `-watch-config`).
|
- **Mission Control**: Produktiv unter `/opt/mission-control`, Source unter `~/mission-control`.
|
||||||
- **3 Modelle / 5 Rollen**: `coder`, `scout`, `vision` (Qwen3-Familie). Modelle werden geswappt, nicht parallel geladen.
|
- **Gedächtnis**: SQLite unter `/srv/models/mission-control-memory.db` (5 Kategorien: user/instruction/stable/versioned/ephemeral).
|
||||||
- **Mission Control**: Produktiv unter `/opt/mission-control` (als Dienst), Source-Repo unter `~/mission-control`.
|
- **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
|
## Entwickeln & Deployen
|
||||||
|
|
||||||
**Wo entwickelt wird:** primär auf einem **Windows-PC** (Repo z. B. `F:\Coding Stuff\mission-control`),
|
**Wo entwickelt wird:** Windows-PC (`F:\Coding Stuff\mission-control`), Ziel: Linux-Bosgame.
|
||||||
Zielsystem ist der **Linux-Bosgame**. Lokal nur Smoke-Test (rendert/bootet sauber?), echter
|
Lokal nur Smoke-Test (rendert/bootet?), echter Funktionstest auf dem Bosgame.
|
||||||
Funktionstest nur auf dem Bosgame (dort läuft llama-swap).
|
|
||||||
|
|
||||||
**Lokaler Smoke-Test (Windows):**
|
**Lokaler Smoke-Test (Windows):**
|
||||||
```bash
|
```bash
|
||||||
python -m venv .venv && .venv/Scripts/python -m pip install -r requirements.txt
|
python -m venv .venv && .venv/Scripts/python -m pip install -r requirements.txt
|
||||||
.venv/Scripts/python -m uvicorn app:app --port 9001
|
.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
|
```bash
|
||||||
ssh -i ~/.ssh/id_ed25519_hermes -o IdentitiesOnly=yes hitonabi@192.168.178.151
|
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):**
|
**Frontend-Build:**
|
||||||
1. Lokal bauen + Smoke-Test → `git push` (origin = Gitea `http://192.168.178.153:3000/Hitonabi/mission-control.git`).
|
```bash
|
||||||
2. Bosgame: `cd ~/mission-control && git pull --ff-only` (Source-Repo).
|
cd frontend && npm run build # vor jedem git push
|
||||||
3. Nach `/opt` ausrollen — **ohne sudo** (`hitonabi` besitzt `/opt/mission-control`), `.venv` ausnehmen:
|
```
|
||||||
```bash
|
Output: `static/dist/main.js` + `static/dist/main.css` — wird committet (kein Build-Schritt auf dem Server).
|
||||||
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`.
|
|
||||||
|
|
||||||
Statische Dateien werden je Request frisch von Platte gelesen → UI-Änderungen wirken schon nach
|
**Deploy-Kette:**
|
||||||
rsync; **Python-Code-Änderungen brauchen den Restart**.
|
```
|
||||||
|
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
|
## Konventionen
|
||||||
|
|
||||||
- KISS über alles: kein schweres Framework, kein Build-Schritt, solange es ohne geht.
|
- **Neuer Bereich**: `routers/<bereich>.py` + `frontend/src/panels/<Bereich>Panel.svelte` + Nav/View in `index.html` + Import in `main.ts`. Danach `npm run build`.
|
||||||
- **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.
|
- **Backend SoC**: gemeinsame Logik in `config/auth/jobengine/llamaswap/hw_math`. Alles unter `/api/*`.
|
||||||
- Endpoint-URLs bleiben unter `/api/*`; neue Bereiche degradieren sauber, wenn ihre Quelle (sysfs, `amd-smi`, `systemctl`) fehlt (z. B. beim Entwickeln auf Windows).
|
- **Sicherheit**: Shell-Befehle → nur LAN. Secrets via stdin. Token optional.
|
||||||
- Funktion darf **nicht** von `localStorage` abhängen (nur das Token-Feld nutzt es, das ist ok).
|
- **SQLite**: WAL-Mode aktiv, `check_same_thread=False` — ausreichend für Single-User.
|
||||||
- **Sicherheit**: Das Backend führt Shell-Befehle aus → ausschließlich im vertrauenswürdigen LAN betreiben, niemals offen ins Internet.
|
- **`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`).
|
- **`${PORT}`** in llama-swap-Config → `str.replace` statt `.format` (KeyError sonst).
|
||||||
- `llama-swap` muss mit **`-watch-config`** laufen, sonst greift das Auto-Einpflegen neuer Modelle nicht.
|
- **`-watch-config`** bei llama-swap nötig für Auto-Einpflegen.
|
||||||
- HuggingFace-Downloads mit **`HF_HUB_DISABLE_XET=1`** (sonst reproduzierbarer Hänger bei ~6 MB).
|
- **`HF_HUB_DISABLE_XET=1`** bei HuggingFace-Downloads (Hänger bei ~6 MB).
|
||||||
- Vision-Modelle in llama.cpp brauchen zusätzlich **`--mmproj <projektor>` und `--jinja`**.
|
- **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`).
|
**v7 (Memory Layer)**: SQLite-Gedächtnis mit 5 Kategorien + MCP-Server für alle Tools live.
|
||||||
Wir befinden uns nun im **Feinschliff- und Wartungsmodus**.
|
**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
|
# Mission Control
|
||||||
|
|
||||||
Eine schlanke Steuerzentrale für deinen lokalen `llama-swap`-Stack auf dem Bosgame M5.
|
Persönliche Steuerzentrale für einen lokalen LLM-Stack auf dem Bosgame M5.
|
||||||
Ein FastAPI-Backend + ein HTML-Dashboard. Kein Build-Schritt, keine Datenbank.
|
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`
|
**LLM-Stack verwalten**
|
||||||
- **Modell holen** — lädt eine GGUF-Datei von HuggingFace (`hf download`) als Hintergrund-Job mit Live-Log
|
- Modelle & Status sehen (llama-swap `/running`, `/v1/models`, `config.yaml`)
|
||||||
- **Einpflegen** — schreibt das Modell automatisch in deine `config.yaml`; `llama-swap` lädt mit `-watch-config` neu
|
- Modelle von HuggingFace laden — als Hintergrund-Job mit Live-Log
|
||||||
- **Wartung** — Container/Toolbox aktualisieren, Modelle aus dem Speicher werfen
|
- Automatisch in llama-swap einpflegen (`-watch-config`)
|
||||||
- **Schnelltest** — Chat-Box, um ein Modell zu wecken und zu prüfen
|
- Optimale Kontextfenster berechnen (hardware-aware, `hw_math`)
|
||||||
|
|
||||||
Was sie **bewusst nicht** macht: Chat-Logs, Inferenz-Monitoring im Detail — dafür hat `llama-swap`
|
**Cookbook**
|
||||||
schon `/ui` und `/log`. Mission Control ergänzt nur, was fehlt.
|
- 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
|
## Voraussetzungen
|
||||||
|
|
||||||
- Python 3.11+
|
- Python 3.11+
|
||||||
- `hf` CLI installiert (`pip install -U "huggingface_hub[cli]"`)
|
- `llama-swap` laufend mit `-watch-config`
|
||||||
- ein laufendes `llama-swap` — gestartet **mit `-watch-config`**, sonst greift das Auto-Einpflegen nicht
|
- `hf` CLI (`pip install -U "huggingface_hub[cli]"`)
|
||||||
|
- Für Voice: `faster-whisper` + Piper Binary (Anleitung im Hermes-Tab)
|
||||||
|
|
||||||
## Installation
|
## Schnellstart (Bosgame)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /opt/mission-control && sudo chown $USER /opt/mission-control
|
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
|
cd /opt/mission-control
|
||||||
python3 -m venv .venv && . .venv/bin/activate
|
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||||
pip install -r requirements.txt
|
|
||||||
uvicorn app:app --host 0.0.0.0 --port 9000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Dann im Browser im LAN: `http://<bosgame-ip>:9000`
|
Als Dienst:
|
||||||
|
|
||||||
### Als Dienst (Autostart)
|
|
||||||
|
|
||||||
Pfade in `mission-control.service` anpassen, dann:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo cp mission-control.service /etc/systemd/system/
|
sudo cp mission-control.service /etc/systemd/system/
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload && sudo systemctl enable --now mission-control
|
||||||
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 |
|
| Variable | Default | Zweck |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | wo `llama-swap` lauscht |
|
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | llama-swap URL |
|
||||||
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | die `llama-swap`-Config |
|
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap Config |
|
||||||
| `MC_MODELS_DIR` | `/srv/models` | wohin GGUFs geladen werden |
|
| `MC_MODELS_DIR` | `/srv/models` | GGUF-Ablageort |
|
||||||
| `MC_CMD_TEMPLATE` | siehe unten | Startbefehl pro Modell |
|
| `MC_TOKEN` | leer | Auth-Token (optional) |
|
||||||
| `MC_UPDATE_CMD` | _(leer)_ | Befehl für „Container aktualisieren" |
|
| `MC_UPDATE_CMD` | leer | Engine-Update-Befehl |
|
||||||
| `MC_DEFAULT_TTL` | `300` | Sekunden bis Auto-Unload |
|
| `MC_SOURCE_DIR` | `~/mission-control` | Self-Update Quelle |
|
||||||
| `MC_TOKEN` | _(leer)_ | optionales Zugriffs-Token |
|
| `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}`
|
- Ausschließlich im vertrauenswürdigen LAN betreiben
|
||||||
werden von Mission Control ersetzt, **`${PORT}` bleibt stehen** (das ersetzt `llama-swap` selbst).
|
- `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`).
|
**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.
|
||||||
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**.
|
|
||||||
|
|
||||||
**✅ 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:
|
**Verifizierte Kompatibilität (Primärquelle):**
|
||||||
`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`.
|
- 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.
|
**Phasen:**
|
||||||
Bauen + Smoke-Test auf Windows, dann push→pull→rsync→restart auf den Bosgame (CLAUDE.md „Entwickeln & Deployen").
|
- [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`.
|
||||||
Ein Commit je Schritt. **Ich (KI) habe key-basierten SSH-Zugang zum Bosgame und kann selbst deployen+restarten.**
|
- [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:**
|
**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.
|
||||||
- **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.
|
|
||||||
|
|
||||||
**→ Aktueller Modus = Wartung & Feinschliff.**
|
**Risiken:** sehr junges Projekt (hohes Release-Tempo) → Version pinnen · zwei Memory-Schichten (eigene via MCP + Hermes-Session-Recall) im Blick behalten.
|
||||||
- Die v2 Roadmap ist damit zu 100% umgesetzt.
|
|
||||||
- Fokus liegt ab sofort auf Stabilität, Bug-Hunting und dem finalen "Polishing".
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## v8.3 — Memory Import (✅ UMGESETZT & live, Stand 2026-06-23)
|
||||||
|
|
||||||
### 1. Server-Management ("Update-Panel 2.0") (✅ Erledigt)
|
- **MemoryPanel**: Import-Button öffnet Formular — Text einfügen, jede Zeile wird ein eigener Eintrag (Batch-POST).
|
||||||
Aktuell gibt es nur "Container aktualisieren" + "Alles aus dem Speicher". Ziel: den kompletten Server aus der UI verwalten.
|
- Quelle wählbar: `claude` · `gemini` · `chatgpt` · `import` · `manual` — Kategorie frei wählbar.
|
||||||
- [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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tech-Leitplanken
|
## v8.2 — UX & Mobile (✅ UMGESETZT & live, Stand 2026-06-23)
|
||||||
|
|
||||||
- **KISS beibehalten** — kein schweres Framework, solange es ohne geht.
|
- **GuidesPanel**: "Begriffe kurz erklärt" jetzt ganz oben; Tab-Navigation mit Active-Highlighting + Scroll-to-Group.
|
||||||
- **Sicherheit zuerst** bei allem mit Shell-/SSH-Zugriff: LAN-only, Auth, minimale Rechte.
|
- **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.
|
||||||
- Backend-Logik in `app.py`, UI in `static/index.html` — Trennung sauber halten.
|
- **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,10 +17,10 @@ Neue Bereiche kommen als routers/<bereich>.py + static/js/panels/<bereich>.js da
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
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 = FastAPI(title="Mission Control")
|
||||||
|
|
||||||
@@ -41,6 +41,11 @@ app.include_router(jobs.router)
|
|||||||
app.include_router(maintenance.router)
|
app.include_router(maintenance.router)
|
||||||
app.include_router(system.router)
|
app.include_router(system.router)
|
||||||
app.include_router(cookbook.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"
|
_STATIC = Path(__file__).parent / "static"
|
||||||
|
|
||||||
@@ -50,9 +55,29 @@ def index():
|
|||||||
return FileResponse(_STATIC / "index.html")
|
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.mount("/static", StaticFiles(directory=_STATIC), name="static")
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(HTTPException)
|
@app.exception_handler(HTTPException)
|
||||||
def _http_exc(_req, exc: HTTPException):
|
def _http_exc(_req, exc: HTTPException):
|
||||||
return JSONResponse(status_code=exc.status_code, content={"error": exc.detail})
|
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 os
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ruamel.yaml import YAML
|
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("/")
|
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"))
|
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"))
|
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.
|
# 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.
|
# {model} = Pfad zur GGUF-Datei, {ctx} = Kontextlaenge, ${PORT} bleibt fuer llama-swap stehen.
|
||||||
# WICHTIG: an deinen Container-/llama-server-Aufruf anpassen (siehe README).
|
# WICHTIG: an deinen Container-/llama-server-Aufruf anpassen (siehe README).
|
||||||
CMD_TEMPLATE = os.environ.get(
|
_DEFAULT_CMD_TEMPLATE = (
|
||||||
"MC_CMD_TEMPLATE",
|
|
||||||
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
|
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
|
||||||
"-c {ctx} -ngl 999 -fa 1 --no-mmap",
|
"-c {ctx} -ngl 999 -fa 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.
|
# Befehl fuer "Container/Toolbox aktualisieren". Standard: kyuz0 refresh-Skript.
|
||||||
UPDATE_CMD = os.environ.get("MC_UPDATE_CMD", "")
|
UPDATE_CMD = os.environ.get("MC_UPDATE_CMD", "")
|
||||||
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
|
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
|
||||||
TOKEN = os.environ.get("MC_TOKEN", "") # leer = keine Auth (nur LAN!)
|
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).
|
# Gemeinsame YAML-Instanz (preserve_quotes haelt Kommentare/Quotes in config.yaml).
|
||||||
yaml = YAML()
|
yaml = YAML()
|
||||||
yaml.preserve_quotes = True
|
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).
|
Abgestimmt auf APUs mit Unified Memory (Bosgame M5 / Strix Halo).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
# Annahme: Bytes per Parameter für GGUF Quants
|
# Annahme: Bytes per Parameter für GGUF Quants
|
||||||
QUANT_BYTES_PER_PARAM = {
|
QUANT_BYTES_PER_PARAM = {
|
||||||
"Q2_K": 0.35,
|
"Q2_K": 0.35,
|
||||||
@@ -34,21 +36,36 @@ def estimate_memory_gb(params_b: float, quant: str, ctx: int) -> float:
|
|||||||
|
|
||||||
return weights + context_vram
|
return weights + context_vram
|
||||||
|
|
||||||
def estimate_speed(req_gb: float, sys_ram_gb: float) -> float:
|
def extract_active_params_b(name: str) -> float | None:
|
||||||
"""Berechnet die geschätzte Tokens/s basierend auf der 273 GB/s Bandbreite der APU."""
|
"""Aktive Parameter bei MoE-Modellen aus dem Namen (z.B. '30B-A3B' → 3.0).
|
||||||
# Strix Halo hat ca 273 GB/s Unified Memory Bandbreite.
|
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
|
bw = 273 if sys_ram_gb > 8 else 70
|
||||||
if req_gb <= 0:
|
if req_gb <= 0:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
# (Bandbreite / Modellgröße) * Effizienz (0.55)
|
|
||||||
raw_tps = (bw / req_gb) * 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
|
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)
|
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.
|
# Das OS und andere Prozesse brauchen RAM. Wir lassen 4GB Puffer.
|
||||||
usable_ram = max(sys_ram_gb - 4.0, 0)
|
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),
|
"req_gb": round(req_gb, 1),
|
||||||
"tps": round(tps, 0)
|
"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, ...).
|
Routern, die laenger laufende Shell-Befehle anstossen (Download, Update, ...).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import glob
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -14,45 +15,193 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
JOBS: dict[str, dict] = {}
|
JOBS: dict[str, dict] = {}
|
||||||
|
_PROCS: dict[str, subprocess.Popen] = {} # laufende Prozesse je Job-ID (fuer Abbruch)
|
||||||
_LOG_CAP = 400
|
_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 = JOBS[job_id]
|
||||||
job["state"] = "running"
|
job["state"] = "running"
|
||||||
try:
|
try:
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
args,
|
args,
|
||||||
|
stdin=subprocess.PIPE if stdin_data is not None else None,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
bufsize=0, # binaer + ungepuffert -> Fortschritt (\r) kommt live an
|
||||||
bufsize=1,
|
|
||||||
env={**os.environ, **(env or {})},
|
env={**os.environ, **(env or {})},
|
||||||
)
|
)
|
||||||
for line in proc.stdout: # type: ignore[union-attr]
|
_PROCS[job_id] = proc
|
||||||
job["log"].append(line.rstrip("\n"))
|
if stdin_data is not None:
|
||||||
if len(job["log"]) > _LOG_CAP:
|
# Secret (z.B. sudo-Passwort) ueber stdin fuettern — landet NICHT im Log/ps.
|
||||||
del job["log"][0]
|
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()
|
proc.wait()
|
||||||
job["returncode"] = proc.returncode
|
if job.get("canceled"):
|
||||||
job["state"] = "done" if proc.returncode == 0 else "failed"
|
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
|
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["state"] = "failed"
|
||||||
job["returncode"] = -1
|
job["returncode"] = -1
|
||||||
|
finally:
|
||||||
|
_PROCS.pop(job_id, None)
|
||||||
job["finished_at"] = time.time()
|
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]
|
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] = {
|
JOBS[job_id] = {
|
||||||
"id": job_id,
|
"id": job_id,
|
||||||
"label": label,
|
"label": label,
|
||||||
"state": "queued",
|
"state": "queued",
|
||||||
"log": [f"$ {' '.join(shlex.quote(a) for a in args)}"],
|
"log": [first_line],
|
||||||
"returncode": None,
|
"returncode": None,
|
||||||
"started_at": time.time(),
|
"started_at": time.time(),
|
||||||
"finished_at": None,
|
"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
|
return job_id
|
||||||
|
|||||||
+57
-3
@@ -6,10 +6,53 @@ Helfer rund um llama-swap und dessen config.yaml.
|
|||||||
sodass Kommentare und Quotes erhalten bleiben.
|
sodass Kommentare und Quotes erhalten bleiben.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from config import CONFIG_PATH, LLAMA_SWAP_URL, yaml
|
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):
|
def _swap_get(path: str):
|
||||||
with httpx.Client(timeout=5.0) as c:
|
with httpx.Client(timeout=5.0) as c:
|
||||||
@@ -29,6 +72,17 @@ def read_config() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def write_config(cfg: dict) -> None:
|
def write_config(cfg: dict) -> None:
|
||||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
"""Schreibt die config.yaml atomar (tmp-Datei + os.replace), damit llama-swap
|
||||||
with CONFIG_PATH.open("w", encoding="utf-8") as f:
|
mit -watch-config nie eine halb geschriebene Datei sieht. Fehlende Schreibrechte
|
||||||
yaml.dump(cfg, f)
|
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_MODELS_DIR=/srv/models
|
||||||
Environment=MC_DEFAULT_TTL=300
|
Environment=MC_DEFAULT_TTL=300
|
||||||
# Befehl zum Starten eines Modells (in config.yaml geschrieben). ${PORT} bleibt stehen!
|
# 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):
|
# Update-Befehl (z.B. kyuz0 refresh oder podman pull):
|
||||||
Environment=MC_UPDATE_CMD=/opt/amd-strix-halo-toolboxes/refresh-toolboxes.sh all
|
Environment=MC_UPDATE_CMD=/opt/amd-strix-halo-toolboxes/refresh-toolboxes.sh all
|
||||||
# Optionales Token fuer minimale Absicherung im LAN:
|
# 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
|
httpx>=0.27
|
||||||
ruamel.yaml>=0.18
|
ruamel.yaml>=0.18
|
||||||
psutil>=5.9.0
|
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 httpx
|
||||||
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
|
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||||
|
|
||||||
from auth import auth
|
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)])
|
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):
|
class AnalyzeRequest(BaseModel):
|
||||||
repo_id: str
|
repo_id: str
|
||||||
ctx: int = 8192
|
ctx: int = 8192
|
||||||
@@ -22,17 +127,33 @@ class EvaluateRequest(BaseModel):
|
|||||||
quant: str
|
quant: str
|
||||||
ctx: int
|
ctx: int
|
||||||
|
|
||||||
def extract_params_b(repo_id: str) -> float:
|
class InstallRecipeReq(BaseModel):
|
||||||
"""Extrahiert die Parametergröße (in Milliarden) aus dem Repo-Namen."""
|
recipe_id: str
|
||||||
# z.B. Qwen2.5-Coder-32B -> 32
|
hf_token: str | None = None
|
||||||
# 8x7B -> 56 (MoE)
|
|
||||||
moe = re.search(r"(\d+)x(\d+(?:\.\d+)?)[bB]", repo_id)
|
class InstallModelReq(BaseModel):
|
||||||
if moe:
|
repo: str
|
||||||
return float(moe.group(1)) * float(moe.group(2))
|
role: str
|
||||||
m = re.search(r"(\d+(?:\.\d+)?)[bB](?![a-zA-Z])", repo_id)
|
params_b: float
|
||||||
if m:
|
quant: str = "Q4_K_M"
|
||||||
return float(m.group(1))
|
file: str | None = None # konkrete GGUF-Datei; None → _pick_gguf wählt automatisch
|
||||||
return 7.0 # Fallback
|
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:
|
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)
|
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 = []
|
results = []
|
||||||
for f in gguf_files:
|
for f in gguf_files:
|
||||||
quant = extract_quant(f)
|
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.
|
# Priority-Score, um den besten Fit an oberste Stelle zu setzen.
|
||||||
# "Q4_K_M" ist oft der Sweetspot.
|
# "Q4_K_M" ist oft der Sweetspot.
|
||||||
@@ -79,6 +200,7 @@ async def analyze_repo(req: AnalyzeRequest):
|
|||||||
"filename": f,
|
"filename": f,
|
||||||
"quant": quant,
|
"quant": quant,
|
||||||
"fit": fit,
|
"fit": fit,
|
||||||
|
"optimal_ctx": max_ctx_for(params_b, quant, ram_gb),
|
||||||
"priority": priority
|
"priority": priority
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -96,5 +218,407 @@ async def analyze_repo(req: AnalyzeRequest):
|
|||||||
def evaluate_single(req: EvaluateRequest):
|
def evaluate_single(req: EvaluateRequest):
|
||||||
ram_gb = psutil.virtual_memory().total / (1024**3)
|
ram_gb = psutil.virtual_memory().total / (1024**3)
|
||||||
fit = evaluate_fit(req.params_b, req.quant, req.ctx, ram_gb)
|
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
|
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 fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from auth import auth
|
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)])
|
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
|
||||||
|
|
||||||
@@ -19,6 +19,26 @@ def job_status(job_id: str):
|
|||||||
return job
|
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")
|
@router.get("/jobs")
|
||||||
def jobs_list():
|
def jobs_list():
|
||||||
return sorted(JOBS.values(), key=lambda j: j["started_at"], reverse=True)[:20]
|
return sorted(JOBS.values(), key=lambda j: j["started_at"], reverse=True)[:20]
|
||||||
|
|||||||
+100
-9
@@ -6,6 +6,7 @@ laeuft als Hintergrund-Job mit Live-Log. Spaeter wandert hier ggf. mehr
|
|||||||
Server-Wartung hinein (siehe Roadmap: Server-Management).
|
Server-Wartung hinein (siehe Roadmap: Server-Management).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -14,7 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisco
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from auth import auth
|
from auth import auth
|
||||||
from config import UPDATE_CMD
|
from config import UPDATE_CMD, SOURCE_DIR, PROD_DIR
|
||||||
from jobengine import start_job
|
from jobengine import start_job
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
|
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
|
||||||
@@ -22,17 +23,47 @@ router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
|
|||||||
class PwdReq(BaseModel):
|
class PwdReq(BaseModel):
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
class UpdateReq(BaseModel):
|
||||||
|
password: str = ""
|
||||||
|
|
||||||
@router.post("/update")
|
@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:
|
if not UPDATE_CMD:
|
||||||
raise HTTPException(400, "Kein Update-Befehl gesetzt (MC_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}
|
return {"job_id": job_id}
|
||||||
|
|
||||||
@router.post("/os-update")
|
@router.post("/os-update")
|
||||||
def os_update(req: PwdReq):
|
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'"
|
# Passwort ueber stdin an sudo -S — NICHT als Klartext-Arg (sonst im Job-Log/ps sichtbar).
|
||||||
job_id = start_job(["bash", "-c", cmd], "os update")
|
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}
|
return {"job_id": job_id}
|
||||||
|
|
||||||
@router.post("/service/{name}/restart")
|
@router.post("/service/{name}/restart")
|
||||||
@@ -44,19 +75,79 @@ def service_restart(name: str):
|
|||||||
|
|
||||||
@router.post("/reboot")
|
@router.post("/reboot")
|
||||||
def reboot(req: PwdReq):
|
def reboot(req: PwdReq):
|
||||||
cmd = f"echo {shlex.quote(req.password)} | sudo -S reboot"
|
# Passwort ueber stdin (nicht in argv -> nicht in ps sichtbar).
|
||||||
subprocess.Popen(["bash", "-c", cmd])
|
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."}
|
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}")
|
@router.websocket("/logs/{service}")
|
||||||
async def stream_logs(websocket: WebSocket, service: str):
|
async def stream_logs(websocket: WebSocket, service: str):
|
||||||
await websocket.accept()
|
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)
|
await websocket.close(code=1008)
|
||||||
return
|
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(
|
process = await asyncio.create_subprocess_exec(
|
||||||
"sudo", "journalctl", "-u", service, "-n", "100", "-f",
|
*cmd,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=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}
|
||||||
+218
-23
@@ -13,11 +13,18 @@ from pydantic import BaseModel
|
|||||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||||
|
|
||||||
from auth import auth
|
from auth import auth
|
||||||
from config import CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, LLAMA_SWAP_URL, MODELS_DIR
|
from config import (CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, HF_DOWNLOAD_ENV, LLAMA_SWAP_URL,
|
||||||
from jobengine import JOBS, start_job
|
MODELS_DIR, TOKEN, hf_bin)
|
||||||
from llamaswap import _swap_get, read_config, write_config
|
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 re
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
import psutil
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
|
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
|
||||||
|
|
||||||
@@ -29,15 +36,22 @@ class DownloadReq(BaseModel):
|
|||||||
repo: str
|
repo: str
|
||||||
file: str
|
file: str
|
||||||
subdir: str | None = None
|
subdir: str | None = None
|
||||||
|
hf_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class RegisterReq(BaseModel):
|
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
|
model_path: str
|
||||||
ctx: int = 8192
|
ctx: int | None = None # None → optimal fuer die Hardware (max_ctx_for)
|
||||||
ttl: int | None = None
|
ttl: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RoleReq(BaseModel):
|
||||||
|
alias: str # die Modell-ID (config-Key)
|
||||||
|
role: str = "" # leer = Rolle entfernen
|
||||||
|
|
||||||
|
|
||||||
class ChatReq(BaseModel):
|
class ChatReq(BaseModel):
|
||||||
model: str
|
model: str
|
||||||
message: str
|
message: str
|
||||||
@@ -45,7 +59,13 @@ class ChatReq(BaseModel):
|
|||||||
|
|
||||||
class UpdateReq(BaseModel):
|
class UpdateReq(BaseModel):
|
||||||
alias: str
|
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")
|
@router.get("/status")
|
||||||
def status():
|
def status():
|
||||||
cfg = read_config()
|
cfg = read_config()
|
||||||
|
try:
|
||||||
|
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
ram_gb = 0
|
||||||
configured = {}
|
configured = {}
|
||||||
for name, spec in (cfg.get("models") or {}).items():
|
for name, spec in (cfg.get("models") or {}).items():
|
||||||
spec = spec or {}
|
spec = spec or {}
|
||||||
@@ -67,6 +91,7 @@ def status():
|
|||||||
size_bytes = None
|
size_bytes = None
|
||||||
quant = ""
|
quant = ""
|
||||||
filename = ""
|
filename = ""
|
||||||
|
path = ""
|
||||||
m_path = re.search(r'-(?:m|-model)\s+([^\s]+)', cmd)
|
m_path = re.search(r'-(?:m|-model)\s+([^\s]+)', cmd)
|
||||||
if m_path:
|
if m_path:
|
||||||
path = m_path.group(1).replace("'", "").replace('"', '')
|
path = m_path.group(1).replace("'", "").replace('"', '')
|
||||||
@@ -77,26 +102,63 @@ def status():
|
|||||||
if q_match:
|
if q_match:
|
||||||
quant = q_match.group(1).upper()
|
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"]
|
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"]
|
caps = ["Code"]
|
||||||
if "--mmproj" in cmd:
|
if "--mmproj" in cmd:
|
||||||
caps.append("Bild")
|
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] = {
|
configured[name] = {
|
||||||
"name": name,
|
"name": name,
|
||||||
|
"role": role,
|
||||||
|
"aliases": aliases,
|
||||||
|
"api_ids": [name] + aliases,
|
||||||
"ttl": spec.get("ttl", cfg.get("globalTTL", 0)),
|
"ttl": spec.get("ttl", cfg.get("globalTTL", 0)),
|
||||||
"cmd": cmd,
|
"cmd": cmd,
|
||||||
"state": "idle",
|
"state": "idle",
|
||||||
|
"incomplete": incomplete,
|
||||||
"port": None,
|
"port": None,
|
||||||
"meta": {
|
"meta": {
|
||||||
"ctx": ctx,
|
"ctx": ctx,
|
||||||
"size_bytes": size_bytes,
|
"size_bytes": size_bytes,
|
||||||
"quant": quant,
|
"quant": quant,
|
||||||
"caps": caps,
|
"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
|
swap_ok = True
|
||||||
try:
|
try:
|
||||||
running = _swap_get("/running")
|
running = _swap_get("/running")
|
||||||
@@ -118,6 +180,7 @@ def status():
|
|||||||
"swap_url": LLAMA_SWAP_URL,
|
"swap_url": LLAMA_SWAP_URL,
|
||||||
"config_path": str(CONFIG_PATH),
|
"config_path": str(CONFIG_PATH),
|
||||||
"models_dir": str(MODELS_DIR),
|
"models_dir": str(MODELS_DIR),
|
||||||
|
"secured": bool(TOKEN),
|
||||||
"models": list(configured.values()),
|
"models": list(configured.values()),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,28 +190,69 @@ def download(req: DownloadReq):
|
|||||||
sub = req.subdir or req.repo.split("/")[-1]
|
sub = req.subdir or req.repo.split("/")[-1]
|
||||||
target = MODELS_DIR / sub
|
target = MODELS_DIR / sub
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
args = ["hf", "download", req.repo, req.file, "--local-dir", str(target)]
|
args = [hf_bin(), "download", req.repo, req.file, "--local-dir", str(target)]
|
||||||
job_id = start_job(args, f"download {req.repo}/{req.file}",
|
env = dict(HF_DOWNLOAD_ENV)
|
||||||
env={"HF_XET_HIGH_PERFORMANCE": "1"})
|
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)
|
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)}
|
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")
|
@router.post("/register")
|
||||||
def register(req: RegisterReq):
|
def register(req: RegisterReq):
|
||||||
if not Path(req.model_path).exists():
|
# Bewusst KEIN exists()-Check: beim frischen Download läuft der hf-Job noch, die Datei kommt
|
||||||
raise HTTPException(404, f"Datei nicht gefunden: {req.model_path}")
|
# erst gleich. Eintrag jetzt schon schreiben → llama-swap (-watch-config) lädt, sobald sie da ist.
|
||||||
cfg = read_config()
|
cfg = read_config()
|
||||||
cmd = CMD_TEMPLATE.replace("{model}", req.model_path).replace("{ctx}", str(req.ctx))
|
ctx = req.ctx
|
||||||
cfg["models"][req.alias] = {
|
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"),
|
"cmd": LiteralScalarString(cmd + "\n"),
|
||||||
"ttl": req.ttl if req.ttl is not None else DEFAULT_TTL,
|
"ttl": req.ttl if req.ttl is not None else DEFAULT_TTL,
|
||||||
}
|
}
|
||||||
|
set_role_alias(cfg, model_id, role)
|
||||||
write_config(cfg)
|
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."}
|
"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")
|
@router.post("/update_model")
|
||||||
def update_model(req: UpdateReq):
|
def update_model(req: UpdateReq):
|
||||||
cfg = read_config()
|
cfg = read_config()
|
||||||
@@ -156,19 +260,110 @@ def update_model(req: UpdateReq):
|
|||||||
raise HTTPException(404, "Modell nicht gefunden")
|
raise HTTPException(404, "Modell nicht gefunden")
|
||||||
|
|
||||||
spec = cfg["models"][req.alias]
|
spec = cfg["models"][req.alias]
|
||||||
cmd = str(spec.get("cmd", ""))
|
|
||||||
|
|
||||||
# Replace or add context size
|
if req.ctx is not None:
|
||||||
if re.search(r'-(?:c|-ctx-size)\s+\d+', cmd):
|
cmd = str(spec.get("cmd", ""))
|
||||||
cmd = re.sub(r'-(?:c|-ctx-size)\s+\d+', f'-c {req.ctx}', cmd)
|
if re.search(r'-(?:c|-ctx-size)\s+\d+', cmd):
|
||||||
else:
|
cmd = re.sub(r'-(?:c|-ctx-size)\s+\d+', f'-c {req.ctx}', cmd)
|
||||||
cmd = cmd.strip() + f" -c {req.ctx}\n"
|
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
|
||||||
|
|
||||||
cfg["models"][req.alias]["cmd"] = LiteralScalarString(cmd)
|
|
||||||
write_config(cfg)
|
write_config(cfg)
|
||||||
return {"ok": True}
|
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")
|
@router.post("/unload")
|
||||||
def unload(model: str | None = None):
|
def unload(model: str | None = None):
|
||||||
path = f"/api/models/unload/{model}" if model else "/api/models/unload"
|
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"]
|
||||||
+62
-4
@@ -91,8 +91,16 @@ a{color:var(--accent);text-decoration:none}
|
|||||||
.dot.on{background:var(--on);box-shadow:0 0 0 0 rgba(63,185,80,.5);animation:pulse 2.2s infinite}
|
.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)}
|
.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)}}
|
@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{font-size:var(--text-sm);color:var(--mut);display:flex;align-items:center;gap:5px}
|
||||||
.top-stat b{color:var(--tx);font-family:var(--mono);font-weight:500;margin-left:4px}
|
.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;
|
||||||
|
}
|
||||||
|
#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)}
|
.top-clock{font-family:var(--mono);font-size:var(--text-sm);color:var(--accent)}
|
||||||
|
|
||||||
/* Security-Chip (zeigt LAN/Token-Status) */
|
/* Security-Chip (zeigt LAN/Token-Status) */
|
||||||
@@ -122,10 +130,60 @@ a{color:var(--accent);text-decoration:none}
|
|||||||
.split{display:grid;gap:var(--sp-4);grid-template-columns:1.05fr 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:1180px){.grid-3{grid-template-columns:1fr 1fr}.kpis{grid-template-columns:repeat(2,1fr)}}
|
||||||
@media(max-width:900px){.split{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}
|
@media(max-width:760px){
|
||||||
|
.grid-3,.grid-2,.kpis{grid-template-columns:1fr}
|
||||||
.sidebar{width:60px;flex-basis:60px}
|
.sidebar{width:60px;flex-basis:60px}
|
||||||
.ni-tx,.brand-tx{display:none}
|
.ni-tx,.brand-tx{display:none}
|
||||||
.nav-item{justify-content:center}.brand{justify-content:center}}
|
.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-Banner */
|
||||||
.alert{
|
.alert{
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
Legacy-Klassen bleiben erhalten, bis alle Panels migriert sind.
|
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) ---- */
|
/* ---- Karte (Grundbaustein) ---- */
|
||||||
.card{
|
.card{
|
||||||
background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);
|
background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);
|
||||||
@@ -96,8 +104,11 @@
|
|||||||
.qa-ic.teal{background:rgba(45,212,191,.13);color:var(--accent)}
|
.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.amber{background:rgba(224,163,46,.13);color:var(--warn)}
|
||||||
.qa-ic.blue{background:rgba(68,147,224,.13);color:var(--act)}
|
.qa-ic.blue{background:rgba(68,147,224,.13);color:var(--act)}
|
||||||
.qa-main{flex:1;min-width:0}
|
.qa-ic.red{background:rgba(240,87,63,.13);color:var(--err)}
|
||||||
.qa-t{font-size:13.5px} .qa-s{font-size:11.5px;color:var(--mut);margin-top:1px}
|
.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}
|
.qa-arrow{color:var(--dim)} .qa-arrow svg{width:16px;height:16px}
|
||||||
|
|
||||||
/* ---- Tabelle ---- */
|
/* ---- Tabelle ---- */
|
||||||
@@ -142,6 +153,14 @@ input:focus,textarea:focus,select:focus{outline:none;border-color:var(--accent)}
|
|||||||
textarea{resize:vertical;min-height:64px;font-family:var(--sans)}
|
textarea{resize:vertical;min-height:64px;font-family:var(--sans)}
|
||||||
.row{display:flex;gap:10px}.row>div{flex:1}
|
.row{display:flex;gap:10px}.row>div{flex:1}
|
||||||
.hint{font-size:12px;color:var(--mut);margin:-4px 0 12px}
|
.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)}
|
.mono-sm{font-family:var(--mono);font-size:11.5px;color:var(--mut)}
|
||||||
|
|
||||||
/* ---- Buttons ---- */
|
/* ---- Buttons ---- */
|
||||||
@@ -154,6 +173,8 @@ button.danger{background:var(--err);border-color:var(--err);color:#fff}
|
|||||||
button.danger:hover{filter:brightness(1.06)}
|
button.danger:hover{filter:brightness(1.06)}
|
||||||
button.warn{background:var(--warn);border-color:var(--warn);color:#3a2a05}
|
button.warn{background:var(--warn);border-color:var(--warn);color:#3a2a05}
|
||||||
button.ghost{padding:6px 11px;font-size:12.5px;background:none}
|
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}
|
button:disabled{opacity:.5;cursor:not-allowed}
|
||||||
.btn-row{display:flex;gap:10px;flex-wrap:wrap}
|
.btn-row{display:flex;gap:10px;flex-wrap:wrap}
|
||||||
|
|
||||||
@@ -176,6 +197,28 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
|||||||
.modal-card p{margin:0 0 18px;color:var(--mut);font-size:13.5px;line-height:1.55}
|
.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}
|
.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 ---- */
|
||||||
.toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:var(--panel2);
|
.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;
|
border:1px solid var(--line2);border-radius:10px;padding:11px 16px;font-size:13.5px;
|
||||||
@@ -206,5 +249,8 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
|||||||
border-radius:var(--radius);padding:var(--sp-3) var(--sp-4);cursor:pointer;transition:border-color .15s,background .15s;
|
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)}
|
color:var(--tx);font-family:var(--sans)}
|
||||||
.card-btn:hover,.card-btn:focus{border-color:var(--accent);background:var(--bg2);outline:none}
|
.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 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)}
|
.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
+23
-49
@@ -4,13 +4,13 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Mission Control</title>
|
<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/base.css">
|
||||||
<link rel="stylesheet" href="/static/css/components.css">
|
<link rel="stylesheet" href="/static/css/components.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
|
|
||||||
<!-- Sidebar: beschriftete Bereichs-Navigation -->
|
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand"><span class="brand-logo" id="logo"></span><span class="brand-tx">Mission Control</span></div>
|
<div class="brand"><span class="brand-logo" id="logo"></span><span class="brand-tx">Mission Control</span></div>
|
||||||
<nav class="side-nav">
|
<nav class="side-nav">
|
||||||
@@ -19,7 +19,11 @@
|
|||||||
<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="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="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="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="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>
|
</nav>
|
||||||
<div class="side-foot">
|
<div class="side-foot">
|
||||||
<span class="nav-item" id="nav-settings"><span class="ni-ic" data-ic="settings"></span><span class="ni-tx">Einstellungen</span></span>
|
<span class="nav-item" id="nav-settings"><span class="ni-ic" data-ic="settings"></span><span class="ni-tx">Einstellungen</span></span>
|
||||||
@@ -27,60 +31,31 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<!-- Topbar -->
|
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<span class="status-pill"><span id="swdot" class="dot"></span><span id="swlabel">verbinde…</span></span>
|
<span class="status-pill"><span id="swdot" class="dot"></span><span id="swlabel">verbinde…</span></span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></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="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>
|
<span class="sec-chip" id="sec-chip"><span data-ic="shield"></span><span id="sec-chip-tx">Nur im Heimnetz</span></span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Alert-Banner (wird per JS ein-/ausgeblendet) -->
|
|
||||||
<div id="alert" class="alert" style="display:none"></div>
|
<div id="alert" class="alert" style="display:none"></div>
|
||||||
|
|
||||||
<!-- Content: genau eine .view ist sichtbar (Hash-Routing) -->
|
|
||||||
<main class="content">
|
<main class="content">
|
||||||
|
<section class="view" data-view="overview"></section>
|
||||||
<section class="view" data-view="overview">
|
<section class="view" data-view="activity" hidden></section>
|
||||||
<div id="ov-hero"></div>
|
<section class="view" data-view="server" hidden></section>
|
||||||
<div class="tiles" id="ov-tiles"></div>
|
<section class="view" data-view="models" hidden></section>
|
||||||
<div class="split">
|
<section class="view" data-view="cookbook" hidden></section>
|
||||||
<div class="card" id="ov-health"></div>
|
<section class="view" data-view="connect" hidden></section>
|
||||||
<div class="card" id="ov-quickstart"></div>
|
<section class="view" data-view="news" hidden></section>
|
||||||
</div>
|
<section class="view" data-view="guides" hidden></section>
|
||||||
<div class="card" id="ov-stack"></div>
|
<section class="view" data-view="memory" hidden></section>
|
||||||
</section>
|
<section class="view" data-view="hermes" hidden></section>
|
||||||
|
|
||||||
<section class="view" data-view="activity" hidden>
|
|
||||||
<div id="act-head"></div>
|
|
||||||
<div class="tiles" 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 id="m-head"></div>
|
|
||||||
<div class="card" id="m-chat"></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>
|
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</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.62); z-index:100; align-items:center; justify-content:center;">
|
<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">
|
<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>
|
<button id="sm-close" class="ghost" style="position:absolute; top:12px; right:12px;">Schließen</button>
|
||||||
@@ -90,17 +65,16 @@
|
|||||||
<input id="token" class="tokin" placeholder="Nur nötig, wenn der Server geschützt ist…" autocomplete="off">
|
<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 class="hint mt-2">Schützt den Zugriff. Wird für API-Aufrufe und Live-Verbindungen mitgeschickt.</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<div id="toast" class="toast"></div>
|
<div id="toast" class="toast"></div>
|
||||||
|
<link rel="stylesheet" href="/static/dist/main.css">
|
||||||
<!-- Icons in Nav/Logo/Chip einsetzen, bevor das Haupt-Modul laedt -->
|
<script type="module" src="/static/dist/main.js"></script>
|
||||||
<script type="module">
|
|
||||||
import { ICON } from "/static/js/core/ui.js";
|
|
||||||
document.getElementById("logo").innerHTML = ICON.logo;
|
|
||||||
document.querySelectorAll("[data-ic]").forEach(n => (n.innerHTML = ICON[n.dataset.ic] || ""));
|
|
||||||
</script>
|
|
||||||
<script type="module" src="/static/js/main.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export function hdr() {
|
|||||||
return h;
|
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 = {}) {
|
export async function api(path, opts = {}) {
|
||||||
const r = await fetch(path, { headers: hdr(), ...opts });
|
const r = await fetch(path, { headers: hdr(), ...opts });
|
||||||
const data = await r.json().catch(() => ({}));
|
const data = await r.json().catch(() => ({}));
|
||||||
|
|||||||
+11
-2
@@ -19,9 +19,13 @@ export function toast(msg, err = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Modell-Status -> Badge-HTML
|
// Modell-Status -> Badge-HTML
|
||||||
export function badge(state) {
|
export function badge(state, progress) {
|
||||||
if (state === "running" || state === "ready") return '<span class="badge b-run">geladen</span>';
|
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 === "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>';
|
return '<span class="badge b-run">bereit</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +78,11 @@ export const ICON = {
|
|||||||
|
|
||||||
export function icon(name) { return ICON[name] || ""; }
|
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)
|
// Format-Helfer (Klartext-Zahlen)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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,270 +0,0 @@
|
|||||||
// cookbook.js — "App-Store" für Modelle (v3): Hardware-Fit als Ampel, Klartext-Urteile.
|
|
||||||
// Backend: /api/cookbook/{analyze,evaluate} (hw_math), /api/download, /api/register.
|
|
||||||
|
|
||||||
import { api } from "../core/api.js";
|
|
||||||
import { $, esc, icon, toast } from "../core/ui.js";
|
|
||||||
|
|
||||||
const CURATED = [
|
|
||||||
{ 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: "Schnell und locker auf deiner Kiste — ideal zum Programmieren.", params_b: 7, quant: "Q4_K_M", ctx: 32768, alias: "coder-fast" },
|
|
||||||
{ 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: "Mehr Können, etwas langsamer — der starke Allround-Coder.", params_b: 32, quant: "Q4_K_M", ctx: 32768, alias: "coder" },
|
|
||||||
{ 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: "Versteht Bilder und Text — gut für multimodale Aufgaben.", params_b: 11, quant: "Q4_K_M", ctx: 8192, alias: "vision" },
|
|
||||||
{ name: "Qwen 2.5 7B", repo: "unsloth/Qwen2.5-7B-Instruct-GGUF", file: "Qwen2.5-7B-Instruct-Q4_K_M.gguf",
|
|
||||||
desc: "Hervorragender Allrounder — schnell und vielseitig.", params_b: 7, quant: "Q4_K_M", ctx: 8192, alias: "scout" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const FILTERS = [
|
|
||||||
{ id: "", label: "Alle" }, { id: "coder", label: "Coder" }, { id: "scout", label: "Scout" },
|
|
||||||
{ id: "vision", label: "Vision" }, { id: "manager", label: "Manager" }, { id: "reviewer", label: "Reviewer" },
|
|
||||||
];
|
|
||||||
|
|
||||||
let lastSys = null;
|
|
||||||
let currentResults = [];
|
|
||||||
let currentAnalysis = null;
|
|
||||||
let activeFilter = "";
|
|
||||||
|
|
||||||
const fitCls = lvl => lvl === "perfect" ? "ok" : lvl === "marginal" ? "warn" : "bad";
|
|
||||||
|
|
||||||
function mount() {
|
|
||||||
const c = $(".view[data-view='cookbook']");
|
|
||||||
c.innerHTML = `
|
|
||||||
<div class="pagehead"><div>
|
|
||||||
<h1>Modell-Cookbook</h1>
|
|
||||||
<div class="sub">Finde ein KI-Modell, das auf deinen Mini-PC passt — den Hardware-Check rechnen wir für dich aus.</div>
|
|
||||||
</div></div>
|
|
||||||
|
|
||||||
<div class="flex gap-3" style="margin:4px 0 6px">
|
|
||||||
<input id="cb-search" placeholder="Modell suchen, z.B. „Llama 3"…" style="margin:0;flex:1">
|
|
||||||
<button class="primary" id="cb-btn-search" style="white-space:nowrap">Suchen</button>
|
|
||||||
</div>
|
|
||||||
<div id="cb-filters" class="flex gap-2" style="flex-wrap:wrap"></div>
|
|
||||||
|
|
||||||
<div class="card-h" style="margin-top:18px;align-items:center">
|
|
||||||
<h3 id="cb-section-title">Empfohlen für deine Hardware</h3>
|
|
||||||
<span class="chip" id="cb-hw">deine Hardware</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend mb-4">
|
|
||||||
<span><i style="background:var(--on)"></i>passt locker</span>
|
|
||||||
<span><i style="background:var(--warn)"></i>läuft, aber knapp</span>
|
|
||||||
<span><i style="background:var(--err)"></i>zu groß für deinen Speicher</span>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-3" id="cb-grid"></div>
|
|
||||||
|
|
||||||
<div id="cb-modal" class="modal-overlay" style="display:none">
|
|
||||||
<div class="modal-card" style="max-width:560px">
|
|
||||||
<button id="cb-modal-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
|
|
||||||
<h3 id="cb-m-title">Modell</h3>
|
|
||||||
<p class="mono-sm" id="cb-m-repo" style="margin:-4px 0 16px">repo/name</p>
|
|
||||||
|
|
||||||
<label>Quantisierung (GGUF-Datei) wählen</label>
|
|
||||||
<select id="cb-m-files"></select>
|
|
||||||
<div class="hint" id="cb-m-loading" style="display:none">Lade Dateien von HuggingFace…</div>
|
|
||||||
|
|
||||||
<div class="row" style="margin-top:4px">
|
|
||||||
<div><label>Alias (Rolle)</label><input id="cb-m-alias" placeholder="z.B. coder"></div>
|
|
||||||
<div><label>Kontext-Größe</label><input id="cb-m-ctx" type="number" value="8192"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="cb-m-fit" 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" id="cb-m-fit-text" style="margin:4px 0 0">Berechne…</div></div>
|
|
||||||
<span id="cb-m-fit-badge"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="primary" id="cb-m-download" style="width:100%">Herunterladen & Einpflegen</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
renderFilters();
|
|
||||||
$("#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-modal").addEventListener("click", e => { if (e.target.id === "cb-modal") $("#cb-modal").style.display = "none"; });
|
|
||||||
$("#cb-m-download").addEventListener("click", doDownload);
|
|
||||||
$("#cb-m-files").addEventListener("change", updateLiveFit);
|
|
||||||
$("#cb-m-ctx").addEventListener("change", reanalyzeCtx);
|
|
||||||
|
|
||||||
renderHwChip();
|
|
||||||
renderCurated();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFilters() {
|
|
||||||
$("#cb-filters").innerHTML = FILTERS.map(f =>
|
|
||||||
`<button class="${activeFilter === f.id ? "primary" : "ghost"}" data-f="${f.id}" style="border-radius:999px">${f.label}</button>`
|
|
||||||
).join("");
|
|
||||||
$("#cb-filters").querySelectorAll("[data-f]").forEach(b =>
|
|
||||||
b.addEventListener("click", () => { activeFilter = b.getAttribute("data-f"); renderFilters(); doSearch(); }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHwChip() {
|
|
||||||
const el = $("#cb-hw"); if (!el) return;
|
|
||||||
if (lastSys?.ram?.total) {
|
|
||||||
el.innerHTML = `${icon("cpu")}${Math.round(lastSys.ram.total / 1024 ** 3)} GB Speicher`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Karten ----
|
|
||||||
function metricLine(fit) { return `~${fit.req_gb.toFixed(1)} GB · ~${Math.round(fit.tps)} Tok/s`; }
|
|
||||||
|
|
||||||
function curatedCard(m, i, fit) {
|
|
||||||
return `<div class="card" style="display:flex;flex-direction:column;cursor:pointer" data-cur="${i}">
|
|
||||||
<div class="flex justify-between items-center"><h3 style="margin:0;font-size:15px;font-weight:500">${esc(m.name)}</h3>
|
|
||||||
<span class="fit-badge ${fitCls(fit.level)}">${esc(fit.text)}</span></div>
|
|
||||||
<div class="text-sm text-mut" style="margin:12px 0;flex:1;line-height:1.5">${esc(m.desc)}</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">${metricLine(fit)}</span><span class="mono-sm">${esc(m.quant)}</span></div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderCurated() {
|
|
||||||
$("#cb-section-title").textContent = "Empfohlen für deine Hardware";
|
|
||||||
const grid = $("#cb-grid"); if (!grid) return;
|
|
||||||
grid.innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Berechne Hardware-Fit…</div>`;
|
|
||||||
try {
|
|
||||||
let html = "";
|
|
||||||
for (let i = 0; i < CURATED.length; i++) {
|
|
||||||
const m = CURATED[i];
|
|
||||||
const fit = await api("/api/cookbook/evaluate", { method: "POST", body: JSON.stringify({ params_b: m.params_b, quant: m.quant, ctx: m.ctx }) });
|
|
||||||
html += curatedCard(m, i, fit);
|
|
||||||
}
|
|
||||||
grid.innerHTML = html;
|
|
||||||
grid.querySelectorAll("[data-cur]").forEach(el => el.addEventListener("click", () => openCurated(+el.getAttribute("data-cur"))));
|
|
||||||
} catch (e) {
|
|
||||||
grid.innerHTML = `<div class="alert err" style="grid-column:1/-1">Konnte Empfehlungen nicht laden: ${esc(e.message)}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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: " + q;
|
|
||||||
const grid = $("#cb-grid");
|
|
||||||
grid.innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">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);
|
|
||||||
currentResults = await r.json();
|
|
||||||
renderResults(currentResults);
|
|
||||||
} catch (e) {
|
|
||||||
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?.length) { grid.innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Keine GGUF-Modelle gefunden.</div>`; return; }
|
|
||||||
grid.innerHTML = results.map((m, i) => `
|
|
||||||
<div class="card" style="display:flex;flex-direction:column;cursor:pointer" data-res="${i}">
|
|
||||||
<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">${esc(m.id.split("/").pop())}</h3>
|
|
||||||
<div class="text-xs text-mut" style="margin-top:3px">${esc(m.author || "")}</div></div>
|
|
||||||
<span class="fit-badge warn" id="cb-b-${i}">prüfe…</span>
|
|
||||||
</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" id="cb-m-${i}">Hardware-Fit…</span>
|
|
||||||
<span class="mono-sm">⬇ ${(m.downloads || 0).toLocaleString()}</span></div>
|
|
||||||
</div>`).join("");
|
|
||||||
grid.querySelectorAll("[data-res]").forEach(el => el.addEventListener("click", () => openResult(+el.getAttribute("data-res"))));
|
|
||||||
results.forEach((m, i) => fetchFitForCard(i, m.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchFitForCard(i, repo_id) {
|
|
||||||
try {
|
|
||||||
const res = await api("/api/cookbook/analyze", { method: "POST", body: JSON.stringify({ repo_id, ctx: 8192 }) });
|
|
||||||
const b = $("#cb-b-" + i), mt = $("#cb-m-" + i);
|
|
||||||
if (!b || !mt) return;
|
|
||||||
if (!res.files?.length) { b.className = "fit-badge bad"; b.textContent = "keine GGUFs"; mt.textContent = "—"; return; }
|
|
||||||
let best = res.files.find(f => f.quant?.includes("Q4_K_M")) || res.files[0];
|
|
||||||
b.className = "fit-badge " + fitCls(best.fit.level); b.textContent = best.fit.text;
|
|
||||||
mt.textContent = metricLine(best.fit) + " · " + (best.quant || "GGUF");
|
|
||||||
} catch {
|
|
||||||
const b = $("#cb-b-" + i); if (b) { b.className = "fit-badge bad"; b.textContent = "Fehler"; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Modal ----
|
|
||||||
function showFit() {
|
|
||||||
const file = $("#cb-m-files").value;
|
|
||||||
const f = currentAnalysis?.files.find(x => x.filename === file);
|
|
||||||
if (!f) { $("#cb-m-fit").style.display = "none"; return; }
|
|
||||||
$("#cb-m-fit").style.display = "flex";
|
|
||||||
$("#cb-m-fit-text").innerHTML = `Bedarf: <b>~${f.fit.req_gb.toFixed(1)} GB</b> · ${currentAnalysis.params_b}B · ${esc(f.quant)} · ~${Math.round(f.fit.tps)} Tok/s`;
|
|
||||||
$("#cb-m-fit-badge").innerHTML = `<span class="fit-badge ${fitCls(f.fit.level)}">${esc(f.fit.text)}</span>`;
|
|
||||||
const btn = $("#cb-m-download");
|
|
||||||
if (f.fit.level === "too_tight") { btn.className = "primary warn"; btn.textContent = "Trotzdem holen (zu groß)"; }
|
|
||||||
else { btn.className = "primary"; btn.textContent = "Herunterladen & Einpflegen"; }
|
|
||||||
}
|
|
||||||
function updateLiveFit() { showFit(); }
|
|
||||||
|
|
||||||
async function reanalyzeCtx() {
|
|
||||||
if (!currentAnalysis) return;
|
|
||||||
const ctx = parseInt($("#cb-m-ctx").value) || 8192;
|
|
||||||
const file = $("#cb-m-files").value;
|
|
||||||
try {
|
|
||||||
currentAnalysis = await api("/api/cookbook/analyze", { method: "POST", body: JSON.stringify({ repo_id: currentAnalysis.repo, ctx }) });
|
|
||||||
$("#cb-m-files").value = file; showFit();
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openModalBase(title, repo) {
|
|
||||||
$("#cb-modal").style.display = "flex";
|
|
||||||
$("#cb-m-title").textContent = title;
|
|
||||||
$("#cb-m-repo").textContent = repo;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openResult(i) {
|
|
||||||
const m = currentResults[i]; if (!m) return;
|
|
||||||
openModalBase(m.id.split("/").pop(), m.id);
|
|
||||||
$("#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;
|
|
||||||
currentAnalysis = await api("/api/cookbook/analyze", { method: "POST", body: JSON.stringify({ repo_id: m.id, ctx }) });
|
|
||||||
$("#cb-m-loading").style.display = "none"; $("#cb-m-files").style.display = "block";
|
|
||||||
if (!currentAnalysis.files?.length) { $("#cb-m-files").innerHTML = "<option>Keine GGUF-Dateien gefunden</option>"; $("#cb-m-fit").style.display = "none"; return; }
|
|
||||||
$("#cb-m-files").innerHTML = currentAnalysis.files.map(f => {
|
|
||||||
const mark = f.fit.level === "perfect" ? "●" : f.fit.level === "marginal" ? "◐" : "○";
|
|
||||||
return `<option value="${esc(f.filename)}">${mark} ${esc(f.filename)}</option>`;
|
|
||||||
}).join("");
|
|
||||||
$("#cb-m-download").disabled = false; showFit();
|
|
||||||
} catch (e) { $("#cb-m-loading").textContent = "Fehler: " + e.message; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openCurated(i) {
|
|
||||||
const m = CURATED[i]; if (!m) return;
|
|
||||||
openModalBase(m.name, m.repo);
|
|
||||||
$("#cb-m-files").style.display = "block"; $("#cb-m-loading").style.display = "none";
|
|
||||||
$("#cb-m-files").innerHTML = `<option value="${esc(m.file)}">${esc(m.file)}</option>`;
|
|
||||||
$("#cb-m-alias").value = m.alias; $("#cb-m-ctx").value = m.ctx; $("#cb-m-download").disabled = false;
|
|
||||||
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 }] };
|
|
||||||
showFit();
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doDownload() {
|
|
||||||
const repo = $("#cb-m-repo").textContent, file = $("#cb-m-files").value;
|
|
||||||
const alias = $("#cb-m-alias").value.trim(), ctx = parseInt($("#cb-m-ctx").value) || 8192;
|
|
||||||
if (!repo || !file || !alias) return toast("Bitte alle Felder ausfüllen.", true);
|
|
||||||
const btn = $("#cb-m-download"); btn.disabled = true; btn.textContent = "Starte…";
|
|
||||||
try {
|
|
||||||
const res = await api("/api/download", { method: "POST", body: JSON.stringify({ repo, file }) });
|
|
||||||
await api("/api/register", { method: "POST", body: JSON.stringify({ alias, model_path: res.expected_path, ctx }) });
|
|
||||||
toast("Download gestartet — siehe Aktivität.");
|
|
||||||
$("#cb-modal").style.display = "none";
|
|
||||||
document.querySelector(".nav-item[data-view='activity']")?.click();
|
|
||||||
} catch (e) { toast("Fehler: " + e.message, true); }
|
|
||||||
btn.disabled = false; btn.textContent = "Herunterladen & Einpflegen";
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSystem(sys) { lastSys = sys; renderHwChip(); }
|
|
||||||
|
|
||||||
export default { id: "cookbook", mount, onSystem };
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
// guides.js — Integrations-Anleitungen (v3): Copy-Paste-Configs für externe Tools.
|
|
||||||
|
|
||||||
import { $ } from "../core/ui.js";
|
|
||||||
|
|
||||||
// Basis-URL aus dem Browser ableiten (zeigt die echte Bosgame-Adresse statt localhost).
|
|
||||||
function apiBase() {
|
|
||||||
const host = location.hostname || "localhost";
|
|
||||||
return `${location.protocol}//${host}:8080/v1`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
const c = $(".view[data-view='guides']");
|
|
||||||
if (!c) return;
|
|
||||||
const url = apiBase();
|
|
||||||
c.innerHTML = `
|
|
||||||
<div class="pagehead"><div>
|
|
||||||
<h1>Guides & Integrationen</h1>
|
|
||||||
<div class="sub">Binde deine lokalen Modelle in andere Tools ein — fertige Configs zum Kopieren.</div></div></div>
|
|
||||||
|
|
||||||
<div class="card" style="padding:0;overflow:hidden">
|
|
||||||
<details class="guide-acc">
|
|
||||||
<summary>Cline / Cursor</summary>
|
|
||||||
<div class="acc-body">
|
|
||||||
<p>Nutze deine lokalen Modelle kostenlos in Cursor oder Cline (Provider: „OpenAI Compatible").</p>
|
|
||||||
<label>Modell-ID</label>
|
|
||||||
<input class="mono-sm" readonly value="coder">
|
|
||||||
<label>Basis-URL (OpenAI-kompatibel)</label>
|
|
||||||
<input class="mono-sm" readonly value="${url}">
|
|
||||||
<label>API-Key</label>
|
|
||||||
<input class="mono-sm" readonly value="(dein Token oder leer lassen)">
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details class="guide-acc">
|
|
||||||
<summary>OpenWebUI</summary>
|
|
||||||
<div class="acc-body">
|
|
||||||
<p>Settings → Admin → Connections → neue OpenAI-Verbindung:</p>
|
|
||||||
<label>Basis-URL</label>
|
|
||||||
<input class="mono-sm" readonly value="${url}">
|
|
||||||
<label>API-Key</label>
|
|
||||||
<input class="mono-sm" readonly value="dummy-key">
|
|
||||||
<p class="hint" style="margin-top:10px">OpenWebUI erkennt automatisch, wenn ein Modell getauscht wird.</p>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details class="guide-acc">
|
|
||||||
<summary>Python / LangChain (openai-SDK)</summary>
|
|
||||||
<div class="acc-body">
|
|
||||||
<p>Mit dem offiziellen <code>openai</code>-Paket:</p>
|
|
||||||
<div class="log" style="max-height:none"><code>from openai import OpenAI
|
|
||||||
|
|
||||||
client = OpenAI(
|
|
||||||
base_url="${url}",
|
|
||||||
api_key="dein_token" # optional
|
|
||||||
)
|
|
||||||
|
|
||||||
resp = client.chat.completions.create(
|
|
||||||
model="coder", # Alias aus dem Cookbook
|
|
||||||
messages=[{"role": "user", "content": "Hallo Modell!"}]
|
|
||||||
)
|
|
||||||
print(resp.choices[0].message.content)</code></div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details class="guide-acc">
|
|
||||||
<summary>n8n (AI-Agent-Nodes)</summary>
|
|
||||||
<div class="acc-body">
|
|
||||||
<p>„OpenAI Chat Model"-Node → Credentials → Custom URL:</p>
|
|
||||||
<label>Basis-URL</label>
|
|
||||||
<input class="mono-sm" readonly value="${url}">
|
|
||||||
<p class="hint" style="margin-top:10px">Modell-ID „coder" eintragen und loslegen.</p>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details class="guide-acc">
|
|
||||||
<summary>Begriffe einfach erklärt (Glossar)</summary>
|
|
||||||
<div class="acc-body">
|
|
||||||
<p><b>LLM-Engine (llama-swap):</b> Der Dienst im Hintergrund, der die Sprachmodelle lädt und eine OpenAI-kompatible Schnittstelle bereitstellt.</p>
|
|
||||||
<p><b>VRAM / Grafikspeicher:</b> Der schnelle Speicher, in dem ein Modell laufen muss. Große Modelle brauchen viel davon.</p>
|
|
||||||
<p><b>Quantisierung (z.B. Q4_K_M):</b> Verkleinert ein Modell (16-Bit → 4-Bit), damit es in den Speicher passt — bei kaum Qualitätsverlust.</p>
|
|
||||||
<p><b>Kontext-Größe:</b> Wie viel Text sich das Modell gleichzeitig „merken" kann. Mehr Kontext = deutlich mehr Speicherbedarf.</p>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mount() { render(); }
|
|
||||||
|
|
||||||
export default { id: "guides", mount };
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
// jobs.js — Aktivität (v3): Live-System-Metriken + Hintergrund-Jobs mit Log.
|
|
||||||
// Exportiert track(id), damit andere Panels einen Job auto-aufklappen.
|
|
||||||
|
|
||||||
import { $, esc, fmtBytes } from "../core/ui.js";
|
|
||||||
|
|
||||||
const tracked = new Set();
|
|
||||||
let JOBS = [];
|
|
||||||
let SYS = null;
|
|
||||||
|
|
||||||
export function track(id) { tracked.add(id); renderJobs(); }
|
|
||||||
|
|
||||||
const hist = { cpu: [], ram: [], gpu: [] };
|
|
||||||
const MAX_HIST = 60;
|
|
||||||
|
|
||||||
function statusBadge(s) { return s === "done" ? '<span class="badge b-run">fertig</span>' : s === "failed" ? '<span class="badge b-err">fehler</span>' : '<span class="badge b-load">läuft…</span>'; }
|
|
||||||
function dotClass(s) { return s === "done" ? "on" : s === "failed" ? "" : "load"; }
|
|
||||||
|
|
||||||
function tile(label, value, sub) {
|
|
||||||
return `<div class="tile"><div class="t-l">${label}</div><div class="t-v">${value}</div><div class="t-s">${sub}</div></div>`;
|
|
||||||
}
|
|
||||||
function meter(label, pct) {
|
|
||||||
const p = Math.max(0, Math.min(100, pct || 0));
|
|
||||||
const cls = p >= 90 ? "bad" : p >= 75 ? "warn" : "";
|
|
||||||
return `<div class="meter"><div class="meter-h"><span class="mk">${label}</span><span class="mv">${Math.round(p)} %</span></div><div class="bar ${cls}"><i style="width:${Math.max(2, p)}%"></i></div></div>`;
|
|
||||||
}
|
|
||||||
function spark(arr, varName) {
|
|
||||||
return '<div style="display:flex;align-items:flex-end;gap:2px;height:38px;margin-top:10px">' +
|
|
||||||
arr.map(v => `<div style="width:4px;background:var(${varName});opacity:.55;height:${Math.max(2, v)}%;border-radius:2px"></div>`).join("") + "</div>";
|
|
||||||
}
|
|
||||||
|
|
||||||
function gpuPct() {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSys() {
|
|
||||||
if (!SYS) return;
|
|
||||||
hist.cpu.push(SYS.cpu.percent); hist.ram.push(SYS.ram.percent); hist.gpu.push(gpuPct());
|
|
||||||
for (const k of ["cpu", "ram", "gpu"]) if (hist[k].length > MAX_HIST) hist[k].shift();
|
|
||||||
|
|
||||||
const k = $("#act-kpis");
|
|
||||||
if (k) k.innerHTML =
|
|
||||||
tile("Prozessor (CPU)", `${Math.round(SYS.cpu.percent)}<small> %</small>`, SYS.cpu.temp != null ? `${Math.round(SYS.cpu.temp)}° CPU-Temp` : "Auslastung") +
|
|
||||||
tile("Arbeitsspeicher", `${Math.round(SYS.ram.percent)}<small> %</small>`, `${fmtBytes(SYS.ram.used)} / ${fmtBytes(SYS.ram.total)}`) +
|
|
||||||
tile("Grafikspeicher", `${Math.round(gpuPct())}<small> %</small>`, SYS.gpu_temp != null ? `${Math.round(SYS.gpu_temp)}° GPU-Temp` : "VRAM + GTT");
|
|
||||||
|
|
||||||
const g = SYS.gpu;
|
|
||||||
const gpuStr = g && (g.vram.total + g.gtt.total) > 0 ? `${fmtBytes(g.vram.used + g.gtt.used)} / ${fmtBytes(g.vram.total + g.gtt.total)}` : "–";
|
|
||||||
const s = $("#act-sys");
|
|
||||||
if (s) s.innerHTML = `
|
|
||||||
<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">${gpuStr}</span></div>
|
|
||||||
<div class="kv-row"><span class="kv-k">Speicherplatz (Disk)</span><span class="kv-v">${Math.round(SYS.disk.percent)} % 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>${spark(hist.cpu, "--accent")}</div>
|
|
||||||
<div><div class="meta text-xs">RAM-Verlauf</div>${spark(hist.ram, "--purple")}</div>
|
|
||||||
<div><div class="meta text-xs">VRAM-Verlauf</div>${spark(hist.gpu, "--on")}</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mount() {
|
|
||||||
$("#act-head").innerHTML = `<div class="pagehead"><div>
|
|
||||||
<h1>Aktivität</h1>
|
|
||||||
<div class="sub">Live-Auslastung und laufende Aufgaben (Downloads, Updates) mit Protokoll.</div></div></div>`;
|
|
||||||
|
|
||||||
$("#v-activity").innerHTML = `
|
|
||||||
<div class="card-h"><h3>Hintergrund-Aufgaben</h3><span class="meta" id="job-count"></span></div>
|
|
||||||
<div class="card-sub">Downloads & Updates erscheinen hier mit Live-Protokoll — zum Aufklappen klicken.</div>
|
|
||||||
<div id="jobs"></div>
|
|
||||||
<div id="jobs-empty" class="empty-c"><div class="e-t">Gerade nichts los.</div><div class="e-s">Alles ruhig — keine laufenden Aufgaben.</div></div>`;
|
|
||||||
|
|
||||||
$("#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 log = tracked.has(j.id) ? `<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; if ($("#act-sys")) renderSys(); }
|
|
||||||
|
|
||||||
export default { id: "jobs", mount, onJobs, onSystem };
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
// models.js — "Modelle"-Ansicht (v3): Schnelltest-Chat, Modell-Tabelle, Kontext-Konfiguration.
|
|
||||||
|
|
||||||
import { api } from "../core/api.js";
|
|
||||||
import { $, badge, esc, toast, confirmModal } from "../core/ui.js";
|
|
||||||
|
|
||||||
let ALL = [];
|
|
||||||
function refreshSoon() { document.dispatchEvent(new Event("mc:refresh")); }
|
|
||||||
|
|
||||||
function capTags(caps) {
|
|
||||||
if (!caps?.length) return "–";
|
|
||||||
return caps.map(c =>
|
|
||||||
c === "Code" ? `<span class="tag code">Code</span>`
|
|
||||||
: c === "Bild" ? `<span class="tag img">Bild</span>`
|
|
||||||
: `<span class="tag text">Text</span>`).join(" ");
|
|
||||||
}
|
|
||||||
function details(meta) {
|
|
||||||
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">${esc(q)} · ${c} · ${s}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mount() {
|
|
||||||
$("#m-head").innerHTML = `<div class="pagehead"><div>
|
|
||||||
<h1>Modelle</h1>
|
|
||||||
<div class="sub">Deine konfigurierten Modelle — testen, Kontext anpassen oder aus dem Speicher werfen.</div></div></div>`;
|
|
||||||
|
|
||||||
$("#m-chat").innerHTML = `
|
|
||||||
<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 id="chat-model"></select>
|
|
||||||
<label>Nachricht</label>
|
|
||||||
<textarea id="chat-msg" placeholder="z.B. „Erklär mir kurz, was du kannst."></textarea>
|
|
||||||
<button class="primary" id="chat-btn">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="card-sub">Modelle laden automatisch, sobald eine Anfrage kommt — du musst nichts manuell starten.</div>
|
|
||||||
<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 id="models"></tbody>
|
|
||||||
</table>
|
|
||||||
<div id="models-empty" class="empty-c" style="display:none">
|
|
||||||
<div class="e-t">Noch keine Modelle konfiguriert</div>
|
|
||||||
<div class="e-s">Hol dir unter „Cookbook" ein passendes Modell.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="cfg-modal" class="modal-overlay" style="display:none">
|
|
||||||
<div class="modal-card">
|
|
||||||
<button id="cfg-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
|
|
||||||
<h3>Modell konfigurieren</h3>
|
|
||||||
<p class="mono-sm" id="cfg-model-name" style="margin:-4px 0 16px"></p>
|
|
||||||
<label>Kontext-Größe (Tokens)</label>
|
|
||||||
<input id="cfg-ctx" type="number" value="8192">
|
|
||||||
<div class="hint">Höhere Werte erlauben längere Texte, brauchen aber mehr Grafikspeicher.</div>
|
|
||||||
<button class="primary" id="cfg-save" style="width:100%;margin-top:6px">Speichern</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
$("#chat-btn").addEventListener("click", sendChat);
|
|
||||||
$("#cfg-close").addEventListener("click", () => $("#cfg-modal").style.display = "none");
|
|
||||||
$("#cfg-modal").addEventListener("click", e => { if (e.target.id === "cfg-modal") $("#cfg-modal").style.display = "none"; });
|
|
||||||
$("#cfg-save").addEventListener("click", saveConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onStatus(s) {
|
|
||||||
ALL = s?.models || [];
|
|
||||||
const tb = $("#models"); if (!tb) return;
|
|
||||||
$("#models-empty").style.display = ALL.length ? "none" : "flex";
|
|
||||||
$("#m-count").textContent = ALL.length ? ALL.length + " konfiguriert" : "";
|
|
||||||
|
|
||||||
const sel = $("#chat-model"), cur = sel.value; sel.innerHTML = "";
|
|
||||||
tb.innerHTML = ALL.map(m => {
|
|
||||||
const fn = m.meta?.filename ? `<div class="li-sub mono-sm">${esc(m.meta.filename)}</div>` : "";
|
|
||||||
return `<tr>
|
|
||||||
<td class="mid" style="font-weight:500">${esc(m.name)}${fn}</td>
|
|
||||||
<td>${capTags(m.meta?.caps)}</td>
|
|
||||||
<td>${details(m.meta)}</td>
|
|
||||||
<td>${badge(m.state)}</td>
|
|
||||||
<td class="port">${m.port ?? "auto"}</td>
|
|
||||||
<td style="text-align:right;white-space:nowrap">
|
|
||||||
<button class="ghost" data-cfg="${esc(m.name)}">Konfigurieren</button>
|
|
||||||
<button class="ghost" data-unload="${esc(m.name)}">Entladen</button>
|
|
||||||
</td></tr>`;
|
|
||||||
}).join("");
|
|
||||||
for (const m of ALL) 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.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, 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 — aktiv beim nächsten Modell-Start."); $("#cfg-modal").style.display = "none"; refreshSoon(); }
|
|
||||||
catch (e) { toast(e.message, true); }
|
|
||||||
$("#cfg-save").disabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unloadOne(m) {
|
|
||||||
if (!await confirmModal({ title: `„${m}" 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(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 Laden 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,143 +0,0 @@
|
|||||||
// overview.js — Dashboard (v3): Klartext-Urteil, Metrik-Kacheln, System-Gesundheit,
|
|
||||||
// geführter Schnellstart, "Dein Stack". Speist sich aus /api/status + /api/system/stream.
|
|
||||||
|
|
||||||
import { api } from "../core/api.js";
|
|
||||||
import { $, esc, icon, toast, fmtBytes, confirmModal } from "../core/ui.js";
|
|
||||||
|
|
||||||
let S = null; // letzter Status
|
|
||||||
let SYS = null; // letzte System-Metriken
|
|
||||||
|
|
||||||
const RUNNING = new Set(["running", "ready", "loading", "starting"]);
|
|
||||||
|
|
||||||
function models() { return S?.models || []; }
|
|
||||||
function activeModel() { return models().find(m => RUNNING.has(m.state)); }
|
|
||||||
|
|
||||||
// ---- Aktionen (geführt) ----
|
|
||||||
function go(view) { document.querySelector(`.nav-item[data-view="${view}"]`)?.click(); }
|
|
||||||
async function freeMemory() {
|
|
||||||
const ok = await confirmModal({
|
|
||||||
title: "Speicher freigeben?",
|
|
||||||
body: "Alle aktuell geladenen Modelle werden aus dem Grafikspeicher geworfen. " +
|
|
||||||
"Beim nächsten Aufruf lädt das jeweilige Modell einfach neu — es geht nichts verloren.",
|
|
||||||
confirmLabel: "Speicher freigeben",
|
|
||||||
});
|
|
||||||
if (!ok) return;
|
|
||||||
try { await api("/api/unload", { method: "POST" }); toast("Speicher freigegeben."); setTimeout(() => document.dispatchEvent(new Event("mc:refresh")), 600); }
|
|
||||||
catch (e) { toast(e.message, true); }
|
|
||||||
}
|
|
||||||
window.mcOv = { go, freeMemory };
|
|
||||||
|
|
||||||
// ---- Hero (menschliches Urteil) ----
|
|
||||||
function renderHero() {
|
|
||||||
let title = "Verbinde…", sub = "";
|
|
||||||
if (S) {
|
|
||||||
const total = models().length, act = activeModel();
|
|
||||||
if (!S.swap_ok) { title = "LLM-Engine offline"; sub = "Der llama-swap-Dienst antwortet gerade nicht."; }
|
|
||||||
else if (SYS && SYS.ram.percent >= 90) { title = "Achtung: Speicher wird knapp."; sub = `Arbeitsspeicher bei ${Math.round(SYS.ram.percent)} % — eventuell Speicher freigeben.`; }
|
|
||||||
else {
|
|
||||||
title = "Alles läuft rund.";
|
|
||||||
sub = `${total} ${total === 1 ? "Modell" : "Modelle"} bereit · ${act ? `„${act.name}" geladen` : "keins geladen"} · keine Warnungen.`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$("#ov-hero").innerHTML = `<div class="pagehead"><div>
|
|
||||||
<h1>${esc(title)}</h1><div class="sub">${esc(sub)}</div></div></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Metrik-Kacheln ----
|
|
||||||
function tile(label, valueHtml, sub, vCls = "", sCls = "") {
|
|
||||||
return `<div class="tile"><div class="t-l">${label}</div>
|
|
||||||
<div class="t-v ${vCls}">${valueHtml}</div><div class="t-s ${sCls}">${sub}</div></div>`;
|
|
||||||
}
|
|
||||||
function tempWord(t) { return t == null ? "" : t < 55 ? "kühl & gesund" : t < 72 ? "normal" : "läuft heiß"; }
|
|
||||||
function renderTiles() {
|
|
||||||
const act = activeModel();
|
|
||||||
const swap = S?.swap_ok;
|
|
||||||
let engine = tile("LLM-Engine", swap ? "Online" : "Offline", "llama-swap", swap ? "ok" : "bad");
|
|
||||||
let model = tile("Aktives Modell", act ? esc(act.name) : "Keins",
|
|
||||||
act ? "im Grafikspeicher" : "nichts geladen");
|
|
||||||
let temp, mem;
|
|
||||||
if (SYS) {
|
|
||||||
const t = SYS.gpu_temp ?? SYS.cpu.temp;
|
|
||||||
temp = tile("GPU-Temperatur", t != null ? `${Math.round(t)}°` : "–", tempWord(t),
|
|
||||||
"", t != null && t < 72 ? "ok" : "");
|
|
||||||
const free = SYS.ram.total - SYS.ram.used;
|
|
||||||
mem = tile("Freier Speicher", `${(free / 1024 ** 3).toFixed(0)}<small> GB</small>`,
|
|
||||||
`von ${(SYS.ram.total / 1024 ** 3).toFixed(0)} GB`);
|
|
||||||
} else {
|
|
||||||
temp = tile("GPU-Temperatur", "–", "messe…");
|
|
||||||
mem = tile("Freier Speicher", "–", "messe…");
|
|
||||||
}
|
|
||||||
$("#ov-tiles").innerHTML = engine + model + temp + mem;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- System-Gesundheit ----
|
|
||||||
function meter(label, pct) {
|
|
||||||
const p = Math.max(0, Math.min(100, pct || 0));
|
|
||||||
const cls = p >= 90 ? "bad" : p >= 75 ? "warn" : "";
|
|
||||||
return `<div class="meter"><div class="meter-h"><span class="mk">${label}</span>
|
|
||||||
<span class="mv">${Math.round(p)} %</span></div>
|
|
||||||
<div class="bar ${cls}"><i style="width:${Math.max(2, p)}%"></i></div></div>`;
|
|
||||||
}
|
|
||||||
function renderHealth() {
|
|
||||||
const head = `<div class="card-h"><h3>System-Gesundheit</h3></div>
|
|
||||||
<div class="card-sub">So ausgelastet ist dein Mini-PC gerade.</div>`;
|
|
||||||
if (!SYS) { $("#ov-health").innerHTML = head + `<div class="empty">Warte auf Messwerte…</div>`; return; }
|
|
||||||
let gpuPct = 0;
|
|
||||||
const g = SYS.gpu;
|
|
||||||
if (g && (g.vram.total + g.gtt.total) > 0)
|
|
||||||
gpuPct = ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100;
|
|
||||||
$("#ov-health").innerHTML = head +
|
|
||||||
meter("Prozessor (CPU)", SYS.cpu.percent) +
|
|
||||||
meter("Arbeitsspeicher (RAM)", SYS.ram.percent) +
|
|
||||||
meter("Grafikspeicher (VRAM)", gpuPct);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Schnellstart ----
|
|
||||||
function qa(ic, tone, title, sub, onclick) {
|
|
||||||
return `<button class="qa" onclick="${onclick}">
|
|
||||||
<span class="qa-ic ${tone}">${icon(ic)}</span>
|
|
||||||
<span class="qa-main"><span class="qa-t">${title}</span><span class="qa-s">${sub}</span></span>
|
|
||||||
<span class="qa-arrow">${icon("chevron")}</span></button>`;
|
|
||||||
}
|
|
||||||
function renderQuickstart() {
|
|
||||||
$("#ov-quickstart").innerHTML =
|
|
||||||
`<div class="card-h"><h3>Schnellstart</h3></div>
|
|
||||||
<div class="card-sub">Die häufigsten Aufgaben — ein Klick.</div>` +
|
|
||||||
qa("search", "teal", "Modell finden", "Passend zu deiner Hardware", "window.mcOv.go('cookbook')") +
|
|
||||||
qa("refresh", "amber", "Speicher freigeben", "Modelle entladen · fragt vorher nach", "window.mcOv.freeMemory()") +
|
|
||||||
qa("file", "blue", "Logs ansehen", "Live mitlesen, was läuft", "window.mcOv.go('server')");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Dein Stack ----
|
|
||||||
function capTag(caps) {
|
|
||||||
if (!caps) return "";
|
|
||||||
if (caps.includes("Bild")) return `<span class="tag img">Bild</span>`;
|
|
||||||
if (caps.includes("Code")) return `<span class="tag code">Code</span>`;
|
|
||||||
return `<span class="tag text">Text</span>`;
|
|
||||||
}
|
|
||||||
function stackRow(m) {
|
|
||||||
const on = RUNNING.has(m.state);
|
|
||||||
const dot = m.state === "loading" || m.state === "starting" ? "load" : on ? "on" : "";
|
|
||||||
const status = on ? (m.state === "loading" ? "lädt…" : `geladen${m.port ? " · Port " + m.port : ""}`) : "bereit";
|
|
||||||
return `<div class="li"><span class="li-dot ${dot}"></span>
|
|
||||||
<span class="li-id" style="flex:1">${esc(m.name)}</span>
|
|
||||||
${capTag(m.meta?.caps)}
|
|
||||||
<span class="li-meta" style="width:120px;text-align:right">${status}</span></div>`;
|
|
||||||
}
|
|
||||||
function renderStack() {
|
|
||||||
const ms = models();
|
|
||||||
$("#ov-stack").innerHTML =
|
|
||||||
`<div class="card-h"><h3>Dein Stack</h3><span class="meta">${ms.length ? ms.length + " konfiguriert" : ""}</span></div>` +
|
|
||||||
(ms.length
|
|
||||||
? `<div class="list">${ms.map(stackRow).join("")}</div>`
|
|
||||||
: `<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></div>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAll() { renderHero(); renderTiles(); renderHealth(); renderQuickstart(); renderStack(); }
|
|
||||||
|
|
||||||
function mount() { renderQuickstart(); renderAll(); }
|
|
||||||
function onStatus(s) { S = s; renderHero(); renderTiles(); renderStack(); }
|
|
||||||
function onSystem(sys) { SYS = sys; renderHero(); renderTiles(); renderHealth(); }
|
|
||||||
|
|
||||||
export default { id: "overview", mount, onStatus, onSystem };
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
// server.js — Server & Wartung (v3): Dienste, OS-Updates, Reboot, Live-Konsole.
|
|
||||||
// Heikle Aktionen mit Klartext-Bestätigung (confirmModal/promptModal).
|
|
||||||
|
|
||||||
import { api, getToken } from "../core/api.js";
|
|
||||||
import { $, toast, icon, confirmModal, promptModal } from "../core/ui.js";
|
|
||||||
import { track } from "./jobs.js";
|
|
||||||
|
|
||||||
function refreshSoon() { document.dispatchEvent(new Event("mc:refresh")); }
|
|
||||||
|
|
||||||
function mount() {
|
|
||||||
$("#wartung").outerHTML = `<div id="wartung" style="display:flex;flex-direction:column;gap:var(--sp-4)">
|
|
||||||
<div class="pagehead"><div>
|
|
||||||
<h1>Server & Wartung</h1>
|
|
||||||
<div class="sub">Dienste steuern, Updates einspielen und live mitlesen — ohne SSH/Terminal.</div>
|
|
||||||
</div></div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-h"><h3>Dienste & Applikation</h3></div>
|
|
||||||
<div class="card-sub">Neustarts sind harmlos und passwortlos — nichts geht dabei verloren.</div>
|
|
||||||
<div class="btn-row">
|
|
||||||
<button id="w-restart-swap">LLM-Engine neustarten</button>
|
|
||||||
<button id="w-restart-mc">Dashboard neustarten</button>
|
|
||||||
<button id="w-update">Nach Updates suchen</button>
|
|
||||||
<button id="w-unload" class="ghost">Grafikspeicher leeren</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<div class="btn-row">
|
|
||||||
<button id="w-os-update">OS-Updates installieren</button>
|
|
||||||
<button id="w-reboot" class="danger">Server neustarten (Reboot)</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-h"><h3>Live-Konsole</h3>
|
|
||||||
<select id="w-console-sel" style="margin:0 0 0 auto;width:220px">
|
|
||||||
<option value="llama-swap">LLM-Engine (llama-swap)</option>
|
|
||||||
<option value="mission-control">Dashboard (mission-control)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="card-sub">Was der Dienst gerade tut — live mitlesen.</div>
|
|
||||||
<div id="w-console" class="console">Verbinde…</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
$("#w-restart-swap").addEventListener("click", () => restartService("llama-swap",
|
|
||||||
"LLM-Engine neustarten?", "Die Engine startet neu. Geladene Modelle werden kurz entladen (~5 Sekunden), laden danach automatisch wieder."));
|
|
||||||
$("#w-restart-mc").addEventListener("click", () => restartService("mission-control",
|
|
||||||
"Dashboard neustarten?", "Diese Oberfläche trennt sich kurz und verbindet automatisch wieder. Laufende Downloads laufen weiter."));
|
|
||||||
$("#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, out = $("#w-console");
|
|
||||||
out.textContent = "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 => { out.textContent += e.data; out.scrollTop = out.scrollHeight; };
|
|
||||||
ws.onclose = () => { out.textContent += "\n— Verbindung getrennt —"; };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restartService(name, title, body) {
|
|
||||||
if (!await confirmModal({ title, body, confirmLabel: "Neustarten" })) return;
|
|
||||||
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 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(refreshSoon, 600); }
|
|
||||||
catch (e) { toast(e.message, true); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function update() {
|
|
||||||
try { const r = await api("/api/update", { method: "POST" }); toast("Update läuft — siehe Aktivität."); track(r.job_id); }
|
|
||||||
catch (e) { 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."); track(r.job_id); }
|
|
||||||
catch (e) { 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) { toast(e.message, true); }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { id: "server", mount };
|
|
||||||
Reference in New Issue
Block a user