feat(v9): Phase 2+3 — harter Cutover auf Hermes-Agent (:8642)
Mission Control proxyt jetzt zum Nous-Hermes-Agent-Server statt zum Eigenbau-Agenten: - routers/hermes.py: Chat-WS streamt als Proxy zu :8642 (OpenAI-API), WS-Contract (thinking/token/done/error) beibehalten -> HermesPanel unveraendert, kein Frontend-Build noetig. Status meldet Hermes-Health. - config.py: HERMES_API_URL + HERMES_API_KEY (Key aus ~/.hermes/.env); HERMES_SIMPLE/COMPLEX_MODEL entfernt (Routing macht Hermes selbst). - hermes_agent.py geloescht (ReAct-Loop, tote Tools, DuckDuckGo, Fake-Stream). - CLAUDE/README/ROADMAP aktualisiert; Phasen 2+3 abgehakt. Lokaler Smoke-Test: App importiert, /api/hermes/status liefert 200. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,6 @@ FastAPI-Backend + Svelte 5-Frontend (Vite-Build). **Leitprinzip: KISS — SoC oh
|
|||||||
- **`llamaswap.py`** — spricht `llama-swap` an und liest/schreibt dessen `config.yaml` per `ruamel.yaml`.
|
- **`llamaswap.py`** — spricht `llama-swap` an und liest/schreibt dessen `config.yaml` per `ruamel.yaml`.
|
||||||
- **`hw_math.py`** — Odysseus-Fit-Mathe: VRAM/RAM-Bedarf, tps-Schätzung, `max_ctx_for`, `extract_params_b`.
|
- **`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.
|
- **`recipes.py`** — kuratierte Use-Case-Stacks fürs Cookbook 2.0.
|
||||||
- **`hermes_agent.py`** — Kern des Hermes-Agenten: Tool-Definitionen, Tool-Dispatcher, LLM-Loop (ReAct), Modell-Routing. **⚠️ DEPRECATED ab v9** — wird durch das Nous Hermes-Agent-Framework ersetzt (siehe ROADMAP v9). Bleibt bis Phase 3 bestehen, dann löschen.
|
|
||||||
- **`mcp_memory.py`** — stdio-MCP-Server: Gedächtnis-Tools für Cline/OpenCode/Claude Code.
|
- **`mcp_memory.py`** — stdio-MCP-Server: Gedächtnis-Tools für Cline/OpenCode/Claude Code.
|
||||||
- **`routers/*.py`** — ein Router je Bereich:
|
- **`routers/*.py`** — ein Router je Bereich:
|
||||||
- `models.py` — status/download/register/update_model/unload/chat
|
- `models.py` — status/download/register/update_model/unload/chat
|
||||||
@@ -24,7 +23,7 @@ FastAPI-Backend + Svelte 5-Frontend (Vite-Build). **Leitprinzip: KISS — SoC oh
|
|||||||
- `integration.py` — test (Engine-Verbindungstest)
|
- `integration.py` — test (Engine-Verbindungstest)
|
||||||
- `news.py` — RSS-Aggregation via stdlib
|
- `news.py` — RSS-Aggregation via stdlib
|
||||||
- `memory.py` — Gedächtnis-CRUD (SQLite, WAL-Mode), 5 Kategorien
|
- `memory.py` — Gedächtnis-CRUD (SQLite, WAL-Mode), 5 Kategorien
|
||||||
- `hermes.py` — Chat-WebSocket/transcribe(Whisper)/tts(Piper)/status/pubkey
|
- `hermes.py` — Chat-WS **proxyt zum Hermes-Agent-Server (:8642)** + transcribe(Whisper)/tts(Piper)/status/pubkey
|
||||||
|
|
||||||
**Frontend** (`frontend/src/`, Svelte 5 + Vite, Build → `static/dist/`):
|
**Frontend** (`frontend/src/`, Svelte 5 + Vite, Build → `static/dist/`):
|
||||||
- **`index.html`** — Gerüst: Sidebar-Nav (10 Tabs), Topbar, Alert-Banner, View-Container je Bereich.
|
- **`index.html`** — Gerüst: Sidebar-Nav (10 Tabs), Topbar, Alert-Banner, View-Container je Bereich.
|
||||||
@@ -85,8 +84,8 @@ Oder über den Self-Update-Button in Mission Control (empfohlen).
|
|||||||
| `MC_DEFAULT_TTL` | `300` | Sekunden bis Auto-Unload |
|
| `MC_DEFAULT_TTL` | `300` | Sekunden bis Auto-Unload |
|
||||||
| `MC_SOURCE_DIR` | `~/mission-control` | Self-Update Quelle |
|
| `MC_SOURCE_DIR` | `~/mission-control` | Self-Update Quelle |
|
||||||
| `MC_MEMORY_DB` | `{MODELS_DIR}/mission-control-memory.db` | SQLite Gedächtnis |
|
| `MC_MEMORY_DB` | `{MODELS_DIR}/mission-control-memory.db` | SQLite Gedächtnis |
|
||||||
| `HERMES_SIMPLE_MODEL` | `scout` | Modell für einfache Tasks |
|
| `HERMES_API_URL` | `http://127.0.0.1:8642/v1` | Hermes-Agent-Server (OpenAI-API) |
|
||||||
| `HERMES_COMPLEX_MODEL` | `coder` | Modell für komplexe Tasks |
|
| `HERMES_API_KEY` | aus `~/.hermes/.env` | API-Key des Hermes-Servers (sonst Env) |
|
||||||
| `HERMES_WINDOWS_HOST` | leer | Windows-PC IP für SSH |
|
| `HERMES_WINDOWS_HOST` | leer | Windows-PC IP für SSH |
|
||||||
| `HERMES_WINDOWS_USER` | `TobisPC` | Windows SSH-Username |
|
| `HERMES_WINDOWS_USER` | `TobisPC` | Windows SSH-Username |
|
||||||
| `HERMES_SSH_KEY` | `~/.ssh/id_ed25519_hermes_agent` | SSH-Key für Hermes |
|
| `HERMES_SSH_KEY` | `~/.ssh/id_ed25519_hermes_agent` | SSH-Key für Hermes |
|
||||||
@@ -113,7 +112,8 @@ Oder über den Self-Update-Button in Mission Control (empfohlen).
|
|||||||
- **Piper TTS**: Binary + Stimm-Modell müssen separat installiert werden (nicht via pip).
|
- **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).
|
- **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`).
|
- **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. `hermes_agent.py` muss entsprechend lesen.
|
- **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.
|
||||||
- **OpenCode Config**: Datei heißt `opencode.jsonc` (nicht `.json`), Key ist `"providers"` (Plural).
|
- **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).
|
- **ConnectPanel hinter NPM-Proxy**: `location.hostname` zeigt Proxy-Domain → LAN-IP Override im Verbinden-Tab setzen (localStorage).
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ Oder über den Self-Update-Button in Mission Control (empfohlen).
|
|||||||
**v8.1 (Bug-Fix)**: system_status-Keys, Model-Routing, asyncio, sudo-PW für Engine-Update, Chat-History in sessionStorage.
|
**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.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.
|
**v8.3 (Memory Import)**: Import aus Cloud-KIs (Claude/Gemini/ChatGPT) — zeilenweiser Batch-POST.
|
||||||
**v9 (Hermes-Agent-Adoption, IN ARBEIT, Entscheidung 2026-06-23)**: Eigenbau-Agent → Nous Hermes-Agent-Framework (MIT). MC wird Control-Plane. Eigenes Memory bleibt via MCP erhalten. 5 Phasen, siehe ROADMAP v9. Aktuell: Phase 0 (Proof auf Bosgame).
|
**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–2 live + Phase 3 (harter Cutover): `hermes_agent.py` entfernt, `routers/hermes.py` proxyt zu `:8642`, Modell-Routing macht Hermes selbst. Offen: Self-Update auch für `hermes-gateway`, Status-/Scheduler-/Skills-Panels (Phase 4). Siehe ROADMAP v9.
|
||||||
|
|
||||||
Offene v8-Einrichtungsschritte (kein Code, nur Setup):
|
Offene v8-Einrichtungsschritte (kein Code, nur Setup):
|
||||||
- Piper Binary + Kerstin-Stimme installieren (Befehle im Hermes-Tab → ⚙ Setup)
|
- Piper Binary + Kerstin-Stimme installieren (Befehle im Hermes-Tab → ⚙ Setup)
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ Alle Einstellungen über Umgebungsvariablen in der systemd-Unit:
|
|||||||
| `MC_UPDATE_CMD` | leer | Engine-Update-Befehl |
|
| `MC_UPDATE_CMD` | leer | Engine-Update-Befehl |
|
||||||
| `MC_SOURCE_DIR` | `~/mission-control` | Self-Update Quelle |
|
| `MC_SOURCE_DIR` | `~/mission-control` | Self-Update Quelle |
|
||||||
| `MC_MEMORY_DB` | `{MODELS_DIR}/mission-control-memory.db` | Gedächtnis-Datenbank |
|
| `MC_MEMORY_DB` | `{MODELS_DIR}/mission-control-memory.db` | Gedächtnis-Datenbank |
|
||||||
| `HERMES_SIMPLE_MODEL` | `scout` | Modell für einfache Tasks |
|
| `HERMES_API_URL` | `http://127.0.0.1:8642/v1` | Hermes-Agent-Server (OpenAI-API) |
|
||||||
| `HERMES_COMPLEX_MODEL` | `coder` | Modell für komplexe Tasks |
|
| `HERMES_API_KEY` | aus `~/.hermes/.env` | API-Key des Hermes-Servers (sonst Env) |
|
||||||
| `HERMES_WINDOWS_HOST` | leer | Windows-PC IP für SSH-Zugriff |
|
| `HERMES_WINDOWS_HOST` | leer | Windows-PC IP für SSH-Zugriff |
|
||||||
| `PIPER_BIN` | `/opt/mission-control/piper/piper` | Piper TTS Binary |
|
| `PIPER_BIN` | `/opt/mission-control/piper/piper` | Piper TTS Binary |
|
||||||
| `PIPER_VOICE` | `.../de_DE-kerstin-low.onnx` | Piper Stimme |
|
| `PIPER_VOICE` | `.../de_DE-kerstin-low.onnx` | Piper Stimme |
|
||||||
|
|||||||
+2
-2
@@ -20,8 +20,8 @@
|
|||||||
**Phasen:**
|
**Phasen:**
|
||||||
- [x] **Phase 0 — Proof auf echter HW (✅ 2026-06-23):** Hermes Agent **v0.17.0** installiert (`~/.hermes/`, Code in `~/.hermes/hermes-agent`). `coder` (Qwen3-Coder-30B-A3B) in llama-swap um `--jinja` ergänzt (Backup: `/etc/llama-swap/config.yaml.bak-pre-jinja`). `hermes config`: provider=custom, base_url=`http://127.0.0.1:8080/v1`, ctx 65536. Proof via `hermes -z … --yolo`: echter Tool-Call (`free -h` → „21 GiB frei", exakt deckungsgleich mit Live-Wert). Befunde: ripgrep fehlt (grep-Fallback, später `apt install ripgrep`); Hermes hat **eigenes lokales STT** (faster-whisper); terminal.backend=local; Version nicht sauber pinbar → v0.17.0 in `~/.hermes/PINNED_VERSION`, **kein** `hermes update`.
|
- [x] **Phase 0 — Proof auf echter HW (✅ 2026-06-23):** Hermes Agent **v0.17.0** installiert (`~/.hermes/`, Code in `~/.hermes/hermes-agent`). `coder` (Qwen3-Coder-30B-A3B) in llama-swap um `--jinja` ergänzt (Backup: `/etc/llama-swap/config.yaml.bak-pre-jinja`). `hermes config`: provider=custom, base_url=`http://127.0.0.1:8080/v1`, ctx 65536. Proof via `hermes -z … --yolo`: echter Tool-Call (`free -h` → „21 GiB frei", exakt deckungsgleich mit Live-Wert). Befunde: ripgrep fehlt (grep-Fallback, später `apt install ripgrep`); Hermes hat **eigenes lokales STT** (faster-whisper); terminal.backend=local; Version nicht sauber pinbar → v0.17.0 in `~/.hermes/PINNED_VERSION`, **kein** `hermes update`.
|
||||||
- [x] **Phase 1 — Memory-Bridge (✅ 2026-06-23):** `mcp_memory.py` via `hermes mcp add mission-control-memory --command /opt/mission-control/.venv/bin/python --env MC_URL=http://127.0.0.1:9000 --args …/mcp_memory.py` registriert (5/5 Tools, Test ✓). Erkenntnis: `mcp_memory.py` ist stdio-MCP→HTTP-Wrapper auf MC `/api/memory` — **MC :9000 muss laufen**. Proof: Hermes liest beide Bestands-Memories *und* schrieb neuen Eintrag (verifiziert in SQLite, 2→3). v7/v8.3-Gedächtnis ohne Migration im Agenten. Hinweis: Hermes' **eigenes** Session-Memory läuft zusätzlich → zwei Schichten im Blick behalten.
|
- [x] **Phase 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.
|
||||||
- [ ] **Phase 2 — Control-Plane:** systemd-Dienst `hermes-agent.service` + MC-Panels (Agent-Status, Chat→:8642, Voice-Wrap).
|
- [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.
|
||||||
- [ ] **Phase 3 — Aufräumen:** `hermes_agent.py` + tote Tools löschen, `routers/hermes.py` zu Proxy verschlanken, Self-Update auf beide Dienste erweitern.
|
- [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.
|
||||||
- [ ] **Phase 4 — Auszahlung:** Cron-Scheduler + Skills im Dashboard sichtbar machen (das geschenkte v9).
|
- [ ] **Phase 4 — Auszahlung:** Cron-Scheduler + Skills im Dashboard sichtbar machen (das geschenkte v9).
|
||||||
|
|
||||||
**Entscheidungen (gesetzt):** Memory via MCP einbinden (nicht migrieren) · HermesPanel behalten (nicht hermes-webui, das ist an Interna gekoppelt) · erst Qwen3-coder behalten, dann Hermes-4.3-36B A/B-testen · WebUI = Lockstep-Falle, stattdessen stabiler OpenAI-API-Server.
|
**Entscheidungen (gesetzt):** Memory via MCP einbinden (nicht migrieren) · HermesPanel behalten (nicht hermes-webui, das ist an Interna gekoppelt) · erst Qwen3-coder behalten, dann Hermes-4.3-36B A/B-testen · WebUI = Lockstep-Falle, stattdessen stabiler OpenAI-API-Server.
|
||||||
|
|||||||
@@ -44,9 +44,31 @@ 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
|
# Hermes Agent (Nous-Framework, eigener Dienst auf :8642 — siehe ROADMAP v9).
|
||||||
HERMES_SIMPLE_MODEL = os.environ.get("HERMES_SIMPLE_MODEL", "scout")
|
# Mission Control proxyt nur noch dorthin; Modell-Routing macht Hermes selbst.
|
||||||
HERMES_COMPLEX_MODEL = os.environ.get("HERMES_COMPLEX_MODEL", "coder")
|
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()
|
||||||
HERMES_WINDOWS_HOST = os.environ.get("HERMES_WINDOWS_HOST", "")
|
HERMES_WINDOWS_HOST = os.environ.get("HERMES_WINDOWS_HOST", "")
|
||||||
HERMES_WINDOWS_USER = os.environ.get("HERMES_WINDOWS_USER", "TobisPC")
|
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")))
|
HERMES_SSH_KEY = Path(os.path.expanduser(os.environ.get("HERMES_SSH_KEY", "~/.ssh/id_ed25519_hermes_agent")))
|
||||||
|
|||||||
-463
@@ -1,463 +0,0 @@
|
|||||||
"""
|
|
||||||
Hermes Agent — lokaler KI-Assistent fuer das Agentic OS.
|
|
||||||
|
|
||||||
Empfaengt Aufgaben per Text (und Voice), nutzt llama-swap als LLM-Backend,
|
|
||||||
kann per Tools auf das Bosgame und spaeter per SSH auf den Windows-PC zugreifen.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable, Awaitable
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from config import LLAMA_SWAP_URL, HERMES_SIMPLE_MODEL, HERMES_COMPLEX_MODEL
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tool-Definitionen (OpenAI Function Calling Format)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
TOOL_DEFS = [
|
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "read_file",
|
|
||||||
"description": "Liest eine Textdatei auf dem Bosgame.",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"path": {"type": "string", "description": "Absoluter Pfad zur Datei"}
|
|
||||||
},
|
|
||||||
"required": ["path"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "list_directory",
|
|
||||||
"description": "Listet Dateien und Ordner eines Verzeichnisses auf dem Bosgame.",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"path": {"type": "string", "description": "Absoluter Pfad zum Verzeichnis"}
|
|
||||||
},
|
|
||||||
"required": ["path"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "run_command",
|
|
||||||
"description": (
|
|
||||||
"Fuehrt einen Lese-Befehl auf dem Bosgame aus (kein sudo, kein rm). "
|
|
||||||
"Geeignet fuer: ls, cat, ps, df, free, journalctl, systemctl status, ip, curl, etc."
|
|
||||||
),
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"command": {"type": "string", "description": "Shell-Befehl"}
|
|
||||||
},
|
|
||||||
"required": ["command"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "get_system_status",
|
|
||||||
"description": "Gibt aktuellen System-Status zurueck: CPU, RAM, GPU, laufende Modelle.",
|
|
||||||
"parameters": {"type": "object", "properties": {}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "get_memories",
|
|
||||||
"description": "Laedt alle gespeicherten Fakten und Entscheidungen.",
|
|
||||||
"parameters": {"type": "object", "properties": {}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "add_memory",
|
|
||||||
"description": "Speichert eine neue Information dauerhaft ins Gedaechtnis.",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"content": {"type": "string", "description": "Der zu speichernde Fakt"},
|
|
||||||
"category": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["user", "instruction", "stable", "versioned", "ephemeral"],
|
|
||||||
"description": (
|
|
||||||
"user=Fakten ueber den User (Wer ist er? Wie arbeitet er?), "
|
|
||||||
"instruction=Verhaltensregeln fuer alle KI-Tools, "
|
|
||||||
"stable=Projektfakten, "
|
|
||||||
"versioned=Tech-Versionen (bei Updates ueberschreiben), "
|
|
||||||
"ephemeral=temporaer (7 Tage)"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["content"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "web_search",
|
|
||||||
"description": "Sucht im Internet nach aktuellen Informationen.",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"query": {"type": "string", "description": "Suchbegriff"}
|
|
||||||
},
|
|
||||||
"required": ["query"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tool-Implementierungen
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_BLOCKED_CMDS = {
|
|
||||||
"rm", "rmdir", "mv", "cp", "dd", "mkfs", "fdisk",
|
|
||||||
"parted", "shutdown", "reboot", "halt", "poweroff",
|
|
||||||
"chmod", "chown", "passwd", "userdel", "useradd",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _exec_read_file(path: str) -> str:
|
|
||||||
try:
|
|
||||||
p = Path(path)
|
|
||||||
if not p.exists():
|
|
||||||
return f"Datei nicht gefunden: {path}"
|
|
||||||
content = p.read_text(errors="replace")
|
|
||||||
if len(content) > 8000:
|
|
||||||
return content[:8000] + f"\n\n... (gekuerzt, gesamt {len(content):,} Zeichen)"
|
|
||||||
return content
|
|
||||||
except Exception as e:
|
|
||||||
return f"Fehler beim Lesen: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def _exec_list_directory(path: str) -> str:
|
|
||||||
try:
|
|
||||||
p = Path(path)
|
|
||||||
if not p.exists():
|
|
||||||
return f"Pfad nicht gefunden: {path}"
|
|
||||||
items = sorted(p.iterdir(), key=lambda x: (x.is_file(), x.name.lower()))
|
|
||||||
lines = []
|
|
||||||
for item in items[:80]:
|
|
||||||
if item.is_dir():
|
|
||||||
lines.append(f"📁 {item.name}/")
|
|
||||||
else:
|
|
||||||
size = item.stat().st_size
|
|
||||||
sz = f"{size/1024/1024:.1f} MB" if size > 1024*1024 else f"{size/1024:.1f} KB" if size > 1024 else f"{size} B"
|
|
||||||
lines.append(f"📄 {item.name} ({sz})")
|
|
||||||
total = sum(1 for _ in p.iterdir())
|
|
||||||
if total > 80:
|
|
||||||
lines.append(f"... und {total - 80} weitere")
|
|
||||||
return "\n".join(lines) if lines else "(leer)"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Fehler: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def _exec_run_command(command: str) -> str:
|
|
||||||
first = command.strip().split()[0] if command.strip() else ""
|
|
||||||
if first in _BLOCKED_CMDS or "sudo" in command:
|
|
||||||
return f"Befehl '{first}' blockiert. Nutze Mission Control fuer Systemoperationen."
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
command, shell=True, capture_output=True, text=True, timeout=30
|
|
||||||
)
|
|
||||||
out = (result.stdout + result.stderr).strip()
|
|
||||||
if len(out) > 4000:
|
|
||||||
out = out[:4000] + "\n... (Ausgabe gekuerzt)"
|
|
||||||
return out or "(kein Output)"
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return "Timeout nach 30 Sekunden."
|
|
||||||
except Exception as e:
|
|
||||||
return f"Fehler: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def _exec_get_system_status() -> str:
|
|
||||||
base = "http://127.0.0.1:9000"
|
|
||||||
try:
|
|
||||||
s = httpx.get(f"{base}/api/status", timeout=5).json()
|
|
||||||
models = s.get("models", [])
|
|
||||||
running = [m for m in models if m.get("state") in ("running", "ready", "loading")]
|
|
||||||
model_str = ", ".join(m["name"] for m in running) if running else "keines"
|
|
||||||
except Exception:
|
|
||||||
model_str = "unbekannt"
|
|
||||||
|
|
||||||
try:
|
|
||||||
d = httpx.get(f"{base}/api/system/status", timeout=5).json()
|
|
||||||
cpu = d.get("cpu", {})
|
|
||||||
ram = d.get("ram", {})
|
|
||||||
gtt = d.get("gpu", {}).get("gtt", {})
|
|
||||||
cpu_pct = round(cpu.get("percent", 0), 1)
|
|
||||||
cpu_temp = cpu.get("temp", "?")
|
|
||||||
ram_used = round(ram.get("used", 0) / (1024 ** 3), 1)
|
|
||||||
ram_total = round(ram.get("total", 0) / (1024 ** 3), 1)
|
|
||||||
gtt_used = round(gtt.get("used", 0) / (1024 ** 3), 1)
|
|
||||||
gtt_total = round(gtt.get("total", 0) / (1024 ** 3), 1)
|
|
||||||
return (
|
|
||||||
f"Aktives Modell: {model_str}\n"
|
|
||||||
f"CPU: {cpu_pct}% | "
|
|
||||||
f"RAM: {ram_used} / {ram_total} GB | "
|
|
||||||
f"GPU: {gtt_used} / {gtt_total} GB GTT\n"
|
|
||||||
f"Temp: CPU {cpu_temp}°C"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return f"Aktives Modell: {model_str}\nSystem-Metriken nicht verfuegbar: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def _exec_get_memories() -> str:
|
|
||||||
try:
|
|
||||||
items = httpx.get("http://127.0.0.1:9000/api/memory", timeout=5).json()
|
|
||||||
if not items:
|
|
||||||
return "Kein Gedaechtnis vorhanden."
|
|
||||||
return "\n".join(f"[{m['category']}] {m['content']}" for m in items)
|
|
||||||
except Exception as e:
|
|
||||||
return f"Fehler: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def _exec_add_memory(content: str, category: str = "stable") -> str:
|
|
||||||
try:
|
|
||||||
r = httpx.post(
|
|
||||||
"http://127.0.0.1:9000/api/memory",
|
|
||||||
json={"content": content, "category": category, "source": "hermes"},
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
return f"Gespeichert: {content}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Fehler: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def _exec_web_search(query: str) -> str:
|
|
||||||
try:
|
|
||||||
r = httpx.get(
|
|
||||||
"https://api.duckduckgo.com/",
|
|
||||||
params={"q": query, "format": "json", "no_html": 1, "skip_disambig": 1},
|
|
||||||
timeout=10,
|
|
||||||
headers={"User-Agent": "HermesAgent/1.0"},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
data = r.json()
|
|
||||||
results = []
|
|
||||||
if data.get("AbstractText"):
|
|
||||||
results.append(data["AbstractText"])
|
|
||||||
for rt in data.get("RelatedTopics", [])[:4]:
|
|
||||||
if isinstance(rt, dict) and rt.get("Text"):
|
|
||||||
results.append(rt["Text"])
|
|
||||||
return "\n\n".join(results) if results else "Keine direkten Ergebnisse. Versuche eine genauere Suchanfrage."
|
|
||||||
except Exception as e:
|
|
||||||
return f"Websuche fehlgeschlagen: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def execute_tool(name: str, args: dict) -> str:
|
|
||||||
if name == "read_file":
|
|
||||||
return _exec_read_file(args.get("path", ""))
|
|
||||||
if name == "list_directory":
|
|
||||||
return _exec_list_directory(args.get("path", ""))
|
|
||||||
if name == "run_command":
|
|
||||||
return _exec_run_command(args.get("command", ""))
|
|
||||||
if name == "get_system_status":
|
|
||||||
return _exec_get_system_status()
|
|
||||||
if name == "get_memories":
|
|
||||||
return _exec_get_memories()
|
|
||||||
if name == "add_memory":
|
|
||||||
return _exec_add_memory(args.get("content", ""), args.get("category", "stable"))
|
|
||||||
if name == "web_search":
|
|
||||||
return _exec_web_search(args.get("query", ""))
|
|
||||||
return f"Unbekanntes Tool: {name}"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Modell-Auswahl
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_COMPLEX_KW = {
|
|
||||||
# Erstellen / Schreiben
|
|
||||||
"schreib", "schreibe", "erstell", "erstelle", "generier", "generiere",
|
|
||||||
"baue", "bau",
|
|
||||||
# Planen / Analysieren / Recherchieren
|
|
||||||
"plan", "analysier", "analysiere", "recherchier", "recherchiere",
|
|
||||||
"vergleich", "vergleiche", "bewert", "bewerte", "untersuche", "untersuchen",
|
|
||||||
# Entwicklung / Code
|
|
||||||
"refactor", "debug", "implementier", "implementiere", "entwickl", "entwickle",
|
|
||||||
"code", "programm", "programmier", "programmiere",
|
|
||||||
# Web-Suche / Recherche
|
|
||||||
"such im internet", "suche im internet", "such online", "suche online",
|
|
||||||
"such nach aktuell", "suche nach aktuell",
|
|
||||||
# Erklären (ausführlich)
|
|
||||||
"erklaer ausfuehrlich", "erklaere ausfuehrlich", "erklaer detailliert",
|
|
||||||
"erklaere detailliert", "erklaer mir genau", "was ist der unterschied",
|
|
||||||
# Optimieren / Verbessern
|
|
||||||
"optimier", "optimiere", "verbessere", "verbessern", "reparier", "repariere",
|
|
||||||
# Multi-Step / Komplex
|
|
||||||
"konfiguriere", "konfigurieren", "installier", "installiere",
|
|
||||||
"wie kann ich", "schritt fuer schritt", "schritt für schritt",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def choose_model(message: str) -> str:
|
|
||||||
"""Waehlt simple/complex Modell — upgraded wenn noetig, degradiert nie."""
|
|
||||||
lower = message.lower()
|
|
||||||
wants_complex = any(kw in lower for kw in _COMPLEX_KW)
|
|
||||||
|
|
||||||
# Nicht downgraden wenn komplexes Modell bereits geladen ist
|
|
||||||
try:
|
|
||||||
s = httpx.get("http://127.0.0.1:9000/api/status", timeout=3).json()
|
|
||||||
loaded = {m["name"] for m in s.get("models", []) if m.get("state") in ("running", "ready")}
|
|
||||||
if HERMES_COMPLEX_MODEL in loaded:
|
|
||||||
return HERMES_COMPLEX_MODEL
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return HERMES_COMPLEX_MODEL if wants_complex else HERMES_SIMPLE_MODEL
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# System-Prompt
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
SYSTEM_PROMPT = """\
|
|
||||||
Du bist Hermes, ein lokaler KI-Assistent der dauerhaft auf dem Bosgame M5 laeuft.
|
|
||||||
|
|
||||||
Dein Zuhause (Bosgame M5):
|
|
||||||
- AMD Strix Halo, ~124 GB GTT-Speicher, Ubuntu 26.04, Kernel 7.0
|
|
||||||
- LAN-IP: 192.168.178.151
|
|
||||||
- Dienste: llama-swap :8080 (Inference), Mission Control :9000 (Dashboard)
|
|
||||||
- Modelle: coder (Qwen3-30B-A3B), scout (Qwen3-8B), vision (Qwen3-VL)
|
|
||||||
- Du laeuft als Teil von Mission Control
|
|
||||||
|
|
||||||
Gespeichertes Wissen (nach Kategorie):
|
|
||||||
{memories}
|
|
||||||
|
|
||||||
Verhaltensregeln (fest):
|
|
||||||
- Antworte praegnant auf Deutsch (max 2-3 Saetze wenn nicht ausdruecklich mehr gewuenscht)
|
|
||||||
- Kuendige an was du tust bevor du es tust
|
|
||||||
- Frag nach bevor du Dateien ueberschreibst oder Dienste neustartest
|
|
||||||
- Nutze get_system_status() wenn du nicht sicher bist was gerade laeuft
|
|
||||||
- Bei einfachen Fragen: keine Tools, direkt antworten
|
|
||||||
|
|
||||||
Gedaechtnis-Kategorien (fuer add_memory):
|
|
||||||
- user → Fakten ueber den User: Wer er ist, wie er arbeitet, was er bevorzugt
|
|
||||||
- instruction → Verhaltensregeln die fuer ALLE KI-Tools gelten sollen
|
|
||||||
- stable → Technische Projektfakten (IPs, Ports, Architektur)
|
|
||||||
- versioned → Tech-Versionen (bei Updates ueberschreiben, nicht anhaengen)
|
|
||||||
- ephemeral → Temporaerer Kontext (7 Tage, dann weg)
|
|
||||||
|
|
||||||
Speichere wichtige Erkenntnisse aktiv per add_memory() — besonders wenn der User
|
|
||||||
etwas ueber sich selbst erwaehnt (→ user) oder Verhaltensregeln nennt (→ instruction).
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# LLM-Aufruf (synchron, in Executor ausfuehren)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _call_llm(model: str, messages: list) -> dict:
|
|
||||||
r = httpx.post(
|
|
||||||
f"{LLAMA_SWAP_URL}/v1/chat/completions",
|
|
||||||
json={
|
|
||||||
"model": model,
|
|
||||||
"messages": messages,
|
|
||||||
"tools": TOOL_DEFS,
|
|
||||||
"tool_choice": "auto",
|
|
||||||
"temperature": 0.3,
|
|
||||||
},
|
|
||||||
timeout=120,
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Haupt-Agent-Loop
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
SendFn = Callable[[dict], Awaitable[None]]
|
|
||||||
|
|
||||||
|
|
||||||
async def run_agent(message: str, send: SendFn) -> None:
|
|
||||||
"""
|
|
||||||
Fuehrt den Agenten-Loop aus und streamt Ergebnisse via send()-Callback.
|
|
||||||
Nachrichten-Typen:
|
|
||||||
{"type": "info", "content": str} — Status-Meldung
|
|
||||||
{"type": "thinking"} — LLM denkt nach
|
|
||||||
{"type": "token", "content": str} — Wort der Antwort
|
|
||||||
{"type": "tool_call", "name": str, "args": dict}
|
|
||||||
{"type": "tool_result", "name": str, "content": str}
|
|
||||||
{"type": "error", "content": str}
|
|
||||||
{"type": "done"}
|
|
||||||
"""
|
|
||||||
memories = _exec_get_memories()
|
|
||||||
model = choose_model(message)
|
|
||||||
|
|
||||||
await send({"type": "info", "content": f"Modell: {model}"})
|
|
||||||
|
|
||||||
messages = [
|
|
||||||
{"role": "system", "content": SYSTEM_PROMPT.format(memories=memories)},
|
|
||||||
{"role": "user", "content": message},
|
|
||||||
]
|
|
||||||
|
|
||||||
for _round in range(8):
|
|
||||||
await send({"type": "thinking"})
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await asyncio.get_running_loop().run_in_executor(
|
|
||||||
None, lambda: _call_llm(model, messages)
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
await send({"type": "error", "content": f"LLM nicht erreichbar: {exc}"})
|
|
||||||
return
|
|
||||||
|
|
||||||
choice = result["choices"][0]
|
|
||||||
msg = choice["message"]
|
|
||||||
finish = choice.get("finish_reason", "")
|
|
||||||
tool_calls = msg.get("tool_calls") or []
|
|
||||||
|
|
||||||
# Text-Antwort: Wort fuer Wort streamen
|
|
||||||
if msg.get("content"):
|
|
||||||
words = msg["content"].split(" ")
|
|
||||||
for i, word in enumerate(words):
|
|
||||||
token = word + (" " if i < len(words) - 1 else "")
|
|
||||||
await send({"type": "token", "content": token})
|
|
||||||
await asyncio.sleep(0.012)
|
|
||||||
|
|
||||||
if not tool_calls or finish == "stop":
|
|
||||||
break
|
|
||||||
|
|
||||||
# Tool-Calls ausfuehren
|
|
||||||
messages.append(msg)
|
|
||||||
|
|
||||||
for tc in tool_calls:
|
|
||||||
fn = tc["function"]
|
|
||||||
name = fn["name"]
|
|
||||||
try:
|
|
||||||
args = json.loads(fn.get("arguments", "{}"))
|
|
||||||
except Exception:
|
|
||||||
args = {}
|
|
||||||
|
|
||||||
await send({"type": "tool_call", "name": name, "args": args})
|
|
||||||
|
|
||||||
tool_out = await asyncio.get_running_loop().run_in_executor(
|
|
||||||
None, lambda n=name, a=args: execute_tool(n, a)
|
|
||||||
)
|
|
||||||
|
|
||||||
preview = tool_out[:300] + "..." if len(tool_out) > 300 else tool_out
|
|
||||||
await send({"type": "tool_result", "name": name, "content": preview})
|
|
||||||
|
|
||||||
messages.append({
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": tc["id"],
|
|
||||||
"content": tool_out,
|
|
||||||
})
|
|
||||||
|
|
||||||
await send({"type": "done"})
|
|
||||||
+78
-11
@@ -18,6 +18,7 @@ import threading
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, UploadFile, File, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Depends, UploadFile, File, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import Response, JSONResponse
|
from fastapi.responses import Response, JSONResponse
|
||||||
|
|
||||||
@@ -25,8 +26,8 @@ from auth import auth
|
|||||||
from config import (
|
from config import (
|
||||||
PIPER_BIN, PIPER_VOICE, WHISPER_MODEL_SIZE,
|
PIPER_BIN, PIPER_VOICE, WHISPER_MODEL_SIZE,
|
||||||
HERMES_WINDOWS_HOST, HERMES_WINDOWS_USER, HERMES_SSH_KEY,
|
HERMES_WINDOWS_HOST, HERMES_WINDOWS_USER, HERMES_SSH_KEY,
|
||||||
|
HERMES_API_URL, HERMES_API_KEY,
|
||||||
)
|
)
|
||||||
from hermes_agent import run_agent
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
@@ -100,7 +101,12 @@ def _tts(text: str) -> Optional[bytes]:
|
|||||||
|
|
||||||
@router.websocket("/hermes/chat")
|
@router.websocket("/hermes/chat")
|
||||||
async def hermes_chat(websocket: WebSocket):
|
async def hermes_chat(websocket: WebSocket):
|
||||||
"""Streaming Chat mit dem Hermes Agent via WebSocket."""
|
"""Streaming-Chat: proxyt zum Hermes-Agent-Server (:8642, OpenAI-kompatibel).
|
||||||
|
|
||||||
|
Behaelt den WS-Contract des Frontends bei (thinking/token/done/error), damit
|
||||||
|
die HermesPanel unveraendert bleibt. Modell-Wahl, Tools, Gedaechtnis und
|
||||||
|
Mehr-Schritt-Logik macht der Hermes-Agent selbst.
|
||||||
|
"""
|
||||||
# Manuelle Token-Auth (WS kann keine HTTP-Header senden)
|
# Manuelle Token-Auth (WS kann keine HTTP-Header senden)
|
||||||
from auth import TOKEN
|
from auth import TOKEN
|
||||||
token_param = websocket.query_params.get("token", "")
|
token_param = websocket.query_params.get("token", "")
|
||||||
@@ -121,18 +127,71 @@ async def hermes_chat(websocket: WebSocket):
|
|||||||
if not message:
|
if not message:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
async def send(obj: dict) -> None:
|
await websocket.send_json({"type": "thinking"})
|
||||||
await websocket.send_json(obj)
|
try:
|
||||||
|
await _proxy_chat(message, websocket)
|
||||||
await run_agent(message, send)
|
except Exception as exc:
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "error", "content": f"Hermes nicht erreichbar: {exc}"}
|
||||||
|
)
|
||||||
|
await websocket.send_json({"type": "done"})
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
try:
|
pass
|
||||||
await websocket.send_json({"type": "error", "content": str(exc)})
|
|
||||||
except Exception:
|
|
||||||
pass
|
async def _proxy_chat(message: str, websocket: WebSocket) -> None:
|
||||||
|
"""Eine Antwort vom Hermes-Agent-Server holen und Tokens ans WS relayen.
|
||||||
|
|
||||||
|
Unterstuetzt sowohl SSE-Streaming als auch eine nicht-streamende
|
||||||
|
JSON-Antwort — in beiden Faellen genau EIN Agent-Lauf (kein Doppel-Call).
|
||||||
|
"""
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if HERMES_API_KEY:
|
||||||
|
headers["Authorization"] = f"Bearer {HERMES_API_KEY}"
|
||||||
|
body = {
|
||||||
|
"model": "hermes-agent",
|
||||||
|
"messages": [{"role": "user", "content": message}],
|
||||||
|
"stream": True,
|
||||||
|
}
|
||||||
|
timeout = httpx.Timeout(300.0, connect=10.0)
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
async with client.stream(
|
||||||
|
"POST", f"{HERMES_API_URL}/chat/completions", headers=headers, json=body
|
||||||
|
) as resp:
|
||||||
|
if resp.status_code != 200:
|
||||||
|
detail = (await resp.aread()).decode(errors="replace")[:300]
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "error", "content": f"HTTP {resp.status_code}: {detail}"}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if "text/event-stream" in resp.headers.get("content-type", ""):
|
||||||
|
async for line in resp.aiter_lines():
|
||||||
|
if not line.startswith("data:"):
|
||||||
|
continue
|
||||||
|
data = line[5:].strip()
|
||||||
|
if data == "[DONE]":
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
delta = json.loads(data)["choices"][0].get("delta", {})
|
||||||
|
piece = delta.get("content") or ""
|
||||||
|
except Exception:
|
||||||
|
piece = ""
|
||||||
|
if piece:
|
||||||
|
await websocket.send_json({"type": "token", "content": piece})
|
||||||
|
else:
|
||||||
|
# Server streamt nicht → ganze JSON-Antwort als ein Token senden
|
||||||
|
raw = await resp.aread()
|
||||||
|
try:
|
||||||
|
d = json.loads(raw)
|
||||||
|
content = d["choices"][0]["message"].get("content", "") or ""
|
||||||
|
except Exception:
|
||||||
|
content = raw.decode(errors="replace")
|
||||||
|
if content:
|
||||||
|
await websocket.send_json({"type": "token", "content": content})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/hermes/transcribe", dependencies=[Depends(auth)])
|
@router.post("/hermes/transcribe", dependencies=[Depends(auth)])
|
||||||
@@ -206,7 +265,15 @@ def hermes_status():
|
|||||||
except Exception:
|
except Exception:
|
||||||
ssh_ok = False
|
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 {
|
return {
|
||||||
|
"hermes": hermes,
|
||||||
"whisper": whisper,
|
"whisper": whisper,
|
||||||
"piper": piper,
|
"piper": piper,
|
||||||
"ssh_configured": ssh_configured,
|
"ssh_configured": ssh_configured,
|
||||||
|
|||||||
Reference in New Issue
Block a user