Compare commits
44
Commits
41714f7cf1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0612395bbf | ||
|
|
16b6fbeaf6 | ||
|
|
ea0c7e21f8 | ||
|
|
f9cc10433a | ||
|
|
8b1555f926 | ||
|
|
02303c35ee | ||
|
|
c2472a7958 | ||
|
|
5b952812ee | ||
|
|
512e24bb6f | ||
|
|
af1a3bf386 | ||
|
|
76e8b8e04c | ||
|
|
6918ddca61 | ||
|
|
318bb47590 | ||
|
|
348e9783aa | ||
|
|
1c7e07cb67 | ||
|
|
778d75ae61 | ||
|
|
f3e3aedc6b | ||
|
|
2d2c182a0c | ||
|
|
831fbfab58 | ||
|
|
a0dedd8c97 | ||
|
|
8633840f94 | ||
|
|
45167b7bb5 | ||
|
|
66781d8d90 | ||
|
|
5c25d6abeb | ||
|
|
05c6c7687a | ||
|
|
0f122a1ad7 | ||
|
|
a773832bca | ||
|
|
17a60e5d3e | ||
|
|
182351e022 | ||
|
|
ec78eb8efe | ||
|
|
f2ead1242f | ||
|
|
e75f1eeb6a | ||
|
|
2bd7c747d9 | ||
|
|
d0694cb637 | ||
|
|
8f313eb68b | ||
|
|
954ad9ade8 | ||
|
|
75fa574a85 | ||
|
|
a3d7933949 | ||
|
|
626281dead | ||
|
|
49ea172617 | ||
|
|
a3650bb0e4 | ||
|
|
514ba44e51 | ||
|
|
617dddf3d8 | ||
|
|
88faf57253 |
+1
-1
@@ -235,7 +235,7 @@ Nur **App neu bauen** (kein Backend). APK 0.2.2.0.
|
|||||||
|
|
||||||
## [0.2.0.4 – 0.2.0.5] — 2026-07-11 — Plan B: Lokales LLM („Gemini-Feeling")
|
## [0.2.0.4 – 0.2.0.5] — 2026-07-11 — Plan B: Lokales LLM („Gemini-Feeling")
|
||||||
|
|
||||||
Ein kleines, schnelles Modell (**Qwen3 8B** via llama.cpp/llama-swap auf der Gamebox-GPU) übernimmt einfache Turns in **<1 s**; alles Schwere/Technische/Werkzeug-artige reicht ein Router automatisch an **Claude** weiter. Ziel: schnelle Antworten ohne die Claude-Max-Subscription aufzugeben.
|
Ein kleines, schnelles Modell (**Qwen3 8B** via llama.cpp/llama-swap auf der AI-Box-GPU) übernimmt einfache Turns in **<1 s**; alles Schwere/Technische/Werkzeug-artige reicht ein Router automatisch an **Claude** weiter. Ziel: schnelle Antworten ohne die Claude-Max-Subscription aufzugeben.
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|
||||||
|
|||||||
@@ -35,18 +35,17 @@ ARIA hat zwei Rollen:
|
|||||||
│ WebSocket Tunnel │ WebSocket Tunnel
|
│ WebSocket Tunnel │ WebSocket Tunnel
|
||||||
▼ ▼
|
▼ ▼
|
||||||
┌─────────────────────────────────┐
|
┌─────────────────────────────────┐
|
||||||
│ Gamebox (Windows + WSL2) │
|
│ Compute-Node(s) (NVIDIA GPU) │
|
||||||
│ RTX 3060, Docker Desktop │
|
│ beliebig viele, je per .env │
|
||||||
|
│ konfiguriert (COMPOSE_PROFILES) │
|
||||||
│ ┌──────────────────────────┐ │
|
│ ┌──────────────────────────┐ │
|
||||||
│ │ aria-f5tts-bridge │ │
|
│ │ aria-voxtral-bridge │ │ Profil: voxtral (Default-STT)
|
||||||
│ │ F5-TTS Voice Cloning │ │
|
│ │ aria-f5tts-bridge │ │ Profil: f5tts (TTS)
|
||||||
│ │ PCM-Streaming an die App │ │
|
│ │ aria-llm-adapter+swap │ │ Profil: llm (lokales LLM)
|
||||||
│ ├──────────────────────────┤ │
|
│ │ aria-whisper-bridge │ │ Profil: whisper (STT-Fallback)
|
||||||
│ │ aria-whisper-bridge │ │
|
|
||||||
│ │ Faster-Whisper CUDA │ │
|
|
||||||
│ │ STT in fast-Echtzeit │ │
|
|
||||||
│ └──────────────────────────┘ │
|
│ └──────────────────────────┘ │
|
||||||
│ Beide teilen ./voices Volume │
|
│ Aufteilbar: 1 Node pro Dienst │
|
||||||
|
│ ODER All-in-One. GPU per *_GPU. │
|
||||||
│ xtts/docker-compose.yml │
|
│ xtts/docker-compose.yml │
|
||||||
└─────────────────────────────────┘
|
└─────────────────────────────────┘
|
||||||
┌─────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────┐
|
||||||
@@ -95,12 +94,16 @@ ARIA hat zwei Rollen:
|
|||||||
|-----|----|-----|
|
|-----|----|-----|
|
||||||
| RVS | Rechenzentrum | `cd rvs && docker compose up -d` |
|
| RVS | Rechenzentrum | `cd rvs && docker compose up -d` |
|
||||||
| ARIA Brain/Bridge/Diagnostic | Debian 13 VM | `./init.sh && ./aria-setup.sh && docker compose up -d` |
|
| ARIA Brain/Bridge/Diagnostic | Debian 13 VM | `./init.sh && ./aria-setup.sh && docker compose up -d` |
|
||||||
| Gamebox-Stack (F5-TTS + Whisper) | Gamebox (GPU) | `cd xtts && docker compose up -d` |
|
| Compute-Node(s) (STT/TTS/LLM) | 1..n GPU-Rechner | `cd xtts && cp .env.example .env && docker compose up -d` |
|
||||||
| Satellit(en) 🛰️ (optional) | Fremdes Netz (Büro …) | `cd satellite && cp .env.example .env && docker compose up -d --build` |
|
| Satellit(en) 🛰️ (optional) | Fremdes Netz (Büro …) | `cd satellite && cp .env.example .env && docker compose up -d --build` |
|
||||||
| Android App | Stefans Handy | APK installieren (Auto-Update via RVS) |
|
| Android App | Stefans Handy | APK installieren (Auto-Update via RVS) |
|
||||||
|
|
||||||
> Der Gamebox-Stack ist optional: ohne ihn faellt STT auf lokales Whisper (CPU,
|
> Compute-Nodes sind optional: ohne sie faellt STT auf lokales Whisper (CPU,
|
||||||
> langsamer) zurueck; TTS bleibt aus (ARIA antwortet dann nur als Text).
|
> langsamer) zurueck; TTS bleibt aus (ARIA antwortet dann nur als Text).
|
||||||
|
> Jeder Node startet per `COMPOSE_PROFILES` in seiner `.env` nur die Dienste,
|
||||||
|
> die er anbieten soll (`voxtral`/`f5tts`/`llm`/`whisper`) — so laesst sich der
|
||||||
|
> GPU-Stack auf mehrere Maschinen verteilen (STT-Box, TTS-Box, LLM-Box) oder
|
||||||
|
> als All-in-One auf einer Kiste fahren (`voxtral,f5tts,llm`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -135,8 +138,8 @@ RVS_PORT=443
|
|||||||
RVS_TLS=true
|
RVS_TLS=true
|
||||||
RVS_TLS_FALLBACK=true
|
RVS_TLS_FALLBACK=true
|
||||||
|
|
||||||
# Pairing-Token: Verbindet App, Bridge, Diagnostic und Gamebox im gleichen RVS-Room
|
# Pairing-Token: Verbindet App, Bridge, Diagnostic und Compute-Nodes im gleichen RVS-Room
|
||||||
# MUSS auf allen Geraeten identisch sein (ARIA-VM, Gaming-PC, App)
|
# MUSS auf allen Geraeten identisch sein (ARIA-VM, Compute-Nodes, App)
|
||||||
RVS_TOKEN= # ./generate-token.sh
|
RVS_TOKEN= # ./generate-token.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -304,28 +307,28 @@ Danach wird der Proxy gepatcht:
|
|||||||
|
|
||||||
## Voice Bridge
|
## Voice Bridge
|
||||||
|
|
||||||
Die Bridge verbindet die Android App mit ARIA und orchestriert die GPU-Services
|
Die Bridge verbindet die Android App mit ARIA und orchestriert die GPU-Dienste
|
||||||
auf der Gamebox.
|
auf den Compute-Nodes.
|
||||||
|
|
||||||
**Nachrichtenfluss:**
|
**Nachrichtenfluss:**
|
||||||
```
|
```
|
||||||
Text: App → RVS → Bridge → aria-brain (HTTP)
|
Text: App → RVS → Bridge → aria-brain (HTTP)
|
||||||
Audio: App → RVS → Bridge → stt_request (RVS) → whisper-bridge (Gamebox)
|
Audio: App → RVS → STT-Node (voxtral/whisper) direkt (Streaming)
|
||||||
→ stt_response → Bridge → aria-brain
|
→ stt_endpoint/stt_stream_done → Bridge → aria-brain
|
||||||
Fallback bei Timeout: lokales faster-whisper (CPU)
|
Fallback bei Timeout: lokales faster-whisper (CPU)
|
||||||
Datei: App → RVS → Bridge → /shared/uploads/ → aria-brain (mit Pfad)
|
Datei: App → RVS → Bridge → /shared/uploads/ → aria-brain (mit Pfad)
|
||||||
|
|
||||||
aria-brain → Antwort → Bridge → RVS → App
|
aria-brain → Antwort → Bridge → RVS → App
|
||||||
→ xtts_request (RVS) → f5tts-bridge
|
→ xtts_request (RVS) → f5tts-Node
|
||||||
→ audio_pcm Stream → RVS → App AudioTrack
|
→ audio_pcm Stream → RVS → App AudioTrack
|
||||||
```
|
```
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- **STT primaer remote**: aria-bridge sendet `stt_request` an die Gamebox-Whisper
|
- **STT primaer remote**: die App streamt Audio direkt an einen STT-Node
|
||||||
(faster-whisper CUDA, fast Echtzeit). 45s Timeout, dann Fallback auf lokales
|
(Voxtral-3B default, faster-whisper Fallback-Profil), fast Echtzeit. Timeout →
|
||||||
CPU-Whisper. Modell-Wahl in Diagnostic, Hot-Swap via config-Broadcast.
|
Fallback auf lokales CPU-Whisper. Modell-Wahl in Diagnostic, Hot-Swap via config.
|
||||||
- **TTS via F5-TTS**: aria-f5tts-bridge auf der Gamebox. Voice Cloning mit
|
- **TTS via F5-TTS**: aria-f5tts-bridge auf einem Compute-Node. Voice Cloning mit
|
||||||
Referenz-Audio + automatisch transkribiertem Referenz-Text.
|
Referenz-Audio + automatisch transkribiertem Referenz-Text.
|
||||||
- **Text-Cleanup**: `<voice>...</voice>` Tag bevorzugt; Markdown, Code,
|
- **Text-Cleanup**: `<voice>...</voice>` Tag bevorzugt; Markdown, Code,
|
||||||
Einheiten und URLs werden TTS-gerecht aufbereitet. Dezimalzahlen werden
|
Einheiten und URLs werden TTS-gerecht aufbereitet. Dezimalzahlen werden
|
||||||
@@ -486,7 +489,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
|||||||
- **Disk-Voll Banner** mit copy-baren Cleanup-Befehlen (safe + aggressiv)
|
- **Disk-Voll Banner** mit copy-baren Cleanup-Befehlen (safe + aggressiv)
|
||||||
- **Token/Call-Metrics**: pro Claude-Call ein Eintrag in `/data/metrics.jsonl` mit ts + Token-Schaetzung. Gehirn-Tab zeigt 1h/5h/24h/30d-Aggregat plus Progress-Bar gegen Plan-Limit (Pro / Max 5x / Max 20x / Custom). Warn-Schwelle 80%, kritisch 90%.
|
- **Token/Call-Metrics**: pro Claude-Call ein Eintrag in `/data/metrics.jsonl` mit ts + Token-Schaetzung. Gehirn-Tab zeigt 1h/5h/24h/30d-Aggregat plus Progress-Bar gegen Plan-Limit (Pro / Max 5x / Max 20x / Custom). Warn-Schwelle 80%, kritisch 90%.
|
||||||
- **Voice Cloning**: Audio-Samples hochladen, Whisper transkribiert den Ref-Text automatisch
|
- **Voice Cloning**: Audio-Samples hochladen, Whisper transkribiert den Ref-Text automatisch
|
||||||
- **Voice Export/Import**: einzelne Stimmen als `.tar.gz` zwischen Gameboxen mitnehmen
|
- **Voice Export/Import**: einzelne Stimmen als `.tar.gz` zwischen Compute-Nodes mitnehmen
|
||||||
- **Settings Export/Import**: `voice_config.json` + `highlight_triggers.json` als JSON-Bundle
|
- **Settings Export/Import**: `voice_config.json` + `highlight_triggers.json` als JSON-Bundle
|
||||||
- **Claude Login**: Browser-Terminal zum Einloggen in den Proxy
|
- **Claude Login**: Browser-Terminal zum Einloggen in den Proxy
|
||||||
- **ARIA Live**: read-only Mirror der Claude-Code-Session — alle Tool-Calls + Inputs + Outputs live in einer Monospace-Liste, farbcodiert. **Persistenz**: jeder `agent_stream`-Event wird parallel in `/shared/logs/agent_stream.jsonl` (soft-cap 50 MB) geschrieben, Live-View laedt beim Tab-Oeffnen / Page-Reload die letzten 200 Eintraege — Browser-Standby wirft nichts mehr weg. Plus ⛔ **Not-Aus**-Button der per RVS einen `cancel_request` mit `hard:true` ausloest → aria-bridge ruft den proxy-internen `/cancel-all` Side-Channel → alle Claude-Subprocesses werden sofort gekillt
|
- **ARIA Live**: read-only Mirror der Claude-Code-Session — alle Tool-Calls + Inputs + Outputs live in einer Monospace-Liste, farbcodiert. **Persistenz**: jeder `agent_stream`-Event wird parallel in `/shared/logs/agent_stream.jsonl` (soft-cap 50 MB) geschrieben, Live-View laedt beim Tab-Oeffnen / Page-Reload die letzten 200 Eintraege — Browser-Standby wirft nichts mehr weg. Plus ⛔ **Not-Aus**-Button der per RVS einen `cancel_request` mit `hard:true` ausloest → aria-bridge ruft den proxy-internen `/cancel-all` Side-Channel → alle Claude-Subprocesses werden sofort gekillt
|
||||||
@@ -515,7 +518,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
|||||||
- **Wake-Word waehrend TTS**: Du kannst "Computer" sagen waehrend ARIA noch redet — AcousticEchoCanceler verhindert dass ARIAs eigene Stimme das Wake-Word triggert
|
- **Wake-Word waehrend TTS**: Du kannst "Computer" sagen waehrend ARIA noch redet — AcousticEchoCanceler verhindert dass ARIAs eigene Stimme das Wake-Word triggert
|
||||||
- **Anruf-Pause + Auto-Resume**: TTS verstummt bei klassischem Anruf oder VoIP-Call (WhatsApp/Signal/Discord). Nach dem Auflegen geht ARIA von der **genauen Stelle** weiter wo sie unterbrochen wurde — die App misst die Position vom Wiedergabe-Anfang und nutzt den WAV-Cache der Antwort
|
- **Anruf-Pause + Auto-Resume**: TTS verstummt bei klassischem Anruf oder VoIP-Call (WhatsApp/Signal/Discord). Nach dem Auflegen geht ARIA von der **genauen Stelle** weiter wo sie unterbrochen wurde — die App misst die Position vom Wiedergabe-Anfang und nutzt den WAV-Cache der Antwort
|
||||||
- **Speech Gate**: Aufnahme wird verworfen wenn keine Sprache erkannt
|
- **Speech Gate**: Aufnahme wird verworfen wenn keine Sprache erkannt
|
||||||
- **STT (Speech-to-Text)**: 16kHz mono → Bridge → Gamebox-Whisper (CUDA) → Text im Chat. Fast in Echtzeit.
|
- **STT (Speech-to-Text)**: 16kHz mono → STT-Node (Voxtral-3B, CUDA) → Text im Chat. Fast in Echtzeit.
|
||||||
- **"ARIA denkt..." Indicator**: Zeigt live den Status vom Core (Denken, Tool, Schreiben) + Abbrechen-Button
|
- **"ARIA denkt..." Indicator**: Zeigt live den Status vom Core (Denken, Tool, Schreiben) + Abbrechen-Button
|
||||||
- **TTS-Wiedergabe**: F5-TTS PCM-Streaming direkt in AudioTrack mit konfigurierbarem Pre-Roll-Buffer (1.0–6.0s, Default 3.5s) gegen Gaps bei Render-Pausen
|
- **TTS-Wiedergabe**: F5-TTS PCM-Streaming direkt in AudioTrack mit konfigurierbarem Pre-Roll-Buffer (1.0–6.0s, Default 3.5s) gegen Gaps bei Render-Pausen
|
||||||
- **Audio-Pause**: Andere Apps (Spotify, YouTube etc.) pausieren komplett waehrend ARIA spricht und kommen erst wieder nach echtem Wiedergabe-Ende
|
- **Audio-Pause**: Andere Apps (Spotify, YouTube etc.) pausieren komplett waehrend ARIA spricht und kommen erst wieder nach echtem Wiedergabe-Ende
|
||||||
@@ -655,7 +658,7 @@ Der Update-Flow:
|
|||||||
App (Mikrofon) → AAC/MP4 Aufnahme → Base64 → RVS → Bridge
|
App (Mikrofon) → AAC/MP4 Aufnahme → Base64 → RVS → Bridge
|
||||||
Bridge: FFmpeg (16kHz PCM) → Whisper STT → Text → aria-brain
|
Bridge: FFmpeg (16kHz PCM) → Whisper STT → Text → aria-brain
|
||||||
Bridge: STT-Ergebnis → RVS → App (Placeholder wird durch transkribierten Text ersetzt)
|
Bridge: STT-Ergebnis → RVS → App (Placeholder wird durch transkribierten Text ersetzt)
|
||||||
aria-brain → Antwort → Bridge → F5-TTS (Gaming-PC) → PCM-Stream → RVS → App
|
aria-brain → Antwort → Bridge → F5-TTS (Compute-Node) → PCM-Stream → RVS → App
|
||||||
App: AudioTrack MODE_STREAM (nahtlos), Cache als WAV pro Message
|
App: AudioTrack MODE_STREAM (nahtlos), Cache als WAV pro Message
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -797,36 +800,57 @@ cp ARIA-v0.0.3.0.apk ~/ARIA-AGENT/rvs/updates/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Gamebox-Stack — F5-TTS + Whisper (GPU-Services)
|
## Compute-Nodes — STT / TTS / LLM (GPU-Dienste)
|
||||||
|
|
||||||
Laeuft auf einem separaten Rechner mit NVIDIA GPU (z.B. Gaming-PC mit RTX 3060).
|
Die GPU-Dienste laufen auf einem oder mehreren separaten Rechnern mit NVIDIA GPU.
|
||||||
Verbindet sich ueber RVS mit der ARIA-Infrastruktur — kein VPN noetig, funktioniert
|
Jeder **Compute-Node** verbindet sich ueber RVS mit der ARIA-Infrastruktur — kein
|
||||||
ueber verschiedene Netze hinweg.
|
VPN noetig, funktioniert ueber verschiedene Netze hinweg. Frueher war das *eine*
|
||||||
|
feste „AI-Box"; jetzt sind es beliebig viele Nodes, jeder per `.env` konfiguriert.
|
||||||
|
|
||||||
### Architektur
|
### Dienste & Profile
|
||||||
|
|
||||||
|
Jeder Dienst haengt an einem Compose-Profil. Ein Node startet ueber
|
||||||
|
`COMPOSE_PROFILES` (in seiner `.env`) nur die Profile, die er anbieten soll:
|
||||||
|
|
||||||
|
| Profil | Container | Rolle |
|
||||||
|
|-----------|------------------------------|-------|
|
||||||
|
| `voxtral` | aria-voxtral-bridge | Default-STT (Voxtral-Mini-3B, ~9 GB) |
|
||||||
|
| `whisper` | aria-whisper-bridge | STT-Fallback (faster-whisper CUDA) |
|
||||||
|
| `f5tts` | aria-f5tts-bridge | TTS (F5-TTS Voice Cloning) |
|
||||||
|
| `llm` | aria-llama-swap + llm-adapter| Lokales LLM (llama-swap, OpenAI-kompat.) |
|
||||||
|
|
||||||
|
### Architektur (Aufteilung auf mehrere Nodes)
|
||||||
|
|
||||||
```
|
```
|
||||||
Gamebox (Windows, RTX 3060, Docker Desktop + WSL2)
|
STT-Box COMPOSE_PROFILES=voxtral VOXTRAL_GPU=0
|
||||||
├── aria-f5tts-bridge F5-TTS Voice Cloning + RVS-Relay
|
TTS-Box COMPOSE_PROFILES=f5tts F5TTS_GPU=0
|
||||||
│ Hoert auf xtts_request, streamt audio_pcm
|
LLM-Box COMPOSE_PROFILES=llm LLM_GPU=0
|
||||||
├── aria-whisper-bridge faster-whisper auf CUDA (float16)
|
-- kleine Karte: Whisper (klein) statt Voxtral, neben F5-TTS --
|
||||||
│ Hoert auf stt_request, antwortet mit stt_response
|
Klein-Box COMPOSE_PROFILES=whisper,f5tts WHISPER_GPU=0 F5TTS_GPU=0
|
||||||
└── ./voices/ Geteilt zwischen beiden:
|
── oder All-in-One ──
|
||||||
{name}.wav — Referenz-Audio (~6-10s)
|
AI-Box COMPOSE_PROFILES=voxtral,f5tts,llm VOXTRAL_GPU=1 F5TTS_GPU=0 LLM_GPU=0
|
||||||
{name}.txt — Referenz-Text (auto via Whisper)
|
|
||||||
|
|
||||||
↕ RVS (Rechenzentrum, WebSocket Relay)
|
↕ RVS (Rechenzentrum, WebSocket Relay)
|
||||||
|
|
||||||
ARIA-VM
|
ARIA-VM
|
||||||
└── aria-bridge: STT primaer remote (45s Timeout, dann lokaler CPU-Fallback)
|
└── aria-bridge: orchestriert TTS/LLM (xtts_request/llm_request),
|
||||||
TTS via xtts_request → audio_pcm Stream
|
lauscht passiv auf den STT-Stream App↔STT-Node.
|
||||||
|
STT-Timeout → lokaler CPU-Whisper-Fallback.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Voraussetzungen
|
> STT: pro Node **genau einen** — Voxtral-3B (~9 GB, beste Qualitaet) *oder*
|
||||||
|
> Whisper (klein, passt neben F5-TTS auf eine GPU mit wenig VRAM). Beide zusammen
|
||||||
|
> beantworten dieselbe Anfrage doppelt.
|
||||||
|
|
||||||
- Docker Desktop mit WSL2 (Windows) oder Docker mit NVIDIA Runtime (Linux)
|
Die STT-Node teilt sich das `./voices/`-Volume mit F5-TTS nur, wenn beide auf
|
||||||
- NVIDIA Container Toolkit
|
demselben Node laufen (Referenz-Text-Transkription beim Voice-Upload). Auf
|
||||||
- GPU mit mindestens 6GB VRAM (Whisper-large + F5-TTS gemeinsam)
|
getrennten Nodes transkribiert F5-TTS ueber den STT-Node via RVS.
|
||||||
|
|
||||||
|
### Voraussetzungen (pro Node)
|
||||||
|
|
||||||
|
- Docker + **NVIDIA Container Toolkit** (registriert die `nvidia`-Runtime — die
|
||||||
|
Compose nutzt `runtime: nvidia` + `NVIDIA_VISIBLE_DEVICES`).
|
||||||
|
- Genug VRAM fuer die gewaehlten Profile (Voxtral-3B ~9 GB, F5-TTS ~1 GB, LLM je Modell).
|
||||||
- **Gleicher RVS_TOKEN wie auf der ARIA-VM!**
|
- **Gleicher RVS_TOKEN wie auf der ARIA-VM!**
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
@@ -834,13 +858,17 @@ ARIA-VM
|
|||||||
```bash
|
```bash
|
||||||
cd xtts
|
cd xtts
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# .env mit RVS-Verbindungsdaten fuellen (gleicher Token wie ARIA-VM!)
|
# .env anpassen:
|
||||||
|
# COMPOSE_PROFILES → welche Dienste dieser Node fahren soll
|
||||||
|
# NODE_NAME → Name des Rechners (erscheint in Diagnostic + Logs)
|
||||||
|
# *_GPU → welche Grafikkarte pro Dienst (NVIDIA_VISIBLE_DEVICES)
|
||||||
|
# RVS_* → gleiche Verbindungsdaten wie die ARIA-VM
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
# Erster Start laedt die Modelle (Whisper ~1-3GB je nach Groesse, F5-TTS ~1GB)
|
# Erster Start laedt die Modelle der aktiven Profile (Voxtral ~9GB, F5-TTS ~1GB)
|
||||||
```
|
```
|
||||||
|
|
||||||
Die Modelle werden in den Volumes `f5tts-models` und `whisper-models` gecacht
|
Die Modelle liegen im Bind-Mount `./hf-cache/` (bzw. `./models/` fuer LLM-GGUFs)
|
||||||
und muessen nur einmal geladen werden.
|
und muessen pro Node nur einmal geladen werden.
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
@@ -862,7 +890,7 @@ In der Diagnostic unter Einstellungen → Sprachausgabe:
|
|||||||
- **TTS aktiv**: Global An/Aus
|
- **TTS aktiv**: Global An/Aus
|
||||||
- **F5-TTS Stimme**: Default oder gecloned (Maia etc.)
|
- **F5-TTS Stimme**: Default oder gecloned (Maia etc.)
|
||||||
|
|
||||||
> F5-TTS ist die einzige Engine — wenn die Gamebox offline ist, bleibt ARIA stumm.
|
> F5-TTS ist die einzige Engine — wenn kein f5tts-Node online ist, bleibt ARIA stumm.
|
||||||
> Chat-Antworten kommen weiter an (nur kein Audio).
|
> Chat-Antworten kommen weiter an (nur kein Audio).
|
||||||
|
|
||||||
### Stimme klonen
|
### Stimme klonen
|
||||||
@@ -1024,7 +1052,7 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
|||||||
- [x] Pre-Roll-Buffer einstellbar in App-Settings
|
- [x] Pre-Roll-Buffer einstellbar in App-Settings
|
||||||
- [x] Decimal-zu-Worte fuer TTS + generisches Acronym-Buchstabieren
|
- [x] Decimal-zu-Worte fuer TTS + generisches Acronym-Buchstabieren
|
||||||
- [x] voice_preload/voice_ready: visueller Status-Indikator beim Stimmen-Wechsel
|
- [x] voice_preload/voice_ready: visueller Status-Indikator beim Stimmen-Wechsel
|
||||||
- [x] Whisper STT auf die Gamebox ausgelagert (CUDA float16, fast Echtzeit)
|
- [x] Whisper STT auf die AI-Box ausgelagert (CUDA float16, fast Echtzeit)
|
||||||
- [x] **F5-TTS ersetzt XTTS** — bessere Voice-Cloning-Qualitaet, Whisper-auto-transkribierter Referenz-Text
|
- [x] **F5-TTS ersetzt XTTS** — bessere Voice-Cloning-Qualitaet, Whisper-auto-transkribierter Referenz-Text
|
||||||
- [x] Audio-Pause statt Ducking (TRANSIENT statt MAY_DUCK) + release-Timing fix
|
- [x] Audio-Pause statt Ducking (TRANSIENT statt MAY_DUCK) + release-Timing fix
|
||||||
- [x] VAD-Stille-Toleranz einstellbar (1-8s) + adaptive Mikro-Baseline + Max-Aufnahme einstellbar (1-30 min)
|
- [x] VAD-Stille-Toleranz einstellbar (1-8s) + adaptive Mikro-Baseline + Max-Aufnahme einstellbar (1-30 min)
|
||||||
@@ -1079,7 +1107,7 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
|||||||
- [x] App: Chat-Suche mit Next/Prev Navigation statt Filter
|
- [x] App: Chat-Suche mit Next/Prev Navigation statt Filter
|
||||||
- [x] Token/Call-Metrics + Subscription-Quota-Tracking (Pro / Max 5x / Max 20x / Custom)
|
- [x] Token/Call-Metrics + Subscription-Quota-Tracking (Pro / Max 5x / Max 20x / Custom)
|
||||||
- [x] Datei-Manager Multi-Select: Bulk-Download als ZIP + Bulk-Delete (Diagnostic + App)
|
- [x] Datei-Manager Multi-Select: Bulk-Download als ZIP + Bulk-Delete (Diagnostic + App)
|
||||||
- [x] **FLUX.1 Bildgenerierung**: eigener `flux-bridge`-Container auf der Gamebox (analog xtts/whisper) mit Hot-Swap zwischen FLUX.1-dev (Quali) und FLUX.1-schnell (Tempo). Default-Modell + Raw-/Switch-Keywords + HuggingFace-Token in Diagnostic-UI verwaltet, automatischer Pipeline-Reload bei Modell-Wechsel. ARIA bekommt `flux_generate`-Tool, Output landet als `/shared/uploads/aria_generated_<ts>.png` und wird via `[FILE: ...]`-Marker als Anhang-Bubble in App + Diagnostic gerendert. Download-Status (mehrere GB) sichtbar als 🎉-Toast wenn fertig
|
- [x] **FLUX.1 Bildgenerierung**: eigener `flux-bridge`-Container auf der AI-Box (analog xtts/whisper) mit Hot-Swap zwischen FLUX.1-dev (Quali) und FLUX.1-schnell (Tempo). Default-Modell + Raw-/Switch-Keywords + HuggingFace-Token in Diagnostic-UI verwaltet, automatischer Pipeline-Reload bei Modell-Wechsel. ARIA bekommt `flux_generate`-Tool, Output landet als `/shared/uploads/aria_generated_<ts>.png` und wird via `[FILE: ...]`-Marker als Anhang-Bubble in App + Diagnostic gerendert. Download-Status (mehrere GB) sichtbar als 🎉-Toast wenn fertig
|
||||||
- [x] **ARIA Live (Diagnostic) + Not-Aus**: read-only Mirror der Claude-Code-Session ersetzt den SSH-Tab. Tool-Calls + Inputs + Outputs (truncated 4 KB) live, farbcodiert. Roter ⛔ Not-Aus-Button schickt `cancel_request` mit `hard:true` → Bridge ruft den proxy-internen `/cancel-all` Side-Channel (Port 3457) → alle Claude-Subprocesses sofort tot. Plus: Idle-Watchdog im Proxy (20 min Inaktivitaet → Subprocess-Kill) + httpx-Timeout-Split im Brain (connect 10s / read 24h) damit lange Pentests durchlaufen
|
- [x] **ARIA Live (Diagnostic) + Not-Aus**: read-only Mirror der Claude-Code-Session ersetzt den SSH-Tab. Tool-Calls + Inputs + Outputs (truncated 4 KB) live, farbcodiert. Roter ⛔ Not-Aus-Button schickt `cancel_request` mit `hard:true` → Bridge ruft den proxy-internen `/cancel-all` Side-Channel (Port 3457) → alle Claude-Subprocesses sofort tot. Plus: Idle-Watchdog im Proxy (20 min Inaktivitaet → Subprocess-Kill) + httpx-Timeout-Split im Brain (connect 10s / read 24h) damit lange Pentests durchlaufen
|
||||||
- [x] **OAuth2-Pipeline ueber RVS-Callback**: Caddy mit Let's Encrypt vor dem RVS, HTTP-Route `/oauth/callback/{service}` broadcastet als `oauth_callback`-WS-Message, aria-bridge forwarded an Brain, Token landet in `/shared/config/oauth_tokens.json` (mode 0600). ARIAs `oauth_register_provider`-Tool legt neue Provider on-demand an (URLs/scopes, nicht Credentials). Diagnostic + App haben beide Provider-Verwaltung inklusive Custom-Provider-Anlage
|
- [x] **OAuth2-Pipeline ueber RVS-Callback**: Caddy mit Let's Encrypt vor dem RVS, HTTP-Route `/oauth/callback/{service}` broadcastet als `oauth_callback`-WS-Message, aria-bridge forwarded an Brain, Token landet in `/shared/config/oauth_tokens.json` (mode 0600). ARIAs `oauth_register_provider`-Tool legt neue Provider on-demand an (URLs/scopes, nicht Credentials). Diagnostic + App haben beide Provider-Verwaltung inklusive Custom-Provider-Anlage
|
||||||
- [x] **Skill-Mgmt-Tools fuer ARIA**: `skill_update` (Code/README/pip_packages mit venv-Rebuild) + `skill_delete` — verhindert Skill-Friedhof mit `-v2`/`-fixed`-Suffixen. Plus App-seitiger SkillBrowser (Run + Live-Output + Logs der letzten 20 Runs) in Settings → 🛠️ Skills
|
- [x] **Skill-Mgmt-Tools fuer ARIA**: `skill_update` (Code/README/pip_packages mit venv-Rebuild) + `skill_delete` — verhindert Skill-Friedhof mit `-v2`/`-fixed`-Suffixen. Plus App-seitiger SkillBrowser (Run + Live-Output + Logs der letzten 20 Runs) in Settings → 🛠️ Skills
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 20404
|
versionCode 20408
|
||||||
versionName "0.2.4.4"
|
versionName "0.2.4.8"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
// weil der False-Positive die AudioFocus-Switch-Logik anwirft (Stefan-Bug 06/2026).
|
// weil der False-Positive die AudioFocus-Switch-Logik anwirft (Stefan-Bug 06/2026).
|
||||||
// Loesung: in dieser Phase keine Detections an JS weiterleiten.
|
// Loesung: in dieser Phase keine Detections an JS weiterleiten.
|
||||||
private const val STARTUP_SUPPRESSION_MS = 600L
|
private const val STARTUP_SUPPRESSION_MS = 600L
|
||||||
|
// PCM-Ringpuffer fuer die Wake-Wort-Bestaetigung: letzte 2s roh (16kHz
|
||||||
|
// mono s16). Bei einer Erkennung wird der Vor-Trigger-Schnipsel an JS
|
||||||
|
// gereicht und dort von Voxtral verifiziert (gegen Musik-Fehltrigger).
|
||||||
|
private const val PCM_RING_SAMPLES = 32000 // 2.0s @ 16kHz
|
||||||
|
private const val PRE_TRIGGER_SAMPLES = 24000 // 1.5s Schnipsel an JS
|
||||||
}
|
}
|
||||||
|
|
||||||
private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
|
private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
|
||||||
@@ -106,6 +111,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
private val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings
|
private val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings
|
||||||
private var consecutiveAboveThreshold: Int = 0
|
private var consecutiveAboveThreshold: Int = 0
|
||||||
private var lastDetectionMs: Long = 0L
|
private var lastDetectionMs: Long = 0L
|
||||||
|
// Roh-PCM-Ringpuffer (letzte ~2s) fuer die Wake-Wort-Bestaetigung. Bei einer
|
||||||
|
// Erkennung wird der Vor-Trigger-Schnipsel base64-kodiert an JS gereicht und
|
||||||
|
// dort von Voxtral verifiziert ("war das wirklich 'Computer' oder Musik?").
|
||||||
|
private val pcmRing = ShortArray(PCM_RING_SAMPLES)
|
||||||
|
private var pcmRingPos = 0
|
||||||
|
private var pcmRingFilled = false
|
||||||
|
private val pcmRingLock = Any()
|
||||||
// Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster
|
// Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster
|
||||||
private var recordingStartedMs: Long = 0L
|
private var recordingStartedMs: Long = 0L
|
||||||
|
|
||||||
@@ -430,6 +442,36 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
embBuffer.clear()
|
embBuffer.clear()
|
||||||
consecutiveAboveThreshold = 0
|
consecutiveAboveThreshold = 0
|
||||||
lastDetectionMs = 0L
|
lastDetectionMs = 0L
|
||||||
|
// PCM-Ring frisch: sonst koennte Alt-Audio aus dem vorigen Arm-Zyklus
|
||||||
|
// in den Bestaetigungs-Schnipsel bluten.
|
||||||
|
synchronized(pcmRingLock) { pcmRingPos = 0; pcmRingFilled = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Letzte ~1.5s Roh-PCM aus dem Ringpuffer als Base64 (s16le, 16kHz mono),
|
||||||
|
* fuer die Voxtral-Wake-Bestaetigung. null wenn noch zu wenig Audio da ist
|
||||||
|
* oder das Kodieren scheitert (dann macht JS fail-open weiter wie bisher). */
|
||||||
|
private fun snapshotPreTrigger(): String? {
|
||||||
|
val out: ByteArray
|
||||||
|
synchronized(pcmRingLock) {
|
||||||
|
val available = if (pcmRingFilled) PCM_RING_SAMPLES else pcmRingPos
|
||||||
|
val n = if (available < PRE_TRIGGER_SAMPLES) available else PRE_TRIGGER_SAMPLES
|
||||||
|
if (n <= 0) return null
|
||||||
|
out = ByteArray(n * 2)
|
||||||
|
var idx = (pcmRingPos - n + PCM_RING_SAMPLES) % PCM_RING_SAMPLES
|
||||||
|
for (i in 0 until n) {
|
||||||
|
val s = pcmRing[idx].toInt()
|
||||||
|
out[i * 2] = (s and 0xFF).toByte()
|
||||||
|
out[i * 2 + 1] = ((s shr 8) and 0xFF).toByte()
|
||||||
|
idx += 1
|
||||||
|
if (idx >= PCM_RING_SAMPLES) idx = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
android.util.Base64.encodeToString(out, android.util.Base64.NO_WRAP)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "snapshotPreTrigger base64 fehlgeschlagen: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun emitDetected() {
|
private fun emitDetected() {
|
||||||
@@ -438,8 +480,10 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
Log.i(TAG, "Wake-Word emit unterdrueckt (sinceStart=${sinceStart}ms < ${STARTUP_SUPPRESSION_MS}ms — Mikro-Spin-up-Spike)")
|
Log.i(TAG, "Wake-Word emit unterdrueckt (sinceStart=${sinceStart}ms < ${STARTUP_SUPPRESSION_MS}ms — Mikro-Spin-up-Spike)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val preTriggerB64 = snapshotPreTrigger()
|
||||||
val params = com.facebook.react.bridge.Arguments.createMap().apply {
|
val params = com.facebook.react.bridge.Arguments.createMap().apply {
|
||||||
putString("model", modelName)
|
putString("model", modelName)
|
||||||
|
if (preTriggerB64 != null) putString("preTriggerPcm", preTriggerB64)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
reactApplicationContext
|
reactApplicationContext
|
||||||
@@ -466,6 +510,14 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
read += n
|
read += n
|
||||||
}
|
}
|
||||||
if (!running.get()) break
|
if (!running.get()) break
|
||||||
|
// Chunk in den PCM-Ringpuffer schreiben (fuer Wake-Wort-Bestaetigung).
|
||||||
|
synchronized(pcmRingLock) {
|
||||||
|
for (i in 0 until CHUNK_SAMPLES) {
|
||||||
|
pcmRing[pcmRingPos] = buf[i]
|
||||||
|
pcmRingPos += 1
|
||||||
|
if (pcmRingPos >= PCM_RING_SAMPLES) { pcmRingPos = 0; pcmRingFilled = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
processChunk(buf)
|
processChunk(buf)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.2.4.4",
|
"version": "0.2.4.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
// folgende identische Events (z.B. zwei 'thinking' hintereinander) den
|
// folgende identische Events (z.B. zwei 'thinking' hintereinander) den
|
||||||
// Stream zumuellen. Eigentlich seltener Fall, aber billig zu pruefen.
|
// Stream zumuellen. Eigentlich seltener Fall, aber billig zu pruefen.
|
||||||
const lastThoughtKeyRef = useRef<string>('');
|
const lastThoughtKeyRef = useRef<string>('');
|
||||||
// Service-Status (Gamebox: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
|
// Service-Status (AI-Box: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
|
||||||
const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string, downloading?: boolean, freshlyDownloaded?: boolean}>>({});
|
const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string, downloading?: boolean, freshlyDownloaded?: boolean}>>({});
|
||||||
const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false);
|
const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false);
|
||||||
// Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button)
|
// Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button)
|
||||||
@@ -384,6 +384,14 @@ const ChatScreen: React.FC = () => {
|
|||||||
// stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest
|
// stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest
|
||||||
// es. Default true (Konversation). false = Einzelaktion/Skill-Antwort.
|
// es. Default true (Konversation). false = Einzelaktion/Skill-Antwort.
|
||||||
const converseRef = useRef<boolean>(true);
|
const converseRef = useRef<boolean>(true);
|
||||||
|
// Passiv-Lausch-Fenster (Weiterreden nach ARIAs Antwort): Umgebungsgeraeusch
|
||||||
|
// (Musik/TV) darf das Fenster NICHT vorzeitig beenden. Bei einem no-speech-
|
||||||
|
// Endpoint (Silero verwirft Musik) wird — solange die Stille-Toleranz ab
|
||||||
|
// Fenster-Oeffnung noch laeuft — nochmal gelauscht statt sofort aufs Wake-Word
|
||||||
|
// zurueckzufallen. Start-Zeit + Re-Listen-Zaehler + Budget hier gemerkt.
|
||||||
|
const passiveListenStartRef = useRef<number>(0);
|
||||||
|
const passiveReListenCountRef = useRef<number>(0);
|
||||||
|
const passiveToleranceRef = useRef<number>(5000);
|
||||||
// Barge-in erlaubt? Default false = Halb-Duplex (waehrend TTS kein Mikro).
|
// Barge-in erlaubt? Default false = Halb-Duplex (waehrend TTS kein Mikro).
|
||||||
const bargeInEnabledRef = useRef<boolean>(false);
|
const bargeInEnabledRef = useRef<boolean>(false);
|
||||||
|
|
||||||
@@ -1583,7 +1591,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gamebox-Bridges (f5tts/whisper/flux) melden Lade-Status — Banner oben.
|
// AI-Box-Bridges (f5tts/whisper/flux) melden Lade-Status — Banner oben.
|
||||||
// Toast bei Download-Ende: erstmaliger HF-Download (mehrere GB) → User
|
// Toast bei Download-Ende: erstmaliger HF-Download (mehrere GB) → User
|
||||||
// soll wissen dass er Bilder/Stimmen jetzt nutzen kann ohne in den
|
// soll wissen dass er Bilder/Stimmen jetzt nutzen kann ohne in den
|
||||||
// Banner gucken zu muessen.
|
// Banner gucken zu muessen.
|
||||||
@@ -1690,8 +1698,12 @@ const ChatScreen: React.FC = () => {
|
|||||||
// Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive).
|
// Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive).
|
||||||
// converse=false (Skill-/Einzelantwort, z.B. 'was laeuft gerade') → auch
|
// converse=false (Skill-/Einzelantwort, z.B. 'was laeuft gerade') → auch
|
||||||
// ohne 30s: vorlesen + direkt zurueck aufs Wake-Word.
|
// ohne 30s: vorlesen + direkt zurueck aufs Wake-Word.
|
||||||
|
// Im Hintergrund normalerweise direkt re-armen (kein Multi-Turn) — ABER
|
||||||
|
// wenn Hintergrund-Wake bewusst AN ist, will der User auch im Hintergrund
|
||||||
|
// ein Gespraech fuehren, also den Konversationsmodus offen halten.
|
||||||
const bg = AppState.currentState !== 'active';
|
const bg = AppState.currentState !== 'active';
|
||||||
wakeWordService.endConversation(bg || !converseRef.current).catch(() => {});
|
const bgForcesArmed = bg && !wakeWordService.isBgWakeEnabled();
|
||||||
|
wakeWordService.endConversation(bgForcesArmed || !converseRef.current).catch(() => {});
|
||||||
});
|
});
|
||||||
return () => unsubPlayback();
|
return () => unsubPlayback();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1791,12 +1803,24 @@ const ChatScreen: React.FC = () => {
|
|||||||
!(m.audioRequestId === ev.audioRequestId
|
!(m.audioRequestId === ev.audioRequestId
|
||||||
&& m.text.includes('Spracheingabe wird verarbeitet'))));
|
&& m.text.includes('Spracheingabe wird verarbeitet'))));
|
||||||
}
|
}
|
||||||
// Kein Re-Arm mehr: nach ARIAs Antwort gab es EIN Stille-Fenster (=
|
// Passiv-Lauschen: leeres Endpoint (no-speech / Silero-Musik / speaker_
|
||||||
// Stille-Toleranz). Kam nichts, ist Schluss → zurück aufs Wake-Word.
|
// mismatch). NICHT sofort beenden — solange die Stille-Toleranz ab
|
||||||
// Kein 30s-Nachlauschen. (speaker_mismatch/no-speech landen beide hier.)
|
// Fenster-Oeffnung noch laeuft, nochmal lauschen. So killt Umgebungs-
|
||||||
|
// musik das Weiterreden nicht: die Musik wird verworfen, das Fenster
|
||||||
|
// bleibt bis zur Toleranz offen, du kannst innerhalb reden. Erst wenn
|
||||||
|
// die Toleranz wirklich um ist (oder zu viele Runden) → aufs Wake-Word.
|
||||||
if (wakeWordService.getState() === 'listening') {
|
if (wakeWordService.getState() === 'listening') {
|
||||||
console.log('[Chat] Passive-Listen: leeres Endpoint — Ende, zurueck aufs Wake-Word');
|
const elapsed = Date.now() - passiveListenStartRef.current;
|
||||||
wakeWordService.exitPassiveListening('timeout').catch(() => {});
|
const budget = passiveToleranceRef.current || 5000;
|
||||||
|
if (elapsed < budget && passiveReListenCountRef.current < 15) {
|
||||||
|
passiveReListenCountRef.current += 1;
|
||||||
|
console.log('[Chat] Passive-Listen: leeres Endpoint (%s) — Umgebung, re-listen (%dms/%dms, #%d)',
|
||||||
|
ev.reason, elapsed, budget, passiveReListenCountRef.current);
|
||||||
|
startPassiveStreamingRecording();
|
||||||
|
} else {
|
||||||
|
console.log('[Chat] Passive-Listen: Stille-Toleranz aufgebraucht (%dms) — Ende, zurueck aufs Wake-Word', elapsed);
|
||||||
|
wakeWordService.exitPassiveListening('timeout').catch(() => {});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
wakeWordService.endConversation();
|
wakeWordService.endConversation();
|
||||||
if (!wakeWordService.isActive()) setWakeWordActive(false);
|
if (!wakeWordService.isActive()) setWakeWordActive(false);
|
||||||
@@ -1808,8 +1832,14 @@ const ChatScreen: React.FC = () => {
|
|||||||
// geschaltet (nach endConversation). Wir starten eine streaming-Aufnahme
|
// geschaltet (nach endConversation). Wir starten eine streaming-Aufnahme
|
||||||
// OHNE User-Bubble + ohne wake-ready-Sound. Speaker-ID-Gating in der
|
// OHNE User-Bubble + ohne wake-ready-Sound. Speaker-ID-Gating in der
|
||||||
// Whisper-Bridge filtert fremde Stimmen weg.
|
// Whisper-Bridge filtert fremde Stimmen weg.
|
||||||
const unsubPassive = wakeWordService.onPassiveListen(() => {
|
const unsubPassive = wakeWordService.onPassiveListen(async () => {
|
||||||
console.log('[Chat] Passive-Listen aktiviert — starte stille Streaming-Aufnahme');
|
console.log('[Chat] Passive-Listen aktiviert — starte stille Streaming-Aufnahme');
|
||||||
|
// Fenster NEU geoeffnet (nach ARIAs Antwort): Budget-Uhr + Re-Listen-Zaehler
|
||||||
|
// zuruecksetzen. Re-Listen ruft startPassiveStreamingRecording direkt (nicht
|
||||||
|
// ueber diesen Callback), also bleibt der Startzeitpunkt erhalten.
|
||||||
|
passiveListenStartRef.current = Date.now();
|
||||||
|
passiveReListenCountRef.current = 0;
|
||||||
|
passiveToleranceRef.current = await loadSttEndpointMs();
|
||||||
startPassiveStreamingRecording();
|
startPassiveStreamingRecording();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2259,7 +2289,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
|
|
||||||
// Aufraeumen von "verarbeitet"-Placeholder die nie ein STT-Result bekommen
|
// Aufraeumen von "verarbeitet"-Placeholder die nie ein STT-Result bekommen
|
||||||
// haben (leere Aufnahme, Wake-Word-Echo, STT-Fehler etc). Timeout skaliert
|
// haben (leere Aufnahme, Wake-Word-Echo, STT-Fehler etc). Timeout skaliert
|
||||||
// mit der Aufnahmedauer — Whisper braucht auf der Gamebox grob real-time/5,
|
// mit der Aufnahmedauer — Whisper braucht auf der AI-Box grob real-time/5,
|
||||||
// plus Bridge-Roundtrip + Network. Formel: 60s Buffer + 1x Aufnahmedauer.
|
// plus Bridge-Roundtrip + Network. Formel: 60s Buffer + 1x Aufnahmedauer.
|
||||||
// Bei 5min Aufnahme = 6 min Wait, bei 5s Aufnahme = 65s. Sicher genug damit
|
// Bei 5min Aufnahme = 6 min Wait, bei 5s Aufnahme = 65s. Sicher genug damit
|
||||||
// langsame STTs nicht versehentlich aufgeraeumt werden.
|
// langsame STTs nicht versehentlich aufgeraeumt werden.
|
||||||
@@ -3030,7 +3060,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Service-Status Banner (Gamebox: F5-TTS / Whisper Lade-Status) */}
|
{/* Service-Status Banner (AI-Box: F5-TTS / Whisper Lade-Status) */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const entries = Object.entries(serviceStatus);
|
const entries = Object.entries(serviceStatus);
|
||||||
if (entries.length === 0 || serviceBannerDismissed) return null;
|
if (entries.length === 0 || serviceBannerDismissed) return null;
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ import wakeWordService, {
|
|||||||
saveWakeThreshold,
|
saveWakeThreshold,
|
||||||
loadBgWakeEnabled,
|
loadBgWakeEnabled,
|
||||||
saveBgWakeEnabled,
|
saveBgWakeEnabled,
|
||||||
|
loadWakeConfirmEnabled,
|
||||||
|
saveWakeConfirmEnabled,
|
||||||
} from '../services/wakeword';
|
} from '../services/wakeword';
|
||||||
import ModeSelector from '../components/ModeSelector';
|
import ModeSelector from '../components/ModeSelector';
|
||||||
import QRScanner from '../components/QRScanner';
|
import QRScanner from '../components/QRScanner';
|
||||||
@@ -216,6 +218,8 @@ const SettingsScreen: React.FC = () => {
|
|||||||
const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT);
|
const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT);
|
||||||
// Hintergrund-Wake: auch bei gesperrtem Bildschirm auf das Wake-Wort hoeren. Default aus.
|
// Hintergrund-Wake: auch bei gesperrtem Bildschirm auf das Wake-Wort hoeren. Default aus.
|
||||||
const [bgWake, setBgWake] = useState<boolean>(false);
|
const [bgWake, setBgWake] = useState<boolean>(false);
|
||||||
|
// Wake-Wort per Voxtral bestaetigen (gegen Musik-Fehltrigger). Default aus.
|
||||||
|
const [wakeConfirm, setWakeConfirm] = useState<boolean>(false);
|
||||||
const [editingPath, setEditingPath] = useState(false);
|
const [editingPath, setEditingPath] = useState(false);
|
||||||
const [xttsVoice, setXttsVoice] = useState('');
|
const [xttsVoice, setXttsVoice] = useState('');
|
||||||
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
|
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
|
||||||
@@ -341,6 +345,7 @@ const SettingsScreen: React.FC = () => {
|
|||||||
isWakeReadySoundEnabled().then(setWakeReadySound);
|
isWakeReadySoundEnabled().then(setWakeReadySound);
|
||||||
loadWakeThreshold().then(setWakeThreshold).catch(() => {});
|
loadWakeThreshold().then(setWakeThreshold).catch(() => {});
|
||||||
loadBgWakeEnabled().then(setBgWake).catch(() => {});
|
loadBgWakeEnabled().then(setBgWake).catch(() => {});
|
||||||
|
loadWakeConfirmEnabled().then(setWakeConfirm).catch(() => {});
|
||||||
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
|
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
|
||||||
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
|
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
|
||||||
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
|
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
|
||||||
@@ -1921,6 +1926,28 @@ const SettingsScreen: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.toggleRow, {marginTop: 20, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 16}]}>
|
||||||
|
<View style={styles.toggleInfo}>
|
||||||
|
<Text style={styles.toggleLabel}>Wake-Wort per Voxtral bestätigen</Text>
|
||||||
|
<Text style={styles.toggleHint}>
|
||||||
|
Gegen Musik-Fehltrigger: nach „{KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS] || wakeKeyword}"
|
||||||
|
prüft Voxtral kurz nach, ob's wirklich das Wake-Wort war (oder nur
|
||||||
|
Musik/TV) — erst dann Gong + Mikro. Kostet ~0,5–1 s Extra vor dem
|
||||||
|
Gong. Empfohlen zusammen mit Hintergrund-Zuhören.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={wakeConfirm}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
setWakeConfirm(val);
|
||||||
|
saveWakeConfirmEnabled(val).catch(() => {});
|
||||||
|
wakeWordService.setWakeConfirmEnabled(val);
|
||||||
|
}}
|
||||||
|
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
|
||||||
|
thumbColor={wakeConfirm ? '#FFFFFF' : '#666680'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden nach der Antwort</Text>
|
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden nach der Antwort</Text>
|
||||||
<Text style={styles.toggleHint}>
|
<Text style={styles.toggleHint}>
|
||||||
Nach einer gesprochenen ARIA-Antwort geht das Mikro auf — du kannst ohne
|
Nach einer gesprochenen ARIA-Antwort geht das Mikro auf — du kannst ohne
|
||||||
|
|||||||
@@ -171,6 +171,72 @@ export async function saveBargeInEnabled(enabled: boolean): Promise<void> {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One-Shot-Transkription eines PCM-Schnipsels (base64, s16le 16kHz mono) via
|
||||||
|
* Voxtral — fuer die Wake-Wort-Bestaetigung. Schickt stt_transcribe_blob und
|
||||||
|
* wartet auf stt_transcribe_result (matching requestId) mit Timeout.
|
||||||
|
* Rueckgabe: Text (evtl. '') bei Antwort, oder null bei Timeout/Fehler →
|
||||||
|
* Aufrufer macht dann fail-open (Wake normal durchlassen). */
|
||||||
|
export async function transcribeBlob(pcmBase64: string, timeoutMs = 2500): Promise<string | null> {
|
||||||
|
if (!pcmBase64) return null;
|
||||||
|
const requestId = `wakeverify_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||||
|
return new Promise<string | null>((resolve) => {
|
||||||
|
let done = false;
|
||||||
|
let unsub: (() => void) | null = null;
|
||||||
|
const timer = setTimeout(() => finish(null), timeoutMs);
|
||||||
|
function finish(val: string | null) {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
try { unsub && unsub(); } catch {}
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(val);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
unsub = rvs.onMessage((msg: any) => {
|
||||||
|
if (msg?.type !== 'stt_transcribe_result') return;
|
||||||
|
const p = (msg as any).payload || {};
|
||||||
|
if (String(p.requestId || '') !== requestId) return;
|
||||||
|
finish(typeof p.text === 'string' ? p.text : '');
|
||||||
|
});
|
||||||
|
rvs.send('stt_transcribe_blob' as any, { requestId, pcm: pcmBase64, language: 'de' });
|
||||||
|
} catch {
|
||||||
|
finish(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fragt die Bridge vor dem Aufnahme-Stream, welche STT-Instanz adressiert
|
||||||
|
* werden soll (Redundanz ueber mehrere STT-Nodes/Apps). Schickt
|
||||||
|
* stt_lease_request, wartet kurz auf stt_lease (matching requestId).
|
||||||
|
* Rueckgabe: instanceId (z.B. "voxtral@box-a") oder '' bei Timeout/keine
|
||||||
|
* Instanz — dann streamt die App wie bisher an ALLE (Broadcast, Single-Node
|
||||||
|
* unveraendert). Bewusst kurzer Timeout, damit die Aufnahme nie haengt. */
|
||||||
|
export async function requestSttLease(timeoutMs = 250): Promise<string> {
|
||||||
|
const requestId = `sttlease_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||||
|
return new Promise<string>((resolve) => {
|
||||||
|
let done = false;
|
||||||
|
let unsub: (() => void) | null = null;
|
||||||
|
const timer = setTimeout(() => finish(''), timeoutMs);
|
||||||
|
function finish(val: string) {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
try { unsub && unsub(); } catch {}
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(val);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
unsub = rvs.onMessage((msg: any) => {
|
||||||
|
if (msg?.type !== 'stt_lease') return;
|
||||||
|
const p = (msg as any).payload || {};
|
||||||
|
if (String(p.requestId || '') !== requestId) return;
|
||||||
|
finish(typeof p.instanceId === 'string' ? p.instanceId : '');
|
||||||
|
});
|
||||||
|
rvs.send('stt_lease_request' as any, { requestId });
|
||||||
|
} catch {
|
||||||
|
finish('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadSttEndpointMs(): Promise<number> {
|
export async function loadSttEndpointMs(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);
|
const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);
|
||||||
@@ -350,6 +416,9 @@ class AudioService {
|
|||||||
// lich Chunks einer alten Session in eine neue mischen.
|
// lich Chunks einer alten Session in eine neue mischen.
|
||||||
private streamRequestId: string = '';
|
private streamRequestId: string = '';
|
||||||
private streamAudioRequestId: string = '';
|
private streamAudioRequestId: string = '';
|
||||||
|
// Adressierte STT-Instanz fuer diesen Stream (Redundanz-Routing). '' =
|
||||||
|
// Broadcast an alle STT-Nodes (Single-Node / kein Lease = wie bisher).
|
||||||
|
private streamTargetInstance: string = '';
|
||||||
// Latch: ist endpointListeners fuer den aktuellen Session-Cycle schon gefeuert
|
// Latch: ist endpointListeners fuer den aktuellen Session-Cycle schon gefeuert
|
||||||
// worden? Wird auf false gesetzt beim startStreamingRecording, auf true beim
|
// worden? Wird auf false gesetzt beim startStreamingRecording, auf true beim
|
||||||
// ersten Endpoint (egal ob via RVS oder Fallback). Verhindert Doppel-Fires.
|
// ersten Endpoint (egal ob via RVS oder Fallback). Verhindert Doppel-Fires.
|
||||||
@@ -1093,6 +1162,15 @@ class AudioService {
|
|||||||
const requestId = `sttstr_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
const requestId = `sttstr_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||||
this.streamRequestId = requestId;
|
this.streamRequestId = requestId;
|
||||||
this.streamAudioRequestId = opts.audioRequestId || '';
|
this.streamAudioRequestId = opts.audioRequestId || '';
|
||||||
|
// Redundanz-Routing: freie STT-Instanz leasen BEVOR Chunks fliessen, damit
|
||||||
|
// start + alle Chunks + end dieselbe Instanz adressieren. Kurzer Timeout →
|
||||||
|
// '' (Broadcast) falls keine Instanz/keine Antwort. Nie blockierend genug
|
||||||
|
// um die Aufnahme spuerbar zu verzoegern.
|
||||||
|
try {
|
||||||
|
this.streamTargetInstance = await requestSttLease();
|
||||||
|
} catch {
|
||||||
|
this.streamTargetInstance = '';
|
||||||
|
}
|
||||||
this.streamGotPartial = false;
|
this.streamGotPartial = false;
|
||||||
this.streamEndpointFired = false;
|
this.streamEndpointFired = false;
|
||||||
this.recordingStartTime = Date.now();
|
this.recordingStartTime = Date.now();
|
||||||
@@ -1113,6 +1191,7 @@ class AudioService {
|
|||||||
requestId: sessionId,
|
requestId: sessionId,
|
||||||
pcm: String(e?.pcm || ''),
|
pcm: String(e?.pcm || ''),
|
||||||
seq: Number(e?.seq || 0),
|
seq: Number(e?.seq || 0),
|
||||||
|
targetInstance: this.streamTargetInstance,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
this.streamPcmErrorSub = emitter.addListener('PcmStreamError', (e: any) => {
|
this.streamPcmErrorSub = emitter.addListener('PcmStreamError', (e: any) => {
|
||||||
@@ -1148,6 +1227,7 @@ class AudioService {
|
|||||||
hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000,
|
hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000,
|
||||||
sampleRate: 16000,
|
sampleRate: 16000,
|
||||||
projectId: opts.projectId || '',
|
projectId: opts.projectId || '',
|
||||||
|
targetInstance: this.streamTargetInstance,
|
||||||
});
|
});
|
||||||
|
|
||||||
// No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer.
|
// No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer.
|
||||||
@@ -1197,7 +1277,7 @@ class AudioService {
|
|||||||
if (!reqId) return;
|
if (!reqId) return;
|
||||||
const audioReqId = this.streamAudioRequestId;
|
const audioReqId = this.streamAudioRequestId;
|
||||||
try {
|
try {
|
||||||
rvs.send('stt_stream_end' as any, { requestId: reqId, reason });
|
rvs.send('stt_stream_end' as any, { requestId: reqId, reason, targetInstance: this.streamTargetInstance });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Audio] stt_stream_end senden fehlgeschlagen:', e);
|
console.warn('[Audio] stt_stream_end senden fehlgeschlagen:', e);
|
||||||
}
|
}
|
||||||
@@ -1232,7 +1312,7 @@ class AudioService {
|
|||||||
if (!reqId) return;
|
if (!reqId) return;
|
||||||
const audioReqId = this.streamAudioRequestId;
|
const audioReqId = this.streamAudioRequestId;
|
||||||
try {
|
try {
|
||||||
rvs.send('stt_stream_end' as any, { requestId: reqId, reason: `cancel:${reason}` });
|
rvs.send('stt_stream_end' as any, { requestId: reqId, reason: `cancel:${reason}`, targetInstance: this.streamTargetInstance });
|
||||||
} catch {}
|
} catch {}
|
||||||
this._cleanupStreamLocal(`cancel:${reason}`);
|
this._cleanupStreamLocal(`cancel:${reason}`);
|
||||||
// Listener feuern damit ChatScreen reagieren kann (endConversation etc.)
|
// Listener feuern damit ChatScreen reagieren kann (endConversation etc.)
|
||||||
|
|||||||
@@ -87,6 +87,27 @@ export async function saveBgWakeEnabled(enabled: boolean): Promise<void> {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wake-Wort-Bestaetigung: nach einem openWakeWord-Trigger den Vor-Trigger-Audio
|
||||||
|
// von Voxtral gegenpruefen lassen ("war das wirklich 'Computer' oder Musik?").
|
||||||
|
// Killt Musik-Fehltrigger (z.B. Pet Shop Boys), kostet ~0.5-1s Extra-Latenz pro
|
||||||
|
// Wake. Default AUS (opt-in), fail-open. Braucht das native preTriggerPcm im
|
||||||
|
// Event (neueres APK) — ohne das macht die App normal weiter.
|
||||||
|
export const WAKE_CONFIRM_STORAGE_KEY = 'aria_wake_confirm_enabled';
|
||||||
|
|
||||||
|
export async function loadWakeConfirmEnabled(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return (await AsyncStorage.getItem(WAKE_CONFIRM_STORAGE_KEY)) === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveWakeConfirmEnabled(enabled: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(WAKE_CONFIRM_STORAGE_KEY, String(enabled));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
/** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in
|
/** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in
|
||||||
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
|
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
|
||||||
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
|
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
|
||||||
@@ -159,6 +180,9 @@ class WakeWordService {
|
|||||||
* Bildschirm. Default false. Wird beim Arm aus AsyncStorage geladen und
|
* Bildschirm. Default false. Wird beim Arm aus AsyncStorage geladen und
|
||||||
* bei Aenderung in den Einstellungen via setBgWakeEnabled() aktualisiert. */
|
* bei Aenderung in den Einstellungen via setBgWakeEnabled() aktualisiert. */
|
||||||
private bgWakeEnabled: boolean = false;
|
private bgWakeEnabled: boolean = false;
|
||||||
|
/** Wake-Wort per Voxtral bestaetigen (gegen Musik-Fehltrigger)? Default false.
|
||||||
|
* Wird beim Arm geladen + per setWakeConfirmEnabled aus den Einstellungen. */
|
||||||
|
private wakeConfirmEnabled: boolean = false;
|
||||||
/** Re-Entry-Guard fuer onWakeDetected: native kann mehrere
|
/** Re-Entry-Guard fuer onWakeDetected: native kann mehrere
|
||||||
* WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS
|
* WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS
|
||||||
* resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite
|
* resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite
|
||||||
@@ -241,15 +265,19 @@ class WakeWordService {
|
|||||||
try {
|
try {
|
||||||
const threshold = await loadWakeThreshold();
|
const threshold = await loadWakeThreshold();
|
||||||
this.bgWakeEnabled = await loadBgWakeEnabled();
|
this.bgWakeEnabled = await loadBgWakeEnabled();
|
||||||
console.log('[WakeWord] init mit threshold=%s, bgWake=%s', threshold, this.bgWakeEnabled);
|
this.wakeConfirmEnabled = await loadWakeConfirmEnabled();
|
||||||
|
console.log('[WakeWord] init mit threshold=%s, bgWake=%s, confirm=%s',
|
||||||
|
threshold, this.bgWakeEnabled, this.wakeConfirmEnabled);
|
||||||
await OpenWakeWord.init(this.keyword, threshold, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
|
await OpenWakeWord.init(this.keyword, threshold, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
|
||||||
// Subscribe nur einmal
|
// Subscribe nur einmal
|
||||||
if (!this.eventSub) {
|
if (!this.eventSub) {
|
||||||
const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord);
|
const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord);
|
||||||
this.eventSub = emitter.addListener('WakeWordDetected', () => {
|
this.eventSub = emitter.addListener('WakeWordDetected', (payload: any) => {
|
||||||
console.log('[WakeWord] Native Detection-Event empfangen');
|
console.log('[WakeWord] Native Detection-Event empfangen');
|
||||||
this.onWakeDetected().catch(err =>
|
// payload.preTriggerPcm (base64 s16le 16kHz) fuer die Bestaetigung —
|
||||||
console.warn('[WakeWord] onWakeDetected crashed:', err));
|
// nur in neueren APKs vorhanden; ohne = fail-open (kein Verify).
|
||||||
|
this.onWakeDetected(payload && payload.preTriggerPcm ? String(payload.preTriggerPcm) : null)
|
||||||
|
.catch(err => console.warn('[WakeWord] onWakeDetected crashed:', err));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.nativeReady = true;
|
this.nativeReady = true;
|
||||||
@@ -348,6 +376,18 @@ class WakeWordService {
|
|||||||
console.log('[WakeWord] Hintergrund-Wake = %s', enabled);
|
console.log('[WakeWord] Hintergrund-Wake = %s', enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wake-Wort-Bestaetigung (Voxtral) ein/aus (aus den Einstellungen). */
|
||||||
|
setWakeConfirmEnabled(enabled: boolean): void {
|
||||||
|
this.wakeConfirmEnabled = enabled;
|
||||||
|
console.log('[WakeWord] Wake-Bestaetigung = %s', enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist Hintergrund-Wake an? Steuert u.a. ob der Konversationsmodus auch im
|
||||||
|
* Hintergrund weiterlaeuft (sonst: im Hintergrund direkt zurueck aufs Wake-Word). */
|
||||||
|
isBgWakeEnabled(): boolean {
|
||||||
|
return this.bgWakeEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
/** App im Vordergrund: Detections wieder freigeben, plus kurzer Cooldown
|
/** App im Vordergrund: Detections wieder freigeben, plus kurzer Cooldown
|
||||||
* als Schutz gegen den AudioFocus-/AudioTrack-Spike direkt nach dem Resume.
|
* als Schutz gegen den AudioFocus-/AudioTrack-Spike direkt nach dem Resume.
|
||||||
* 1s statt 3s — 3s hat sich "traege" angefuehlt (Trigger direkt nach dem
|
* 1s statt 3s — 3s hat sich "traege" angefuehlt (Trigger direkt nach dem
|
||||||
@@ -358,8 +398,10 @@ class WakeWordService {
|
|||||||
console.log('[WakeWord] App im Vordergrund — Cooldown 1s aktiv');
|
console.log('[WakeWord] App im Vordergrund — Cooldown 1s aktiv');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
|
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten.
|
||||||
private async onWakeDetected(): Promise<void> {
|
* preTriggerPcm: base64 s16le 16kHz Vor-Trigger-Audio fuer die Bestaetigung
|
||||||
|
* (null = nicht verfuegbar → keine Bestaetigung, normal weiter). */
|
||||||
|
private async onWakeDetected(preTriggerPcm: string | null = null): Promise<void> {
|
||||||
if (this.inBackground && !this.bgWakeEnabled) {
|
if (this.inBackground && !this.bgWakeEnabled) {
|
||||||
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund, Hintergrund-Wake aus)');
|
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund, Hintergrund-Wake aus)');
|
||||||
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background (bg-wake off)')).catch(()=>{});
|
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background (bg-wake off)')).catch(()=>{});
|
||||||
@@ -405,6 +447,22 @@ class WakeWordService {
|
|||||||
// Kein erneutes setState — wir bleiben in 'conversing'.
|
// Kein erneutes setState — wir bleiben in 'conversing'.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Wake-Wort-Bestaetigung (gegen Musik-Fehltrigger): den Vor-Trigger-Schnipsel
|
||||||
|
// von Voxtral gegenpruefen. Bestaetigt → weiter (Gong + Mikro). Verworfen
|
||||||
|
// (Musik/Rauschen, kein "Computer") → kein Dialog, kein Gong, re-arm. Fail-
|
||||||
|
// open: ohne PCM / bei Timeout/Fehler laeuft es normal durch.
|
||||||
|
if (this.wakeConfirmEnabled && preTriggerPcm) {
|
||||||
|
const confirmed = await this.confirmWake(preTriggerPcm);
|
||||||
|
if (!confirmed) {
|
||||||
|
this.detectionInProgress = false;
|
||||||
|
if (this.nativeReady && OpenWakeWord) {
|
||||||
|
try { await OpenWakeWord.start(); } catch (e) {
|
||||||
|
console.warn('[WakeWord] re-arm nach verworfener Bestaetigung failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
this.setState('conversing');
|
this.setState('conversing');
|
||||||
// Direkt feuern — KEIN setTimeout. Im Hintergrund (Display aus) parkt
|
// Direkt feuern — KEIN setTimeout. Im Hintergrund (Display aus) parkt
|
||||||
// Android den JS-Thread; ein setTimeout(200ms) kann dann Minuten lang
|
// Android den JS-Thread; ein setTimeout(200ms) kann dann Minuten lang
|
||||||
@@ -418,6 +476,34 @@ class WakeWordService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Voxtral-Bestaetigung des Vor-Trigger-Schnipsels. true = Wake-Wort erkannt
|
||||||
|
* (oder fail-open bei Timeout/Fehler), false = Musik/Rauschen → verwerfen. */
|
||||||
|
private async confirmWake(pcm: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const audio = await import('./audio');
|
||||||
|
const text = await audio.transcribeBlob(pcm);
|
||||||
|
if (text === null) {
|
||||||
|
console.log('[WakeWord] Bestaetigung: Timeout/Fehler → fail-open (durchlassen)');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const norm = text.toLowerCase();
|
||||||
|
// Distinktive Wake-Wort-Bestandteile (>= 4 Zeichen; 'hey' o.ae. rausfiltern,
|
||||||
|
// taucht sonst in Song-Texten auf und wuerde faelschlich bestaetigen).
|
||||||
|
const kwWords = this.keyword.toLowerCase().replace(/_/g, ' ')
|
||||||
|
.split(/\s+/).filter(w => w.length >= 4);
|
||||||
|
if (kwWords.length === 0) return true; // zu kurzes Keyword → nicht pruefbar
|
||||||
|
const ok = kwWords.some(w => norm.includes(w));
|
||||||
|
console.log('[WakeWord] Bestaetigung: text=%o kw=%o → %s',
|
||||||
|
text, kwWords, ok ? 'BESTAETIGT' : 'verworfen (Musik-FP?)');
|
||||||
|
import('./logger').then(m => m.reportAppDebug('wake.confirm',
|
||||||
|
`text="${text.slice(0, 40)}" kw=${kwWords.join('|')} → ${ok ? 'ok' : 'reject'}`)).catch(() => {});
|
||||||
|
return ok;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[WakeWord] confirmWake err → fail-open:', e);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann
|
/** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann
|
||||||
* "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im
|
* "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im
|
||||||
* Native-Modul verhindert dass ARIAs eigene Stimme triggert.
|
* Native-Modul verhindert dass ARIAs eigene Stimme triggert.
|
||||||
|
|||||||
+92
-13
@@ -132,6 +132,34 @@ WEB_SEARCH_TOOL = {
|
|||||||
|
|
||||||
# Meta-Tool: ARIA kann selbst neue Skills bauen
|
# Meta-Tool: ARIA kann selbst neue Skills bauen
|
||||||
META_TOOLS = [
|
META_TOOLS = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "ear_control",
|
||||||
|
"description": (
|
||||||
|
"Schaltet dein Ohr (den Wake-Word-Listener) an oder aus. Nutze das, "
|
||||||
|
"wenn Stefan will dass du aufhoerst zuzuhoeren — EGAL wie er es "
|
||||||
|
"formuliert: 'leg dich schlafen', 'geh schlafen', 'Ohr aus', 'gute "
|
||||||
|
"Nacht', 'mach mal Pause vom Zuhoeren', 'ich geh ins Bett, du kannst "
|
||||||
|
"aus' → action='off'. Wenn er dich wieder aktivieren will ('Ohr an', "
|
||||||
|
"'wach auf', 'hoer wieder zu') → action='on'. Reiner Steuerbefehl: "
|
||||||
|
"die App stoppt/startet den Listener, du bestaetigst nur kurz (wird "
|
||||||
|
"nicht vorgelesen). Nach 'off' geht Wieder-An per 'Ohr an' (Text/"
|
||||||
|
"Aufnahme-Knopf) oder App-Button."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["off", "on"],
|
||||||
|
"description": "off = Ohr aus (nicht mehr zuhoeren), on = Ohr wieder an",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["action"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -831,7 +859,7 @@ META_TOOLS = [
|
|||||||
"function": {
|
"function": {
|
||||||
"name": "flux_generate",
|
"name": "flux_generate",
|
||||||
"description": (
|
"description": (
|
||||||
"Generiere ein Bild aus einem Text-Prompt via FLUX auf der Gamebox-GPU. "
|
"Generiere ein Bild aus einem Text-Prompt via FLUX auf der AI-Box-GPU. "
|
||||||
"Brauchbar fuer 'mal mir ein X', 'wie sieht ein Y aus?', Mockups, "
|
"Brauchbar fuer 'mal mir ein X', 'wie sieht ein Y aus?', Mockups, "
|
||||||
"Konzept-Skizzen, Memes. Render dauert 20-90s — kuendige es Stefan "
|
"Konzept-Skizzen, Memes. Render dauert 20-90s — kuendige es Stefan "
|
||||||
"kurz an, dann ist er nicht ueberrascht.\n\n"
|
"kurz an, dann ist er nicht ueberrascht.\n\n"
|
||||||
@@ -1156,9 +1184,11 @@ META_TOOLS = [
|
|||||||
"description": (
|
"description": (
|
||||||
"Zeigt welche ARIA-Satelliten (Aussenposten-Container in FREMDEN Netzen, "
|
"Zeigt welche ARIA-Satelliten (Aussenposten-Container in FREMDEN Netzen, "
|
||||||
"z.B. 'Buero') gerade ONLINE sind und was sie koennen (discover / "
|
"z.B. 'Buero') gerade ONLINE sind und was sie koennen (discover / "
|
||||||
"dial.launch / wol / http). Nutze das ZUERST, wenn Stefan etwas 'im "
|
"dial.launch / wol / http). Nutze das ZUERST, wenn es um ein Geraet/einen "
|
||||||
"Buero' / 'im Netz X' / 'auf dem <Geraet> dort' machen will — so weisst "
|
"Host in einem Netz geht, auf dem Du nicht direkt sitzt (Zuhause, Buero, "
|
||||||
"Du welche Netze erreichbar sind."
|
"jede private IP wie 192.168.x) — z.B. Drucker-Fuellstand, NAS, Smart-TV. "
|
||||||
|
"Rufe es IMMER auf, BEVOR Du sagst ein Geraet sei nicht erreichbar — ein "
|
||||||
|
"Satellit im Zielnetz ist der Weg hinein."
|
||||||
),
|
),
|
||||||
"parameters": {"type": "object", "properties": {}},
|
"parameters": {"type": "object", "properties": {}},
|
||||||
},
|
},
|
||||||
@@ -1169,8 +1199,11 @@ META_TOOLS = [
|
|||||||
"name": "satellite_devices",
|
"name": "satellite_devices",
|
||||||
"description": (
|
"description": (
|
||||||
"Listet die Geraete im Netz eines Satelliten (Fire TV, Chromecast, "
|
"Listet die Geraete im Netz eines Satelliten (Fire TV, Chromecast, "
|
||||||
"Smart-TVs, Drucker, NAS, Hosts ...). Nutze es um herauszufinden welches "
|
"Smart-TVs, Drucker, Switches, Router, APs, NAS, Hosts ...). Nutze es um "
|
||||||
"Geraet gemeint ist, BEVOR Du satellite_command aufrufst."
|
"herauszufinden welches Geraet gemeint ist, BEVOR Du satellite_command "
|
||||||
|
"aufrufst. SNMP-faehige Geraete tragen ein 'snmp'-Feld (Name, Beschreibung, "
|
||||||
|
"Standort, Uptime) und einen praeziseren 'type' (switch/router/nas ...) — "
|
||||||
|
"gute Quelle um Netz-Hardware zu erklaeren."
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -1190,8 +1223,28 @@ META_TOOLS = [
|
|||||||
"Beispiel YouTube-Video auf Fire TV: action='dial.launch', "
|
"Beispiel YouTube-Video auf Fire TV: action='dial.launch', "
|
||||||
"device='Fire TV', params={'app':'YouTube','v':'<videoId>'}. Weitere "
|
"device='Fire TV', params={'app':'YouTube','v':'<videoId>'}. Weitere "
|
||||||
"Aktionen: 'wol' (params={'mac':'...'}) zum Aufwecken, "
|
"Aktionen: 'wol' (params={'mac':'...'}) zum Aufwecken, "
|
||||||
"'http.get'/'http.post' (params={'url':'...'}) fuer lokale Webhooks. "
|
"'snmp.printer' (params={'ip':'<drucker-ip>'}) — BEVORZUGT fuer Drucker-"
|
||||||
"Geht nur, wenn der Satellit Steuerung erlaubt (siehe satellite_list)."
|
"Tinte/Toner: liest die Fuellstaende zuverlaessig als Prozent aus der "
|
||||||
|
"Printer-MIB (kein HTML-Scrapen). 'snmp.ports' (params={'ip':'...'}) — "
|
||||||
|
"Switch/Router-Interfaces: aktive/freie Ports + Linkspeed (beantwortet "
|
||||||
|
"'sind noch Ports frei'). 'snmp.info' (params={'ip':'...'}) — Modell, "
|
||||||
|
"Seriennummer, INSTALLIERTE Firmware/Software-Version (ob ein Update "
|
||||||
|
"existiert, weiss SNMP NICHT). 'snmp.get'/'snmp.walk' "
|
||||||
|
"(params={'ip':'...','oid':'...','community':'public'}) fuer beliebige "
|
||||||
|
"SNMP-Werte. 'fritzbox.info' / 'fritzbox.hosts' (params={'ip':'<fritzbox>'}) "
|
||||||
|
"— Internetverbindung/Datenrate/externe IP bzw. verbundene Geraete (braucht "
|
||||||
|
"hinterlegten FritzBox-Login). Fuer Geraete mit hinterlegten Zugangsdaten "
|
||||||
|
"(community/v3/Login) nutzt der Satellit diese automatisch — Du musst keine "
|
||||||
|
"Passwoerter mitgeben. "
|
||||||
|
"'http.get'/'http.post' (params={'url':'...'}) fuer lokale Webhooks UND "
|
||||||
|
"um Geraete-Statusseiten zu lesen (Fallback fuer Tinte, NAS ...). Bei grossen "
|
||||||
|
"Seiten NICHT blind paginieren: setze params['contains'] (String oder "
|
||||||
|
"Liste) — dann kommen nur Zeilen zurueck, die einen der Begriffe enthalten "
|
||||||
|
"(z.B. contains=['ink','toner','cyan','magenta','yellow','black','%'] fuer "
|
||||||
|
"Tinte). Zusaetzlich moeglich: params['offset'] und params['max_chars'] "
|
||||||
|
"(Default 20000). Die Antwort meldet total_chars + truncated, damit Du "
|
||||||
|
"siehst, ob noch mehr da ist. Geht nur, wenn der Satellit Steuerung "
|
||||||
|
"erlaubt (siehe satellite_list)."
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -1455,7 +1508,12 @@ _WAKE_OFF_RE = re.compile(
|
|||||||
rf"|\b(?:beende|beend\w*|deaktivier\w*|stopp?e?)\b[^.!?]{{0,20}}\b{_WAKE_NOUN}\b"
|
rf"|\b(?:beende|beend\w*|deaktivier\w*|stopp?e?)\b[^.!?]{{0,20}}\b{_WAKE_NOUN}\b"
|
||||||
rf"|h[oö]r\s+auf\s+(?:zu\s*)?(?:zu)?(?:h[oö]r|lausch)\w*"
|
rf"|h[oö]r\s+auf\s+(?:zu\s*)?(?:zu)?(?:h[oö]r|lausch)\w*"
|
||||||
rf"|h[oö]r\s+nicht\s+mehr\s+zu"
|
rf"|h[oö]r\s+nicht\s+mehr\s+zu"
|
||||||
rf"|geh\s+schlafen",
|
# Schlaf-Idiome NUR als klare Imperative an ARIA ('geh/leg dich schlafen',
|
||||||
|
# 'leg dich aufs Ohr/hin'). Mehrdeutige Gruesse ('gute Nacht', 'schlaf gut')
|
||||||
|
# bewusst NICHT — das sind Verabschiedungen, kein Ohr-Aus-Befehl; die faengt
|
||||||
|
# ARIA per ear_control aus dem Kontext ab, wenn's wirklich gemeint ist.
|
||||||
|
rf"|(?:geh|leg\s+dich)\s+schlafen"
|
||||||
|
rf"|leg\s+dich\s+(?:aufs?\s+ohr|hin)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1685,9 +1743,12 @@ class Agent:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Kuratierte Tool-Auswahl fuers lokale Tier (B1b): web_search (local-only,
|
# Kuratierte Tool-Auswahl fuers lokale Tier (B1b): web_search (local-only,
|
||||||
# SearXNG) + memory_search/trigger_timer (aus META_TOOLS) + Spotify-Skill.
|
# SearXNG) + memory_search/trigger_timer (aus META_TOOLS) + Spotify-Skill +
|
||||||
# Bewusst klein (Speed + Sicherheit); alles andere → Claude.
|
# die Satelliten-Tools (Augen/Haende in fremden Netzen — auch das lokale Tier
|
||||||
_LOCAL_TOOL_NAMES = {"memory_search", "trigger_timer"}
|
# muss ein Geraet im Heim-/Buero-Netz erreichen koennen, statt zu fabulieren).
|
||||||
|
# Sonst bewusst klein (Speed + Sicherheit); alles andere → Claude.
|
||||||
|
_LOCAL_TOOL_NAMES = {"memory_search", "trigger_timer",
|
||||||
|
"satellite_list", "satellite_devices", "satellite_command"}
|
||||||
|
|
||||||
def _build_local_tools(self) -> list:
|
def _build_local_tools(self) -> list:
|
||||||
tools = [WEB_SEARCH_TOOL]
|
tools = [WEB_SEARCH_TOOL]
|
||||||
@@ -2132,6 +2193,7 @@ class Agent:
|
|||||||
# Konversation bleiben gesprochen.
|
# Konversation bleiben gesprochen.
|
||||||
self._claude_turn_speak = True
|
self._claude_turn_speak = True
|
||||||
self._claude_turn_converse = True # Default Gespraech; run_*-Skill setzt es
|
self._claude_turn_converse = True # Default Gespraech; run_*-Skill setzt es
|
||||||
|
self._ear_control = None # ear_control-Tool: 'off'|'on' oder None
|
||||||
try:
|
try:
|
||||||
for iteration in range(self.MAX_TOOL_ITERATIONS):
|
for iteration in range(self.MAX_TOOL_ITERATIONS):
|
||||||
result = self.proxy.chat_full(messages, tools=tools,
|
result = self.proxy.chat_full(messages, tools=tools,
|
||||||
@@ -2245,8 +2307,16 @@ class Agent:
|
|||||||
converse = False
|
converse = False
|
||||||
elif _wants_continue:
|
elif _wants_continue:
|
||||||
converse = True
|
converse = True
|
||||||
|
# ear_control-Tool aufgerufen → Ohr schalten. answered_by=wake-off/wake-on
|
||||||
|
# nutzt die bestehende Plumbing (main.py → wake_off/wake_on → Bridge → App).
|
||||||
|
# Reiner Steuerbefehl: still + kein Weiterlauschen.
|
||||||
|
answered_by = "claude"
|
||||||
|
if self._ear_control == "off":
|
||||||
|
answered_by, speak, converse = "wake-off", False, False
|
||||||
|
elif self._ear_control == "on":
|
||||||
|
answered_by, speak, converse = "wake-on", False, False
|
||||||
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
|
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
|
||||||
return (final_reply, "claude", speak, converse, awaiting_reply)
|
return (final_reply, answered_by, speak, converse, awaiting_reply)
|
||||||
|
|
||||||
# ── Tool-Dispatcher ───────────────────────────────────────
|
# ── Tool-Dispatcher ───────────────────────────────────────
|
||||||
|
|
||||||
@@ -2841,6 +2911,15 @@ class Agent:
|
|||||||
f"WICHTIG: Schreibe in deiner Antwort an Stefan den Pfad EXAKT als "
|
f"WICHTIG: Schreibe in deiner Antwort an Stefan den Pfad EXAKT als "
|
||||||
f"Marker: [FILE: {result['path']}] — dann zeigt die App das Bild inline."
|
f"Marker: [FILE: {result['path']}] — dann zeigt die App das Bild inline."
|
||||||
)
|
)
|
||||||
|
if name == "ear_control":
|
||||||
|
action = (arguments.get("action") or "").strip().lower()
|
||||||
|
if action not in ("off", "on"):
|
||||||
|
return "FEHLER: action muss 'off' oder 'on' sein."
|
||||||
|
self._ear_control = action
|
||||||
|
logger.info("[ear_control] action=%s — App schaltet den Listener", action)
|
||||||
|
return ("Ohr wird ausgeschaltet — Wieder-An per 'Ohr an' (Text/"
|
||||||
|
"Aufnahme-Knopf) oder App-Button." if action == "off"
|
||||||
|
else "Ohr wird wieder eingeschaltet.")
|
||||||
if name == "memory_search":
|
if name == "memory_search":
|
||||||
query = (arguments.get("query") or "").strip()
|
query = (arguments.get("query") or "").strip()
|
||||||
if not query:
|
if not query:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Local-LLM-Client (Plan B) — Brain-Seite.
|
Local-LLM-Client (Plan B) — Brain-Seite.
|
||||||
|
|
||||||
Ruft das schnelle lokale LLM (Qwen3 auf der Gamebox) ueber die Bridge:
|
Ruft das schnelle lokale LLM (Qwen3 auf der AI-Box) ueber die Bridge:
|
||||||
Brain → HTTP /internal/local-llm → Bridge → RVS → llm-adapter → llama.cpp
|
Brain → HTTP /internal/local-llm → Bridge → RVS → llm-adapter → llama.cpp
|
||||||
|
|
||||||
Analog zum Claude-`proxy_client`, nur ueber die Bridge (die ist der RVS-Client;
|
Analog zum Claude-`proxy_client`, nur ueber die Bridge (die ist der RVS-Client;
|
||||||
|
|||||||
@@ -199,6 +199,13 @@ def build_voice_flow_section() -> str:
|
|||||||
"- Ohne Marker = normales Gespraech: du wirst vorgelesen und ich lausche "
|
"- Ohne Marker = normales Gespraech: du wirst vorgelesen und ich lausche "
|
||||||
"danach kurz weiter (Stefan kann einfach antworten, ohne 'Computer').",
|
"danach kurz weiter (Stefan kann einfach antworten, ohne 'Computer').",
|
||||||
"- Nie widerspruechlich: `[[ENDE]]` schlaegt `[[WEITER]]`.",
|
"- Nie widerspruechlich: `[[ENDE]]` schlaegt `[[WEITER]]`.",
|
||||||
|
"",
|
||||||
|
"**Ohr aus/an:** Wenn Stefan will dass du aufhoerst zuzuhoeren — egal wie "
|
||||||
|
"formuliert ('leg dich schlafen', 'geh schlafen', 'Ohr aus', 'gute Nacht', "
|
||||||
|
"'mach Pause vom Zuhoeren') — ruf das Tool `ear_control(action='off')`. "
|
||||||
|
"Wieder aktivieren ('Ohr an', 'wach auf', 'hoer wieder zu') → "
|
||||||
|
"`ear_control(action='on')`. Die App stoppt/startet dann den Listener; du "
|
||||||
|
"bestaetigst nur kurz.",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -475,13 +475,32 @@ SEED_RULES: List[dict] = [
|
|||||||
"zeigt UND steuerbar macht. So erreichst Du Netze, in denen der Haupt-Stack "
|
"zeigt UND steuerbar macht. So erreichst Du Netze, in denen der Haupt-Stack "
|
||||||
"NICHT steht.\n"
|
"NICHT steht.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Wenn Stefan etwas 'im Buero' / 'im Netz X' / 'auf dem <Geraet> dort' will:\n"
|
"WICHTIG — nicht nur bei 'im Buero'/'im Netz X': IMMER wenn eine Anfrage ein "
|
||||||
|
"GERAET oder einen HOST betrifft, das/der in einem Netz lebt, auf dem Du NICHT "
|
||||||
|
"direkt sitzt (Stefans Zuhause, Buero, Werkstatt, irgendein LAN mit privater "
|
||||||
|
"IP wie 192.168.x/10.x) — z.B. Drucker-Fuellstand, NAS, Smart-TV, ein Host per "
|
||||||
|
"IP — dann pruefe ZUERST `satellite_list`, ob ein Satellit dieses Netz abdeckt. "
|
||||||
|
"Sage NIEMALS 'da komm ich nicht ran' / 'bin nicht im Netz', BEVOR Du "
|
||||||
|
"`satellite_list` aufgerufen hast — ein Satellit im Zielnetz ist genau der Weg "
|
||||||
|
"hinein. Nur wenn wirklich keiner online ist, ist 'erreiche ich nicht' korrekt.\n"
|
||||||
|
"\n"
|
||||||
|
"Ablauf:\n"
|
||||||
" 1. `satellite_list` — welche Satelliten/Netze sind online + was koennen sie.\n"
|
" 1. `satellite_list` — welche Satelliten/Netze sind online + was koennen sie.\n"
|
||||||
" 2. `satellite_devices(satellite='Buero')` — welche Geraete gibt es dort "
|
" 2. `satellite_devices(satellite='Buero')` — welche Geraete gibt es dort "
|
||||||
"(Fire TV, Chromecast, Smart-TVs, Drucker, NAS ...). Nutze es um das "
|
"(Fire TV, Chromecast, Smart-TVs, Drucker, NAS ...). Nutze es um das "
|
||||||
"gemeinte Geraet zu finden, BEVOR Du steuerst.\n"
|
"gemeinte Geraet zu finden, BEVOR Du steuerst.\n"
|
||||||
" 3. `satellite_command(...)` — Aktion ausfuehren.\n"
|
" 3. `satellite_command(...)` — Aktion ausfuehren.\n"
|
||||||
"\n"
|
"\n"
|
||||||
|
"Beispiel 'Patronenstand vom Drucker zuhause': satellite_list -> Satellit im "
|
||||||
|
"Heimnetz online? -> BEVORZUGT satellite_command(satellite='<Heim>', "
|
||||||
|
"action='snmp.printer', params={'ip':'<drucker-ip>'}) -> liefert supplies mit "
|
||||||
|
"name + percent je Patrone (BK/C/M/Y), zuverlaessig aus der Printer-MIB. "
|
||||||
|
"NUR falls SNMP nichts liefert, als Fallback die HTML-Statusseite: "
|
||||||
|
"action='http.get', params={'url':'http://<drucker-ip>/general/status.html', "
|
||||||
|
"'contains':['ink','toner','cyan','magenta','yellow','black','%']} — der "
|
||||||
|
"contains-Filter zieht die relevanten Zeilen (sonst wird der Body bei "
|
||||||
|
"max_chars, Default 20000, abgeschnitten). NICHT aus dem Gedaechtnis raten.\n"
|
||||||
|
"\n"
|
||||||
"Beispiel 'spiel YouTube-Video auf dem Buero-Stick':\n"
|
"Beispiel 'spiel YouTube-Video auf dem Buero-Stick':\n"
|
||||||
" satellite_command(satellite='Buero', device='Fire TV', "
|
" satellite_command(satellite='Buero', device='Fire TV', "
|
||||||
"action='dial.launch', params={'app':'YouTube','v':'<videoId>'})\n"
|
"action='dial.launch', params={'app':'YouTube','v':'<videoId>'})\n"
|
||||||
|
|||||||
+177
-35
@@ -2,7 +2,7 @@
|
|||||||
ARIA Voice Bridge — Hauptmodul.
|
ARIA Voice Bridge — Hauptmodul.
|
||||||
|
|
||||||
Verbindet die Android App (via RVS) mit ARIA-Core. Spracheingabe laeuft
|
Verbindet die Android App (via RVS) mit ARIA-Core. Spracheingabe laeuft
|
||||||
ueber die whisper-bridge (Gamebox, faster-whisper auf CUDA), Sprachausgabe
|
ueber die whisper-bridge (AI-Box, faster-whisper auf CUDA), Sprachausgabe
|
||||||
ueber die f5tts-bridge (Voice Cloning, satzweises PCM-Streaming).
|
ueber die f5tts-bridge (Voice Cloning, satzweises PCM-Streaming).
|
||||||
|
|
||||||
Nachrichtenfluss:
|
Nachrichtenfluss:
|
||||||
@@ -479,7 +479,7 @@ class STTEngine:
|
|||||||
Erkannter Text oder leerer String.
|
Erkannter Text oder leerer String.
|
||||||
"""
|
"""
|
||||||
if self.model is None:
|
if self.model is None:
|
||||||
# Lazy-Load: normalerweise laeuft STT remote auf der Gamebox.
|
# Lazy-Load: normalerweise laeuft STT remote auf der AI-Box.
|
||||||
# Erst wenn das Fallback hier zuschlaegt, laden wir lokal.
|
# Erst wenn das Fallback hier zuschlaegt, laden wir lokal.
|
||||||
logger.info("Lokales Whisper-Fallback — Modell wird nachgeladen...")
|
logger.info("Lokales Whisper-Fallback — Modell wird nachgeladen...")
|
||||||
try:
|
try:
|
||||||
@@ -654,7 +654,7 @@ class ARIABridge:
|
|||||||
self._seen_client_msg_ids: "OrderedDict[str, float]" = OrderedDict()
|
self._seen_client_msg_ids: "OrderedDict[str, float]" = OrderedDict()
|
||||||
self._SEEN_CLIENT_MSG_LIMIT = 200
|
self._SEEN_CLIENT_MSG_LIMIT = 200
|
||||||
|
|
||||||
# Komponenten (TTS: F5-TTS remote auf der Gamebox, lokales TTS wurde entfernt)
|
# Komponenten (TTS: F5-TTS remote auf der AI-Box, lokales TTS wurde entfernt)
|
||||||
self.tts_enabled = True
|
self.tts_enabled = True
|
||||||
self.xtts_voice = ""
|
self.xtts_voice = ""
|
||||||
self._f5tts_config: dict = {}
|
self._f5tts_config: dict = {}
|
||||||
@@ -686,7 +686,7 @@ class ARIABridge:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
self._persistent_xtts_speed = None
|
self._persistent_xtts_speed = None
|
||||||
# F5-TTS-Felder aufsammeln (werden spaeter via RVS rebroadcastet,
|
# F5-TTS-Felder aufsammeln (werden spaeter via RVS rebroadcastet,
|
||||||
# damit die f5tts-bridge auf der Gamebox die Settings auch nach
|
# damit die f5tts-bridge auf der AI-Box die Settings auch nach
|
||||||
# Restart wiederbekommt — sonst stuende sie auf Hard-Defaults)
|
# Restart wiederbekommt — sonst stuende sie auf Hard-Defaults)
|
||||||
for k in ("f5ttsModel", "f5ttsCkptFile", "f5ttsVocabFile",
|
for k in ("f5ttsModel", "f5ttsCkptFile", "f5ttsVocabFile",
|
||||||
"f5ttsCfgStrength", "f5ttsNfeStep"):
|
"f5ttsCfgStrength", "f5ttsNfeStep"):
|
||||||
@@ -746,7 +746,7 @@ class ARIABridge:
|
|||||||
# Gleiche Logik fuer die Wiedergabegeschwindigkeit (F5-TTS speed-Param,
|
# Gleiche Logik fuer die Wiedergabegeschwindigkeit (F5-TTS speed-Param,
|
||||||
# App-Setting aria_tts_speed, 1.0 = normal).
|
# App-Setting aria_tts_speed, 1.0 = normal).
|
||||||
self._next_speed_override: Optional[float] = None
|
self._next_speed_override: Optional[float] = None
|
||||||
# STT-Requests die aktuell auf Antwort von der whisper-bridge (Gamebox) warten.
|
# STT-Requests die aktuell auf Antwort von der whisper-bridge (AI-Box) warten.
|
||||||
# requestId → Future mit dem Text (oder None bei Fehler).
|
# requestId → Future mit dem Text (oder None bei Fehler).
|
||||||
self._pending_stt: dict[str, asyncio.Future] = {}
|
self._pending_stt: dict[str, asyncio.Future] = {}
|
||||||
# whisper-bridge service_status: True wenn ready, False/None wenn loading/unbekannt.
|
# whisper-bridge service_status: True wenn ready, False/None wenn loading/unbekannt.
|
||||||
@@ -766,7 +766,12 @@ class ARIABridge:
|
|||||||
# requestId → Future (sat_devices / sat_result), analog _pending_flux.
|
# requestId → Future (sat_devices / sat_result), analog _pending_flux.
|
||||||
self._satellites: dict[str, dict] = {}
|
self._satellites: dict[str, dict] = {}
|
||||||
self._pending_sat: dict[str, asyncio.Future] = {}
|
self._pending_sat: dict[str, asyncio.Future] = {}
|
||||||
# FLUX-Render-Requests die aktuell auf Antwort der flux-bridge (Gamebox) warten.
|
# Compute-Fleet: GPU-Worker (voxtral/whisper/f5tts/llm) melden sich per
|
||||||
|
# worker_hello, halten sich per worker_ping frisch. instanceId →
|
||||||
|
# {service, node, gpus, model, busy, last_seen}. Genutzt fuer die
|
||||||
|
# Diagnostic-Flotten-Anzeige und (Stage 3) targetInstance-Routing.
|
||||||
|
self._workers: dict[str, dict] = {}
|
||||||
|
# FLUX-Render-Requests die aktuell auf Antwort der flux-bridge (AI-Box) warten.
|
||||||
# requestId → Future mit dem flux_response-Payload (oder None bei Fehler).
|
# requestId → Future mit dem flux_response-Payload (oder None bei Fehler).
|
||||||
self._pending_flux: dict[str, asyncio.Future] = {}
|
self._pending_flux: dict[str, asyncio.Future] = {}
|
||||||
# flux-bridge service_status: True wenn ready. Render-Timeouts werden
|
# flux-bridge service_status: True wenn ready. Render-Timeouts werden
|
||||||
@@ -774,7 +779,7 @@ class ARIABridge:
|
|||||||
self._remote_flux_ready: bool = False
|
self._remote_flux_ready: bool = False
|
||||||
# Lokales LLM (Plan B): requestId → Future mit dem llm_response-Payload.
|
# Lokales LLM (Plan B): requestId → Future mit dem llm_response-Payload.
|
||||||
# Analog zu _pending_flux — Brain ruft /internal/local-llm, wir relayen
|
# Analog zu _pending_flux — Brain ruft /internal/local-llm, wir relayen
|
||||||
# llm_request via RVS an den llm-adapter (Gamebox) und warten auf
|
# llm_request via RVS an den llm-adapter (AI-Box) und warten auf
|
||||||
# llm_response.
|
# llm_response.
|
||||||
self._pending_llm: dict[str, asyncio.Future] = {}
|
self._pending_llm: dict[str, asyncio.Future] = {}
|
||||||
# User-Message-Counter fuer Auto-Compact. Bei zu langer Konversation
|
# User-Message-Counter fuer Auto-Compact. Bei zu langer Konversation
|
||||||
@@ -810,7 +815,7 @@ class ARIABridge:
|
|||||||
logger.info("ARIA Voice Bridge startet...")
|
logger.info("ARIA Voice Bridge startet...")
|
||||||
logger.info("=" * 50)
|
logger.info("=" * 50)
|
||||||
|
|
||||||
# STT wird standardmaessig von der whisper-bridge (Gamebox) erledigt.
|
# STT wird standardmaessig von der whisper-bridge (AI-Box) erledigt.
|
||||||
# Lokales Whisper ist nur Fallback und wird lazy geladen wenn remote nicht
|
# Lokales Whisper ist nur Fallback und wird lazy geladen wenn remote nicht
|
||||||
# antwortet. Das spart RAM auf der VM und Startup-Zeit.
|
# antwortet. Das spart RAM auf der VM und Startup-Zeit.
|
||||||
|
|
||||||
@@ -1679,20 +1684,27 @@ class ARIABridge:
|
|||||||
if len(self._xtts_request_to_message) > 100:
|
if len(self._xtts_request_to_message) > 100:
|
||||||
oldest = next(iter(self._xtts_request_to_message))
|
oldest = next(iter(self._xtts_request_to_message))
|
||||||
self._xtts_request_to_message.pop(oldest, None)
|
self._xtts_request_to_message.pop(oldest, None)
|
||||||
|
# Redundanz: freie f5tts-Instanz waehlen und gezielt adressieren.
|
||||||
|
# None (keine Instanz bekannt / alle offline) → kein targetInstance,
|
||||||
|
# Broadcast wie bisher (Single-Node laeuft unveraendert).
|
||||||
|
tts_target = self._pick_worker("f5tts")
|
||||||
|
tts_payload = {
|
||||||
|
"text": tts_text,
|
||||||
|
"voice": xtts_voice,
|
||||||
|
"speed": xtts_speed,
|
||||||
|
"language": "de",
|
||||||
|
"requestId": xtts_request_id,
|
||||||
|
"messageId": message_id,
|
||||||
|
}
|
||||||
|
if tts_target:
|
||||||
|
tts_payload["targetInstance"] = tts_target
|
||||||
await self._send_to_rvs({
|
await self._send_to_rvs({
|
||||||
"type": "xtts_request",
|
"type": "xtts_request",
|
||||||
"payload": {
|
"payload": tts_payload,
|
||||||
"text": tts_text,
|
|
||||||
"voice": xtts_voice,
|
|
||||||
"speed": xtts_speed,
|
|
||||||
"language": "de",
|
|
||||||
"requestId": xtts_request_id,
|
|
||||||
"messageId": message_id,
|
|
||||||
},
|
|
||||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
})
|
})
|
||||||
logger.info("[core] XTTS-Request gesendet (voice=%s, speed=%.2fx): '%s'",
|
logger.info("[core] XTTS-Request gesendet (voice=%s, speed=%.2fx, target=%s): '%s'",
|
||||||
xtts_voice or "default", xtts_speed, tts_text[:60])
|
xtts_voice or "default", xtts_speed, tts_target or "(broadcast)", tts_text[:60])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("[core] XTTS-Request fehlgeschlagen: %s — kein Audio", e)
|
logger.error("[core] XTTS-Request fehlgeschlagen: %s — kein Audio", e)
|
||||||
|
|
||||||
@@ -1747,7 +1759,7 @@ class ARIABridge:
|
|||||||
"""Broadcastet die aktuelle voice_config.json einmalig nach RVS-Connect.
|
"""Broadcastet die aktuelle voice_config.json einmalig nach RVS-Connect.
|
||||||
|
|
||||||
Damit bekommen frisch verbundene Bridges (insbesondere die f5tts-bridge
|
Damit bekommen frisch verbundene Bridges (insbesondere die f5tts-bridge
|
||||||
auf der Gamebox nach Container-Restart) die zuletzt in Diagnostic
|
auf der AI-Box nach Container-Restart) die zuletzt in Diagnostic
|
||||||
gewaehlten Settings — ohne dass der User in Diagnostic was klicken muss.
|
gewaehlten Settings — ohne dass der User in Diagnostic was klicken muss.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
@@ -2190,7 +2202,7 @@ class ARIABridge:
|
|||||||
await self._broadcast_current_mode()
|
await self._broadcast_current_mode()
|
||||||
|
|
||||||
# Persistierte Voice-Config broadcasten — die f5tts-bridge auf
|
# Persistierte Voice-Config broadcasten — die f5tts-bridge auf
|
||||||
# der Gamebox bekommt damit nach Restart die zuletzt in
|
# der AI-Box bekommt damit nach Restart die zuletzt in
|
||||||
# Diagnostic gewaehlten Settings wieder (sonst stuende sie auf
|
# Diagnostic gewaehlten Settings wieder (sonst stuende sie auf
|
||||||
# ihren Hard-Defaults).
|
# ihren Hard-Defaults).
|
||||||
asyncio.create_task(self._broadcast_persisted_config())
|
asyncio.create_task(self._broadcast_persisted_config())
|
||||||
@@ -2551,7 +2563,7 @@ class ARIABridge:
|
|||||||
elif msg_type == "config":
|
elif msg_type == "config":
|
||||||
# Konfiguration von App/Diagnostic empfangen + persistent speichern.
|
# Konfiguration von App/Diagnostic empfangen + persistent speichern.
|
||||||
# Felder die nicht direkt zur aria-bridge gehoeren (f5tts*) werden
|
# Felder die nicht direkt zur aria-bridge gehoeren (f5tts*) werden
|
||||||
# nur persistiert; die f5tts-bridge auf der Gamebox empfaengt den
|
# nur persistiert; die f5tts-bridge auf der AI-Box empfaengt den
|
||||||
# gleichen RVS-Broadcast und reagiert selber.
|
# gleichen RVS-Broadcast und reagiert selber.
|
||||||
changed = False
|
changed = False
|
||||||
if "ttsEnabled" in payload:
|
if "ttsEnabled" in payload:
|
||||||
@@ -2575,7 +2587,7 @@ class ARIABridge:
|
|||||||
new_model = payload["whisperModel"]
|
new_model = payload["whisperModel"]
|
||||||
allowed = {"tiny", "base", "small", "medium", "large-v3"}
|
allowed = {"tiny", "base", "small", "medium", "large-v3"}
|
||||||
if new_model in allowed and new_model != self.stt_engine.model_size:
|
if new_model in allowed and new_model != self.stt_engine.model_size:
|
||||||
logger.info("[rvs] Whisper-Modell → %s (nur Config; Modell laedt Gamebox)",
|
logger.info("[rvs] Whisper-Modell → %s (nur Config; Modell laedt AI-Box)",
|
||||||
new_model)
|
new_model)
|
||||||
self.stt_engine.model_size = new_model
|
self.stt_engine.model_size = new_model
|
||||||
self.stt_engine.model = None
|
self.stt_engine.model = None
|
||||||
@@ -2794,8 +2806,18 @@ class ARIABridge:
|
|||||||
"message": payload.get("message", ""),
|
"message": payload.get("message", ""),
|
||||||
"stack": payload.get("stack", ""),
|
"stack": payload.get("stack", ""),
|
||||||
}
|
}
|
||||||
with (log_dir / "app.log").open("a", encoding="utf-8") as f:
|
log_path = log_dir / "app.log"
|
||||||
|
with log_path.open("a", encoding="utf-8") as f:
|
||||||
f.write(json.dumps(line, ensure_ascii=False) + "\n")
|
f.write(json.dumps(line, ensure_ascii=False) + "\n")
|
||||||
|
# Rotation: app.log waechst sonst unbegrenzt (jede App-Log-Zeile
|
||||||
|
# haengt an). Bei >5 MB die letzten 2000 Zeilen behalten.
|
||||||
|
try:
|
||||||
|
if log_path.stat().st_size > 5 * 1024 * 1024:
|
||||||
|
tail = log_path.read_text(encoding="utf-8",
|
||||||
|
errors="ignore").splitlines()[-2000:]
|
||||||
|
log_path.write_text("\n".join(tail) + "\n", encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("[app-log] %s %s: %s",
|
logger.info("[app-log] %s %s: %s",
|
||||||
line["level"], line["scope"], line["message"][:120])
|
line["level"], line["scope"], line["message"][:120])
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -3436,7 +3458,7 @@ class ARIABridge:
|
|||||||
return
|
return
|
||||||
|
|
||||||
elif msg_type == "llm_response":
|
elif msg_type == "llm_response":
|
||||||
# Antwort des llm-adapter (Gamebox) auf unseren llm_request.
|
# Antwort des llm-adapter (AI-Box) auf unseren llm_request.
|
||||||
request_id = payload.get("requestId", "")
|
request_id = payload.get("requestId", "")
|
||||||
future = self._pending_llm.get(request_id)
|
future = self._pending_llm.get(request_id)
|
||||||
if future is None or future.done():
|
if future is None or future.done():
|
||||||
@@ -3445,7 +3467,7 @@ class ARIABridge:
|
|||||||
return
|
return
|
||||||
|
|
||||||
elif msg_type == "service_status":
|
elif msg_type == "service_status":
|
||||||
# Gamebox-Bridges (whisper / f5tts / flux) melden ihren Lade-Status.
|
# AI-Box-Bridges (whisper / f5tts / flux) melden ihren Lade-Status.
|
||||||
# Wir nutzen das fuer den dynamischen STT-Timeout: solange whisper
|
# Wir nutzen das fuer den dynamischen STT-Timeout: solange whisper
|
||||||
# im 'loading' steckt, geben wir der Bridge mehr Zeit (Modell-Download
|
# im 'loading' steckt, geben wir der Bridge mehr Zeit (Modell-Download
|
||||||
# kann 1-2 Min dauern), statt nach 45s lokal zu fallbacken.
|
# kann 1-2 Min dauern), statt nach 45s lokal zu fallbacken.
|
||||||
@@ -3528,6 +3550,62 @@ class ARIABridge:
|
|||||||
self._satellites[sid]["caps"], self._satellites[sid]["control"])
|
self._satellites[sid]["caps"], self._satellites[sid]["control"])
|
||||||
return
|
return
|
||||||
|
|
||||||
|
elif msg_type == "worker_hello":
|
||||||
|
iid = (payload.get("instanceId") or "").strip()
|
||||||
|
if iid:
|
||||||
|
prev = self._workers.get(iid, {})
|
||||||
|
_models = payload.get("models")
|
||||||
|
self._workers[iid] = {
|
||||||
|
"instanceId": iid,
|
||||||
|
"service": payload.get("service") or "",
|
||||||
|
"node": payload.get("node") or "",
|
||||||
|
"gpus": payload.get("gpus") or "",
|
||||||
|
"model": payload.get("model") or "",
|
||||||
|
# models: welche Modelle die Box fahren kann (llm/llama-swap).
|
||||||
|
# Fallback auf [model] fuer alte Adapter ohne models-Feld.
|
||||||
|
"models": [m for m in _models if m] if isinstance(_models, list)
|
||||||
|
else ([payload.get("model")] if payload.get("model") else []),
|
||||||
|
"busy": bool(prev.get("busy", False)),
|
||||||
|
"last_seen": time.time(),
|
||||||
|
}
|
||||||
|
logger.info("[worker] online: %s (service=%s node=%s gpus=%s model=%s)",
|
||||||
|
iid, self._workers[iid]["service"], self._workers[iid]["node"],
|
||||||
|
self._workers[iid]["gpus"] or "?", self._workers[iid]["model"] or "?")
|
||||||
|
return
|
||||||
|
|
||||||
|
elif msg_type == "stt_lease_request":
|
||||||
|
# Die App fragt vor dem Aufnahme-Stream, welche STT-Instanz sie
|
||||||
|
# adressieren soll (Redundanz ueber mehrere Apps/Nodes). Wir waehlen
|
||||||
|
# eine freie STT-Instanz und antworten per stt_lease. Ist keine
|
||||||
|
# Instanz bekannt (instanceId leer), streamt die App wie bisher an
|
||||||
|
# ALLE (Broadcast) — Single-Node bleibt unveraendert.
|
||||||
|
req_id = (payload.get("requestId") or "").strip()
|
||||||
|
iid = self._pick_stt_worker() or ""
|
||||||
|
await self._send_to_rvs({
|
||||||
|
"type": "stt_lease",
|
||||||
|
"payload": {"requestId": req_id, "instanceId": iid},
|
||||||
|
"timestamp": int(time.time() * 1000),
|
||||||
|
})
|
||||||
|
logger.info("[stt-lease] req=%s → %s", req_id[:8] if req_id else "?",
|
||||||
|
iid or "(broadcast)")
|
||||||
|
return
|
||||||
|
|
||||||
|
elif msg_type == "worker_ping":
|
||||||
|
iid = (payload.get("instanceId") or "").strip()
|
||||||
|
if iid:
|
||||||
|
w = self._workers.get(iid)
|
||||||
|
if w is None:
|
||||||
|
# Ping ohne vorheriges hello (Bridge-Neustart) → Minimal-Eintrag,
|
||||||
|
# service aus der instanceId ableiten (Form: "service@node").
|
||||||
|
svc = iid.split("@", 1)[0]
|
||||||
|
w = self._workers[iid] = {
|
||||||
|
"instanceId": iid, "service": svc, "node": "", "gpus": "",
|
||||||
|
"model": "", "busy": False, "last_seen": 0.0,
|
||||||
|
}
|
||||||
|
w["busy"] = bool(payload.get("busy", False))
|
||||||
|
w["last_seen"] = time.time()
|
||||||
|
return
|
||||||
|
|
||||||
elif msg_type in ("sat_devices", "sat_result"):
|
elif msg_type in ("sat_devices", "sat_result"):
|
||||||
req_id = payload.get("requestId", "")
|
req_id = payload.get("requestId", "")
|
||||||
future = self._pending_sat.get(req_id)
|
future = self._pending_sat.get(req_id)
|
||||||
@@ -3553,11 +3631,11 @@ class ARIABridge:
|
|||||||
else:
|
else:
|
||||||
logger.debug("[rvs] Unbekannter Typ: %s", msg_type)
|
logger.debug("[rvs] Unbekannter Typ: %s", msg_type)
|
||||||
|
|
||||||
# STT-Orchestrierung: zuerst Remote (Gamebox), Fallback lokal.
|
# STT-Orchestrierung: zuerst Remote (AI-Box), Fallback lokal.
|
||||||
# Zwei Timeouts:
|
# Zwei Timeouts:
|
||||||
# ready=True → 45s reicht selbst fuer lange Audios
|
# ready=True → 45s reicht selbst fuer lange Audios
|
||||||
# ready=False → 300s, weil das Modell evtl. noch heruntergeladen wird
|
# ready=False → 300s, weil das Modell evtl. noch heruntergeladen wird
|
||||||
# (large-v3 ~3GB, kann auf der Gamebox 1-2 Min dauern).
|
# (large-v3 ~3GB, kann auf der AI-Box 1-2 Min dauern).
|
||||||
_STT_REMOTE_TIMEOUT_READY_S = 45.0
|
_STT_REMOTE_TIMEOUT_READY_S = 45.0
|
||||||
_STT_REMOTE_TIMEOUT_LOADING_S = 300.0
|
_STT_REMOTE_TIMEOUT_LOADING_S = 300.0
|
||||||
|
|
||||||
@@ -3910,15 +3988,15 @@ class ARIABridge:
|
|||||||
_FLUX_TIMEOUT_LOADING_S = 900.0 # 15 min beim allerersten Mal (Modell-Download)
|
_FLUX_TIMEOUT_LOADING_S = 900.0 # 15 min beim allerersten Mal (Modell-Download)
|
||||||
|
|
||||||
# ── Local-LLM-Roundtrip: Brain → Bridge → RVS → llm-adapter → zurueck ──
|
# ── Local-LLM-Roundtrip: Brain → Bridge → RVS → llm-adapter → zurueck ──
|
||||||
# Qwen3 auf der Gamebox antwortet auf kurze Turns in <1 s. Grosszuegiger
|
# Qwen3 auf der AI-Box antwortet auf kurze Turns in <1 s. Grosszuegiger
|
||||||
# Timeout deckt Kaltstart / laengere Antworten / Netz-Jitter (Gamebox@home)
|
# Timeout deckt Kaltstart / laengere Antworten / Netz-Jitter (AI-Box@home)
|
||||||
# ab. Bei Timeout faellt der Router im Brain per Escalation auf Claude.
|
# ab. Bei Timeout faellt der Router im Brain per Escalation auf Claude.
|
||||||
_LLM_TIMEOUT_S = 30.0
|
_LLM_TIMEOUT_S = 30.0
|
||||||
|
|
||||||
async def _local_llm(self, messages: list, max_tokens: int = 512,
|
async def _local_llm(self, messages: list, max_tokens: int = 512,
|
||||||
temperature: float = 0.7, stop=None, tools=None,
|
temperature: float = 0.7, stop=None, tools=None,
|
||||||
model=None) -> dict:
|
model=None) -> dict:
|
||||||
"""Schickt einen llm_request an den llm-adapter (Gamebox), wartet auf
|
"""Schickt einen llm_request an den llm-adapter (AI-Box), wartet auf
|
||||||
llm_response. tools (B1b) werden durchgereicht; tool_calls kommen zurueck.
|
llm_response. tools (B1b) werden durchgereicht; tool_calls kommen zurueck.
|
||||||
Rueckgabe: {ok, content, tool_calls, model, elapsedMs} oder {ok:False, error}."""
|
Rueckgabe: {ok, content, tool_calls, model, elapsedMs} oder {ok:False, error}."""
|
||||||
if self.ws_rvs is None:
|
if self.ws_rvs is None:
|
||||||
@@ -3943,8 +4021,15 @@ class ARIABridge:
|
|||||||
req_payload["tools"] = tools
|
req_payload["tools"] = tools
|
||||||
if model:
|
if model:
|
||||||
req_payload["model"] = model
|
req_payload["model"] = model
|
||||||
logger.info("[rvs] llm_request → llm-adapter (id=%s, msgs=%d, max_tokens=%d, tools=%d, model=%s)",
|
# Redundanz/Multitasking: freie llm-Instanz gezielt adressieren, die
|
||||||
request_id[:8], len(messages), max_tokens, len(tools) if tools else 0, model or "-")
|
# das gewaehlte Modell fahren kann; None → Broadcast wie bisher.
|
||||||
|
# Mehrere Boxen mit demselben Modell → Round-Robin (pro Projekt verteilt).
|
||||||
|
llm_target = self._pick_worker("llm", model=model or None)
|
||||||
|
if llm_target:
|
||||||
|
req_payload["targetInstance"] = llm_target
|
||||||
|
logger.info("[rvs] llm_request → llm-adapter (id=%s, msgs=%d, max_tokens=%d, tools=%d, model=%s, target=%s)",
|
||||||
|
request_id[:8], len(messages), max_tokens, len(tools) if tools else 0,
|
||||||
|
model or "-", llm_target or "(broadcast)")
|
||||||
ok = await self._send_to_rvs({
|
ok = await self._send_to_rvs({
|
||||||
"type": "llm_request",
|
"type": "llm_request",
|
||||||
"payload": req_payload,
|
"payload": req_payload,
|
||||||
@@ -3955,7 +4040,7 @@ class ARIABridge:
|
|||||||
try:
|
try:
|
||||||
result = await asyncio.wait_for(future, timeout=self._LLM_TIMEOUT_S)
|
result = await asyncio.wait_for(future, timeout=self._LLM_TIMEOUT_S)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
return {"ok": False, "error": f"Timeout ({self._LLM_TIMEOUT_S:.0f}s) — Gamebox nicht erreichbar?"}
|
return {"ok": False, "error": f"Timeout ({self._LLM_TIMEOUT_S:.0f}s) — AI-Box nicht erreichbar?"}
|
||||||
if not isinstance(result, dict) or not result.get("ok"):
|
if not isinstance(result, dict) or not result.get("ok"):
|
||||||
err = (result or {}).get("error") if isinstance(result, dict) else "leeres Resultat"
|
err = (result or {}).get("error") if isinstance(result, dict) else "leeres Resultat"
|
||||||
return {"ok": False, "error": err or "llm-adapter Fehler"}
|
return {"ok": False, "error": err or "llm-adapter Fehler"}
|
||||||
@@ -4435,6 +4520,9 @@ class ARIABridge:
|
|||||||
elif method == "POST" and path == "/internal/satellite-list":
|
elif method == "POST" and path == "/internal/satellite-list":
|
||||||
# Brain fragt: welche Satelliten/Netze sind online + Capabilities.
|
# Brain fragt: welche Satelliten/Netze sind online + Capabilities.
|
||||||
await _send_response(writer, 200, {"ok": True, "satellites": self._satellite_list()})
|
await _send_response(writer, 200, {"ok": True, "satellites": self._satellite_list()})
|
||||||
|
elif method in ("GET", "POST") and path == "/internal/worker-list":
|
||||||
|
# Diagnostic/Brain fragt: welche Compute-Worker sind online (Flotte).
|
||||||
|
await _send_response(writer, 200, {"ok": True, "workers": self._worker_list()})
|
||||||
elif method == "POST" and path == "/internal/satellite":
|
elif method == "POST" and path == "/internal/satellite":
|
||||||
# Brain-Tool: Discovery oder Command an einen Satelliten.
|
# Brain-Tool: Discovery oder Command an einen Satelliten.
|
||||||
# body: {op:'discover'|'command', satellite, device?, action?, params?}
|
# body: {op:'discover'|'command', satellite, device?, action?, params?}
|
||||||
@@ -4455,7 +4543,7 @@ class ARIABridge:
|
|||||||
await _send_response(writer, 200, result)
|
await _send_response(writer, 200, result)
|
||||||
elif method == "POST" and path == "/internal/flux-generate":
|
elif method == "POST" and path == "/internal/flux-generate":
|
||||||
# Vom Brain (flux_generate-Tool) gefeuert. Wir routen den
|
# Vom Brain (flux_generate-Tool) gefeuert. Wir routen den
|
||||||
# Render-Request via RVS an die flux-bridge (Gamebox),
|
# Render-Request via RVS an die flux-bridge (AI-Box),
|
||||||
# warten synchron auf die PNG-Antwort, speichern sie nach
|
# warten synchron auf die PNG-Antwort, speichern sie nach
|
||||||
# /shared/uploads/ und melden Pfad + Render-Stats zurueck.
|
# /shared/uploads/ und melden Pfad + Render-Stats zurueck.
|
||||||
# Brain referenziert das Bild dann mit [FILE:]-Marker in
|
# Brain referenziert das Bild dann mit [FILE:]-Marker in
|
||||||
@@ -4492,7 +4580,7 @@ class ARIABridge:
|
|||||||
await _send_response(writer, status, result)
|
await _send_response(writer, status, result)
|
||||||
elif method == "POST" and path == "/internal/local-llm":
|
elif method == "POST" and path == "/internal/local-llm":
|
||||||
# Vom Brain (Router / Testchat) gefeuert. Wir relayen den
|
# Vom Brain (Router / Testchat) gefeuert. Wir relayen den
|
||||||
# Chat-Request via RVS an den llm-adapter (Gamebox Qwen3),
|
# Chat-Request via RVS an den llm-adapter (AI-Box Qwen3),
|
||||||
# warten synchron auf llm_response und geben content zurueck.
|
# warten synchron auf llm_response und geben content zurueck.
|
||||||
try:
|
try:
|
||||||
data = json.loads(body.decode("utf-8", "ignore"))
|
data = json.loads(body.decode("utf-8", "ignore"))
|
||||||
@@ -4684,6 +4772,60 @@ class ARIABridge:
|
|||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
# worker_ping kommt alle ~10s; nach 35s ohne Ping gilt ein Worker als offline.
|
||||||
|
WORKER_OFFLINE_S = 35
|
||||||
|
|
||||||
|
def _worker_list(self) -> list[dict]:
|
||||||
|
"""Bekannte Compute-Worker (Flotte). online = kuerzlich per Ping gesehen."""
|
||||||
|
now = time.time()
|
||||||
|
out = []
|
||||||
|
for w in self._workers.values():
|
||||||
|
out.append({
|
||||||
|
"instanceId": w["instanceId"], "service": w.get("service") or "",
|
||||||
|
"node": w.get("node") or "", "gpus": w.get("gpus") or "",
|
||||||
|
"model": w.get("model") or "", "models": w.get("models") or [],
|
||||||
|
"busy": bool(w.get("busy")),
|
||||||
|
"online": (now - w.get("last_seen", 0)) < self.WORKER_OFFLINE_S,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _pick_worker(self, service: str, model: Optional[str] = None) -> Optional[str]:
|
||||||
|
"""Waehlt eine online, moeglichst freie Instanz des Diensts (Round-Robin
|
||||||
|
ueber die freien). Gibt die instanceId oder None. Fuer Stage-3-Routing
|
||||||
|
(targetInstance).
|
||||||
|
|
||||||
|
model: wenn gesetzt (nur llm sinnvoll), kommen nur Boxen in Frage, die das
|
||||||
|
Modell fahren koennen (models-Liste oder legacy model-Feld). Meldet KEINE
|
||||||
|
Box das Modell → None (nachsichtig: Aufrufer faellt auf Broadcast zurueck)."""
|
||||||
|
now = time.time()
|
||||||
|
online = [w for w in self._workers.values()
|
||||||
|
if w.get("service") == service
|
||||||
|
and (now - w.get("last_seen", 0)) < self.WORKER_OFFLINE_S]
|
||||||
|
if model:
|
||||||
|
online = [w for w in online
|
||||||
|
if model in (w.get("models") or [])
|
||||||
|
or w.get("model") == model]
|
||||||
|
if not online:
|
||||||
|
return None
|
||||||
|
free = [w for w in online if not w.get("busy")]
|
||||||
|
pool = free or online # alle busy → trotzdem eine nehmen (least-bad)
|
||||||
|
# Round-Robin: rotierender Zeiger pro Dienst(+Modell).
|
||||||
|
rr_key = f"{service}:{model}" if model else service
|
||||||
|
rr = getattr(self, "_worker_rr", None)
|
||||||
|
if rr is None:
|
||||||
|
rr = self._worker_rr = {}
|
||||||
|
idx = rr.get(rr_key, 0) % len(pool)
|
||||||
|
rr[rr_key] = idx + 1
|
||||||
|
chosen = pool[idx]
|
||||||
|
chosen["busy"] = True # optimistisch, bis der naechste Ping korrigiert
|
||||||
|
return chosen["instanceId"]
|
||||||
|
|
||||||
|
def _pick_stt_worker(self) -> Optional[str]:
|
||||||
|
"""Waehlt eine STT-Instanz fuer ein App-Lease. Voxtral (Default-STT) hat
|
||||||
|
Vorrang, Whisper ist der Fallback. None → keine online (App streamt dann
|
||||||
|
ohne targetInstance = heutiges Broadcast-Verhalten)."""
|
||||||
|
return self._pick_worker("voxtral") or self._pick_worker("whisper")
|
||||||
|
|
||||||
async def _satellite_request(self, op: str, satellite: str = "",
|
async def _satellite_request(self, op: str, satellite: str = "",
|
||||||
device: str = "", action: str = "",
|
device: str = "", action: str = "",
|
||||||
params: Optional[dict] = None,
|
params: Optional[dict] = None,
|
||||||
|
|||||||
+808
-267
File diff suppressed because it is too large
Load Diff
+268
-6
@@ -349,6 +349,66 @@ function loadLocalModels() {
|
|||||||
return DEFAULT_LOCAL_MODELS;
|
return DEFAULT_LOCAL_MODELS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── LLM-Modell-Katalog (Stage D): herunterladbare GGUF-Modelle ───────
|
||||||
|
// /shared/config/llm_catalog.json — kuratierte Liste guter GGUF-Modelle plus
|
||||||
|
// per HuggingFace-Refresh nachgeladene. Der llm-adapter zieht ein Modell via
|
||||||
|
// -hf beim ersten Load. { id(key), hfRepo, quant, sizeGB, description, source }.
|
||||||
|
const LLM_CATALOG_FILE = "/shared/config/llm_catalog.json";
|
||||||
|
const DEFAULT_LLM_CATALOG = [
|
||||||
|
{ id: "qwen3-8b", hfRepo: "Qwen/Qwen3-8B-GGUF", quant: "Q4_K_M", ctx: 8192, sizeGB: 6, description: "Bestes Tool-Calling, passt auf 12 GB.", source: "curated" },
|
||||||
|
{ id: "qwen3-4b", hfRepo: "Qwen/Qwen3-4B-GGUF", quant: "Q4_K_M", ctx: 8192, sizeGB: 3, description: "Kleiner + flotter, etwas schwaecher.", source: "curated" },
|
||||||
|
{ id: "qwen3-14b", hfRepo: "Qwen/Qwen3-14B-GGUF", quant: "Q4_K_M", ctx: 8192, sizeGB: 10, description: "Staerker, braucht mehr VRAM (~16 GB).", source: "curated" },
|
||||||
|
{ id: "llama-3.1-8b", hfRepo: "bartowski/Meta-Llama-3.1-8B-Instruct-GGUF", quant: "Q4_K_M", ctx: 8192, sizeGB: 5, description: "Llama 3.1 8B Instruct.", source: "curated" },
|
||||||
|
{ id: "mistral-small-3", hfRepo: "bartowski/Mistral-Small-24B-Instruct-2501-GGUF", quant: "Q4_K_M", ctx: 8192, sizeGB: 14, description: "Mistral Small 24B — stark, viel VRAM.", source: "curated" },
|
||||||
|
{ id: "gemma-2-9b", hfRepo: "bartowski/gemma-2-9b-it-GGUF", quant: "Q4_K_M", ctx: 8192, sizeGB: 6, description: "Google Gemma 2 9B Instruct.", source: "curated" },
|
||||||
|
];
|
||||||
|
function loadLlmCatalog() {
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(fs.readFileSync(LLM_CATALOG_FILE, "utf-8"));
|
||||||
|
if (Array.isArray(arr) && arr.length && arr.every(m => m && typeof m.id === "string")) return arr;
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
fs.mkdirSync("/shared/config", { recursive: true });
|
||||||
|
fs.writeFileSync(LLM_CATALOG_FILE, JSON.stringify(DEFAULT_LLM_CATALOG, null, 2));
|
||||||
|
} catch {}
|
||||||
|
return DEFAULT_LLM_CATALOG;
|
||||||
|
}
|
||||||
|
function saveLlmCatalog(arr) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync("/shared/config", { recursive: true });
|
||||||
|
const tmp = LLM_CATALOG_FILE + ".tmp";
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(arr, null, 2));
|
||||||
|
fs.renameSync(tmp, LLM_CATALOG_FILE);
|
||||||
|
return true;
|
||||||
|
} catch (e) { log("warn", "llm", `Katalog speichern fehlgeschlagen: ${e.message}`); return false; }
|
||||||
|
}
|
||||||
|
function slugModelId(repo) {
|
||||||
|
return String(repo).toLowerCase().replace(/^.*\//, "").replace(/-gguf$/,"").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "model";
|
||||||
|
}
|
||||||
|
// Holt populaere GGUF-Modelle von der HuggingFace-API und merged sie in den
|
||||||
|
// Katalog (kuratierte Eintraege + Beschreibungen bleiben erhalten).
|
||||||
|
async function refreshLlmCatalogFromHF() {
|
||||||
|
const url = "https://huggingface.co/api/models?search=GGUF&sort=downloads&direction=-1&limit=40";
|
||||||
|
const r = await fetch(url, { headers: { "User-Agent": "aria-diagnostic" } });
|
||||||
|
if (!r.ok) throw new Error(`HF API ${r.status}`);
|
||||||
|
const list = await r.json();
|
||||||
|
const existing = loadLlmCatalog();
|
||||||
|
const byId = new Map(existing.map(m => [m.id, m]));
|
||||||
|
let added = 0;
|
||||||
|
for (const m of (Array.isArray(list) ? list : [])) {
|
||||||
|
const repo = m.id || m.modelId;
|
||||||
|
if (!repo || !/gguf/i.test(repo)) continue;
|
||||||
|
const id = slugModelId(repo);
|
||||||
|
if (byId.has(id)) continue; // kuratierte/vorhandene nicht ueberschreiben
|
||||||
|
const entry = { id, hfRepo: repo, quant: "Q4_K_M", ctx: 8192, sizeGB: 0,
|
||||||
|
description: `HuggingFace · ${(m.downloads || 0).toLocaleString("de")} Downloads`, source: "hf" };
|
||||||
|
byId.set(id, entry); added++;
|
||||||
|
}
|
||||||
|
const merged = Array.from(byId.values());
|
||||||
|
saveLlmCatalog(merged);
|
||||||
|
return { models: merged, added };
|
||||||
|
}
|
||||||
|
|
||||||
// ── File-Project-Manifest ───────────────────────────────────────────
|
// ── File-Project-Manifest ───────────────────────────────────────────
|
||||||
// Jeder Eintrag map[absoluter_pfad] = project_id (leer = Hauptchat).
|
// Jeder Eintrag map[absoluter_pfad] = project_id (leer = Hauptchat).
|
||||||
// Wird vom files-list-Endpoint + files-set-project gepflegt.
|
// Wird vom files-list-Endpoint + files-set-project gepflegt.
|
||||||
@@ -490,6 +550,82 @@ function broadcastSatellites() {
|
|||||||
broadcast({ type: "sat_update", satellites: satelliteList() });
|
broadcast({ type: "sat_update", satellites: satelliteList() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Compute-Fleet: GPU-Worker (voxtral/whisper/f5tts/llm) ──────────
|
||||||
|
// Worker melden sich per worker_hello + halten sich per worker_ping (busy) frisch.
|
||||||
|
const workers = new Map(); // instanceId → {instanceId, service, node, gpus, model, busy, last_seen}
|
||||||
|
const WORKER_OFFLINE_MS = 35000; // ping ~10s; nach 35s ohne Ping = offline
|
||||||
|
|
||||||
|
function workerList() {
|
||||||
|
const now = Date.now();
|
||||||
|
return Array.from(workers.values()).map(w => ({
|
||||||
|
instanceId: w.instanceId, service: w.service, node: w.node,
|
||||||
|
gpus: w.gpus, model: w.model, models: w.models || [], busy: !!w.busy,
|
||||||
|
online: (now - (w.last_seen || 0)) < WORKER_OFFLINE_MS,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastWorkers() {
|
||||||
|
broadcast({ type: "worker_update", workers: workerList() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Voice-Flotte: zentraler Stimmen-Store + Auto-Provisioning ──────
|
||||||
|
// Der Diagnostic-Server ist der "Stimmen-Bibliothekar": Stimmen liegen
|
||||||
|
// zentral als /shared/voices/{name}.tar.gz (das f5tts-Export-Artefakt =
|
||||||
|
// wav+txt). Meldet sich eine f5tts-Box (worker_hello), gleichen wir ab und
|
||||||
|
// schieben ihr fehlende Stimmen (xtts_import_voice, targetInstance) bzw. ziehen
|
||||||
|
// bei ihr vorhandene, zentral fehlende Stimmen (xtts_export_voice) in den Store.
|
||||||
|
const CENTRAL_VOICES_DIR = "/shared/voices";
|
||||||
|
const centralExportPending = new Map(); // requestId -> name (unsere eigenen Export-Anfragen)
|
||||||
|
|
||||||
|
function ensureCentralVoicesDir() {
|
||||||
|
try { fs.mkdirSync(CENTRAL_VOICES_DIR, { recursive: true }); } catch (_) {}
|
||||||
|
}
|
||||||
|
function centralVoiceNames() {
|
||||||
|
ensureCentralVoicesDir();
|
||||||
|
try {
|
||||||
|
return fs.readdirSync(CENTRAL_VOICES_DIR)
|
||||||
|
.filter(f => f.endsWith(".tar.gz"))
|
||||||
|
.map(f => f.slice(0, -7));
|
||||||
|
} catch (_) { return []; }
|
||||||
|
}
|
||||||
|
function readCentralVoiceB64(name) {
|
||||||
|
try { return fs.readFileSync(`${CENTRAL_VOICES_DIR}/${name}.tar.gz`).toString("base64"); }
|
||||||
|
catch (_) { return null; }
|
||||||
|
}
|
||||||
|
function writeCentralVoice(name, dataB64) {
|
||||||
|
ensureCentralVoicesDir();
|
||||||
|
try { fs.writeFileSync(`${CENTRAL_VOICES_DIR}/${name}.tar.gz`, Buffer.from(dataB64, "base64")); return true; }
|
||||||
|
catch (e) { log("warn", "voice", `zentral schreiben ${name} fehlgeschlagen: ${e.message}`); return false; }
|
||||||
|
}
|
||||||
|
function deleteCentralVoice(name) {
|
||||||
|
try { fs.unlinkSync(`${CENTRAL_VOICES_DIR}/${name}.tar.gz`); log("info", "voice", `zentral geloescht: ${name}`); }
|
||||||
|
catch (_) {}
|
||||||
|
}
|
||||||
|
function requestCentralExport(name) {
|
||||||
|
// Broadcast-Export-Anfrage; die Box, die die Stimme hat, antwortet. Wir
|
||||||
|
// korrelieren die Antwort ueber requestId (nur unsere eigenen verarbeiten).
|
||||||
|
const requestId = "central_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8);
|
||||||
|
centralExportPending.set(requestId, name);
|
||||||
|
setTimeout(() => centralExportPending.delete(requestId), 30000);
|
||||||
|
sendToRVS_raw({ type: "xtts_export_voice", payload: { name, requestId }, timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
function provisionVoiceToInstance(name, instanceId) {
|
||||||
|
const data = readCentralVoiceB64(name);
|
||||||
|
if (!data) return;
|
||||||
|
sendToRVS_raw({ type: "xtts_import_voice",
|
||||||
|
payload: { name, data, targetInstance: instanceId }, timestamp: Date.now() });
|
||||||
|
log("info", "voice", `provisioniere '${name}' → ${instanceId}`);
|
||||||
|
}
|
||||||
|
// Abgleich beim worker_hello einer f5tts-Box: push (zentral→Box) + pull (Box→zentral, seed).
|
||||||
|
function reconcileVoices(instanceId, boxVoices) {
|
||||||
|
const central = centralVoiceNames();
|
||||||
|
const boxSet = new Set(Array.isArray(boxVoices) ? boxVoices : []);
|
||||||
|
const centralSet = new Set(central);
|
||||||
|
for (const name of central) if (!boxSet.has(name)) provisionVoiceToInstance(name, instanceId);
|
||||||
|
for (const name of boxSet) if (!centralSet.has(name)) requestCentralExport(name);
|
||||||
|
log("info", "voice", `reconcile ${instanceId}: box=${boxSet.size} central=${central.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── OpenClaw Gateway Verbindung ─────────────────────────
|
// ── OpenClaw Gateway Verbindung ─────────────────────────
|
||||||
|
|
||||||
async function connectGateway() {
|
async function connectGateway() {
|
||||||
@@ -935,6 +1071,66 @@ function connectRVS(forcePlain) {
|
|||||||
if (p.satellite && satellites.has(p.satellite)) satellites.get(p.satellite).last_seen = Date.now();
|
if (p.satellite && satellites.has(p.satellite)) satellites.get(p.satellite).last_seen = Date.now();
|
||||||
broadcast({ type: "sat_devices", satellite: p.satellite || "",
|
broadcast({ type: "sat_devices", satellite: p.satellite || "",
|
||||||
location: p.location || "", devices: p.devices || [] });
|
location: p.location || "", devices: p.devices || [] });
|
||||||
|
} else if (msg.type === "sat_creds_list_result" || msg.type === "sat_creds_result") {
|
||||||
|
// Credential-Store-Antworten eines Satelliten → an den Browser.
|
||||||
|
broadcast({ type: msg.type, payload: msg.payload || {} });
|
||||||
|
} else if (msg.type === "worker_hello") {
|
||||||
|
// Ein Compute-Worker (GPU-Dienst) meldet sich mit seiner Identitaet.
|
||||||
|
const p = msg.payload || {};
|
||||||
|
if (p.instanceId) {
|
||||||
|
const prev = workers.get(p.instanceId) || {};
|
||||||
|
// War die Box vor diesem hello schon frisch gesehen? worker_hello wird
|
||||||
|
// jetzt alle ~30s wiederholt — Reconcile nur beim ERSTEN/erneuten
|
||||||
|
// Auftauchen, nicht bei jedem Resend.
|
||||||
|
const wasFresh = prev.last_seen && (Date.now() - prev.last_seen < WORKER_OFFLINE_MS);
|
||||||
|
workers.set(p.instanceId, {
|
||||||
|
instanceId: p.instanceId, service: p.service || "",
|
||||||
|
node: p.node || "", gpus: p.gpus || "", model: p.model || "",
|
||||||
|
models: Array.isArray(p.models) ? p.models : (p.model ? [p.model] : []),
|
||||||
|
busy: !!prev.busy, last_seen: Date.now(),
|
||||||
|
});
|
||||||
|
broadcastWorkers();
|
||||||
|
// Voice-Flotte: f5tts-Box NEU online → Stimmen abgleichen/provisionieren.
|
||||||
|
if ((p.service || "") === "f5tts" && !wasFresh) reconcileVoices(p.instanceId, p.voices);
|
||||||
|
}
|
||||||
|
} else if (msg.type === "worker_ping") {
|
||||||
|
// Heartbeat eines Workers (traegt busy-Status).
|
||||||
|
const p = msg.payload || {};
|
||||||
|
if (p.instanceId) {
|
||||||
|
let w = workers.get(p.instanceId);
|
||||||
|
if (!w) {
|
||||||
|
const svc = String(p.instanceId).split("@")[0];
|
||||||
|
w = { instanceId: p.instanceId, service: svc, node: "", gpus: "", model: "", models: [], busy: false, last_seen: 0 };
|
||||||
|
workers.set(p.instanceId, w);
|
||||||
|
}
|
||||||
|
w.busy = !!p.busy;
|
||||||
|
w.last_seen = Date.now();
|
||||||
|
broadcastWorkers();
|
||||||
|
}
|
||||||
|
} else if (msg.type === "xtts_voice_saved") {
|
||||||
|
// Neue Stimme (App- ODER Diagnostic-Upload, via RVS-Broadcast) → zentral
|
||||||
|
// sichern. Online-Boxen haben sie durch den voice_upload-Broadcast schon;
|
||||||
|
// der zentrale Store macht sie persistent + fuer spaeter joinende Boxen
|
||||||
|
// verfuegbar (die holt dann reconcileVoices ab).
|
||||||
|
const p = msg.payload || {};
|
||||||
|
if (p.name && !p.error) {
|
||||||
|
log("info", "voice", `Stimme '${p.name}' gespeichert → zentraler Ingest`);
|
||||||
|
requestCentralExport(p.name);
|
||||||
|
}
|
||||||
|
} else if (msg.type === "xtts_voice_exported") {
|
||||||
|
// Antwort auf eine UNSERER zentralen Export-Anfragen (requestId-Match) →
|
||||||
|
// in den zentralen Store schreiben. Browser-initiierte Exports tragen
|
||||||
|
// keinen centralExportPending-requestId und werden hier ignoriert.
|
||||||
|
const p = msg.payload || {};
|
||||||
|
const rid = p.requestId || "";
|
||||||
|
if (rid && centralExportPending.has(rid)) {
|
||||||
|
centralExportPending.delete(rid);
|
||||||
|
if (p.ok && p.name && p.data) writeCentralVoice(p.name, p.data);
|
||||||
|
}
|
||||||
|
} else if (msg.type === "xtts_delete_voice") {
|
||||||
|
// App-initiierter Delete (Broadcast) → zentrale Kopie mitloeschen.
|
||||||
|
const p = msg.payload || {};
|
||||||
|
if (p.name) deleteCentralVoice(p.name);
|
||||||
} else if (msg.type === "agent_activity") {
|
} else if (msg.type === "agent_activity") {
|
||||||
// Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
|
// Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
|
||||||
// weiterreichen, damit der Thinking-Indikator im Chat erscheint.
|
// weiterreichen, damit der Thinking-Indikator im Chat erscheint.
|
||||||
@@ -981,7 +1177,7 @@ function connectRVS(forcePlain) {
|
|||||||
}
|
}
|
||||||
broadcast({ type: "voice_ready", payload: msg.payload });
|
broadcast({ type: "voice_ready", payload: msg.payload });
|
||||||
} else if (msg.type === "service_status") {
|
} else if (msg.type === "service_status") {
|
||||||
// Gamebox-Bridges (f5tts/whisper) melden ihren Lade-Status —
|
// AI-Box-Bridges (f5tts/whisper) melden ihren Lade-Status —
|
||||||
// an Browser durchreichen fuer das Banner unten rechts
|
// an Browser durchreichen fuer das Banner unten rechts
|
||||||
const svc = msg.payload?.service || "?";
|
const svc = msg.payload?.service || "?";
|
||||||
const state = msg.payload?.state || "?";
|
const state = msg.payload?.state || "?";
|
||||||
@@ -996,6 +1192,12 @@ function connectRVS(forcePlain) {
|
|||||||
log("info", "rvs", `service_status ${svc} ${state}${model ? ` (${model})` : ""}`);
|
log("info", "rvs", `service_status ${svc} ${state}${model ? ` (${model})` : ""}`);
|
||||||
}
|
}
|
||||||
broadcast({ type: "service_status", payload: msg.payload });
|
broadcast({ type: "service_status", payload: msg.payload });
|
||||||
|
} else if (msg.type === "llm_provision_result") {
|
||||||
|
// Ergebnis eines Modell-Downloads/Aktivierens → an Browser (Katalog-Status).
|
||||||
|
broadcast({ type: "llm_provision_result", payload: msg.payload || {} });
|
||||||
|
} else if (msg.type === "node_stats" || msg.type === "node_stats_history" || msg.type === "node_stats_reset_done") {
|
||||||
|
// Auslastungs-Monitor (Stage E): Box-Antworten an die Browser durchreichen.
|
||||||
|
broadcast({ type: msg.type, payload: msg.payload || {} });
|
||||||
} else if (msg.type === "audio_pcm" && msg.payload && _previewPending.size > 0) {
|
} else if (msg.type === "audio_pcm" && msg.payload && _previewPending.size > 0) {
|
||||||
// PCM-Chunks einer laufenden Voice-Preview — sammeln + WAV bauen
|
// PCM-Chunks einer laufenden Voice-Preview — sammeln + WAV bauen
|
||||||
_handlePreviewChunk(msg.payload);
|
_handlePreviewChunk(msg.payload);
|
||||||
@@ -1040,7 +1242,7 @@ function connectRVS(forcePlain) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs) {
|
function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs, timeoutMs = 15000) {
|
||||||
if (!RVS_HOST || !RVS_TOKEN) return;
|
if (!RVS_HOST || !RVS_TOKEN) return;
|
||||||
const proto = RVS_TLS === "true" ? "wss" : "ws";
|
const proto = RVS_TLS === "true" ? "wss" : "ws";
|
||||||
const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`;
|
const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`;
|
||||||
@@ -1048,7 +1250,7 @@ function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs) {
|
|||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
try { freshWs.close(); } catch (_) {}
|
try { freshWs.close(); } catch (_) {}
|
||||||
clientWs.send(JSON.stringify({ type: expectType, payload: { voices: [], error: "Timeout" }, timestamp: Date.now() }));
|
clientWs.send(JSON.stringify({ type: expectType, payload: { voices: [], error: "Timeout" }, timestamp: Date.now() }));
|
||||||
}, 15000);
|
}, timeoutMs);
|
||||||
freshWs.on("open", () => {
|
freshWs.on("open", () => {
|
||||||
freshWs.send(JSON.stringify({ type: sendType, payload: sendPayload, timestamp: Date.now() }));
|
freshWs.send(JSON.stringify({ type: sendType, payload: sendPayload, timestamp: Date.now() }));
|
||||||
});
|
});
|
||||||
@@ -1738,6 +1940,20 @@ const server = http.createServer((req, res) => {
|
|||||||
} else if (req.url === "/api/local-models-list" && req.method === "GET") {
|
} else if (req.url === "/api/local-models-list" && req.method === "GET") {
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
res.end(JSON.stringify({ ok: true, models: loadLocalModels() }));
|
res.end(JSON.stringify({ ok: true, models: loadLocalModels() }));
|
||||||
|
} else if (req.url === "/api/llm-catalog" && req.method === "GET") {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: true, models: loadLlmCatalog() }));
|
||||||
|
} else if (req.url === "/api/llm-catalog/refresh" && req.method === "POST") {
|
||||||
|
refreshLlmCatalogFromHF()
|
||||||
|
.then(r => {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: true, models: r.models, added: r.added }));
|
||||||
|
log("info", "llm", `LLM-Katalog von HuggingFace aktualisiert: +${r.added} Modelle`);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: false, error: err.message, models: loadLlmCatalog() }));
|
||||||
|
});
|
||||||
} else if (req.url === "/api/local-llm-config" && req.method === "GET") {
|
} else if (req.url === "/api/local-llm-config" && req.method === "GET") {
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
res.end(JSON.stringify(readLocalLlmConfig()));
|
res.end(JSON.stringify(readLocalLlmConfig()));
|
||||||
@@ -2505,6 +2721,8 @@ wss.on("connection", (ws) => {
|
|||||||
if (currentDiskStatus) ws.send(JSON.stringify(currentDiskStatus));
|
if (currentDiskStatus) ws.send(JSON.stringify(currentDiskStatus));
|
||||||
// Aktuell bekannte Satelliten mitgeben (RVS replayt sat_hello nicht).
|
// Aktuell bekannte Satelliten mitgeben (RVS replayt sat_hello nicht).
|
||||||
ws.send(JSON.stringify({ type: "sat_update", satellites: satelliteList() }));
|
ws.send(JSON.stringify({ type: "sat_update", satellites: satelliteList() }));
|
||||||
|
// Aktuell bekannte Compute-Worker mitgeben (RVS replayt worker_hello nicht).
|
||||||
|
ws.send(JSON.stringify({ type: "worker_update", workers: workerList() }));
|
||||||
|
|
||||||
ws.on("message", (raw) => {
|
ws.on("message", (raw) => {
|
||||||
try {
|
try {
|
||||||
@@ -2536,11 +2754,28 @@ wss.on("connection", (ws) => {
|
|||||||
} else if (msg.action === "sat_list") {
|
} else if (msg.action === "sat_list") {
|
||||||
// Browser will die aktuelle Satelliten-Liste.
|
// Browser will die aktuelle Satelliten-Liste.
|
||||||
ws.send(JSON.stringify({ type: "sat_update", satellites: satelliteList() }));
|
ws.send(JSON.stringify({ type: "sat_update", satellites: satelliteList() }));
|
||||||
|
} else if (msg.action === "worker_list") {
|
||||||
|
// Browser will die aktuelle Compute-Flotte.
|
||||||
|
ws.send(JSON.stringify({ type: "worker_update", workers: workerList() }));
|
||||||
} else if (msg.action === "sat_discover") {
|
} else if (msg.action === "sat_discover") {
|
||||||
// Browser triggert einen Geraete-Scan auf einem Satelliten.
|
// Browser triggert einen Geraete-Scan auf einem Satelliten.
|
||||||
sendToRVS_raw({ type: "sat_discover",
|
sendToRVS_raw({ type: "sat_discover",
|
||||||
payload: { satellite: msg.satellite || "", force: true },
|
payload: { satellite: msg.satellite || "", force: true },
|
||||||
timestamp: Date.now() });
|
timestamp: Date.now() });
|
||||||
|
} else if (msg.action === "sat_creds_list") {
|
||||||
|
sendToRVS_raw({ type: "sat_creds_list",
|
||||||
|
payload: { satellite: msg.satellite || "", requestId: "dc_" + Date.now() },
|
||||||
|
timestamp: Date.now() });
|
||||||
|
} else if (msg.action === "sat_creds_set") {
|
||||||
|
sendToRVS_raw({ type: "sat_creds_set",
|
||||||
|
payload: { satellite: msg.satellite || "", ip: msg.ip || "",
|
||||||
|
creds: msg.creds || {}, requestId: "dc_" + Date.now() },
|
||||||
|
timestamp: Date.now() });
|
||||||
|
} else if (msg.action === "sat_creds_delete") {
|
||||||
|
sendToRVS_raw({ type: "sat_creds_delete",
|
||||||
|
payload: { satellite: msg.satellite || "", ip: msg.ip || "",
|
||||||
|
type: msg.credType || "", requestId: "dc_" + Date.now() },
|
||||||
|
timestamp: Date.now() });
|
||||||
} else if (msg.action === "test_proxy") {
|
} else if (msg.action === "test_proxy") {
|
||||||
testProxy(msg.text);
|
testProxy(msg.text);
|
||||||
} else if (msg.action === "check_proxy_auth") {
|
} else if (msg.action === "check_proxy_auth") {
|
||||||
@@ -2568,7 +2803,7 @@ wss.on("connection", (ws) => {
|
|||||||
// Datei von Diagnostic an Bridge via RVS senden
|
// Datei von Diagnostic an Bridge via RVS senden
|
||||||
sendToRVS_raw({
|
sendToRVS_raw({
|
||||||
type: "file",
|
type: "file",
|
||||||
payload: { name: msg.name, type: msg.type, size: msg.size, base64: msg.base64 },
|
payload: { name: msg.name, type: msg.type, size: msg.size, base64: msg.base64, projectId: msg.projectId || "" },
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`);
|
log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`);
|
||||||
@@ -2611,9 +2846,11 @@ wss.on("connection", (ws) => {
|
|||||||
// tar.gz (base64) an XTTS-Bridge schicken — die packt aus
|
// tar.gz (base64) an XTTS-Bridge schicken — die packt aus
|
||||||
sendToRVS_withResponse("xtts_import_voice", { name: msg.name, data: msg.data }, "xtts_voice_imported", ws);
|
sendToRVS_withResponse("xtts_import_voice", { name: msg.name, data: msg.data }, "xtts_voice_imported", ws);
|
||||||
} else if (msg.action === "xtts_delete_voice") {
|
} else if (msg.action === "xtts_delete_voice") {
|
||||||
// Weiterleiten an XTTS-Bridge, die antwortet mit neuer Liste
|
// Weiterleiten an alle f5tts-Boxen (Broadcast) + zentrale Kopie loeschen.
|
||||||
|
// (Der eigene Broadcast kommt nicht zu uns zurueck, daher hier direkt.)
|
||||||
sendToRVS_raw({ type: "xtts_delete_voice", payload: { name: msg.name }, timestamp: Date.now() });
|
sendToRVS_raw({ type: "xtts_delete_voice", payload: { name: msg.name }, timestamp: Date.now() });
|
||||||
log("info", "server", `Voice-Delete '${msg.name}' an XTTS-Bridge gesendet`);
|
if (msg.name) deleteCentralVoice(msg.name);
|
||||||
|
log("info", "server", `Voice-Delete '${msg.name}' an f5tts-Boxen + zentral geloescht`);
|
||||||
} else if (msg.action === "delete_chat_message") {
|
} else if (msg.action === "delete_chat_message") {
|
||||||
// Bubble loeschen — Bridge raeumt chat_backup.jsonl + Brain-conversation
|
// Bubble loeschen — Bridge raeumt chat_backup.jsonl + Brain-conversation
|
||||||
// + broadcastet chat_message_deleted via RVS.
|
// + broadcastet chat_message_deleted via RVS.
|
||||||
@@ -2702,6 +2939,31 @@ wss.on("connection", (ws) => {
|
|||||||
// Sessions- und Brain-File-Viewer entfernt — Sessions sind raus, Memory
|
// Sessions- und Brain-File-Viewer entfernt — Sessions sind raus, Memory
|
||||||
// laeuft jetzt komplett ueber die Vector-DB im aria-brain (siehe Gehirn-Tab).
|
// laeuft jetzt komplett ueber die Vector-DB im aria-brain (siehe Gehirn-Tab).
|
||||||
// restart_session kommt weiter rein, weil der Watchdog ihn manchmal triggert.
|
// restart_session kommt weiter rein, weil der Watchdog ihn manchmal triggert.
|
||||||
|
} else if (msg.action === "llm_provision_model") {
|
||||||
|
// Modell auf eine bestimmte LLM-Box laden/aktivieren (Stage D).
|
||||||
|
sendToRVS_raw({ type: "llm_provision_model", payload: {
|
||||||
|
targetInstance: msg.targetInstance || "",
|
||||||
|
key: msg.key, hfRepo: msg.hfRepo, quant: msg.quant, ctx: msg.ctx, ngl: msg.ngl,
|
||||||
|
}, timestamp: Date.now() });
|
||||||
|
log("info", "llm", `provision '${msg.key}' (${msg.hfRepo}) → ${msg.targetInstance || "?"}`);
|
||||||
|
} else if (msg.action === "llm_remove_model") {
|
||||||
|
sendToRVS_raw({ type: "llm_remove_model", payload: {
|
||||||
|
targetInstance: msg.targetInstance || "", key: msg.key }, timestamp: Date.now() });
|
||||||
|
log("info", "llm", `remove '${msg.key}' → ${msg.targetInstance || "?"}`);
|
||||||
|
} else if (msg.action === "llm_test") {
|
||||||
|
// Test-Chat: kurze Nachricht direkt ans lokale LLM (llm_request/llm_response).
|
||||||
|
const reqId = "diagtest_" + Date.now();
|
||||||
|
sendToRVS_withResponse("llm_request", {
|
||||||
|
requestId: reqId,
|
||||||
|
messages: [{ role: "user", content: String(msg.text || "Sag kurz Hallo.") }],
|
||||||
|
max_tokens: 256, temperature: 0.5,
|
||||||
|
model: msg.model || "", targetInstance: msg.targetInstance || "",
|
||||||
|
}, "llm_response", ws, 120000); // 2min: erster Modell-Swap laedt das GGUF kalt (mehrere GB) — 15s reichen dann nicht
|
||||||
|
log("info", "llm", `Test-Chat → ${msg.model || "?"} @ ${msg.targetInstance || "(broadcast)"}`);
|
||||||
|
} else if (msg.action === "node_stats_stream_start" || msg.action === "node_stats_stream_stop"
|
||||||
|
|| msg.action === "node_stats_history_request" || msg.action === "node_stats_reset") {
|
||||||
|
// Auslastungs-Monitor (Stage E): an die Box (targetInstance) durchreichen.
|
||||||
|
sendToRVS_raw({ type: msg.action, payload: { targetInstance: msg.targetInstance || "" }, timestamp: Date.now() });
|
||||||
} else if (msg.action === "restart_session") {
|
} else if (msg.action === "restart_session") {
|
||||||
handleRestartSession(ws);
|
handleRestartSession(ws);
|
||||||
// ── Einstellungen ──
|
// ── Einstellungen ──
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# FLUX.1-dev Bildgenerierung — Architektur & Stand
|
# FLUX.1-dev Bildgenerierung — Architektur & Stand
|
||||||
|
|
||||||
Ergaenzung des ARIA-Agent-Stacks um native Text-to-Image-Generierung via
|
Ergaenzung des ARIA-Agent-Stacks um native Text-to-Image-Generierung via
|
||||||
FLUX.1-dev auf der Gamebox. Folgt dem **gleichen Pattern wie f5tts / whisper**:
|
FLUX.1-dev auf der AI-Box. Folgt dem **gleichen Pattern wie f5tts / whisper**:
|
||||||
ein eigener Container auf dem Gaming-PC, der sich selbst per WebSocket zum
|
ein eigener Container auf dem Gaming-PC, der sich selbst per WebSocket zum
|
||||||
RVS verbindet und auf seinen Request-Typ lauscht.
|
RVS verbindet und auf seinen Request-Typ lauscht.
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ aria-bridge ── send_to_core ──▶ aria-brain
|
|||||||
RVS
|
RVS
|
||||||
│ fanout
|
│ fanout
|
||||||
▼
|
▼
|
||||||
flux-bridge (Gamebox)
|
flux-bridge (AI-Box)
|
||||||
│ FluxPipeline.from_pretrained(...)
|
│ FluxPipeline.from_pretrained(...)
|
||||||
│ pipeline(prompt, width, height, steps, guidance).images[0]
|
│ pipeline(prompt, width, height, steps, guidance).images[0]
|
||||||
│ PIL → PNG → base64
|
│ PIL → PNG → base64
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Plan B — Lokaler LLM-Router (Gamebox) neben Claude
|
# Plan B — Lokaler LLM-Router (AI-Box) neben Claude
|
||||||
|
|
||||||
**Ziel:** „Gemini-Feeling" für den Alltag, ohne die Claude-Max-Subscription
|
**Ziel:** „Gemini-Feeling" für den Alltag, ohne die Claude-Max-Subscription
|
||||||
aufzugeben. Ein schnelles lokales LLM beantwortet die einfachen ~80 % der Turns
|
aufzugeben. Ein schnelles lokales LLM beantwortet die einfachen ~80 % der Turns
|
||||||
@@ -11,7 +11,7 @@ Gemessen (10.07.2026): CLI-Round-trip über den Claude-Max-Proxy hat einen
|
|||||||
**harten Boden von ~3,5 s** (Subprozess-Start pro Turn). Streaming-API würde das
|
**harten Boden von ~3,5 s** (Subprozess-Start pro Turn). Streaming-API würde das
|
||||||
brechen, kostet aber API-Geld → verliert die Max-Subscription. Ein lokales
|
brechen, kostet aber API-Geld → verliert die Max-Subscription. Ein lokales
|
||||||
LLM für die einfachen Turns umgeht den 3,5-s-Boden komplett und ist **gratis**
|
LLM für die einfachen Turns umgeht den 3,5-s-Boden komplett und ist **gratis**
|
||||||
(läuft auf vorhandener Gamebox-GPU). Echtes Speech-to-Speech-Duplex (Gemini
|
(läuft auf vorhandener AI-Box-GPU). Echtes Speech-to-Speech-Duplex (Gemini
|
||||||
Live nativ) ist mit einem Text-Modell als Hirn prinzipiell nicht drin.
|
Live nativ) ist mit einem Text-Modell als Hirn prinzipiell nicht drin.
|
||||||
|
|
||||||
## Modell & Serving (entschieden)
|
## Modell & Serving (entschieden)
|
||||||
@@ -19,17 +19,17 @@ Live nativ) ist mit einem Text-Modell als Hirn prinzipiell nicht drin.
|
|||||||
- **Modell:** Qwen3 8B, GGUF **Q4_K_M** (~6 GB). Bestes Tool-Calling der 7/8B-
|
- **Modell:** Qwen3 8B, GGUF **Q4_K_M** (~6 GB). Bestes Tool-Calling der 7/8B-
|
||||||
Klasse, solides Deutsch, Apache-2.0. Alt.: Mistral Small 3 7B (schneller,
|
Klasse, solides Deutsch, Apache-2.0. Alt.: Mistral Small 3 7B (schneller,
|
||||||
weniger Tool-Calling).
|
weniger Tool-Calling).
|
||||||
- **Serving:** **llama.cpp `llama-server`** im Docker-Container auf der Gamebox
|
- **Serving:** **llama.cpp `llama-server`** im Docker-Container auf der AI-Box
|
||||||
(kein Ollama nötig — nativer OpenAI-kompatibler `/v1/chat/completions`).
|
(kein Ollama nötig — nativer OpenAI-kompatibler `/v1/chat/completions`).
|
||||||
- **VRAM-Budget:** 12-GB-Karte, Whisper-small (~1–2 GB) + F5-TTS (~1–2 GB) →
|
- **VRAM-Budget:** 12-GB-Karte, Whisper-small (~1–2 GB) + F5-TTS (~1–2 GB) →
|
||||||
~8–9 GB frei → passt. (FLUX ist auf 12 GB eh raus.)
|
~8–9 GB frei → passt. (FLUX ist auf 12 GB eh raus.)
|
||||||
|
|
||||||
## Anbindung: über den RVS, wie TTS/STT (kein IP-Pflegen)
|
## Anbindung: über den RVS, wie TTS/STT (kein IP-Pflegen)
|
||||||
|
|
||||||
Die Gamebox ist ein anderer Host als das Brain. Statt direktem HTTP (IP/Port/
|
Die AI-Box ist ein anderer Host als das Brain. Statt direktem HTTP (IP/Port/
|
||||||
Firewall) läuft das LLM **über den RVS-Token-Room**, exakt wie Whisper/F5-TTS:
|
Firewall) läuft das LLM **über den RVS-Token-Room**, exakt wie Whisper/F5-TTS:
|
||||||
|
|
||||||
- llama.cpp hört nur auf localhost der Gamebox.
|
- llama.cpp hört nur auf localhost der AI-Box.
|
||||||
- Ein **dünner RVS-Adapter** daneben (Vorbild: whisper-/xtts-Bridge) verbindet
|
- Ein **dünner RVS-Adapter** daneben (Vorbild: whisper-/xtts-Bridge) verbindet
|
||||||
sich mit dem RVS-Token, lauscht auf `llm_request`, ruft lokal llama-server,
|
sich mit dem RVS-Token, lauscht auf `llm_request`, ruft lokal llama-server,
|
||||||
schickt `llm_response` (korreliert per requestId) zurück.
|
schickt `llm_response` (korreliert per requestId) zurück.
|
||||||
@@ -115,7 +115,7 @@ mit Ziel lokal.
|
|||||||
|
|
||||||
## Phasen
|
## Phasen
|
||||||
|
|
||||||
- **B0 — Infra:** llama.cpp-Container + RVS-Adapter auf der Gamebox,
|
- **B0 — Infra:** llama.cpp-Container + RVS-Adapter auf der AI-Box,
|
||||||
`ALLOWED_TYPES`, `local_llm_chat()` im Brain. Isoliert testen („sag hallo").
|
`ALLOWED_TYPES`, `local_llm_chat()` im Brain. Isoliert testen („sag hallo").
|
||||||
- **B1 — Router + lokale Tools:** Heuristik Tier-1/2 + Escalation, schlanke
|
- **B1 — Router + lokale Tools:** Heuristik Tier-1/2 + Escalation, schlanke
|
||||||
Persona lokal, **kuratierte Tool-Auswahl lokal** (Adapter/Bridge/Brain-Tool-
|
Persona lokal, **kuratierte Tool-Auswahl lokal** (Adapter/Bridge/Brain-Tool-
|
||||||
@@ -207,8 +207,8 @@ Zerfaellt in zwei Teile:
|
|||||||
(Docker-Socket) + Controller mit Placement-Policy + Reconciliation +
|
(Docker-Socket) + Controller mit Placement-Policy + Reconciliation +
|
||||||
Broadcast-Kollisions-Vermeidung (nicht 2× dieselbe Faehigkeit). = Mini-Nomad.
|
Broadcast-Kollisions-Vermeidung (nicht 2× dieselbe Faehigkeit). = Mini-Nomad.
|
||||||
|
|
||||||
**Empfehlung:** Fuer 2 Gameboxen NICHT bauen — statische Platzierung reicht
|
**Empfehlung:** Fuer 2 AI-Boxen NICHT bauen — statische Platzierung reicht
|
||||||
(Gamebox1=LLM, Gamebox2=Voice). Dynamisches Laden/Entladen zum VRAM-Freimachen
|
(AI-Box1=LLM, AI-Box2=Voice). Dynamisches Laden/Entladen zum VRAM-Freimachen
|
||||||
deckt `llama-swap` innerhalb eines Hosts (B0.5). Waechst die Flotte: erst den
|
deckt `llama-swap` innerhalb eines Hosts (B0.5). Waechst die Flotte: erst den
|
||||||
billigen Heartbeat-Teil; fuer echte Orchestrierung Docker Swarm / Nomad nehmen
|
billigen Heartbeat-Teil; fuer echte Orchestrierung Docker Swarm / Nomad nehmen
|
||||||
statt selbst einen Scheduler zu bauen.
|
statt selbst einen Scheduler zu bauen.
|
||||||
@@ -227,7 +227,7 @@ lohnt nicht):
|
|||||||
einen Heartbeat via RVS (Host, GPU-Util, VRAM frei/belegt, laufende
|
einen Heartbeat via RVS (Host, GPU-Util, VRAM frei/belegt, laufende
|
||||||
GPU-Container). Diagnostic zeigt pro Host VRAM-Balken + Dienste + „Host X hat
|
GPU-Container). Diagnostic zeigt pro Host VRAM-Balken + Dienste + „Host X hat
|
||||||
N GB frei". Kein Start/Stop, nur Sicht + Hinweis wohin verschiebbar.
|
N GB frei". Kein Start/Stop, nur Sicht + Hinweis wohin verschiebbar.
|
||||||
- **Zukunft (Gamebox3, 4×3060 = 48 GB):** neuer Host, eigenes Profil, `up` →
|
- **Zukunft (AI-Box3, 4×3060 = 48 GB):** neuer Host, eigenes Profil, `up` →
|
||||||
erscheint im Dashboard; grosses lokales LLM oder FLUX-Vollausbau dorthin.
|
erscheint im Dashboard; grosses lokales LLM oder FLUX-Vollausbau dorthin.
|
||||||
Ohne Orchestrator.
|
Ohne Orchestrator.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
ARIA FLUX-Bridge — laeuft auf der Gamebox (RTX 3060).
|
ARIA FLUX-Bridge — laeuft auf der AI-Box (RTX 3060).
|
||||||
|
|
||||||
Empfaengt flux_request via RVS → FLUX.1-dev/-schnell auf GPU → sendet
|
Empfaengt flux_request via RVS → FLUX.1-dev/-schnell auf GPU → sendet
|
||||||
flux_response mit base64-PNG zurueck an die aria-bridge. Diese speichert
|
flux_response mit base64-PNG zurueck an die aria-bridge. Diese speichert
|
||||||
|
|||||||
@@ -183,9 +183,9 @@ Wichtige Mechanismen:
|
|||||||
- [x] Decimal-zu-Worte fuer TTS (0.1 → null komma eins, mit IP-Schutz-Lookahead)
|
- [x] Decimal-zu-Worte fuer TTS (0.1 → null komma eins, mit IP-Schutz-Lookahead)
|
||||||
- [x] Generic Acronym-Buchstabieren (XTTS → X T T S, USB → U S B, ueber expliziter Liste)
|
- [x] Generic Acronym-Buchstabieren (XTTS → X T T S, USB → U S B, ueber expliziter Liste)
|
||||||
- [x] voice_preload/voice_ready: Stille Mini-Render bei Voice-Wechsel + Toast/Status "bereit"
|
- [x] voice_preload/voice_ready: Stille Mini-Render bei Voice-Wechsel + Toast/Status "bereit"
|
||||||
- [x] Whisper STT auf die Gamebox ausgelagert (faster-whisper CUDA, float16) — neuer aria-whisper-bridge Container
|
- [x] Whisper STT auf die AI-Box ausgelagert (faster-whisper CUDA, float16) — neuer aria-whisper-bridge Container
|
||||||
- [x] aria-bridge: STT primaer remote (Gamebox), Fallback lokal nach 45s Timeout
|
- [x] aria-bridge: STT primaer remote (AI-Box), Fallback lokal nach 45s Timeout
|
||||||
- [x] Whisper-Modell hot-swap auf Gamebox via config-Broadcast aus Diagnostic
|
- [x] Whisper-Modell hot-swap auf AI-Box via config-Broadcast aus Diagnostic
|
||||||
- [x] **F5-TTS ersetzt XTTS komplett** — neuer aria-f5tts-bridge Container, Voice Cloning, satzweises Streaming
|
- [x] **F5-TTS ersetzt XTTS komplett** — neuer aria-f5tts-bridge Container, Voice Cloning, satzweises Streaming
|
||||||
- [x] Voice-Upload mit Whisper-Auto-Transkription — User muss keinen Referenz-Text eintippen
|
- [x] Voice-Upload mit Whisper-Auto-Transkription — User muss keinen Referenz-Text eintippen
|
||||||
- [x] Audio-Pause statt Ducking: Spotify/YouTube pausieren komplett waehrend TTS (TRANSIENT statt MAY_DUCK)
|
- [x] Audio-Pause statt Ducking: Spotify/YouTube pausieren komplett waehrend TTS (TRANSIENT statt MAY_DUCK)
|
||||||
@@ -334,7 +334,7 @@ Skills mit Tool-Use.
|
|||||||
|
|
||||||
- [x] Datei-Manager (Diagnostic + App-Modal): /shared/uploads/ verwalten, Multi-Select + Select-All + Bulk-Download als ZIP + Bulk-Delete
|
- [x] Datei-Manager (Diagnostic + App-Modal): /shared/uploads/ verwalten, Multi-Select + Select-All + Bulk-Download als ZIP + Bulk-Delete
|
||||||
- [x] Wipe-All-Button (Memory + Stimmen + Settings)
|
- [x] Wipe-All-Button (Memory + Stimmen + Settings)
|
||||||
- [x] Voice Export/Import pro Stimme (Diagnostic + XTTS-Bridge auf Gamebox)
|
- [x] Voice Export/Import pro Stimme (Diagnostic + XTTS-Bridge auf AI-Box)
|
||||||
- [x] F5/Whisper-Settings als JSON-Bundle Export/Import
|
- [x] F5/Whisper-Settings als JSON-Bundle Export/Import
|
||||||
- [x] App Chat-Suche umgebaut: Highlight + Next/Prev statt Filter
|
- [x] App Chat-Suche umgebaut: Highlight + Next/Prev statt Filter
|
||||||
- [x] App Pinch-Zoom in Bildern rewriten (Multi-Touch-Race-Bugs)
|
- [x] App Pinch-Zoom in Bildern rewriten (Multi-Touch-Race-Bugs)
|
||||||
@@ -399,7 +399,7 @@ Skills mit Tool-Use.
|
|||||||
### Architektur
|
### Architektur
|
||||||
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
|
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
|
||||||
- [ ] RVS Zombie-Connections endgueltig loesen
|
- [ ] RVS Zombie-Connections endgueltig loesen
|
||||||
- [ ] Gamebox: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push
|
- [ ] AI-Box: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push
|
||||||
- [ ] Erste Skills bauen lassen (yt-dlp, pdf-extract, image-resize, etc.) — durch normale Anfragen, ARIA legt sie selbst an
|
- [ ] Erste Skills bauen lassen (yt-dlp, pdf-extract, image-resize, etc.) — durch normale Anfragen, ARIA legt sie selbst an
|
||||||
- [ ] Heartbeat (periodische Selbst-Checks)
|
- [ ] Heartbeat (periodische Selbst-Checks)
|
||||||
- [ ] Lokales LLM als Waechter (Triage vor Claude-Call)
|
- [ ] Lokales LLM als Waechter (Triage vor Claude-Call)
|
||||||
|
|||||||
+15
-1
@@ -63,7 +63,7 @@ const ALLOWED_TYPES = new Set([
|
|||||||
"agent_stream",
|
"agent_stream",
|
||||||
"oauth_callback",
|
"oauth_callback",
|
||||||
// Lokales LLM (Plan B) — Router im Brain schickt einfache Turns an das
|
// Lokales LLM (Plan B) — Router im Brain schickt einfache Turns an das
|
||||||
// Qwen3 auf der Gamebox (via Bridge → RVS → llm-adapter → llama.cpp).
|
// Qwen3 auf der AI-Box (via Bridge → RVS → llm-adapter → llama.cpp).
|
||||||
// llm_partial ist fuer B2 (Token-Streaming) reserviert, noch ungenutzt.
|
// llm_partial ist fuer B2 (Token-Streaming) reserviert, noch ungenutzt.
|
||||||
"llm_request", "llm_response", "llm_partial",
|
"llm_request", "llm_response", "llm_partial",
|
||||||
// Workspace-Desktop (Code-Projekte): Live-Code-Editor (CodeMirror in der App)
|
// Workspace-Desktop (Code-Projekte): Live-Code-Editor (CodeMirror in der App)
|
||||||
@@ -80,6 +80,20 @@ const ALLOWED_TYPES = new Set([
|
|||||||
// sat_hello, liefern Geraete-Inventar (sat_devices) auf sat_discover und
|
// sat_hello, liefern Geraete-Inventar (sat_devices) auf sat_discover und
|
||||||
// fuehren Aktionen aus (sat_command → sat_result).
|
// fuehren Aktionen aus (sat_command → sat_result).
|
||||||
"sat_hello", "sat_discover", "sat_devices", "sat_command", "sat_result",
|
"sat_hello", "sat_discover", "sat_devices", "sat_command", "sat_result",
|
||||||
|
// Satelliten-Credential-Store: Diagnostic legt pro Geraet Zugangsdaten ab
|
||||||
|
// (SNMP/HTTP/FritzBox), der Satellit speichert sie verschluesselt.
|
||||||
|
"sat_creds_set", "sat_creds_delete", "sat_creds_list",
|
||||||
|
"sat_creds_result", "sat_creds_list_result",
|
||||||
|
// Compute-Flotte (AI-Boxen): Worker (f5tts/whisper/voxtral/llm-adapter) melden
|
||||||
|
// sich per worker_hello an und pingen per worker_ping; der Diagnostic-Server
|
||||||
|
// aggregiert das und broadcastet worker_update/worker_list an die Browser-UI.
|
||||||
|
// node_stats_* speisen den Auslastungs-Monitor (live nvidia-smi + Historie).
|
||||||
|
// OHNE diese Typen verwirft der RVS die Meldungen an der Allow-List (Z. 312),
|
||||||
|
// und die Box bleibt in der Flotte unsichtbar, obwohl sie sendet.
|
||||||
|
"worker_hello", "worker_ping", "worker_update", "worker_list",
|
||||||
|
"node_stats", "node_stats_stream_start", "node_stats_stream_stop",
|
||||||
|
"node_stats_history_request", "node_stats_history",
|
||||||
|
"node_stats_reset", "node_stats_reset_done",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Token-Raum: token -> { clients: Set<ws> }
|
// Token-Raum: token -> { clients: Set<ws> }
|
||||||
|
|||||||
+31
-2
@@ -24,9 +24,38 @@ CONTROL_ENABLED=true
|
|||||||
# Erlaubte Steuer-Aktionen (kommagetrennt). Alles andere wird abgelehnt.
|
# Erlaubte Steuer-Aktionen (kommagetrennt). Alles andere wird abgelehnt.
|
||||||
# dial.launch App-Launch via DIAL (z.B. YouTube-Video auf Fire TV / Smart-TV)
|
# dial.launch App-Launch via DIAL (z.B. YouTube-Video auf Fire TV / Smart-TV)
|
||||||
# wol Wake-on-LAN (Geraet per MAC aufwecken)
|
# wol Wake-on-LAN (Geraet per MAC aufwecken)
|
||||||
# http.get generischer HTTP-GET (z.B. lokale IoT-Webhooks)
|
# http.get generischer HTTP-GET (z.B. lokale IoT-Webhooks, Statusseiten)
|
||||||
# http.post generischer HTTP-POST
|
# http.post generischer HTTP-POST
|
||||||
CONTROL_ALLOWLIST=dial.launch,wol,http.get
|
# snmp.get einzelner SNMP-Wert (params: ip, oid)
|
||||||
|
# snmp.walk SNMP-Teilbaum (params: ip, oid)
|
||||||
|
# snmp.printer Drucker-Fuellstaende (Tinte/Toner) aus der Printer-MIB (params: ip)
|
||||||
|
# snmp.ports Switch/Router-Interfaces: aktive/freie Ports (params: ip)
|
||||||
|
# snmp.info Modell/Seriennummer/Firmware-Version (params: ip)
|
||||||
|
# fritzbox.info FritzBox: Verbindung/Datenrate/externe IP (TR-064, braucht Login)
|
||||||
|
# fritzbox.hosts FritzBox: verbundene Geraete (TR-064, braucht Login)
|
||||||
|
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts
|
||||||
|
|
||||||
|
# ─── Credential-Store (optional) ───────────────────────────────────
|
||||||
|
# Pro Geraet koennen im Diagnostic Zugangsdaten hinterlegt werden (SNMP-Community/
|
||||||
|
# v3, HTTP-Basic, FritzBox-Login). Der Satellit speichert sie VERSCHLUESSELT im
|
||||||
|
# Bind-Volume ./data. Der Schluessel wird beim ersten Start automatisch erzeugt
|
||||||
|
# (./data/creds.key) — oder hier fest vorgeben (Fernet-Key, base64):
|
||||||
|
# CREDS_KEY=
|
||||||
|
|
||||||
|
# ─── SNMP (optional) ───────────────────────────────────────────────
|
||||||
|
# Defaults fuer die snmp.*-Aktionen; pro Request per params ueberschreibbar.
|
||||||
|
SNMP_COMMUNITY=public # Drucker/Switches antworten meist auf 'public'
|
||||||
|
SNMP_VERSION=2c # 1 | 2c
|
||||||
|
SNMP_TIMEOUT_SEC=5
|
||||||
|
# Discovery-Anreicherung: jedes entdeckte Geraet wird beim Scan kurz per SNMP
|
||||||
|
# nach Name/Beschreibung/Standort/Uptime gefragt (Switches, Router, APs, NAS ...).
|
||||||
|
SNMP_DISCOVERY=true
|
||||||
|
SNMP_DISCOVERY_CONCURRENCY=16 # parallele SNMP-Abfragen pro Scan
|
||||||
|
SNMP_DISCOVERY_TIMEOUT=2 # Timeout je Geraet (s) — Nicht-SNMP-Hosts fallen schnell raus
|
||||||
|
|
||||||
|
# ─── HTTP (optional) ───────────────────────────────────────────────
|
||||||
|
HTTP_TIMEOUT_SEC=10 # Timeout fuer http.get/http.post
|
||||||
|
HTTP_MAX_CHARS=20000 # Default-Body-Ausschnitt (offset/max_chars pro Request)
|
||||||
|
|
||||||
# ─── Discovery-Tuning (optional) ───────────────────────────────────
|
# ─── Discovery-Tuning (optional) ───────────────────────────────────
|
||||||
SCAN_INTERVAL_SEC=300 # Hintergrund-Rescan-Intervall
|
SCAN_INTERVAL_SEC=300 # Hintergrund-Rescan-Intervall
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
# Verschluesselter Credential-Store + Schluessel (nie einchecken!)
|
||||||
|
data/
|
||||||
@@ -5,6 +5,12 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# net-snmp-CLI (snmpget/snmpwalk) fuer die snmp.*-Aktionen — z.B. Drucker-
|
||||||
|
# Tintenstaende zuverlaessig aus der Printer-MIB statt HTML zu scrapen.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends snmp \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
|||||||
@@ -21,3 +21,6 @@ services:
|
|||||||
network_mode: host
|
network_mode: host
|
||||||
env_file: .env
|
env_file: .env
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
# Persistenter Credential-Store (verschluesselt) + Schluesseldatei.
|
||||||
|
- ./data:/data
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
websockets>=12.0
|
websockets>=12.0
|
||||||
zeroconf>=0.131.0
|
zeroconf>=0.131.0
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
|
cryptography>=42.0 # Verschluesselung des Geraete-Credential-Stores (Fernet)
|
||||||
|
|||||||
+629
-6
@@ -35,6 +35,7 @@ import re
|
|||||||
import socket
|
import socket
|
||||||
import struct
|
import struct
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import websockets
|
import websockets
|
||||||
@@ -113,7 +114,9 @@ SATELLITE_LOCATION = (os.environ.get("SATELLITE_LOCATION") or SATELLITE_ID).stri
|
|||||||
CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False)
|
CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False)
|
||||||
CONTROL_ALLOWLIST = [
|
CONTROL_ALLOWLIST = [
|
||||||
a.strip() for a in
|
a.strip() for a in
|
||||||
os.environ.get("CONTROL_ALLOWLIST", "dial.launch,wol,http.get").split(",")
|
os.environ.get("CONTROL_ALLOWLIST",
|
||||||
|
"dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,"
|
||||||
|
"snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts").split(",")
|
||||||
if a.strip()
|
if a.strip()
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -121,6 +124,114 @@ SCAN_INTERVAL_SEC = int(os.environ.get("SCAN_INTERVAL_SEC", "300") or "300")
|
|||||||
DISCOVER_TIMEOUT_SEC = float(os.environ.get("DISCOVER_TIMEOUT_SEC", "6") or "6")
|
DISCOVER_TIMEOUT_SEC = float(os.environ.get("DISCOVER_TIMEOUT_SEC", "6") or "6")
|
||||||
DEVICE_CACHE_TTL_SEC = int(os.environ.get("DEVICE_CACHE_TTL_SEC", "120") or "120")
|
DEVICE_CACHE_TTL_SEC = int(os.environ.get("DEVICE_CACHE_TTL_SEC", "120") or "120")
|
||||||
|
|
||||||
|
# http.get/http.post: Body-Ausschnitt. Default grosszuegig (ganze Statusseiten
|
||||||
|
# passen), mit hartem Deckel gegen Riesen-Payloads durchs RVS. offset/max_chars
|
||||||
|
# pro Request ueberschreibbar; contains-Filter zieht nur relevante Zeilen.
|
||||||
|
HTTP_TIMEOUT_SEC = float(os.environ.get("HTTP_TIMEOUT_SEC", "10") or "10")
|
||||||
|
HTTP_MAX_CHARS = int(os.environ.get("HTTP_MAX_CHARS", "20000") or "20000")
|
||||||
|
HTTP_MAX_CHARS_HARD = int(os.environ.get("HTTP_MAX_CHARS_HARD", "200000") or "200000")
|
||||||
|
|
||||||
|
# SNMP (net-snmp-CLI): Default-Community/Version + Timeout. Drucker antworten
|
||||||
|
# i.d.R. auf community 'public', v2c.
|
||||||
|
SNMP_COMMUNITY = os.environ.get("SNMP_COMMUNITY", "public") or "public"
|
||||||
|
SNMP_VERSION = os.environ.get("SNMP_VERSION", "2c") or "2c"
|
||||||
|
SNMP_TIMEOUT_SEC = float(os.environ.get("SNMP_TIMEOUT_SEC", "5") or "5")
|
||||||
|
# Printer-MIB (RFC 3805) prtMarkerSuppliesEntry-Spalten (numerisch, ohne MIB-Files):
|
||||||
|
SNMP_SUPPLY_DESC = "1.3.6.1.2.1.43.11.1.1.6.1" # Beschreibung (z.B. "Black Ink")
|
||||||
|
SNMP_SUPPLY_MAX = "1.3.6.1.2.1.43.11.1.1.8.1" # Max-Kapazitaet
|
||||||
|
SNMP_SUPPLY_LVL = "1.3.6.1.2.1.43.11.1.1.9.1" # aktueller Fuellstand
|
||||||
|
|
||||||
|
# SNMP-Anreicherung bei der Discovery: jedes entdeckte Geraet mit IP wird kurz
|
||||||
|
# nach seiner System-Group (RFC 1213) gefragt. Switches/Router/APs/NAS geben so
|
||||||
|
# Name, Beschreibung, Standort & Uptime preis -> im Inventar (satellite_devices)
|
||||||
|
# sichtbar. Abschaltbar; kurzer Timeout + parallel, damit der Scan flott bleibt.
|
||||||
|
SNMP_DISCOVERY = _env_bool("SNMP_DISCOVERY", True)
|
||||||
|
SNMP_DISCOVERY_CONCURRENCY = int(os.environ.get("SNMP_DISCOVERY_CONCURRENCY", "16") or "16")
|
||||||
|
SNMP_DISCOVERY_TIMEOUT = float(os.environ.get("SNMP_DISCOVERY_TIMEOUT", "2") or "2")
|
||||||
|
# System-Group (RFC 1213) .0-Instanzen:
|
||||||
|
SNMP_SYS_OIDS = {
|
||||||
|
"descr": "1.3.6.1.2.1.1.1.0", # sysDescr
|
||||||
|
"objectid": "1.3.6.1.2.1.1.2.0", # sysObjectID
|
||||||
|
"uptime": "1.3.6.1.2.1.1.3.0", # sysUpTime
|
||||||
|
"contact": "1.3.6.1.2.1.1.4.0", # sysContact
|
||||||
|
"name": "1.3.6.1.2.1.1.5.0", # sysName
|
||||||
|
"location": "1.3.6.1.2.1.1.6.0", # sysLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Geraete-Credential-Store (verschluesselt, pro IP) ─────────────
|
||||||
|
# Diagnostic legt via sat_creds_set pro Geraet Zugangsdaten ab (SNMP-Community/
|
||||||
|
# v3, HTTP-Basic, FritzBox-Login). Der Satellit nutzt sie automatisch bei snmp.*/
|
||||||
|
# http/fritzbox. Persistiert verschluesselt (Fernet) in einem Bind-Volume.
|
||||||
|
CREDS_PATH = os.environ.get("CREDS_PATH", "/data/credentials.json.enc")
|
||||||
|
CREDS_KEY_PATH = os.environ.get("CREDS_KEY_PATH", "/data/creds.key")
|
||||||
|
_CREDS: dict = {} # {ip: {snmp:{...}, http:{...}, fritzbox:{...}}}
|
||||||
|
_creds_fernet = None # Fernet-Instanz (lazy)
|
||||||
|
|
||||||
|
|
||||||
|
def _creds_cipher():
|
||||||
|
"""Fernet-Instanz; Schluessel aus CREDS_KEY (env) oder Schluesseldatei im
|
||||||
|
Volume (wird beim ersten Start erzeugt, 0600)."""
|
||||||
|
global _creds_fernet
|
||||||
|
if _creds_fernet is not None:
|
||||||
|
return _creds_fernet
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
key = os.environ.get("CREDS_KEY", "").strip().encode() or None
|
||||||
|
if not key:
|
||||||
|
kp = Path(CREDS_KEY_PATH)
|
||||||
|
if kp.exists():
|
||||||
|
key = kp.read_bytes().strip()
|
||||||
|
else:
|
||||||
|
key = Fernet.generate_key()
|
||||||
|
kp.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
kp.write_bytes(key)
|
||||||
|
try:
|
||||||
|
os.chmod(kp, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
logger.info("[creds] neuer Verschluesselungs-Schluessel erzeugt: %s", CREDS_KEY_PATH)
|
||||||
|
_creds_fernet = Fernet(key)
|
||||||
|
return _creds_fernet
|
||||||
|
|
||||||
|
|
||||||
|
def _creds_load() -> None:
|
||||||
|
global _CREDS
|
||||||
|
p = Path(CREDS_PATH)
|
||||||
|
if not p.exists():
|
||||||
|
_CREDS = {}
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
blob = p.read_bytes()
|
||||||
|
raw = _creds_cipher().decrypt(blob)
|
||||||
|
_CREDS = json.loads(raw.decode("utf-8")) or {}
|
||||||
|
logger.info("[creds] %d Geraete-Eintraege geladen", len(_CREDS))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[creds] laden fehlgeschlagen (%s) — starte leer", exc)
|
||||||
|
_CREDS = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _creds_save() -> None:
|
||||||
|
p = Path(CREDS_PATH)
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
blob = _creds_cipher().encrypt(json.dumps(_CREDS).encode("utf-8"))
|
||||||
|
p.write_bytes(blob)
|
||||||
|
try:
|
||||||
|
os.chmod(p, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _creds_for(ip: str) -> dict:
|
||||||
|
return _CREDS.get((ip or "").strip(), {}) if ip else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _creds_public_summary() -> list:
|
||||||
|
"""Fuer sat_creds_list: welche Geraete welche Cred-Typen haben — OHNE Secrets."""
|
||||||
|
out = []
|
||||||
|
for ip, entry in sorted(_CREDS.items()):
|
||||||
|
types = [t for t in ("snmp", "http", "fritzbox") if entry.get(t)]
|
||||||
|
out.append({"ip": ip, "types": types})
|
||||||
|
return out
|
||||||
|
|
||||||
HEARTBEAT_SEC = 25
|
HEARTBEAT_SEC = 25
|
||||||
|
|
||||||
# mDNS-Servicetypen, die fuer ARIA interessant sind.
|
# mDNS-Servicetypen, die fuer ARIA interessant sind.
|
||||||
@@ -420,6 +531,16 @@ async def _control(action: str, params: dict, devices: list[dict]) -> dict:
|
|||||||
return await loop.run_in_executor(None, _do_wol, params)
|
return await loop.run_in_executor(None, _do_wol, params)
|
||||||
if action in ("http.get", "http.post"):
|
if action in ("http.get", "http.post"):
|
||||||
return await loop.run_in_executor(None, _do_http, action, params)
|
return await loop.run_in_executor(None, _do_http, action, params)
|
||||||
|
if action in ("snmp.get", "snmp.walk"):
|
||||||
|
return await loop.run_in_executor(None, _do_snmp, action, params)
|
||||||
|
if action == "snmp.printer":
|
||||||
|
return await loop.run_in_executor(None, _do_snmp_printer, params)
|
||||||
|
if action == "snmp.ports":
|
||||||
|
return await loop.run_in_executor(None, _do_snmp_ports, params)
|
||||||
|
if action == "snmp.info":
|
||||||
|
return await loop.run_in_executor(None, _do_snmp_info, params)
|
||||||
|
if action in ("fritzbox.info", "fritzbox.hosts"):
|
||||||
|
return await loop.run_in_executor(None, _do_fritzbox, action, params)
|
||||||
return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."}
|
return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
|
return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
|
||||||
@@ -472,14 +593,424 @@ def _do_wol(params: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _do_http(action: str, params: dict) -> dict:
|
def _do_http(action: str, params: dict) -> dict:
|
||||||
|
"""HTTP-GET/POST vom Satelliten aus (lokale Webhooks, Geraete-Statusseiten …).
|
||||||
|
|
||||||
|
params:
|
||||||
|
url Pflicht (http/https).
|
||||||
|
body/headers optional (POST).
|
||||||
|
offset ab welchem Zeichen der Body zurueckgegeben wird (Default 0).
|
||||||
|
max_chars wie viele Zeichen max. (Default HTTP_MAX_CHARS, hart gedeckelt).
|
||||||
|
contains String oder Liste: nur Zeilen, die (case-insensitive) einen der
|
||||||
|
Begriffe enthalten, werden zurueckgegeben. Ideal um aus einer
|
||||||
|
grossen Statusseite nur die relevanten Werte (z.B. Tinte) zu
|
||||||
|
ziehen, ohne die ganze Seite zu paginieren.
|
||||||
|
Antwort enthaelt total_chars + truncated, damit der Aufrufer weiss, ob noch
|
||||||
|
mehr da ist."""
|
||||||
import requests
|
import requests
|
||||||
url = params.get("url") or ""
|
url = params.get("url") or ""
|
||||||
if not url.startswith(("http://", "https://")):
|
if not url.startswith(("http://", "https://")):
|
||||||
return {"ok": False, "error": "url (http/https) erforderlich."}
|
return {"ok": False, "error": "url (http/https) erforderlich."}
|
||||||
method = "GET" if action == "http.get" else "POST"
|
method = "GET" if action == "http.get" else "POST"
|
||||||
|
# HTTP-Basic-Auth: explizite params > gespeicherte http-Creds fuer den Host.
|
||||||
|
auth = None
|
||||||
|
hcreds = {}
|
||||||
|
try:
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
host = urlparse(url).hostname or ""
|
||||||
|
hcreds = _creds_for(host).get("http", {})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
user = params.get("user") or hcreds.get("user")
|
||||||
|
pw = params.get("pass") or params.get("password") or hcreds.get("pass")
|
||||||
|
if user or pw: # Benutzer optional — manche Geraete nutzen Password-only-Basic-Auth
|
||||||
|
auth = (str(user or ""), str(pw or ""))
|
||||||
r = requests.request(method, url, data=params.get("body"),
|
r = requests.request(method, url, data=params.get("body"),
|
||||||
headers=params.get("headers"), timeout=6)
|
headers=params.get("headers"), auth=auth,
|
||||||
return {"ok": True, "result": {"status": r.status_code, "body": r.text[:2000]}}
|
timeout=HTTP_TIMEOUT_SEC)
|
||||||
|
text = r.text
|
||||||
|
total = len(text)
|
||||||
|
|
||||||
|
contains = params.get("contains")
|
||||||
|
if contains:
|
||||||
|
terms = [contains] if isinstance(contains, str) else list(contains)
|
||||||
|
terms = [str(t).lower() for t in terms if str(t).strip()]
|
||||||
|
if terms:
|
||||||
|
lines = [ln for ln in text.splitlines()
|
||||||
|
if any(t in ln.lower() for t in terms)]
|
||||||
|
text = "\n".join(lines)
|
||||||
|
|
||||||
|
try:
|
||||||
|
offset = max(0, int(params.get("offset", 0)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
offset = 0
|
||||||
|
try:
|
||||||
|
max_chars = int(params.get("max_chars", HTTP_MAX_CHARS))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
max_chars = HTTP_MAX_CHARS
|
||||||
|
max_chars = max(1, min(max_chars, HTTP_MAX_CHARS_HARD))
|
||||||
|
|
||||||
|
body = text[offset:offset + max_chars]
|
||||||
|
returned_end = offset + len(body)
|
||||||
|
truncated = returned_end < len(text)
|
||||||
|
return {"ok": True, "result": {
|
||||||
|
"status": r.status_code,
|
||||||
|
"body": body,
|
||||||
|
"total_chars": total, # Groesse der Roh-Antwort
|
||||||
|
"filtered": bool(contains), # contains-Filter aktiv?
|
||||||
|
"offset": offset,
|
||||||
|
"returned_chars": len(body),
|
||||||
|
"truncated": truncated, # noch mehr Text nach diesem Ausschnitt?
|
||||||
|
}}
|
||||||
|
|
||||||
|
|
||||||
|
def _snmp_run(args: list, timeout: float) -> tuple:
|
||||||
|
"""Fuehrt ein net-snmp-CLI-Tool aus. Gibt (ok, stdout|fehlertext)."""
|
||||||
|
import subprocess
|
||||||
|
try:
|
||||||
|
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False, "snmp-Tools fehlen im Container (Paket 'snmp' im Dockerfile)."
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "SNMP-Timeout — Geraet antwortet nicht (community/version/IP pruefen)."
|
||||||
|
if r.returncode != 0:
|
||||||
|
return False, (r.stderr or r.stdout or "SNMP-Fehler").strip()[:200]
|
||||||
|
return True, r.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def _snmp_base_args(params: dict, ip: str = "") -> list:
|
||||||
|
"""Version/Community bzw. v3-Auth. Prioritaet: explizite params > gespeicherte
|
||||||
|
Creds fuer die IP > globale Defaults. OHNE -t/-r (haengt der Aufrufer an)."""
|
||||||
|
creds = _creds_for(ip).get("snmp", {}) if ip else {}
|
||||||
|
version = str(params.get("version") or creds.get("version") or SNMP_VERSION)
|
||||||
|
if version == "3":
|
||||||
|
v3 = creds.get("v3", {}) or {}
|
||||||
|
user = str(params.get("user") or v3.get("user") or "")
|
||||||
|
level = str(params.get("level") or v3.get("level") or "authPriv")
|
||||||
|
args = ["-v", "3", "-u", user, "-l", level]
|
||||||
|
ap = params.get("authProto") or v3.get("authProto")
|
||||||
|
ak = params.get("authKey") or v3.get("authKey")
|
||||||
|
pp = params.get("privProto") or v3.get("privProto")
|
||||||
|
pk = params.get("privKey") or v3.get("privKey")
|
||||||
|
if ap and ak:
|
||||||
|
args += ["-a", str(ap), "-A", str(ak)]
|
||||||
|
if pp and pk:
|
||||||
|
args += ["-x", str(pp), "-X", str(pk)]
|
||||||
|
return args
|
||||||
|
community = str(params.get("community") or creds.get("community") or SNMP_COMMUNITY)
|
||||||
|
return ["-v", version, "-c", community]
|
||||||
|
|
||||||
|
|
||||||
|
def _snmp_target(params: dict) -> str:
|
||||||
|
return (params.get("ip") or params.get("host") or params.get("device") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _do_snmp(action: str, params: dict) -> dict:
|
||||||
|
"""Generisches snmp.get / snmp.walk.
|
||||||
|
params: {ip|host, oid, community?='public', version?='2c'}."""
|
||||||
|
ip = _snmp_target(params)
|
||||||
|
if not ip:
|
||||||
|
return {"ok": False, "error": "ip/host erforderlich."}
|
||||||
|
oid = str(params.get("oid") or "").strip()
|
||||||
|
if not oid:
|
||||||
|
return {"ok": False, "error": "oid erforderlich (z.B. 1.3.6.1.2.1.1.5.0 fuer sysName)."}
|
||||||
|
tool = "snmpwalk" if action == "snmp.walk" else "snmpget"
|
||||||
|
# -OQ: OID = Wert, ohne Typannotation; numerische OIDs brauchen keine MIB-Files.
|
||||||
|
args = [tool, "-OQ", *_snmp_base_args(params, ip), "-t", "2", "-r", "1", ip, oid]
|
||||||
|
ok, out = _snmp_run(args, SNMP_TIMEOUT_SEC)
|
||||||
|
if not ok:
|
||||||
|
return {"ok": False, "error": out}
|
||||||
|
lines = [ln.strip() for ln in out.splitlines() if ln.strip()]
|
||||||
|
return {"ok": True, "result": {"ip": ip, "oid": oid, "lines": lines[:200]}}
|
||||||
|
|
||||||
|
|
||||||
|
def _snmp_walk_values(ip: str, base: list, oid: str) -> list:
|
||||||
|
"""snmpwalk -Oqv (nur Werte, in OID-Index-Reihenfolge)."""
|
||||||
|
ok, out = _snmp_run(["snmpwalk", "-Oqv", *base, ip, oid], SNMP_TIMEOUT_SEC)
|
||||||
|
if not ok:
|
||||||
|
return []
|
||||||
|
return [ln.strip().strip('"') for ln in out.splitlines() if ln.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _do_snmp_printer(params: dict) -> dict:
|
||||||
|
"""Komfort: liest die Verbrauchsmaterialien (Tinte/Toner) aus der Printer-MIB
|
||||||
|
und rechnet Fuellstaende in Prozent. params: {ip|host, community?, version?}."""
|
||||||
|
ip = _snmp_target(params)
|
||||||
|
if not ip:
|
||||||
|
return {"ok": False, "error": "ip/host erforderlich."}
|
||||||
|
base = [*_snmp_base_args(params, ip), "-t", "2", "-r", "1"]
|
||||||
|
descs = _snmp_walk_values(ip, base, SNMP_SUPPLY_DESC)
|
||||||
|
if not descs:
|
||||||
|
return {"ok": False, "error":
|
||||||
|
"Keine Printer-MIB-Daten (Geraet unterstuetzt kein SNMP, falsche "
|
||||||
|
"community/version, oder es ist kein Drucker)."}
|
||||||
|
lvls = _snmp_walk_values(ip, base, SNMP_SUPPLY_LVL)
|
||||||
|
maxs = _snmp_walk_values(ip, base, SNMP_SUPPLY_MAX)
|
||||||
|
supplies = []
|
||||||
|
for i, name in enumerate(descs):
|
||||||
|
lvl = _to_int(lvls[i]) if i < len(lvls) else None
|
||||||
|
mx = _to_int(maxs[i]) if i < len(maxs) else None
|
||||||
|
percent = None
|
||||||
|
if lvl is not None and mx and mx > 0 and lvl >= 0:
|
||||||
|
percent = round(lvl / mx * 100)
|
||||||
|
elif lvl == -3:
|
||||||
|
percent = "vorhanden (Stand unbekannt)" # RFC: some remaining
|
||||||
|
elif lvl in (-1, -2):
|
||||||
|
percent = "unbekannt"
|
||||||
|
supplies.append({"name": name, "level": lvl, "max": mx, "percent": percent})
|
||||||
|
return {"ok": True, "result": {"ip": ip, "supplies": supplies}}
|
||||||
|
|
||||||
|
|
||||||
|
# ifTable (RFC 1213) Spalten:
|
||||||
|
_IF_DESCR = "1.3.6.1.2.1.2.2.1.2"
|
||||||
|
_IF_TYPE = "1.3.6.1.2.1.2.2.1.3"
|
||||||
|
_IF_SPEED = "1.3.6.1.2.1.2.2.1.5"
|
||||||
|
_IF_ADMIN = "1.3.6.1.2.1.2.2.1.7" # up(1) down(2)
|
||||||
|
_IF_OPER = "1.3.6.1.2.1.2.2.1.8" # up(1) down(2) ...
|
||||||
|
_IF_ALIAS = "1.3.6.1.2.1.31.1.1.1.18" # ifAlias (ifXTable, optional)
|
||||||
|
|
||||||
|
|
||||||
|
def _do_snmp_ports(params: dict) -> dict:
|
||||||
|
"""Interface-Uebersicht eines Switches/Routers: welche Ports sind aktiv (Link),
|
||||||
|
welche frei. params: {ip|host, community?/v3?}. ethernetCsmacd(6)=echte Ports;
|
||||||
|
Loopback/VLAN etc. werden als 'other' markiert, nicht als freier Port gezaehlt."""
|
||||||
|
ip = _snmp_target(params)
|
||||||
|
if not ip:
|
||||||
|
return {"ok": False, "error": "ip/host erforderlich."}
|
||||||
|
base = [*_snmp_base_args(params, ip), "-t", "2", "-r", "1"]
|
||||||
|
descr = _snmp_walk_values(ip, base, _IF_DESCR)
|
||||||
|
if not descr:
|
||||||
|
return {"ok": False, "error":
|
||||||
|
"Keine Interface-Daten (kein SNMP / falsche Credentials / kein Switch)."}
|
||||||
|
types = _snmp_walk_values(ip, base, _IF_TYPE)
|
||||||
|
opers = _snmp_walk_values(ip, base, _IF_OPER)
|
||||||
|
admins = _snmp_walk_values(ip, base, _IF_ADMIN)
|
||||||
|
speeds = _snmp_walk_values(ip, base, _IF_SPEED)
|
||||||
|
aliases = _snmp_walk_values(ip, base, _IF_ALIAS)
|
||||||
|
ports = []
|
||||||
|
up = down_free = disabled = 0
|
||||||
|
for i, name in enumerate(descr):
|
||||||
|
itype = _to_int(types[i]) if i < len(types) else None
|
||||||
|
oper = _to_int(opers[i]) if i < len(opers) else None
|
||||||
|
admin = _to_int(admins[i]) if i < len(admins) else None
|
||||||
|
speed = _to_int(speeds[i]) if i < len(speeds) else None
|
||||||
|
is_eth = (itype == 6) # ethernetCsmacd
|
||||||
|
state = ("up" if oper == 1 else
|
||||||
|
"disabled" if admin == 2 else "down")
|
||||||
|
if is_eth:
|
||||||
|
if state == "up":
|
||||||
|
up += 1
|
||||||
|
elif state == "disabled":
|
||||||
|
disabled += 1
|
||||||
|
else:
|
||||||
|
down_free += 1
|
||||||
|
ports.append({
|
||||||
|
"name": name.strip('"'),
|
||||||
|
"alias": (aliases[i].strip('"') if i < len(aliases) else ""),
|
||||||
|
"physical": is_eth,
|
||||||
|
"state": state,
|
||||||
|
"speedMbps": round(speed / 1_000_000) if speed else None,
|
||||||
|
})
|
||||||
|
return {"ok": True, "result": {
|
||||||
|
"ip": ip,
|
||||||
|
"summary": {"physical_ports": up + down_free + disabled,
|
||||||
|
"up": up, "free": down_free, "disabled": disabled},
|
||||||
|
"ports": ports,
|
||||||
|
}}
|
||||||
|
|
||||||
|
|
||||||
|
# entPhysicalTable (RFC 4133) — Modell/Serie/Firmware:
|
||||||
|
_ENT_MODEL = "1.3.6.1.2.1.47.1.1.1.1.13" # entPhysicalModelName
|
||||||
|
_ENT_SERIAL = "1.3.6.1.2.1.47.1.1.1.1.11" # entPhysicalSerialNum
|
||||||
|
_ENT_SWREV = "1.3.6.1.2.1.47.1.1.1.1.10" # entPhysicalSoftwareRev
|
||||||
|
_ENT_FWREV = "1.3.6.1.2.1.47.1.1.1.1.9" # entPhysicalFirmwareRev
|
||||||
|
|
||||||
|
|
||||||
|
def _do_snmp_info(params: dict) -> dict:
|
||||||
|
"""Geraeteinfo: sysName/sysDescr + (falls vorhanden) Modell, Seriennummer,
|
||||||
|
Firmware-/Software-Version aus der Entity-MIB. Sagt die INSTALLIERTE Version —
|
||||||
|
ob ein Update existiert, weiss SNMP nicht (Hersteller-Sache)."""
|
||||||
|
ip = _snmp_target(params)
|
||||||
|
if not ip:
|
||||||
|
return {"ok": False, "error": "ip/host erforderlich."}
|
||||||
|
sysinfo = _snmp_system(ip, str(params.get("community") or ""), str(params.get("version") or ""))
|
||||||
|
base = [*_snmp_base_args(params, ip), "-t", "2", "-r", "1"]
|
||||||
|
|
||||||
|
def _first(oid):
|
||||||
|
vals = [v for v in _snmp_walk_values(ip, base, oid)
|
||||||
|
if v and "No Such" not in v]
|
||||||
|
return vals[0] if vals else None
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"ip": ip,
|
||||||
|
"name": (sysinfo or {}).get("name"),
|
||||||
|
"descr": (sysinfo or {}).get("descr"),
|
||||||
|
"location": (sysinfo or {}).get("location"),
|
||||||
|
"uptime": (sysinfo or {}).get("uptime"),
|
||||||
|
"model": _first(_ENT_MODEL),
|
||||||
|
"serial": _first(_ENT_SERIAL),
|
||||||
|
"firmware": _first(_ENT_FWREV) or _first(_ENT_SWREV),
|
||||||
|
}
|
||||||
|
if not any(result[k] for k in ("name", "descr", "model", "firmware")):
|
||||||
|
return {"ok": False, "error": "Kein SNMP / keine verwertbaren Infos."}
|
||||||
|
return {"ok": True, "result": result}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── FritzBox (TR-064) ─────────────────────────────────────────────
|
||||||
|
# TR-064 ist SOAP+Digest-Auth — zu fummelig fuer on-the-fly http.post, daher ein
|
||||||
|
# schlanker Reader. Braucht FritzBox-Login (Credential-Store, Typ 'fritzbox').
|
||||||
|
|
||||||
|
def _tr064(ip: str, user: str, pw: str, service: str, control: str,
|
||||||
|
action: str, args: Optional[dict] = None) -> dict:
|
||||||
|
"""Ein TR-064-SOAP-Call. Gibt {ok, fields|error}. fields = alle <NewX>-Tags."""
|
||||||
|
import requests
|
||||||
|
from requests.auth import HTTPDigestAuth
|
||||||
|
body = "".join(f"<{k}>{v}</{k}>" for k, v in (args or {}).items())
|
||||||
|
envelope = (
|
||||||
|
'<?xml version="1.0"?>'
|
||||||
|
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" '
|
||||||
|
's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body>'
|
||||||
|
f'<u:{action} xmlns:u="{service}">{body}</u:{action}>'
|
||||||
|
'</s:Body></s:Envelope>'
|
||||||
|
)
|
||||||
|
url = f"http://{ip}:49000{control}"
|
||||||
|
try:
|
||||||
|
r = requests.post(url, data=envelope.encode("utf-8"),
|
||||||
|
headers={"Content-Type": 'text/xml; charset="utf-8"',
|
||||||
|
"SOAPAction": f"{service}#{action}"},
|
||||||
|
auth=HTTPDigestAuth(user, pw), timeout=HTTP_TIMEOUT_SEC)
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": f"TR-064 nicht erreichbar: {exc}"}
|
||||||
|
if r.status_code == 401:
|
||||||
|
return {"ok": False, "error": "TR-064 Auth fehlgeschlagen (FritzBox-Login pruefen)."}
|
||||||
|
if r.status_code != 200:
|
||||||
|
return {"ok": False, "error": f"TR-064 HTTP {r.status_code}"}
|
||||||
|
fields = {m.group(1): m.group(2) for m in
|
||||||
|
re.finditer(r"<(New[^>/]+)>(.*?)</\1>", r.text, re.DOTALL)}
|
||||||
|
return {"ok": True, "fields": fields}
|
||||||
|
|
||||||
|
|
||||||
|
def _do_fritzbox(action: str, params: dict) -> dict:
|
||||||
|
"""fritzbox.info -> Modell/Firmware/Verbindung/externe IP/Datenrate.
|
||||||
|
fritzbox.hosts -> Liste der bekannten Geraete (Name/IP/MAC/aktiv)."""
|
||||||
|
ip = _snmp_target(params)
|
||||||
|
if not ip:
|
||||||
|
return {"ok": False, "error": "ip/host erforderlich."}
|
||||||
|
fb = _creds_for(ip).get("fritzbox", {})
|
||||||
|
user = str(params.get("user") or fb.get("user") or "")
|
||||||
|
pw = str(params.get("pass") or params.get("password") or fb.get("pass") or "")
|
||||||
|
if not pw:
|
||||||
|
return {"ok": False, "error":
|
||||||
|
"Kein FritzBox-Login hinterlegt. In der Geraeteliste Credentials "
|
||||||
|
"(Typ 'fritzbox') fuer diese IP setzen."}
|
||||||
|
|
||||||
|
if action == "fritzbox.hosts":
|
||||||
|
p = _tr064(ip, user, pw, "urn:dslforum-org:service:Hosts:1",
|
||||||
|
"/upnp/control/hosts", "X_AVM-DE_GetHostListPath")
|
||||||
|
if not p.get("ok"):
|
||||||
|
return p
|
||||||
|
path = p["fields"].get("NewX_AVM-DE_HostListPath", "")
|
||||||
|
if not path:
|
||||||
|
return {"ok": False, "error": "FritzBox lieferte keinen Host-Listen-Pfad."}
|
||||||
|
import requests
|
||||||
|
from requests.auth import HTTPDigestAuth
|
||||||
|
try:
|
||||||
|
r = requests.get(f"http://{ip}:49000{path}",
|
||||||
|
auth=HTTPDigestAuth(user, pw), timeout=HTTP_TIMEOUT_SEC)
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": f"Host-Liste nicht abrufbar: {exc}"}
|
||||||
|
hosts = []
|
||||||
|
for item in re.finditer(r"<Item>(.*?)</Item>", r.text, re.DOTALL):
|
||||||
|
blk = item.group(1)
|
||||||
|
|
||||||
|
def _t(tag):
|
||||||
|
m = re.search(rf"<{tag}>(.*?)</{tag}>", blk, re.DOTALL)
|
||||||
|
return m.group(1) if m else ""
|
||||||
|
hosts.append({"name": _t("HostName"), "ip": _t("IPAddress"),
|
||||||
|
"mac": _t("MACAddress"),
|
||||||
|
"active": _t("Active") in ("1", "true")})
|
||||||
|
return {"ok": True, "result": {"ip": ip, "count": len(hosts), "hosts": hosts}}
|
||||||
|
|
||||||
|
# fritzbox.info (Default): mehrere Services, Teil-Fehler tolerieren.
|
||||||
|
info = {"ip": ip}
|
||||||
|
dev = _tr064(ip, user, pw, "urn:dslforum-org:service:DeviceInfo:1",
|
||||||
|
"/upnp/control/deviceinfo", "GetInfo")
|
||||||
|
if dev.get("ok"):
|
||||||
|
f = dev["fields"]
|
||||||
|
info.update({"model": f.get("NewModelName"), "firmware": f.get("NewSoftwareVersion"),
|
||||||
|
"serial": f.get("NewSerialNumber"), "uptime_s": _to_int(f.get("NewUpTime"))})
|
||||||
|
st = _tr064(ip, user, pw, "urn:dslforum-org:service:WANIPConnection:1",
|
||||||
|
"/upnp/control/wanipconnection1", "GetStatusInfo")
|
||||||
|
if st.get("ok"):
|
||||||
|
info["connection"] = st["fields"].get("NewConnectionStatus")
|
||||||
|
info["connection_uptime_s"] = _to_int(st["fields"].get("NewUptime"))
|
||||||
|
ext = _tr064(ip, user, pw, "urn:dslforum-org:service:WANIPConnection:1",
|
||||||
|
"/upnp/control/wanipconnection1", "GetExternalIPAddress")
|
||||||
|
if ext.get("ok"):
|
||||||
|
info["external_ip"] = ext["fields"].get("NewExternalIPAddress")
|
||||||
|
link = _tr064(ip, user, pw, "urn:dslforum-org:service:WANCommonInterfaceConfig:1",
|
||||||
|
"/upnp/control/wancommonifconfig1", "GetCommonLinkProperties")
|
||||||
|
if link.get("ok"):
|
||||||
|
f = link["fields"]
|
||||||
|
dn = _to_int(f.get("NewLayer1DownstreamMaxBitRate"))
|
||||||
|
upr = _to_int(f.get("NewLayer1UpstreamMaxBitRate"))
|
||||||
|
info["downstream_mbit"] = round(dn / 1_000_000, 1) if dn else None
|
||||||
|
info["upstream_mbit"] = round(upr / 1_000_000, 1) if upr else None
|
||||||
|
info["physical_link"] = f.get("NewPhysicalLinkStatus")
|
||||||
|
if len(info) == 1:
|
||||||
|
return {"ok": False, "error":
|
||||||
|
"FritzBox antwortet nicht auf TR-064 (Login/Rechte pruefen; TR-064 in "
|
||||||
|
"der FritzBox unter Heimnetz > Netzwerkeinstellungen aktivieren)."}
|
||||||
|
return {"ok": True, "result": info}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_int(s: str):
|
||||||
|
try:
|
||||||
|
return int(str(s).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _snmp_system(ip: str, community: str = "", version: str = "") -> Optional[dict]:
|
||||||
|
"""Fragt die SNMP-System-Group eines Hosts ab (ein snmpget, alle 6 OIDs).
|
||||||
|
Gibt {descr,name,contact,location,uptime,objectid} oder None (kein SNMP).
|
||||||
|
Nutzt gespeicherte Creds fuer die IP; kurzer Timeout, keine Retries ->
|
||||||
|
Nicht-SNMP-Hosts scheitern schnell."""
|
||||||
|
params = {}
|
||||||
|
if community:
|
||||||
|
params["community"] = community
|
||||||
|
if version:
|
||||||
|
params["version"] = version
|
||||||
|
base = [*_snmp_base_args(params, ip), "-t", "1", "-r", "0"]
|
||||||
|
keys = list(SNMP_SYS_OIDS.keys())
|
||||||
|
ok, out = _snmp_run(["snmpget", "-Oqv", *base, ip, *SNMP_SYS_OIDS.values()],
|
||||||
|
SNMP_DISCOVERY_TIMEOUT)
|
||||||
|
if not ok:
|
||||||
|
return None
|
||||||
|
vals = out.splitlines()
|
||||||
|
info = {}
|
||||||
|
for k, v in zip(keys, vals):
|
||||||
|
v = (v or "").strip().strip('"')
|
||||||
|
if v and "No Such" not in v and "No more" not in v:
|
||||||
|
info[k] = v
|
||||||
|
return info or None
|
||||||
|
|
||||||
|
|
||||||
|
def _snmp_kind(descr: str) -> str:
|
||||||
|
"""Grobe Geraeteklasse aus sysDescr (fuer type im Inventar)."""
|
||||||
|
d = (descr or "").lower()
|
||||||
|
if any(k in d for k in ("switch", "catalyst", "procurve", "aruba", "powerconnect")):
|
||||||
|
return "switch"
|
||||||
|
if any(k in d for k in ("router", "mikrotik", "routeros", "edgeos", "openwrt", "pfsense", "fritz!box")):
|
||||||
|
return "router"
|
||||||
|
if any(k in d for k in ("access point", "accesspoint", "unifi", "wifi", "wlan")):
|
||||||
|
return "access-point"
|
||||||
|
if any(k in d for k in ("printer", "laserjet", "officejet", "brother", "epson", "kyocera")):
|
||||||
|
return "printer"
|
||||||
|
if any(k in d for k in ("nas", "synology", "qnap", "truenas", "diskstation")):
|
||||||
|
return "nas"
|
||||||
|
if any(k in d for k in ("ups", "usv", "smart-ups")):
|
||||||
|
return "ups"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
# ─── Helpers ────────────────────────────────────────────────────────
|
# ─── Helpers ────────────────────────────────────────────────────────
|
||||||
@@ -531,13 +1062,42 @@ class Satellite:
|
|||||||
ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
|
ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
|
||||||
arp = await loop.run_in_executor(None, _discover_arp)
|
arp = await loop.run_in_executor(None, _discover_arp)
|
||||||
self._devices = _merge_devices(mdns, ssdp, arp)
|
self._devices = _merge_devices(mdns, ssdp, arp)
|
||||||
|
if SNMP_DISCOVERY:
|
||||||
|
await self._enrich_snmp(self._devices)
|
||||||
self._devices_ts = time.time()
|
self._devices_ts = time.time()
|
||||||
logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d)",
|
n_snmp = sum(1 for d in self._devices if d.get("snmpCapable"))
|
||||||
len(self._devices), len(mdns), len(ssdp), len(arp))
|
logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d, snmp=%d)",
|
||||||
|
len(self._devices), len(mdns), len(ssdp), len(arp), n_snmp)
|
||||||
finally:
|
finally:
|
||||||
self._scanning = False
|
self._scanning = False
|
||||||
return self._devices
|
return self._devices
|
||||||
|
|
||||||
|
async def _enrich_snmp(self, devices: list) -> None:
|
||||||
|
"""Fragt jedes Geraet mit IP parallel per SNMP-System-Group ab und haengt
|
||||||
|
die Infos an. Verbessert Name/Typ, wenn bisher nur eine IP bekannt war."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
sem = asyncio.Semaphore(SNMP_DISCOVERY_CONCURRENCY)
|
||||||
|
|
||||||
|
async def _one(dev: dict) -> None:
|
||||||
|
ip = dev.get("ip") or ""
|
||||||
|
if not ip:
|
||||||
|
return
|
||||||
|
async with sem:
|
||||||
|
info = await loop.run_in_executor(None, _snmp_system, ip)
|
||||||
|
if not info:
|
||||||
|
return
|
||||||
|
dev["snmp"] = info
|
||||||
|
dev["snmpCapable"] = True
|
||||||
|
# Name aufwerten, wenn er bisher nur die IP/leer war.
|
||||||
|
if info.get("name") and dev.get("name", "") in ("", ip):
|
||||||
|
dev["name"] = info["name"]
|
||||||
|
# Typ aufwerten, wenn bisher generisch (host/leer).
|
||||||
|
kind = _snmp_kind(info.get("descr", ""))
|
||||||
|
if kind and dev.get("type", "") in ("", "host"):
|
||||||
|
dev["type"] = kind
|
||||||
|
|
||||||
|
await asyncio.gather(*(_one(d) for d in devices))
|
||||||
|
|
||||||
async def _send(self, message: dict) -> None:
|
async def _send(self, message: dict) -> None:
|
||||||
if self.ws is None:
|
if self.ws is None:
|
||||||
return
|
return
|
||||||
@@ -597,7 +1157,12 @@ class Satellite:
|
|||||||
params = payload.get("params") or {}
|
params = payload.get("params") or {}
|
||||||
if payload.get("device") and "device" not in params:
|
if payload.get("device") and "device" not in params:
|
||||||
params["device"] = payload.get("device")
|
params["device"] = payload.get("device")
|
||||||
devices = await self._scan()
|
# Geraeteliste nur scannen, wenn die Aktion sie wirklich braucht
|
||||||
|
# (dial.launch loest ein Geraet auf, oder es wurde ein device-Ref
|
||||||
|
# mitgegeben). http.get/http.post/wol arbeiten direkt mit url/mac —
|
||||||
|
# ein voller LAN-Scan davor kostete nur unnoetig viele Sekunden.
|
||||||
|
needs_devices = action == "dial.launch" or bool(params.get("device"))
|
||||||
|
devices = await self._scan() if needs_devices else self._devices
|
||||||
result = await _control(action, params, devices)
|
result = await _control(action, params, devices)
|
||||||
await self._send({
|
await self._send({
|
||||||
"type": "sat_result",
|
"type": "sat_result",
|
||||||
@@ -605,6 +1170,63 @@ class Satellite:
|
|||||||
"timestamp": int(time.time() * 1000),
|
"timestamp": int(time.time() * 1000),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
elif mtype == "sat_creds_set":
|
||||||
|
if not self._for_me(payload):
|
||||||
|
return
|
||||||
|
ip = (payload.get("ip") or "").strip()
|
||||||
|
creds = payload.get("creds") or {}
|
||||||
|
ok = False
|
||||||
|
if ip and isinstance(creds, dict):
|
||||||
|
entry = _CREDS.setdefault(ip, {})
|
||||||
|
for t in ("snmp", "http", "fritzbox"):
|
||||||
|
if t in creds:
|
||||||
|
if creds[t]: # leeres Objekt = Typ loeschen
|
||||||
|
entry[t] = creds[t]
|
||||||
|
else:
|
||||||
|
entry.pop(t, None)
|
||||||
|
if not entry:
|
||||||
|
_CREDS.pop(ip, None)
|
||||||
|
try:
|
||||||
|
_creds_save()
|
||||||
|
ok = True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[creds] speichern fehlgeschlagen: %s", exc)
|
||||||
|
await self._send({"type": "sat_creds_result",
|
||||||
|
"payload": {"requestId": payload.get("requestId", ""),
|
||||||
|
"satellite": SATELLITE_ID, "ok": ok, "ip": ip},
|
||||||
|
"timestamp": int(time.time() * 1000)})
|
||||||
|
|
||||||
|
elif mtype == "sat_creds_delete":
|
||||||
|
if not self._for_me(payload):
|
||||||
|
return
|
||||||
|
ip = (payload.get("ip") or "").strip()
|
||||||
|
ctype = (payload.get("type") or "").strip()
|
||||||
|
if ip in _CREDS:
|
||||||
|
if ctype:
|
||||||
|
_CREDS[ip].pop(ctype, None)
|
||||||
|
if not _CREDS[ip]:
|
||||||
|
_CREDS.pop(ip, None)
|
||||||
|
else:
|
||||||
|
_CREDS.pop(ip, None)
|
||||||
|
try:
|
||||||
|
_creds_save()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[creds] speichern fehlgeschlagen: %s", exc)
|
||||||
|
await self._send({"type": "sat_creds_result",
|
||||||
|
"payload": {"requestId": payload.get("requestId", ""),
|
||||||
|
"satellite": SATELLITE_ID, "ok": True, "ip": ip},
|
||||||
|
"timestamp": int(time.time() * 1000)})
|
||||||
|
|
||||||
|
elif mtype == "sat_creds_list":
|
||||||
|
if not self._for_me(payload):
|
||||||
|
return
|
||||||
|
await self._send({"type": "sat_creds_list_result",
|
||||||
|
"payload": {"requestId": payload.get("requestId", ""),
|
||||||
|
"satellite": SATELLITE_ID,
|
||||||
|
"location": SATELLITE_LOCATION,
|
||||||
|
"items": _creds_public_summary()},
|
||||||
|
"timestamp": int(time.time() * 1000)})
|
||||||
|
|
||||||
async def _periodic_scan(self) -> None:
|
async def _periodic_scan(self) -> None:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -626,6 +1248,7 @@ class Satellite:
|
|||||||
if not RVS_HOST or not RVS_TOKEN:
|
if not RVS_HOST or not RVS_TOKEN:
|
||||||
logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).")
|
logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).")
|
||||||
return
|
return
|
||||||
|
_creds_load()
|
||||||
asyncio.create_task(self._periodic_scan())
|
asyncio.create_task(self._periodic_scan())
|
||||||
backoff = 1
|
backoff = 1
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
+43
-3
@@ -1,11 +1,51 @@
|
|||||||
# ════════════════════════════════════════════════
|
# ════════════════════════════════════════════════
|
||||||
# ARIA XTTS v2 — Konfiguration
|
# ARIA Compute-Node — Konfiguration
|
||||||
# Kopieren nach .env und anpassen
|
# Kopieren nach .env und anpassen (pro Worker-Node eine eigene .env)
|
||||||
# ════════════════════════════════════════════════
|
# ════════════════════════════════════════════════
|
||||||
|
|
||||||
# RVS Verbindung (gleiche Daten wie auf der ARIA-VM)
|
# ─── Welche Dienste startet DIESER Node? ──────────
|
||||||
|
# Komma-getrennt aus: voxtral, whisper, f5tts, llm
|
||||||
|
# Nur die aufgefuehrten Dienste starten bei `docker compose up -d`.
|
||||||
|
# nur STT-Box (Voxtral) → COMPOSE_PROFILES=voxtral
|
||||||
|
# nur STT-Box (Whisper) → COMPOSE_PROFILES=whisper
|
||||||
|
# nur TTS-Box → COMPOSE_PROFILES=f5tts
|
||||||
|
# nur LLM-Box → COMPOSE_PROFILES=llm
|
||||||
|
# Kleine Karte (wenig VRAM): Whisper + F5-TTS auf EINER GPU
|
||||||
|
# → COMPOSE_PROFILES=whisper,f5tts
|
||||||
|
# All-in-One, grosse Karte → COMPOSE_PROFILES=voxtral,f5tts,llm
|
||||||
|
# Hinweis: voxtral UND whisper zusammen NICHT sinnvoll — beide sind STT und
|
||||||
|
# wuerden dieselbe Anfrage doppelt beantworten. Pro Node genau EINEN STT waehlen:
|
||||||
|
# Voxtral-3B (~9 GB, beste Qualitaet) ODER Whisper (klein, passt neben F5-TTS).
|
||||||
|
COMPOSE_PROFILES=whisper,f5tts
|
||||||
|
|
||||||
|
# ─── Node-Name ────────────────────────────────────
|
||||||
|
# Freier Name dieses Rechners. Erscheint in Diagnostic (Flotten-Anzeige) und
|
||||||
|
# in den Logs, und bildet die Instanz-ID der Dienste (z.B. f5tts@ai-box).
|
||||||
|
NODE_NAME=ai-box
|
||||||
|
|
||||||
|
# ─── GPU-Zuordnung pro Dienst ─────────────────────
|
||||||
|
# Setzt NVIDIA_VISIBLE_DEVICES fuer den jeweiligen Container.
|
||||||
|
# Einzelne Karte → "0" oder "1"; mehrere Karten → "0,1".
|
||||||
|
# Braucht das NVIDIA Container Toolkit (registriert die `nvidia`-Runtime).
|
||||||
|
# Nur die GPUs der aktiven Profile (COMPOSE_PROFILES) sind ueberhaupt relevant.
|
||||||
|
WHISPER_GPU=0 # kleines STT → passt neben F5-TTS auf EINE Karte
|
||||||
|
F5TTS_GPU=0 # TTS ist klein → gleiche Karte wie Whisper
|
||||||
|
VOXTRAL_GPU=1 # STT-3B ~9 GB → nur auf einer groesseren Karte (z.B. 12 GB)
|
||||||
|
LLM_GPU=0 # lokales LLM (teilt sich ggf. die Karte mit F5-TTS)
|
||||||
|
|
||||||
|
# ─── RVS-Verbindung ───────────────────────────────────────
|
||||||
|
# WICHTIG: Host, Port UND Token muessen EXAKT mit dem ARIA-Stack (Bridge/
|
||||||
|
# Diagnostic) uebereinstimmen — das RVS gruppiert Clients pro Server+Token in
|
||||||
|
# einen Raum. Bei abweichendem Port/Host landet die Box in einem ANDEREN Raum
|
||||||
|
# und taucht nicht in der Compute-Flotte auf.
|
||||||
RVS_HOST=mobil.hacker-net.de
|
RVS_HOST=mobil.hacker-net.de
|
||||||
RVS_PORT=444
|
RVS_PORT=444
|
||||||
RVS_TLS=true
|
RVS_TLS=true
|
||||||
RVS_TLS_FALLBACK=true
|
RVS_TLS_FALLBACK=true
|
||||||
RVS_TOKEN=dein_token_hier
|
RVS_TOKEN=dein_token_hier
|
||||||
|
|
||||||
|
# ─── Optional ─────────────────────────────────────
|
||||||
|
# HF_TOKEN= # nur falls ein HF-gated Modell (z.B. Voxtral) geladen wird
|
||||||
|
# WHISPER_MODEL=small # tiny|base|small|medium|large-v3 (Hot-Swap via Diagnostic)
|
||||||
|
# WHISPER_LANGUAGE=de
|
||||||
|
# LLM_MODEL=qwen3-8b # Key aus llama-swap/config.yaml
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
# ai-box — KI-Box (gpubox) Bootstrap
|
# xtts — AI-Box (Compute-Node) Setup & Bootstrap
|
||||||
|
|
||||||
Macht aus einem frisch installierten **Debian Trixie** (headless, nur SSH) einen
|
Der `xtts`-Stack sind ARIAs GPU-Dienste (STT: Voxtral/Whisper · TTS: F5-TTS ·
|
||||||
startklaren GPU-Satelliten-Host für den `xtts`-Stack (STT/TTS/LLM).
|
lokales LLM). Er läuft auf einer oder mehreren **AI-Boxen** — jede per `.env`
|
||||||
|
konfiguriert (`COMPOSE_PROFILES`, GPU-Zuordnung). Details zu Profilen/GPU-Wahl
|
||||||
|
stehen im Haupt-README (Abschnitt „Compute-Nodes").
|
||||||
|
|
||||||
|
`bootstrap.sh` macht aus einem frisch installierten **Debian Trixie** (headless,
|
||||||
|
nur SSH) einen startklaren GPU-Host für diesen Stack.
|
||||||
|
|
||||||
## Was das Script tut
|
## Was das Script tut
|
||||||
|
|
||||||
@@ -20,13 +25,13 @@ Alles **idempotent** — mehrfach ausführbar.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repo> ARIA-AGENT
|
git clone <repo> ARIA-AGENT
|
||||||
cd ARIA-AGENT/ai-box
|
cd ARIA-AGENT/xtts
|
||||||
|
|
||||||
sudo ./bootstrap.sh
|
sudo ./bootstrap.sh
|
||||||
# → Wenn der Treiber frisch installiert wurde: einmal neu starten, dann nochmal:
|
# → Wenn der Treiber frisch installiert wurde: einmal neu starten, dann nochmal:
|
||||||
sudo reboot
|
sudo reboot
|
||||||
# … nach dem Boot:
|
# … nach dem Boot:
|
||||||
cd ARIA-AGENT/ai-box
|
cd ARIA-AGENT/xtts
|
||||||
sudo ./bootstrap.sh --up --token <DEIN_RVS_TOKEN>
|
sudo ./bootstrap.sh --up --token <DEIN_RVS_TOKEN>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
# GPU-Satelliten-Host fuer den xtts-Stack (Voxtral/Whisper STT, F5-TTS, lokales LLM).
|
# GPU-Satelliten-Host fuer den xtts-Stack (Voxtral/Whisper STT, F5-TTS, lokales LLM).
|
||||||
#
|
#
|
||||||
# Ablauf nach `git clone`:
|
# Ablauf nach `git clone`:
|
||||||
# cd ARIA-AGENT/ai-box
|
# cd ARIA-AGENT/xtts
|
||||||
# sudo ./bootstrap.sh # richtet Treiber + Docker + NVIDIA-Toolkit ein
|
# sudo ./bootstrap.sh # richtet Treiber + Docker + NVIDIA-Toolkit ein
|
||||||
# # (falls Treiber frisch installiert: einmal `sudo reboot`, dann Script erneut)
|
# # (falls Treiber frisch installiert: einmal `sudo reboot`, dann Script erneut)
|
||||||
# sudo ./bootstrap.sh --up # dazu: xtts-Stack (Whisper+F5+LLM) hochziehen
|
# sudo ./bootstrap.sh --up # dazu: xtts-Stack (Whisper+F5+LLM) hochziehen
|
||||||
@@ -122,7 +122,7 @@ EOF
|
|||||||
ok "nvidia-driver aus backports installiert: ${NEWV}"
|
ok "nvidia-driver aus backports installiert: ${NEWV}"
|
||||||
echo
|
echo
|
||||||
echo -e "${c_y}==> REBOOT noetig, damit der neue Treiber laedt:${c_0}"
|
echo -e "${c_y}==> REBOOT noetig, damit der neue Treiber laedt:${c_0}"
|
||||||
echo -e "${c_y} sudo reboot && danach: cd ai-box && sudo ./bootstrap.sh --up --token <TOKEN>${c_0}"
|
echo -e "${c_y} sudo reboot && danach: cd xtts && sudo ./bootstrap.sh --up --token <TOKEN>${c_0}"
|
||||||
echo -e "${c_y} (nvidia-smi zeigt dann die neue Version + CUDA-Level)${c_0}"
|
echo -e "${c_y} (nvidia-smi zeigt dann die neue Version + CUDA-Level)${c_0}"
|
||||||
exit 0
|
exit 0
|
||||||
else
|
else
|
||||||
+101
-98
@@ -1,45 +1,50 @@
|
|||||||
# ════════════════════════════════════════════════
|
# ════════════════════════════════════════════════
|
||||||
# ARIA Gamebox Stack — GPU F5-TTS + Whisper STT
|
# ARIA Compute-Node — GPU-Dienste (STT / TTS / LLM)
|
||||||
# Laeuft auf dem Gaming-PC (RTX 3060)
|
|
||||||
# Verbindet sich zum RVS fuer TTS/STT-Requests
|
|
||||||
#
|
#
|
||||||
# FLUX-Bildgenerierung liegt im /flux Verzeichnis im Repo-Root —
|
# KEINE feste "AI-Box" mehr: dieser Stack laeuft auf beliebig vielen
|
||||||
# eigener Compose-Stack, kann auch auf einer anderen Maschine laufen.
|
# Worker-Nodes. Jeder Node startet ueber COMPOSE_PROFILES nur die Dienste,
|
||||||
|
# die er anbieten soll, und pinnt sie per *_GPU auf bestimmte Grafikkarten.
|
||||||
|
# Alles verbindet sich ueber RVS mit der ARIA-Infrastruktur — kein VPN.
|
||||||
|
#
|
||||||
|
# Beispiele (in der jeweiligen .env):
|
||||||
|
# STT-Box → COMPOSE_PROFILES=voxtral
|
||||||
|
# TTS-Box → COMPOSE_PROFILES=f5tts
|
||||||
|
# LLM-Box → COMPOSE_PROFILES=llm
|
||||||
|
# All-in-One → COMPOSE_PROFILES=voxtral,f5tts,llm (die alte AI-Box)
|
||||||
|
#
|
||||||
|
# FLUX-Bildgenerierung liegt im /flux Verzeichnis — eigener Stack.
|
||||||
# ════════════════════════════════════════════════
|
# ════════════════════════════════════════════════
|
||||||
#
|
#
|
||||||
# Voraussetzungen:
|
# Voraussetzungen:
|
||||||
# - Docker Desktop mit WSL2
|
# - Docker + NVIDIA Container Toolkit (registriert die `nvidia`-Runtime)
|
||||||
# - NVIDIA Container Toolkit
|
# - .env mit RVS-Verbindungsdaten, COMPOSE_PROFILES, NODE_NAME, *_GPU
|
||||||
# - .env mit RVS-Verbindungsdaten
|
|
||||||
#
|
#
|
||||||
# Start: docker compose up -d
|
# Start: docker compose up -d (liest COMPOSE_PROFILES aus der .env)
|
||||||
# ════════════════════════════════════════════════
|
# ════════════════════════════════════════════════
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
# ─── F5-TTS Bridge (GPU) ──────────────────────
|
# ─── F5-TTS Bridge (GPU) ──────────────────────
|
||||||
# Ersetzt den frueheren XTTS-Stack. Empfaengt xtts_request via RVS,
|
# Empfaengt xtts_request via RVS, rendert via F5-TTS mit Voice-Cloning,
|
||||||
# rendert via F5-TTS mit Voice-Cloning, streamt PCM an die App.
|
# streamt PCM an die App. Voice-Upload: speichert WAV und laesst eine
|
||||||
# Voice-Upload: speichert WAV und laesst whisper-bridge den Referenz-
|
# STT-Bridge den Referenztext transkribieren — der User tippt nichts.
|
||||||
# text transkribieren — der User muss nichts eintippen.
|
|
||||||
f5tts-bridge:
|
f5tts-bridge:
|
||||||
build: ./f5tts
|
build: ./f5tts
|
||||||
container_name: aria-f5tts-bridge
|
container_name: aria-f5tts-bridge
|
||||||
deploy:
|
profiles: ["f5tts"] # startet nur mit COMPOSE_PROFILES=…f5tts…
|
||||||
resources:
|
runtime: nvidia
|
||||||
reservations:
|
|
||||||
devices:
|
|
||||||
- driver: nvidia
|
|
||||||
device_ids: ["0"] # TTS → GPU 0 (8 GB; F5 ist klein)
|
|
||||||
capabilities: [gpu]
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./voices:/voices # WAV + TXT Referenz
|
- ./voices:/voices # WAV + TXT Referenz
|
||||||
- ./hf-cache:/root/.cache/huggingface # HF-Cache als Bind-Mount.
|
- ./hf-cache:/root/.cache/huggingface # HF-Cache als Bind-Mount.
|
||||||
# Direkt sichtbar im xtts/hf-cache/,
|
# Direkt sichtbar im xtts/hf-cache/,
|
||||||
# einfach manuell zu loeschen, kein
|
# einfach manuell zu loeschen, kein
|
||||||
# Docker-Desktop .vhdx Bloat.
|
# Docker-Desktop .vhdx Bloat.
|
||||||
# Wird mit whisper-bridge geteilt.
|
# Wird mit STT-Bridges geteilt.
|
||||||
environment:
|
environment:
|
||||||
|
# GPU-Wahl: NVIDIA_VISIBLE_DEVICES (mehrere via "0,1"). Default GPU 0.
|
||||||
|
- NVIDIA_VISIBLE_DEVICES=${F5TTS_GPU:-0}
|
||||||
|
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||||
|
- NODE_NAME=${NODE_NAME:-node} # erscheint in Diagnostic + Logs
|
||||||
# Bootstrap-only — alle anderen F5-TTS-Settings (Modell, cfg_strength,
|
# Bootstrap-only — alle anderen F5-TTS-Settings (Modell, cfg_strength,
|
||||||
# nfe_step, Custom-Checkpoint) kommen ueber Diagnostic via RVS-config.
|
# nfe_step, Custom-Checkpoint) kommen ueber Diagnostic via RVS-config.
|
||||||
- RVS_HOST=${RVS_HOST}
|
- RVS_HOST=${RVS_HOST}
|
||||||
@@ -51,27 +56,22 @@ services:
|
|||||||
- VOICES_DIR=/voices
|
- VOICES_DIR=/voices
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# ─── Whisper STT (GPU) ────────────────────────
|
# ─── Whisper STT (GPU) — opt-in Fallback-STT ──
|
||||||
# Faster-Whisper auf der Gamebox statt auf der VM (CPU) —
|
# Faster-Whisper auf CUDA. Verbindet sich selbst per WebSocket an den RVS
|
||||||
# deutlich schneller. Verbindet sich selbst per WebSocket an
|
# und nimmt stt_request / stt_stream_* Nachrichten entgegen. Zusaetzlich
|
||||||
# den RVS und nimmt dort stt_request Nachrichten der aria-bridge
|
# nutzt die f5tts-bridge Whisper intern fuer die Referenz-Transkription bei
|
||||||
# entgegen, antwortet mit stt_response. Zusaetzlich nutzt die
|
# Voice-Uploads. Modell-Hot-Swap via Diagnostic (config-Broadcast).
|
||||||
# f5tts-bridge Whisper intern fuer die Referenz-Transkription bei
|
# Nur EINEN STT-Provider pro Node laufen lassen (voxtral ODER whisper) —
|
||||||
# Voice-Uploads. Laedt das Modell beim Start vor; auf Config-
|
# sonst beantworten beide dieselbe Anfrage doppelt.
|
||||||
# Broadcasts (Diagnostic → whisperModel) wird zur Laufzeit hot-
|
|
||||||
# swapped.
|
|
||||||
whisper-bridge:
|
whisper-bridge:
|
||||||
build: ./whisper
|
build: ./whisper
|
||||||
container_name: aria-whisper-bridge
|
container_name: aria-whisper-bridge
|
||||||
profiles: ["whisper"] # Fallback-STT — startet nur mit --profile whisper
|
profiles: ["whisper"] # startet nur mit COMPOSE_PROFILES=…whisper…
|
||||||
deploy:
|
runtime: nvidia
|
||||||
resources:
|
|
||||||
reservations:
|
|
||||||
devices:
|
|
||||||
- driver: nvidia
|
|
||||||
device_ids: ["1"] # STT/groesstes Modell → GPU 1 (12 GB; spaeter Voxtral-STT-3B ~9 GB)
|
|
||||||
capabilities: [gpu]
|
|
||||||
environment:
|
environment:
|
||||||
|
- NVIDIA_VISIBLE_DEVICES=${WHISPER_GPU:-1}
|
||||||
|
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||||
|
- NODE_NAME=${NODE_NAME:-node}
|
||||||
- RVS_HOST=${RVS_HOST}
|
- RVS_HOST=${RVS_HOST}
|
||||||
- RVS_PORT=${RVS_PORT:-443}
|
- RVS_PORT=${RVS_PORT:-443}
|
||||||
- RVS_TLS=${RVS_TLS:-true}
|
- RVS_TLS=${RVS_TLS:-true}
|
||||||
@@ -91,74 +91,22 @@ services:
|
|||||||
# Container-Restarts.
|
# Container-Restarts.
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# ─── Lokales LLM (Plan B, B0.5) — llama-swap (GPU) ────────────
|
|
||||||
# llama-swap laedt/swappt mehrere Modelle on-demand (nur eins passt gleich-
|
|
||||||
# zeitig in die 12 GB). Welches geladen wird, bestimmt das `model`-Feld im
|
|
||||||
# Request — das Brain schickt es aus local_llm.json mit. Erster Load eines
|
|
||||||
# Modells zieht das GGUF via -hf von HF (Cache unter /models, persistent).
|
|
||||||
# OpenAI-kompatibel auf :8080, nur im Compose-Netz; die Bruecke macht der
|
|
||||||
# llm-adapter. Modell-Liste: ./llama-swap/config.yaml.
|
|
||||||
#
|
|
||||||
# BLIND GEBAUT (kein Gamebox-Test hier): beim ersten Start
|
|
||||||
# `docker logs -f aria-llama-swap` pruefen. Image bundelt llama-server.
|
|
||||||
llama-swap:
|
|
||||||
image: ghcr.io/mostlygeek/llama-swap:unified-cuda
|
|
||||||
container_name: aria-llama-swap
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
reservations:
|
|
||||||
devices:
|
|
||||||
- driver: nvidia
|
|
||||||
device_ids: ["0"] # LLM → GPU 0 (8 GB, teilt sich mit TTS)
|
|
||||||
capabilities: [gpu]
|
|
||||||
volumes:
|
|
||||||
- ./models:/models # HF-Download-Cache (persistent)
|
|
||||||
- ./llama-swap/config.yaml:/app/config.yaml:ro # Modell-Liste
|
|
||||||
environment:
|
|
||||||
- LLAMA_CACHE=/models # llama-server legt -hf-Downloads hier ab
|
|
||||||
command: ["--config", "/app/config.yaml", "--listen", "0.0.0.0:8080"]
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# ─── Local-LLM-Adapter — RVS <-> llama.cpp (Plan B, B0) ──────
|
|
||||||
# Verbindet sich per Token an den RVS (wie f5tts/whisper), nimmt
|
|
||||||
# llm_request entgegen, ruft llama.cpp lokal, antwortet llm_response.
|
|
||||||
llm-adapter:
|
|
||||||
build: ./llm-adapter
|
|
||||||
container_name: aria-llm-adapter
|
|
||||||
depends_on:
|
|
||||||
- llama-swap
|
|
||||||
environment:
|
|
||||||
- RVS_HOST=${RVS_HOST}
|
|
||||||
- RVS_PORT=${RVS_PORT:-443}
|
|
||||||
- RVS_TLS=${RVS_TLS:-true}
|
|
||||||
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
|
|
||||||
- RVS_TOKEN=${RVS_TOKEN}
|
|
||||||
- LLAMA_URL=http://llama-swap:8080
|
|
||||||
- LLM_MODEL=${LLM_MODEL:-qwen3-8b}
|
|
||||||
# Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig.
|
|
||||||
- LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600}
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# ─── Voxtral STT-3B (Transformers, GPU) — DEFAULT-STT ─────────
|
# ─── Voxtral STT-3B (Transformers, GPU) — DEFAULT-STT ─────────
|
||||||
# Ersetzt whisper als STT. Laeuft auf Treiber 550/CUDA 12.4 (torch cu124, KEIN
|
# Voxtral-Mini-3B-2507 (~9 GB bf16). Laeuft auf Treiber 550/CUDA 12.4
|
||||||
# Treiber-Upgrade noetig). Modell Voxtral-Mini-3B-2507 (~9 GB bf16) → GPU 1.
|
# (torch cu124, KEIN Treiber-Upgrade noetig). Whisper ist der opt-in
|
||||||
# Startet bei jedem `docker compose up -d`. Whisper ist der opt-in Fallback
|
# Fallback — immer nur EINEN STT-Provider pro Node aktiv haben.
|
||||||
# (Profil "whisper") — beide zusammen wuerden stt_* doppelt beantworten, also
|
|
||||||
# immer nur EINEN STT laufen lassen.
|
|
||||||
voxtral-bridge:
|
voxtral-bridge:
|
||||||
build: ./voxtral
|
build: ./voxtral
|
||||||
container_name: aria-voxtral-bridge
|
container_name: aria-voxtral-bridge
|
||||||
deploy:
|
profiles: ["voxtral"] # startet nur mit COMPOSE_PROFILES=…voxtral…
|
||||||
resources:
|
runtime: nvidia
|
||||||
reservations:
|
|
||||||
devices:
|
|
||||||
- driver: nvidia
|
|
||||||
device_ids: ["1"] # 12-GB-Karte (STT-3B ~9 GB); GPU 0 (8 GB) bleibt fuer F5/LLM
|
|
||||||
capabilities: [gpu]
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./hf-cache:/root/.cache/huggingface # gleicher Modell-Cache wie whisper/f5
|
- ./hf-cache:/root/.cache/huggingface # gleicher Modell-Cache wie whisper/f5
|
||||||
- ./voice-id:/voice-id # Speaker-Fingerprint (wie whisper)
|
- ./voice-id:/voice-id # Speaker-Fingerprint (wie whisper)
|
||||||
environment:
|
environment:
|
||||||
|
- NVIDIA_VISIBLE_DEVICES=${VOXTRAL_GPU:-1} # STT-3B ~9 GB → 12-GB-Karte
|
||||||
|
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||||
|
- NODE_NAME=${NODE_NAME:-node}
|
||||||
- RVS_HOST=${RVS_HOST}
|
- RVS_HOST=${RVS_HOST}
|
||||||
- RVS_PORT=${RVS_PORT:-443}
|
- RVS_PORT=${RVS_PORT:-443}
|
||||||
- RVS_TLS=${RVS_TLS:-true}
|
- RVS_TLS=${RVS_TLS:-true}
|
||||||
@@ -169,3 +117,58 @@ services:
|
|||||||
- HUGGING_FACE_HUB_TOKEN=${HF_TOKEN:-} # falls das Modell HF-gated ist
|
- HUGGING_FACE_HUB_TOKEN=${HF_TOKEN:-} # falls das Modell HF-gated ist
|
||||||
- PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True # weniger VRAM-Fragmentierung
|
- PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True # weniger VRAM-Fragmentierung
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ─── Lokales LLM — llama-swap (GPU) ───────────
|
||||||
|
# llama-swap laedt/swappt mehrere Modelle on-demand. Welches geladen wird,
|
||||||
|
# bestimmt das `model`-Feld im Request (Brain schickt es aus local_llm.json).
|
||||||
|
# Erster Load zieht das GGUF via -hf von HF (Cache unter /models, persistent).
|
||||||
|
# OpenAI-kompatibel auf :8080, nur im Compose-Netz; die Bruecke macht der
|
||||||
|
# llm-adapter. Die Modell-Liste erzeugt der llm-adapter dynamisch aus
|
||||||
|
# ./llama-swap/config.yaml (Basis) + Registry → /models/llama-swap.config.yaml.
|
||||||
|
llama-swap:
|
||||||
|
image: ghcr.io/mostlygeek/llama-swap:unified-cuda
|
||||||
|
container_name: aria-llama-swap
|
||||||
|
profiles: ["llm"] # startet nur mit COMPOSE_PROFILES=…llm…
|
||||||
|
runtime: nvidia
|
||||||
|
volumes:
|
||||||
|
- ./models:/models # HF-Cache + generierte Config
|
||||||
|
environment:
|
||||||
|
- NVIDIA_VISIBLE_DEVICES=${LLM_GPU:-0}
|
||||||
|
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||||
|
- LLAMA_CACHE=/models # llama-server legt -hf-Downloads hier ab
|
||||||
|
# Liest die vom llm-adapter generierte Config. Beim allerersten Boot faengt
|
||||||
|
# restart: unless-stopped die Reihenfolge ab, bis der Adapter sie geschrieben hat.
|
||||||
|
command: ["--config", "/models/llama-swap.config.yaml", "--listen", "0.0.0.0:8080"]
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ─── Local-LLM-Adapter — RVS <-> llama.cpp ────
|
||||||
|
# Verbindet sich per Token an den RVS (wie f5tts/whisper), nimmt llm_request
|
||||||
|
# entgegen, ruft llama.cpp lokal, antwortet llm_response. Verwaltet ausserdem
|
||||||
|
# llama-swaps Config (Modelle hinzufuegen/entfernen via llm_provision_model).
|
||||||
|
llm-adapter:
|
||||||
|
build: ./llm-adapter
|
||||||
|
container_name: aria-llm-adapter
|
||||||
|
profiles: ["llm"]
|
||||||
|
runtime: nvidia # nur fuer nvidia-smi (Auslastungs-Monitor) — kein Compute
|
||||||
|
depends_on:
|
||||||
|
- llama-swap
|
||||||
|
volumes:
|
||||||
|
- ./models:/models # generierte Config + Registry + Cache
|
||||||
|
- ./llama-swap:/llamaswap:ro # Basis-Template (config.yaml)
|
||||||
|
environment:
|
||||||
|
- NVIDIA_VISIBLE_DEVICES=all # alle Karten sichtbar (nur nvidia-smi)
|
||||||
|
- NVIDIA_DRIVER_CAPABILITIES=utility # utility = nvidia-smi, KEIN VRAM/Compute
|
||||||
|
- NODE_NAME=${NODE_NAME:-node}
|
||||||
|
- RVS_HOST=${RVS_HOST}
|
||||||
|
- RVS_PORT=${RVS_PORT:-443}
|
||||||
|
- RVS_TLS=${RVS_TLS:-true}
|
||||||
|
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
|
||||||
|
- RVS_TOKEN=${RVS_TOKEN}
|
||||||
|
- LLAMA_URL=http://llama-swap:8080
|
||||||
|
- LLM_MODEL=${LLM_MODEL:-qwen3-8b}
|
||||||
|
- LLAMA_BASE_CONFIG=/llamaswap/config.yaml
|
||||||
|
- LLAMA_GEN_CONFIG=/models/llama-swap.config.yaml
|
||||||
|
- LLM_REGISTRY=/models/aria_models.json
|
||||||
|
# Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig.
|
||||||
|
- LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600}
|
||||||
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ COPY requirements.txt .
|
|||||||
RUN printf 'torch==2.6.0\ntorchaudio==2.6.0\n' > /tmp/torch-constraint.txt && \
|
RUN printf 'torch==2.6.0\ntorchaudio==2.6.0\n' > /tmp/torch-constraint.txt && \
|
||||||
pip3 install --no-cache-dir -c /tmp/torch-constraint.txt -r requirements.txt
|
pip3 install --no-cache-dir -c /tmp/torch-constraint.txt -r requirements.txt
|
||||||
|
|
||||||
|
COPY node_stats.py .
|
||||||
COPY bridge.py .
|
COPY bridge.py .
|
||||||
|
|
||||||
CMD ["python3", "bridge.py"]
|
CMD ["python3", "bridge.py"]
|
||||||
|
|||||||
+87
-8
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
ARIA F5-TTS Bridge — laeuft auf der Gamebox (RTX 3060).
|
ARIA F5-TTS Bridge — laeuft auf der AI-Box (RTX 3060).
|
||||||
|
|
||||||
Empfaengt xtts_request via RVS → F5-TTS Voice Cloning auf GPU → streamt
|
Empfaengt xtts_request via RVS → F5-TTS Voice Cloning auf GPU → streamt
|
||||||
16-bit PCM Chunks als audio_pcm Nachrichten zurueck an die App.
|
16-bit PCM Chunks als audio_pcm Nachrichten zurueck an die App.
|
||||||
@@ -60,6 +60,24 @@ RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
|||||||
# f5ttsCkptFile, f5ttsVocabFile, f5ttsCfgStrength, f5ttsNfeStep).
|
# f5ttsCkptFile, f5ttsVocabFile, f5ttsCfgStrength, f5ttsNfeStep).
|
||||||
F5TTS_DEVICE = os.getenv("F5TTS_DEVICE", "cuda") # nur Bootstrap
|
F5TTS_DEVICE = os.getenv("F5TTS_DEVICE", "cuda") # nur Bootstrap
|
||||||
|
|
||||||
|
# ── Compute-Fleet: Worker-Identitaet & Registrierung ──────────────
|
||||||
|
# Meldet sich bei der aria-bridge (worker_hello) + periodischer worker_ping.
|
||||||
|
NODE_NAME = os.getenv("NODE_NAME", "node").strip() or "node"
|
||||||
|
GPU_IDS = os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip()
|
||||||
|
WORKER_SERVICE = "f5tts"
|
||||||
|
INSTANCE_ID = f"{WORKER_SERVICE}@{NODE_NAME}"
|
||||||
|
WORKER_PING_INTERVAL_S = int(os.getenv("WORKER_PING_INTERVAL_S", "10"))
|
||||||
|
# Empfangs-Watchdog: kommt in RX_STALE_S kein Broadcast rein (ein echter Raum hat
|
||||||
|
# staendig Traffic, z.B. sat_hello alle 25s / Brain-Polling), gilt die Verbindung
|
||||||
|
# als halb-tot (Caddy pongt die WS-Pings selbst) -> Zwangs-Reconnect.
|
||||||
|
RX_STALE_S = int(os.getenv("RX_STALE_S", "60"))
|
||||||
|
_tts_busy = False # True waehrend eine Synthese laeuft (busy-Report im ping)
|
||||||
|
|
||||||
|
# ── Auslastungs-Monitor (Stage E) ──────────────────────────
|
||||||
|
import node_stats
|
||||||
|
STATS_PATH = os.getenv("STATS_PATH", f"/root/.cache/huggingface/aria_stats_{WORKER_SERVICE}.json")
|
||||||
|
_stats = node_stats.NodeStats(INSTANCE_ID, NODE_NAME, STATS_PATH, logger=logger)
|
||||||
|
|
||||||
DEFAULT_F5TTS_MODEL = "F5TTS_v1_Base"
|
DEFAULT_F5TTS_MODEL = "F5TTS_v1_Base"
|
||||||
DEFAULT_F5TTS_CKPT_FILE = "" # leer = Default-Checkpoint von HF
|
DEFAULT_F5TTS_CKPT_FILE = "" # leer = Default-Checkpoint von HF
|
||||||
DEFAULT_F5TTS_VOCAB_FILE = "" # leer = Default-Vocab vom Modell
|
DEFAULT_F5TTS_VOCAB_FILE = "" # leer = Default-Vocab vom Modell
|
||||||
@@ -378,7 +396,7 @@ async def _send(ws, mtype: str, payload: dict) -> None:
|
|||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
# DEBUG-LOG ueber RVS → /shared/logs/app.log
|
# DEBUG-LOG ueber RVS → /shared/logs/app.log
|
||||||
#
|
#
|
||||||
# Gleiches Pattern wie in whisper-bridge: Stefan's Gamebox ist
|
# Gleiches Pattern wie in whisper-bridge: Stefan's AI-Box ist
|
||||||
# Windows (kein SSH), in Zukunft koennten whisper + f5tts auf
|
# Windows (kein SSH), in Zukunft koennten whisper + f5tts auf
|
||||||
# unterschiedlichen Hosts laufen. Logs ueber RVS heisst: ein Pfad.
|
# unterschiedlichen Hosts laufen. Logs ueber RVS heisst: ein Pfad.
|
||||||
#
|
#
|
||||||
@@ -460,13 +478,16 @@ _tts_queue: asyncio.Queue[tuple] = asyncio.Queue()
|
|||||||
|
|
||||||
async def _tts_worker(ws, runner: F5Runner) -> None:
|
async def _tts_worker(ws, runner: F5Runner) -> None:
|
||||||
"""Serialisiert Synthesen — GPU kann sonst OOM gehen."""
|
"""Serialisiert Synthesen — GPU kann sonst OOM gehen."""
|
||||||
|
global _tts_busy
|
||||||
while True:
|
while True:
|
||||||
text, voice, request_id, message_id, language, speed = await _tts_queue.get()
|
text, voice, request_id, message_id, language, speed = await _tts_queue.get()
|
||||||
|
_tts_busy = True
|
||||||
try:
|
try:
|
||||||
await _do_tts(ws, runner, text, voice, request_id, message_id, language, speed)
|
await _do_tts(ws, runner, text, voice, request_id, message_id, language, speed)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("TTS-Worker Fehler")
|
logger.exception("TTS-Worker Fehler")
|
||||||
finally:
|
finally:
|
||||||
|
_tts_busy = False
|
||||||
_tts_queue.task_done()
|
_tts_queue.task_done()
|
||||||
|
|
||||||
|
|
||||||
@@ -663,6 +684,18 @@ async def handle_voice_upload(ws, payload: dict) -> None:
|
|||||||
await _send(ws, "xtts_voice_saved", {"name": name, "error": str(e)[:200]})
|
await _send(ws, "xtts_voice_saved", {"name": name, "error": str(e)[:200]})
|
||||||
|
|
||||||
|
|
||||||
|
def _local_voice_names() -> list:
|
||||||
|
"""Namen der lokal vorhandenen Nutzer-Stimmen (ohne den Box-lokalen
|
||||||
|
default_ref-Fallback) — fuer die Provisioning-Reconciliation im worker_hello."""
|
||||||
|
names = []
|
||||||
|
if VOICES_DIR.exists():
|
||||||
|
for wav in sorted(VOICES_DIR.glob("*.wav")):
|
||||||
|
if wav.stem == "default_ref":
|
||||||
|
continue
|
||||||
|
names.append(wav.stem)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
async def handle_list_voices(ws) -> None:
|
async def handle_list_voices(ws) -> None:
|
||||||
try:
|
try:
|
||||||
voices = []
|
voices = []
|
||||||
@@ -699,13 +732,16 @@ async def handle_delete_voice(ws, payload: dict) -> None:
|
|||||||
async def handle_export_voice(ws, payload: dict) -> None:
|
async def handle_export_voice(ws, payload: dict) -> None:
|
||||||
"""Packt eine Stimme (.wav + .txt) als tar.gz und sendet sie base64 zurueck."""
|
"""Packt eine Stimme (.wav + .txt) als tar.gz und sendet sie base64 zurueck."""
|
||||||
name = (payload.get("name") or "").strip()
|
name = (payload.get("name") or "").strip()
|
||||||
|
# requestId aus dem Request zuruueckspiegeln — der Diagnostic-Bibliothekar
|
||||||
|
# korreliert damit zentrale Exports gegen browser-initiierte.
|
||||||
|
req_id = payload.get("requestId", "") or ""
|
||||||
if not name:
|
if not name:
|
||||||
await _send(ws, "xtts_voice_exported", {"ok": False, "error": "name fehlt"})
|
await _send(ws, "xtts_voice_exported", {"ok": False, "requestId": req_id, "error": "name fehlt"})
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
wav, txt = voice_paths(name)
|
wav, txt = voice_paths(name)
|
||||||
if not wav.exists():
|
if not wav.exists():
|
||||||
await _send(ws, "xtts_voice_exported", {"ok": False, "name": name, "error": "Stimme nicht gefunden"})
|
await _send(ws, "xtts_voice_exported", {"ok": False, "requestId": req_id, "name": name, "error": "Stimme nicht gefunden"})
|
||||||
return
|
return
|
||||||
import io, tarfile
|
import io, tarfile
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
@@ -715,10 +751,10 @@ async def handle_export_voice(ws, payload: dict) -> None:
|
|||||||
tar.add(txt, arcname=txt.name)
|
tar.add(txt, arcname=txt.name)
|
||||||
data = base64.b64encode(buf.getvalue()).decode("ascii")
|
data = base64.b64encode(buf.getvalue()).decode("ascii")
|
||||||
logger.info("Voice exportiert: %s (%d KB tar.gz)", name, len(buf.getvalue()) // 1024)
|
logger.info("Voice exportiert: %s (%d KB tar.gz)", name, len(buf.getvalue()) // 1024)
|
||||||
await _send(ws, "xtts_voice_exported", {"ok": True, "name": name, "data": data})
|
await _send(ws, "xtts_voice_exported", {"ok": True, "requestId": req_id, "name": name, "data": data})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("handle_export_voice Fehler")
|
logger.exception("handle_export_voice Fehler")
|
||||||
await _send(ws, "xtts_voice_exported", {"ok": False, "name": name, "error": str(e)[:200]})
|
await _send(ws, "xtts_voice_exported", {"ok": False, "requestId": req_id, "name": name, "error": str(e)[:200]})
|
||||||
|
|
||||||
|
|
||||||
async def handle_import_voice(ws, payload: dict) -> None:
|
async def handle_import_voice(ws, payload: dict) -> None:
|
||||||
@@ -808,6 +844,31 @@ async def _broadcast_status(ws, state: str, **extra) -> None:
|
|||||||
await _send(ws, "service_status", payload)
|
await _send(ws, "service_status", payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def _worker_register(ws, *, model: str = "", busy_fn=None) -> None:
|
||||||
|
"""Meldet diesen Worker bei der aria-bridge an (worker_hello) und haelt die
|
||||||
|
Flotten-Registry per periodischem worker_ping frisch. worker_hello wird
|
||||||
|
zusaetzlich alle ~30s WIEDERHOLT (wie der Satellit sat_hello), damit auch ein
|
||||||
|
neu gestartetes Diagnostic/Bridge uns lernt — RVS spielt hellos nicht nach."""
|
||||||
|
def _hello():
|
||||||
|
return {"instanceId": INSTANCE_ID, "service": WORKER_SERVICE,
|
||||||
|
"node": NODE_NAME, "gpus": GPU_IDS, "model": model,
|
||||||
|
"voices": _local_voice_names()}
|
||||||
|
try:
|
||||||
|
await _send(ws, "worker_hello", _hello())
|
||||||
|
n = 0
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(WORKER_PING_INTERVAL_S)
|
||||||
|
n += 1
|
||||||
|
busy = bool(busy_fn()) if busy_fn else False
|
||||||
|
await _send(ws, "worker_ping", {"instanceId": INSTANCE_ID, "busy": busy})
|
||||||
|
if n % 3 == 0:
|
||||||
|
await _send(ws, "worker_hello", _hello())
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
return # Socket tot → still beenden; run_loop reconnectet + startet neu
|
||||||
|
|
||||||
|
|
||||||
async def run_loop(runner: F5Runner) -> None:
|
async def run_loop(runner: F5Runner) -> None:
|
||||||
use_tls = RVS_TLS
|
use_tls = RVS_TLS
|
||||||
retry_s = 2
|
retry_s = 2
|
||||||
@@ -816,7 +877,7 @@ async def run_loop(runner: F5Runner) -> None:
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
scheme = "wss" if use_tls else "ws"
|
scheme = "wss" if use_tls else "ws"
|
||||||
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
|
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -855,15 +916,31 @@ async def run_loop(runner: F5Runner) -> None:
|
|||||||
|
|
||||||
# TTS-Worker fuer diese Verbindung starten
|
# TTS-Worker fuer diese Verbindung starten
|
||||||
worker = asyncio.create_task(_tts_worker(ws, runner))
|
worker = asyncio.create_task(_tts_worker(ws, runner))
|
||||||
|
ping_task = asyncio.create_task(_worker_register(
|
||||||
|
ws, model=runner.model_id,
|
||||||
|
busy_fn=lambda: _tts_busy or not _tts_queue.empty()))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async for raw in ws:
|
while True:
|
||||||
|
try:
|
||||||
|
raw = await asyncio.wait_for(ws.recv(), timeout=RX_STALE_S)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("Kein RVS-Traffic seit %ds — Verbindung halb-tot, reconnect", RX_STALE_S)
|
||||||
|
raise ConnectionError("rvs-stale")
|
||||||
try:
|
try:
|
||||||
msg = json.loads(raw)
|
msg = json.loads(raw)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
mtype = msg.get("type", "")
|
mtype = msg.get("type", "")
|
||||||
payload = msg.get("payload", {}) or {}
|
payload = msg.get("payload", {}) or {}
|
||||||
|
# Redundanz-Routing: gezielt an eine andere Instanz
|
||||||
|
# adressiert → ignorieren. Ohne targetInstance → wie bisher.
|
||||||
|
tgt = payload.get("targetInstance")
|
||||||
|
if tgt and tgt != INSTANCE_ID:
|
||||||
|
continue
|
||||||
|
# Auslastungs-Monitor (node_stats_*) abfangen.
|
||||||
|
if await _stats.handle(ws, mtype, payload, _send):
|
||||||
|
continue
|
||||||
|
|
||||||
if mtype == "xtts_request":
|
if mtype == "xtts_request":
|
||||||
try:
|
try:
|
||||||
@@ -958,6 +1035,7 @@ async def run_loop(runner: F5Runner) -> None:
|
|||||||
_last_diag_voice = ""
|
_last_diag_voice = ""
|
||||||
finally:
|
finally:
|
||||||
worker.cancel()
|
worker.cancel()
|
||||||
|
ping_task.cancel()
|
||||||
try:
|
try:
|
||||||
await worker
|
await worker
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -985,6 +1063,7 @@ async def main() -> None:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
VOICES_DIR.mkdir(parents=True, exist_ok=True)
|
VOICES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
runner = F5Runner()
|
runner = F5Runner()
|
||||||
|
asyncio.create_task(_stats.run_sampler()) # Auslastungs-Sampler (Stage E)
|
||||||
await run_loop(runner)
|
await run_loop(runner)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""
|
||||||
|
ARIA Node-Stats — Auslastungs-Monitor pro Box (GPU + optional Tokens).
|
||||||
|
|
||||||
|
Identische Kopie in jedem Worker-Build-Context (f5tts/whisper/voxtral/llm-adapter),
|
||||||
|
weil jeder Worker ein eigener Docker-Build-Context ist.
|
||||||
|
|
||||||
|
Aufgaben:
|
||||||
|
- Sampler-Loop (alle SAMPLE_SEC): nvidia-smi-Auslastung + Token-Delta → Ringpuffer
|
||||||
|
(persistent als JSON auf der Box). Laeuft unabhaengig vom Modal.
|
||||||
|
- Live-Stream: bei node_stats_stream_start jede Sekunde rohes nvidia-smi + Werte
|
||||||
|
senden (bis stop / Auto-Timeout).
|
||||||
|
- History-Request + Reset (Besen).
|
||||||
|
|
||||||
|
Reicht `handle(ws, mtype, payload)` in die Worker-Message-Loop ein; gibt True
|
||||||
|
zurueck, wenn die Nachricht eine node_stats_*-Nachricht war.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
SAMPLE_SEC = int(os.getenv("STATS_SAMPLE_SEC", "15"))
|
||||||
|
HISTORY_CAP = int(os.getenv("STATS_HISTORY_CAP", "500")) # ~2h bei 15s
|
||||||
|
STREAM_MAX_SEC = int(os.getenv("STATS_STREAM_MAX_SEC", "300"))
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_cmd(*args, timeout=8) -> str:
|
||||||
|
"""Fuehrt ein Kommando aus, gibt stdout (str) zurueck; '' bei Fehler."""
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*args,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||||
|
return (out or b"").decode("utf-8", "replace")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class NodeStats:
|
||||||
|
def __init__(self, instance_id: str, node_name: str, history_path: str,
|
||||||
|
token_getter=None, logger=None):
|
||||||
|
self.instance_id = instance_id
|
||||||
|
self.node_name = node_name
|
||||||
|
self.history_path = history_path
|
||||||
|
self.token_getter = token_getter # callable -> kumulative Token-Zahl (oder None)
|
||||||
|
self.log = logger
|
||||||
|
self.samples = self._load()
|
||||||
|
self._last_tokens = self._tokens_now()
|
||||||
|
self._stream_task = None
|
||||||
|
|
||||||
|
# ── Persistenz ──────────────────────────────────────────
|
||||||
|
def _load(self) -> list:
|
||||||
|
try:
|
||||||
|
with open(self.history_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _persist(self) -> None:
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(self.history_path) or ".", exist_ok=True)
|
||||||
|
tmp = self.history_path + ".tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
json.dump(self.samples[-HISTORY_CAP:], f)
|
||||||
|
os.replace(tmp, self.history_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _tokens_now(self) -> int:
|
||||||
|
try:
|
||||||
|
return int(self.token_getter()) if self.token_getter else 0
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# ── nvidia-smi ──────────────────────────────────────────
|
||||||
|
async def _query_gpu(self) -> dict:
|
||||||
|
"""Aggregierte GPU-Werte ueber alle sichtbaren Karten."""
|
||||||
|
out = await _run_cmd(
|
||||||
|
"nvidia-smi",
|
||||||
|
"--query-gpu=utilization.gpu,memory.used,memory.total",
|
||||||
|
"--format=csv,noheader,nounits")
|
||||||
|
utils, used, total = [], 0, 0
|
||||||
|
for line in out.strip().splitlines():
|
||||||
|
parts = [p.strip() for p in line.split(",")]
|
||||||
|
if len(parts) < 3:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
utils.append(float(parts[0]))
|
||||||
|
used += float(parts[1])
|
||||||
|
total += float(parts[2])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
|
||||||
|
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
|
||||||
|
|
||||||
|
async def _nvidia_smi_text(self) -> str:
|
||||||
|
txt = await _run_cmd("nvidia-smi")
|
||||||
|
return txt or "nvidia-smi nicht verfuegbar"
|
||||||
|
|
||||||
|
# ── Sampler (Verlauf) ───────────────────────────────────
|
||||||
|
async def run_sampler(self) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
g = await self._query_gpu()
|
||||||
|
now_tok = self._tokens_now()
|
||||||
|
dtok = max(0, now_tok - self._last_tokens)
|
||||||
|
self._last_tokens = now_tok
|
||||||
|
self.samples.append({
|
||||||
|
"ts": int(time.time()),
|
||||||
|
"gpu": g["gpu"], "memUsed": g["memUsed"],
|
||||||
|
"memTotal": g["memTotal"], "tokens": dtok,
|
||||||
|
})
|
||||||
|
if len(self.samples) > HISTORY_CAP:
|
||||||
|
self.samples = self.samples[-HISTORY_CAP:]
|
||||||
|
self._persist()
|
||||||
|
except Exception as e:
|
||||||
|
if self.log:
|
||||||
|
self.log.debug("node_stats sample fehlgeschlagen: %s", e)
|
||||||
|
await asyncio.sleep(SAMPLE_SEC)
|
||||||
|
|
||||||
|
# ── Live-Stream ─────────────────────────────────────────
|
||||||
|
async def _stream(self, ws, send) -> None:
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
while time.time() - t0 < STREAM_MAX_SEC:
|
||||||
|
g = await self._query_gpu()
|
||||||
|
smi = await self._nvidia_smi_text()
|
||||||
|
await send(ws, "node_stats", {
|
||||||
|
"instanceId": self.instance_id, "node": self.node_name,
|
||||||
|
"nvidiaSmi": smi, **g,
|
||||||
|
})
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Dispatch ────────────────────────────────────────────
|
||||||
|
async def handle(self, ws, mtype: str, payload: dict, send) -> bool:
|
||||||
|
if mtype == "node_stats_stream_start":
|
||||||
|
if self._stream_task and not self._stream_task.done():
|
||||||
|
self._stream_task.cancel()
|
||||||
|
self._stream_task = asyncio.create_task(self._stream(ws, send))
|
||||||
|
return True
|
||||||
|
if mtype == "node_stats_stream_stop":
|
||||||
|
if self._stream_task:
|
||||||
|
self._stream_task.cancel()
|
||||||
|
self._stream_task = None
|
||||||
|
return True
|
||||||
|
if mtype == "node_stats_history_request":
|
||||||
|
await send(ws, "node_stats_history", {
|
||||||
|
"instanceId": self.instance_id, "node": self.node_name,
|
||||||
|
"samples": self.samples[-HISTORY_CAP:],
|
||||||
|
"tokenCapable": self.token_getter is not None,
|
||||||
|
"sampleSec": SAMPLE_SEC,
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
if mtype == "node_stats_reset":
|
||||||
|
self.samples = []
|
||||||
|
self._persist()
|
||||||
|
await send(ws, "node_stats_reset_done",
|
||||||
|
{"instanceId": self.instance_id, "node": self.node_name})
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -3,6 +3,7 @@ FROM python:3.11-slim
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY node_stats.py .
|
||||||
COPY adapter.py .
|
COPY adapter.py .
|
||||||
|
|
||||||
CMD ["python", "-u", "adapter.py"]
|
CMD ["python", "-u", "adapter.py"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Local-LLM-Adapter (Gamebox) — Plan B, Phase B0
|
# Local-LLM-Adapter (AI-Box) — Plan B, Phase B0
|
||||||
|
|
||||||
Bringt ein lokales, schnelles LLM (Qwen3 8B) auf die Gamebox und haengt es
|
Bringt ein lokales, schnelles LLM (Qwen3 8B) auf die AI-Box und haengt es
|
||||||
per RVS an ARIA — fuer die einfachen ~80 % der Turns (<1 s), waehrend Claude
|
per RVS an ARIA — fuer die einfachen ~80 % der Turns (<1 s), waehrend Claude
|
||||||
das Tiefen-Hirn bleibt. Siehe `docs/plan-local-llm-router.md` im Repo-Root.
|
das Tiefen-Hirn bleibt. Siehe `docs/plan-local-llm-router.md` im Repo-Root.
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ das Tiefen-Hirn bleibt. Siehe `docs/plan-local-llm-router.md` im Repo-Root.
|
|||||||
cached es unter `xtts/models/` (Bind-Mount → kein Re-Download bei Restart).
|
cached es unter `xtts/models/` (Bind-Mount → kein Re-Download bei Restart).
|
||||||
Default: **Qwen3 8B, Q4_K_M** aus dem offiziellen Repo `Qwen/Qwen3-8B-GGUF`.
|
Default: **Qwen3 8B, Q4_K_M** aus dem offiziellen Repo `Qwen/Qwen3-8B-GGUF`.
|
||||||
|
|
||||||
Modell/Quant wechseln = in der `.env` der Gamebox setzen (kein Code):
|
Modell/Quant wechseln = in der `.env` der AI-Box setzen (kein Code):
|
||||||
|
|
||||||
```
|
```
|
||||||
LLM_HF_REPO=Qwen/Qwen3-8B-GGUF # HF-Repo
|
LLM_HF_REPO=Qwen/Qwen3-8B-GGUF # HF-Repo
|
||||||
@@ -35,7 +35,7 @@ umstellen + Container neu — Ein-Zeilen-Wechsel, kein Code.
|
|||||||
Modelle) ist ein geplanter Folge-Baustein via `llama-swap` — siehe
|
Modelle) ist ein geplanter Folge-Baustein via `llama-swap` — siehe
|
||||||
`docs/plan-local-llm-router.md`.
|
`docs/plan-local-llm-router.md`.
|
||||||
|
|
||||||
## Start (auf der Gamebox)
|
## Start (auf der AI-Box)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd xtts
|
cd xtts
|
||||||
|
|||||||
+260
-10
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
ARIA Local-LLM-Adapter (Gamebox) — Plan B, Phase B0.
|
ARIA Local-LLM-Adapter (AI-Box) — Plan B, Phase B0.
|
||||||
|
|
||||||
Bruecke zwischen RVS und dem lokalen llama.cpp-Server. Spiegelt das Muster der
|
Bruecke zwischen RVS und dem lokalen llama.cpp-Server. Spiegelt das Muster der
|
||||||
whisper-bridge: verbindet sich per WebSocket mit dem RVS (Token-Room, TLS mit
|
whisper-bridge: verbindet sich per WebSocket mit dem RVS (Token-Room, TLS mit
|
||||||
@@ -7,7 +7,7 @@ ws-Fallback, Reconnect-Backoff), lauscht auf `llm_request` und ruft den lokalen
|
|||||||
llama.cpp-`/v1/chat/completions`-Endpoint (OpenAI-kompatibel), antwortet mit
|
llama.cpp-`/v1/chat/completions`-Endpoint (OpenAI-kompatibel), antwortet mit
|
||||||
`llm_response` (korreliert per requestId).
|
`llm_response` (korreliert per requestId).
|
||||||
|
|
||||||
Topologie: Gamebox steht zuhause, ARIA im RZ — die Kommunikation laeuft ueber
|
Topologie: AI-Box steht zuhause, ARIA im RZ — die Kommunikation laeuft ueber
|
||||||
den RVS (wie TTS/STT), keine IPs zu pflegen. Nur URL + Token.
|
den RVS (wie TTS/STT), keine IPs zu pflegen. Nur URL + Token.
|
||||||
|
|
||||||
Env:
|
Env:
|
||||||
@@ -47,6 +47,19 @@ RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
|||||||
LLAMA_URL = os.getenv("LLAMA_URL", "http://llama:8081").rstrip("/")
|
LLAMA_URL = os.getenv("LLAMA_URL", "http://llama:8081").rstrip("/")
|
||||||
LLM_MODEL = os.getenv("LLM_MODEL", "qwen3-8b")
|
LLM_MODEL = os.getenv("LLM_MODEL", "qwen3-8b")
|
||||||
LLM_TIMEOUT_SEC = float(os.getenv("LLM_TIMEOUT_SEC", "60"))
|
LLM_TIMEOUT_SEC = float(os.getenv("LLM_TIMEOUT_SEC", "60"))
|
||||||
|
|
||||||
|
# ── Compute-Fleet: Worker-Identitaet & Registrierung ──────────────
|
||||||
|
# Meldet sich bei der aria-bridge (worker_hello) + periodischer worker_ping.
|
||||||
|
NODE_NAME = os.getenv("NODE_NAME", "node").strip() or "node"
|
||||||
|
GPU_IDS = os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip()
|
||||||
|
WORKER_SERVICE = "llm"
|
||||||
|
INSTANCE_ID = f"{WORKER_SERVICE}@{NODE_NAME}"
|
||||||
|
WORKER_PING_INTERVAL_S = int(os.getenv("WORKER_PING_INTERVAL_S", "10"))
|
||||||
|
# Empfangs-Watchdog: kommt in RX_STALE_S kein Broadcast rein (ein echter Raum hat
|
||||||
|
# staendig Traffic, z.B. sat_hello alle 25s / Brain-Polling), gilt die Verbindung
|
||||||
|
# als halb-tot (Caddy pongt die WS-Pings selbst) -> Zwangs-Reconnect.
|
||||||
|
RX_STALE_S = int(os.getenv("RX_STALE_S", "60"))
|
||||||
|
_inflight = 0 # laufende llm_requests (busy-Report im ping)
|
||||||
# Qwen3 hat Thinking-Mode default AN — dann verbraet es Tokens in einem
|
# Qwen3 hat Thinking-Mode default AN — dann verbraet es Tokens in einem
|
||||||
# <think>-Block und liefert (bei kleinem max_tokens) leeren/abgeschnittenen
|
# <think>-Block und liefert (bei kleinem max_tokens) leeren/abgeschnittenen
|
||||||
# content, ausserdem 3x langsamer. ARIAs schnelles Tier will KEIN Grübeln
|
# content, ausserdem 3x langsamer. ARIAs schnelles Tier will KEIN Grübeln
|
||||||
@@ -56,6 +69,94 @@ LLM_TIMEOUT_SEC = float(os.getenv("LLM_TIMEOUT_SEC", "60"))
|
|||||||
# empfindlich reagiert: LLM_DISABLE_THINKING=false setzen.
|
# empfindlich reagiert: LLM_DISABLE_THINKING=false setzen.
|
||||||
LLM_DISABLE_THINKING = os.getenv("LLM_DISABLE_THINKING", "true").lower() == "true"
|
LLM_DISABLE_THINKING = os.getenv("LLM_DISABLE_THINKING", "true").lower() == "true"
|
||||||
|
|
||||||
|
# ── Modell-Verwaltung (Stage D): Adapter besitzt llama-swaps Config ──
|
||||||
|
# llama-swap liest die GENERIERTE Config (beschreibbar, im /models-Bind). Wir
|
||||||
|
# erzeugen sie aus dem Basis-Template (kuratierte Defaults) + der persistenten
|
||||||
|
# Box-Registry (per Diagnostic hinzugefuegte Modelle). So werden neue Modelle
|
||||||
|
# ohne Image-Rebuild waehlbar.
|
||||||
|
import yaml # pyyaml
|
||||||
|
BASE_CONFIG_PATH = os.getenv("LLAMA_BASE_CONFIG", "/llamaswap/config.yaml")
|
||||||
|
GEN_CONFIG_PATH = os.getenv("LLAMA_GEN_CONFIG", "/models/llama-swap.config.yaml")
|
||||||
|
REGISTRY_PATH = os.getenv("LLM_REGISTRY", "/models/aria_models.json")
|
||||||
|
|
||||||
|
# ── Auslastungs-Monitor (Stage E) ──────────────────────────
|
||||||
|
import node_stats
|
||||||
|
STATS_PATH = os.getenv("STATS_PATH", "/models/aria_stats.json")
|
||||||
|
_total_tokens = 0 # kumulativ, fuer den Token-Graph
|
||||||
|
_stats = node_stats.NodeStats(INSTANCE_ID, NODE_NAME, STATS_PATH,
|
||||||
|
token_getter=lambda: _total_tokens, logger=logger)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_registry() -> list:
|
||||||
|
try:
|
||||||
|
with open(REGISTRY_PATH) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _save_registry(reg: list) -> None:
|
||||||
|
try:
|
||||||
|
tmp = REGISTRY_PATH + ".tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
json.dump(reg, f, indent=2)
|
||||||
|
os.replace(tmp, REGISTRY_PATH)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Registry speichern fehlgeschlagen: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_config() -> int:
|
||||||
|
"""Schreibt die llama-swap-Config aus Basis-Template + Registry. Gibt die
|
||||||
|
Anzahl Modelle zurueck. Idempotent, bei jeder Aenderung + beim Start."""
|
||||||
|
base = {}
|
||||||
|
try:
|
||||||
|
with open(BASE_CONFIG_PATH) as f:
|
||||||
|
base = yaml.safe_load(f) or {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Basis-Template %s nicht lesbar (%s)", BASE_CONFIG_PATH, e)
|
||||||
|
models = dict(base.get("models") or {})
|
||||||
|
for e in _load_registry():
|
||||||
|
key = (e.get("key") or "").strip()
|
||||||
|
repo = (e.get("hfRepo") or "").strip()
|
||||||
|
if not key or not repo:
|
||||||
|
continue
|
||||||
|
quant = (e.get("quant") or "Q4_K_M").strip()
|
||||||
|
ctx = int(e.get("ctx") or 8192)
|
||||||
|
ngl = int(e.get("ngl") or 99)
|
||||||
|
models[key] = {
|
||||||
|
"cmd": (f"llama-server --port ${{PORT}} --host 127.0.0.1\n"
|
||||||
|
f"-hf {repo}:{quant}\n-ngl {ngl} -c {ctx} --jinja"),
|
||||||
|
"ttl": 3600,
|
||||||
|
}
|
||||||
|
out = dict(base)
|
||||||
|
out["models"] = models
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(GEN_CONFIG_PATH), exist_ok=True)
|
||||||
|
tmp = GEN_CONFIG_PATH + ".tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
yaml.safe_dump(out, f, sort_keys=False, default_flow_style=False)
|
||||||
|
os.replace(tmp, GEN_CONFIG_PATH)
|
||||||
|
logger.info("llama-swap-Config generiert: %d Modelle → %s", len(models), GEN_CONFIG_PATH)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Config schreiben fehlgeschlagen: %s", e)
|
||||||
|
return len(models)
|
||||||
|
|
||||||
|
|
||||||
|
async def _reload_llama() -> None:
|
||||||
|
"""Stoesst llama-swap-Reload an. Viele Builds watchen die Config-Datei ohnehin;
|
||||||
|
zusaetzlich versuchen wir bekannte Reload-Endpunkte (Fehler ignoriert)."""
|
||||||
|
for path in ("/api/config/reload", "/reload"):
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as c:
|
||||||
|
r = await c.post(f"{LLAMA_URL}{path}")
|
||||||
|
if r.status_code < 400:
|
||||||
|
logger.info("llama-swap reload via %s", path)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.info("llama-swap reload: kein Endpoint — verlasse mich auf File-Watch")
|
||||||
|
|
||||||
|
|
||||||
async def _send(ws, mtype: str, payload: dict) -> None:
|
async def _send(ws, mtype: str, payload: dict) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -99,11 +200,17 @@ async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
msg = (data.get("choices") or [{}])[0].get("message", {}) or {}
|
msg = (data.get("choices") or [{}])[0].get("message", {}) or {}
|
||||||
|
usage = data.get("usage") or {}
|
||||||
|
try:
|
||||||
|
global _total_tokens
|
||||||
|
_total_tokens += int(usage.get("total_tokens") or 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"content": msg.get("content") or "",
|
"content": msg.get("content") or "",
|
||||||
"tool_calls": msg.get("tool_calls") or None,
|
"tool_calls": msg.get("tool_calls") or None,
|
||||||
"usage": data.get("usage"),
|
"usage": usage,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("llama.cpp-Call fehlgeschlagen: %s", e)
|
logger.warning("llama.cpp-Call fehlgeschlagen: %s", e)
|
||||||
@@ -124,7 +231,63 @@ async def _emit_llm_status(ws, state: str, model: str, **extra) -> None:
|
|||||||
{"service": "llm", "state": state, "model": model, **extra})
|
{"service": "llm", "state": state, "model": model, **extra})
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_available_models() -> list:
|
||||||
|
"""Fragt llama-swap ab, welche Modelle diese Box fahren kann (GET /v1/models,
|
||||||
|
OpenAI-kompatibel → {data:[{id},...]}). Das sind die config.yaml-Keys.
|
||||||
|
Defensiv: bei Fehler Fallback auf [LLM_MODEL]."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.get(f"{LLAMA_URL}/v1/models")
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||||
|
return ids or [LLM_MODEL]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("llama-swap /v1/models nicht abfragbar (%s) — Fallback [%s]", e, LLM_MODEL)
|
||||||
|
return [LLM_MODEL]
|
||||||
|
|
||||||
|
|
||||||
|
async def _announce(ws) -> None:
|
||||||
|
"""Sendet ein frisches worker_hello mit der aktuellen Modell-Liste (nach
|
||||||
|
Provision/Remove aufrufen, damit Bridge+Diagnostic das neue Modell lernen)."""
|
||||||
|
models = await _fetch_available_models()
|
||||||
|
await _send(ws, "worker_hello", {
|
||||||
|
"instanceId": INSTANCE_ID, "service": WORKER_SERVICE,
|
||||||
|
"node": NODE_NAME, "gpus": GPU_IDS, "model": LLM_MODEL,
|
||||||
|
"models": models, # welche Modelle diese Box fahren kann (llama-swap-Keys)
|
||||||
|
})
|
||||||
|
logger.info("worker_hello: models=%s", models)
|
||||||
|
|
||||||
|
|
||||||
|
async def _worker_register(ws) -> None:
|
||||||
|
"""Meldet diesen Worker bei der aria-bridge an (worker_hello) und haelt die
|
||||||
|
Flotten-Registry per periodischem worker_ping (mit busy-Status) frisch."""
|
||||||
|
try:
|
||||||
|
await _announce(ws)
|
||||||
|
n = 0
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(WORKER_PING_INTERVAL_S)
|
||||||
|
n += 1
|
||||||
|
await _send(ws, "worker_ping",
|
||||||
|
{"instanceId": INSTANCE_ID, "busy": _inflight > 0})
|
||||||
|
if n % 3 == 0: # ~30s worker_hello wiederholen (wie der Satellit) →
|
||||||
|
await _announce(ws) # auch neu gestartetes Diagnostic/Bridge lernt uns
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
return # Socket tot → still beenden; _run reconnectet + startet neu
|
||||||
|
|
||||||
|
|
||||||
async def _handle_llm_request(ws, payload: dict) -> None:
|
async def _handle_llm_request(ws, payload: dict) -> None:
|
||||||
|
global _last_model, _inflight
|
||||||
|
_inflight += 1
|
||||||
|
try:
|
||||||
|
await _do_llm_request(ws, payload)
|
||||||
|
finally:
|
||||||
|
_inflight -= 1
|
||||||
|
|
||||||
|
|
||||||
|
async def _do_llm_request(ws, payload: dict) -> None:
|
||||||
global _last_model
|
global _last_model
|
||||||
req_id = payload.get("requestId", "")
|
req_id = payload.get("requestId", "")
|
||||||
messages = payload.get("messages") or []
|
messages = payload.get("messages") or []
|
||||||
@@ -177,6 +340,61 @@ async def _handle_llm_request(ws, payload: dict) -> None:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_provision(ws, payload: dict) -> None:
|
||||||
|
"""Fuegt ein Modell hinzu: Registry+Config schreiben, reload, dann Warmup
|
||||||
|
(zieht das GGUF via -hf beim ersten Load). Meldet die neue Modell-Liste."""
|
||||||
|
key = (payload.get("key") or "").strip()
|
||||||
|
repo = (payload.get("hfRepo") or "").strip()
|
||||||
|
if not key or not repo:
|
||||||
|
await _send(ws, "llm_provision_result",
|
||||||
|
{"instanceId": INSTANCE_ID, "key": key, "ok": False, "error": "key/hfRepo fehlt"})
|
||||||
|
return
|
||||||
|
entry = {
|
||||||
|
"key": key, "hfRepo": repo,
|
||||||
|
"quant": (payload.get("quant") or "Q4_K_M").strip(),
|
||||||
|
"ctx": int(payload.get("ctx") or 8192),
|
||||||
|
"ngl": int(payload.get("ngl") or 99),
|
||||||
|
}
|
||||||
|
reg = [e for e in _load_registry() if e.get("key") != key]
|
||||||
|
reg.append(entry)
|
||||||
|
_save_registry(reg)
|
||||||
|
_generate_config()
|
||||||
|
await _reload_llama()
|
||||||
|
await _announce(ws) # Bridge/Diagnostic lernen das neue Modell
|
||||||
|
# Warmup: Mini-Request → llama-swap laedt/zieht das Modell (Fortschritt via
|
||||||
|
# service_status loading→ready, freshlyDownloaded).
|
||||||
|
await _emit_llm_status(ws, "loading", key)
|
||||||
|
t0 = time.time()
|
||||||
|
res = await _call_llama([{"role": "user", "content": "hi"}],
|
||||||
|
max_tokens=1, temperature=0.0, stop=None, model=key)
|
||||||
|
dt = time.time() - t0
|
||||||
|
if res.get("ok"):
|
||||||
|
_ready_models.add(key)
|
||||||
|
await _emit_llm_status(ws, "ready", key, loadSeconds=round(dt, 1),
|
||||||
|
freshlyDownloaded=dt > 25)
|
||||||
|
else:
|
||||||
|
await _emit_llm_status(ws, "error", key, error=(res.get("error") or "")[:160])
|
||||||
|
await _send(ws, "llm_provision_result",
|
||||||
|
{"instanceId": INSTANCE_ID, "key": key, "ok": res.get("ok", False),
|
||||||
|
"error": res.get("error"), "elapsedMs": int(dt * 1000)})
|
||||||
|
logger.info("provision %s (%s) → ok=%s %.1fs", key, repo, res.get("ok"), dt)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_remove(ws, payload: dict) -> None:
|
||||||
|
"""Entfernt ein Modell aus Registry+Config (GGUF bleibt im Cache)."""
|
||||||
|
key = (payload.get("key") or "").strip()
|
||||||
|
if not key:
|
||||||
|
return
|
||||||
|
reg = [e for e in _load_registry() if e.get("key") != key]
|
||||||
|
_save_registry(reg)
|
||||||
|
_generate_config()
|
||||||
|
await _reload_llama()
|
||||||
|
await _announce(ws)
|
||||||
|
await _send(ws, "llm_provision_result",
|
||||||
|
{"instanceId": INSTANCE_ID, "key": key, "ok": True, "removed": True})
|
||||||
|
logger.info("removed model %s", key)
|
||||||
|
|
||||||
|
|
||||||
async def _run() -> None:
|
async def _run() -> None:
|
||||||
if not RVS_HOST:
|
if not RVS_HOST:
|
||||||
logger.error("RVS_HOST nicht gesetzt — Abbruch")
|
logger.error("RVS_HOST nicht gesetzt — Abbruch")
|
||||||
@@ -185,13 +403,21 @@ async def _run() -> None:
|
|||||||
logger.error("RVS_TOKEN nicht gesetzt — Abbruch")
|
logger.error("RVS_TOKEN nicht gesetzt — Abbruch")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# llama-swap-Config aus Basis-Template + Registry erzeugen, BEVOR llama-swap
|
||||||
|
# sie braucht (llama-swap restart: unless-stopped faengt die Erst-Boot-
|
||||||
|
# Reihenfolge ab, falls es kurz vor uns startet).
|
||||||
|
_generate_config()
|
||||||
|
|
||||||
|
# Auslastungs-Sampler (GPU + Tokens) laeuft unabhaengig vom RVS.
|
||||||
|
asyncio.create_task(_stats.run_sampler())
|
||||||
|
|
||||||
use_tls = RVS_TLS
|
use_tls = RVS_TLS
|
||||||
retry_s = 2
|
retry_s = 2
|
||||||
tls_fallback_tried = False
|
tls_fallback_tried = False
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
scheme = "wss" if use_tls else "ws"
|
scheme = "wss" if use_tls else "ws"
|
||||||
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
|
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||||
try:
|
try:
|
||||||
logger.info("Verbinde zu RVS: %s (llama=%s)", masked, LLAMA_URL)
|
logger.info("Verbinde zu RVS: %s (llama=%s)", masked, LLAMA_URL)
|
||||||
@@ -201,19 +427,43 @@ async def _run() -> None:
|
|||||||
logger.info("RVS verbunden — llm-adapter online")
|
logger.info("RVS verbunden — llm-adapter online")
|
||||||
retry_s = 2
|
retry_s = 2
|
||||||
tls_fallback_tried = False
|
tls_fallback_tried = False
|
||||||
async for raw in ws:
|
ping_task = asyncio.create_task(_worker_register(ws))
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
raw = await asyncio.wait_for(ws.recv(), timeout=RX_STALE_S)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("Kein RVS-Traffic seit %ds — Verbindung halb-tot, reconnect", RX_STALE_S)
|
||||||
|
raise ConnectionError("rvs-stale")
|
||||||
try:
|
try:
|
||||||
msg = json.loads(raw)
|
msg = json.loads(raw)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
if msg.get("type") != "llm_request":
|
mtype = msg.get("type")
|
||||||
continue
|
|
||||||
payload = msg.get("payload", {}) or {}
|
payload = msg.get("payload", {}) or {}
|
||||||
# Jede Anfrage nebenlaeufig — llama.cpp serialisiert intern,
|
# Redundanz-Routing: gezielt an eine andere Instanz adressiert
|
||||||
# aber wir blockieren so nicht den Empfang weiterer Messages.
|
# → ignorieren. Ohne targetInstance → wie bisher (jeder nimmt).
|
||||||
asyncio.create_task(_handle_llm_request(ws, payload))
|
tgt = payload.get("targetInstance")
|
||||||
|
if tgt and tgt != INSTANCE_ID:
|
||||||
|
continue
|
||||||
|
# Auslastungs-Monitor (node_stats_*) abfangen.
|
||||||
|
if await _stats.handle(ws, mtype, payload, _send):
|
||||||
|
continue
|
||||||
|
if mtype not in ("llm_request", "llm_provision_model", "llm_remove_model"):
|
||||||
|
continue
|
||||||
|
if mtype == "llm_provision_model":
|
||||||
|
asyncio.create_task(_handle_provision(ws, payload))
|
||||||
|
elif mtype == "llm_remove_model":
|
||||||
|
asyncio.create_task(_handle_remove(ws, payload))
|
||||||
|
else:
|
||||||
|
# Jede Anfrage nebenlaeufig — llama.cpp serialisiert intern,
|
||||||
|
# aber wir blockieren so nicht den Empfang weiterer Messages.
|
||||||
|
asyncio.create_task(_handle_llm_request(ws, payload))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("RVS-Verbindung verloren/fehlgeschlagen: %s", e)
|
logger.warning("RVS-Verbindung verloren/fehlgeschlagen: %s", e)
|
||||||
|
try:
|
||||||
|
ping_task.cancel()
|
||||||
|
except NameError:
|
||||||
|
pass
|
||||||
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
||||||
tls_fallback_tried = True
|
tls_fallback_tried = True
|
||||||
use_tls = False
|
use_tls = False
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""
|
||||||
|
ARIA Node-Stats — Auslastungs-Monitor pro Box (GPU + optional Tokens).
|
||||||
|
|
||||||
|
Identische Kopie in jedem Worker-Build-Context (f5tts/whisper/voxtral/llm-adapter),
|
||||||
|
weil jeder Worker ein eigener Docker-Build-Context ist.
|
||||||
|
|
||||||
|
Aufgaben:
|
||||||
|
- Sampler-Loop (alle SAMPLE_SEC): nvidia-smi-Auslastung + Token-Delta → Ringpuffer
|
||||||
|
(persistent als JSON auf der Box). Laeuft unabhaengig vom Modal.
|
||||||
|
- Live-Stream: bei node_stats_stream_start jede Sekunde rohes nvidia-smi + Werte
|
||||||
|
senden (bis stop / Auto-Timeout).
|
||||||
|
- History-Request + Reset (Besen).
|
||||||
|
|
||||||
|
Reicht `handle(ws, mtype, payload)` in die Worker-Message-Loop ein; gibt True
|
||||||
|
zurueck, wenn die Nachricht eine node_stats_*-Nachricht war.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
SAMPLE_SEC = int(os.getenv("STATS_SAMPLE_SEC", "15"))
|
||||||
|
HISTORY_CAP = int(os.getenv("STATS_HISTORY_CAP", "500")) # ~2h bei 15s
|
||||||
|
STREAM_MAX_SEC = int(os.getenv("STATS_STREAM_MAX_SEC", "300"))
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_cmd(*args, timeout=8) -> str:
|
||||||
|
"""Fuehrt ein Kommando aus, gibt stdout (str) zurueck; '' bei Fehler."""
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*args,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||||
|
return (out or b"").decode("utf-8", "replace")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class NodeStats:
|
||||||
|
def __init__(self, instance_id: str, node_name: str, history_path: str,
|
||||||
|
token_getter=None, logger=None):
|
||||||
|
self.instance_id = instance_id
|
||||||
|
self.node_name = node_name
|
||||||
|
self.history_path = history_path
|
||||||
|
self.token_getter = token_getter # callable -> kumulative Token-Zahl (oder None)
|
||||||
|
self.log = logger
|
||||||
|
self.samples = self._load()
|
||||||
|
self._last_tokens = self._tokens_now()
|
||||||
|
self._stream_task = None
|
||||||
|
|
||||||
|
# ── Persistenz ──────────────────────────────────────────
|
||||||
|
def _load(self) -> list:
|
||||||
|
try:
|
||||||
|
with open(self.history_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _persist(self) -> None:
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(self.history_path) or ".", exist_ok=True)
|
||||||
|
tmp = self.history_path + ".tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
json.dump(self.samples[-HISTORY_CAP:], f)
|
||||||
|
os.replace(tmp, self.history_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _tokens_now(self) -> int:
|
||||||
|
try:
|
||||||
|
return int(self.token_getter()) if self.token_getter else 0
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# ── nvidia-smi ──────────────────────────────────────────
|
||||||
|
async def _query_gpu(self) -> dict:
|
||||||
|
"""Aggregierte GPU-Werte ueber alle sichtbaren Karten."""
|
||||||
|
out = await _run_cmd(
|
||||||
|
"nvidia-smi",
|
||||||
|
"--query-gpu=utilization.gpu,memory.used,memory.total",
|
||||||
|
"--format=csv,noheader,nounits")
|
||||||
|
utils, used, total = [], 0, 0
|
||||||
|
for line in out.strip().splitlines():
|
||||||
|
parts = [p.strip() for p in line.split(",")]
|
||||||
|
if len(parts) < 3:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
utils.append(float(parts[0]))
|
||||||
|
used += float(parts[1])
|
||||||
|
total += float(parts[2])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
|
||||||
|
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
|
||||||
|
|
||||||
|
async def _nvidia_smi_text(self) -> str:
|
||||||
|
txt = await _run_cmd("nvidia-smi")
|
||||||
|
return txt or "nvidia-smi nicht verfuegbar"
|
||||||
|
|
||||||
|
# ── Sampler (Verlauf) ───────────────────────────────────
|
||||||
|
async def run_sampler(self) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
g = await self._query_gpu()
|
||||||
|
now_tok = self._tokens_now()
|
||||||
|
dtok = max(0, now_tok - self._last_tokens)
|
||||||
|
self._last_tokens = now_tok
|
||||||
|
self.samples.append({
|
||||||
|
"ts": int(time.time()),
|
||||||
|
"gpu": g["gpu"], "memUsed": g["memUsed"],
|
||||||
|
"memTotal": g["memTotal"], "tokens": dtok,
|
||||||
|
})
|
||||||
|
if len(self.samples) > HISTORY_CAP:
|
||||||
|
self.samples = self.samples[-HISTORY_CAP:]
|
||||||
|
self._persist()
|
||||||
|
except Exception as e:
|
||||||
|
if self.log:
|
||||||
|
self.log.debug("node_stats sample fehlgeschlagen: %s", e)
|
||||||
|
await asyncio.sleep(SAMPLE_SEC)
|
||||||
|
|
||||||
|
# ── Live-Stream ─────────────────────────────────────────
|
||||||
|
async def _stream(self, ws, send) -> None:
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
while time.time() - t0 < STREAM_MAX_SEC:
|
||||||
|
g = await self._query_gpu()
|
||||||
|
smi = await self._nvidia_smi_text()
|
||||||
|
await send(ws, "node_stats", {
|
||||||
|
"instanceId": self.instance_id, "node": self.node_name,
|
||||||
|
"nvidiaSmi": smi, **g,
|
||||||
|
})
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Dispatch ────────────────────────────────────────────
|
||||||
|
async def handle(self, ws, mtype: str, payload: dict, send) -> bool:
|
||||||
|
if mtype == "node_stats_stream_start":
|
||||||
|
if self._stream_task and not self._stream_task.done():
|
||||||
|
self._stream_task.cancel()
|
||||||
|
self._stream_task = asyncio.create_task(self._stream(ws, send))
|
||||||
|
return True
|
||||||
|
if mtype == "node_stats_stream_stop":
|
||||||
|
if self._stream_task:
|
||||||
|
self._stream_task.cancel()
|
||||||
|
self._stream_task = None
|
||||||
|
return True
|
||||||
|
if mtype == "node_stats_history_request":
|
||||||
|
await send(ws, "node_stats_history", {
|
||||||
|
"instanceId": self.instance_id, "node": self.node_name,
|
||||||
|
"samples": self.samples[-HISTORY_CAP:],
|
||||||
|
"tokenCapable": self.token_getter is not None,
|
||||||
|
"sampleSec": SAMPLE_SEC,
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
if mtype == "node_stats_reset":
|
||||||
|
self.samples = []
|
||||||
|
self._persist()
|
||||||
|
await send(ws, "node_stats_reset_done",
|
||||||
|
{"instanceId": self.instance_id, "node": self.node_name})
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
websockets>=12.0
|
websockets>=12.0
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
|
pyyaml>=6.0
|
||||||
|
|||||||
@@ -21,6 +21,6 @@ COPY requirements.txt .
|
|||||||
RUN printf 'torch==2.6.0\ntorchaudio==2.6.0\n' > /tmp/torch-constraint.txt && \
|
RUN printf 'torch==2.6.0\ntorchaudio==2.6.0\n' > /tmp/torch-constraint.txt && \
|
||||||
pip3 install --no-cache-dir -c /tmp/torch-constraint.txt -r requirements.txt
|
pip3 install --no-cache-dir -c /tmp/torch-constraint.txt -r requirements.txt
|
||||||
|
|
||||||
COPY bridge.py speaker_id.py ./
|
COPY bridge.py speaker_id.py node_stats.py ./
|
||||||
|
|
||||||
CMD ["python3", "bridge.py"]
|
CMD ["python3", "bridge.py"]
|
||||||
|
|||||||
+115
-2
@@ -58,6 +58,25 @@ VOXTRAL_MODEL = os.getenv("VOXTRAL_MODEL", "mistralai/Voxtral-Mini-3B-2507")
|
|||||||
VOXTRAL_LANGUAGE = os.getenv("VOXTRAL_LANGUAGE", "de")
|
VOXTRAL_LANGUAGE = os.getenv("VOXTRAL_LANGUAGE", "de")
|
||||||
VOXTRAL_DEVICE = os.getenv("VOXTRAL_DEVICE", "cuda")
|
VOXTRAL_DEVICE = os.getenv("VOXTRAL_DEVICE", "cuda")
|
||||||
|
|
||||||
|
# ── Compute-Fleet: Worker-Identitaet & Registrierung ──────────────
|
||||||
|
# Jeder Node meldet sich bei der aria-bridge (worker_hello) und haelt die
|
||||||
|
# Registry per periodischem worker_ping frisch. INSTANCE_ID adressiert diesen
|
||||||
|
# Worker bei Redundanz (targetInstance-Routing, Stage 3).
|
||||||
|
NODE_NAME = os.getenv("NODE_NAME", "node").strip() or "node"
|
||||||
|
GPU_IDS = os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip()
|
||||||
|
WORKER_SERVICE = "voxtral"
|
||||||
|
INSTANCE_ID = f"{WORKER_SERVICE}@{NODE_NAME}"
|
||||||
|
WORKER_PING_INTERVAL_S = int(os.getenv("WORKER_PING_INTERVAL_S", "10"))
|
||||||
|
# Empfangs-Watchdog: kommt in RX_STALE_S kein Broadcast rein (ein echter Raum hat
|
||||||
|
# staendig Traffic, z.B. sat_hello alle 25s / Brain-Polling), gilt die Verbindung
|
||||||
|
# als halb-tot (Caddy pongt die WS-Pings selbst) -> Zwangs-Reconnect.
|
||||||
|
RX_STALE_S = int(os.getenv("RX_STALE_S", "60"))
|
||||||
|
|
||||||
|
# ── Auslastungs-Monitor (Stage E) ──────────────────────────
|
||||||
|
import node_stats
|
||||||
|
STATS_PATH = os.getenv("STATS_PATH", f"/root/.cache/huggingface/aria_stats_{WORKER_SERVICE}.json")
|
||||||
|
_stats = node_stats.NodeStats(INSTANCE_ID, NODE_NAME, STATS_PATH, logger=logger)
|
||||||
|
|
||||||
STREAM_TRANSCRIBE_INTERVAL_MS = int(os.getenv("STREAM_TRANSCRIBE_INTERVAL_MS", "1000"))
|
STREAM_TRANSCRIBE_INTERVAL_MS = int(os.getenv("STREAM_TRANSCRIBE_INTERVAL_MS", "1000"))
|
||||||
STREAM_DEFAULT_ENDPOINT_MS = 2400
|
STREAM_DEFAULT_ENDPOINT_MS = 2400
|
||||||
STREAM_DEFAULT_HARD_CAP_MS = 300000
|
STREAM_DEFAULT_HARD_CAP_MS = 300000
|
||||||
@@ -126,6 +145,11 @@ SILERO_VAD_ENABLED = os.getenv("SILERO_VAD_ENABLED", "true").lower() in ("1", "t
|
|||||||
SILERO_VAD_THRESHOLD = float(os.getenv("SILERO_VAD_THRESHOLD", "0.5"))
|
SILERO_VAD_THRESHOLD = float(os.getenv("SILERO_VAD_THRESHOLD", "0.5"))
|
||||||
SILERO_MIN_SPEECH_MS = int(os.getenv("SILERO_MIN_SPEECH_MS", "150"))
|
SILERO_MIN_SPEECH_MS = int(os.getenv("SILERO_MIN_SPEECH_MS", "150"))
|
||||||
SILERO_PAD_MS = int(os.getenv("SILERO_PAD_MS", "200"))
|
SILERO_PAD_MS = int(os.getenv("SILERO_PAD_MS", "200"))
|
||||||
|
# Speech-Endpoint gegen laute Umgebungsmusik: der RMS-Stille-Endpoint feuert bei
|
||||||
|
# durchgehender Musik NIE (Energie bleibt oben). Deshalb waehrend lauter Phasen
|
||||||
|
# periodisch (alle X ms) mit Silero pruefen, ob im letzten endpoint_ms-Fenster
|
||||||
|
# ueberhaupt noch Sprache ist — wenn nicht (nur Musik/Stille), Turn beenden.
|
||||||
|
STREAM_SPEECH_ENDPOINT_CHECK_MS = int(os.getenv("STREAM_SPEECH_ENDPOINT_CHECK_MS", "700"))
|
||||||
|
|
||||||
_vad_state = {"model": None, "get_ts": None, "failed": False}
|
_vad_state = {"model": None, "get_ts": None, "failed": False}
|
||||||
|
|
||||||
@@ -281,6 +305,7 @@ class StreamSession:
|
|||||||
last_growth_at: float = 0.0
|
last_growth_at: float = 0.0
|
||||||
last_transcribe_at: float = 0.0
|
last_transcribe_at: float = 0.0
|
||||||
last_voice_at: float = 0.0
|
last_voice_at: float = 0.0
|
||||||
|
last_speech_check_at: float = 0.0 # Drossel fuer den Silero-Speech-Endpoint
|
||||||
noise_floor: float = 0.0
|
noise_floor: float = 0.0
|
||||||
closed: bool = False
|
closed: bool = False
|
||||||
endpoint_sent: bool = False
|
endpoint_sent: bool = False
|
||||||
@@ -506,6 +531,26 @@ class SessionManager:
|
|||||||
"audioRequestId": sess.audio_request_id,
|
"audioRequestId": sess.audio_request_id,
|
||||||
"text": "",
|
"text": "",
|
||||||
})
|
})
|
||||||
|
# Speech-Endpoint gegen laute Umgebungsmusik: es ist gerade laut (rms
|
||||||
|
# ueber Schwelle) — aber ist es Sprache oder Musik? Der RMS-Endpoint
|
||||||
|
# unten wuerde bei Musik NIE feuern (last_voice_at bleibt frisch).
|
||||||
|
# Deshalb gedrosselt mit Silero das letzte endpoint_ms-Fenster pruefen:
|
||||||
|
# KEINE Sprache drin (nur Musik) → Turn ist zu Ende. Real gesprochene
|
||||||
|
# Turns haben Sprache im Fenster → laufen weiter.
|
||||||
|
if (self._buffer_ms(sess) >= sess.endpoint_ms
|
||||||
|
and (now - sess.last_speech_check_at) * 1000.0 >= STREAM_SPEECH_ENDPOINT_CHECK_MS):
|
||||||
|
sess.last_speech_check_at = now
|
||||||
|
try:
|
||||||
|
tail_bytes = int(sess.endpoint_ms / 1000.0 * sess.sample_rate) * 2
|
||||||
|
tail = pcm_s16le_to_float32(bytes(sess.pcm_buffer[-tail_bytes:]))
|
||||||
|
segs = _speech_segments(tail)
|
||||||
|
if segs is not None and len(segs) == 0:
|
||||||
|
logger.info("Stream %s: Speech-Endpoint (keine Sprache im letzten %dms — Musik/Stille) → finalize",
|
||||||
|
sess.request_id[:8], sess.endpoint_ms)
|
||||||
|
await self._finalize(sess, "endpoint")
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Speech-Endpoint-Check fehlgeschlagen — ignoriert")
|
||||||
else:
|
else:
|
||||||
self._update_noise_floor(sess, rms)
|
self._update_noise_floor(sess, rms)
|
||||||
# No-Speech-Timeout: wurde die GANZE Zeit KEINE Stimme erkannt
|
# No-Speech-Timeout: wurde die GANZE Zeit KEINE Stimme erkannt
|
||||||
@@ -669,13 +714,35 @@ async def _broadcast_status(ws, state: str, **extra) -> None:
|
|||||||
await _send(ws, "service_status", payload)
|
await _send(ws, "service_status", payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def _worker_register(ws, *, model: str = "", busy_fn=None) -> None:
|
||||||
|
"""Meldet diesen Worker bei der aria-bridge an (worker_hello) und haelt die
|
||||||
|
Flotten-Registry per periodischem worker_ping (mit busy-Status) frisch."""
|
||||||
|
def _hello():
|
||||||
|
return {"instanceId": INSTANCE_ID, "service": WORKER_SERVICE,
|
||||||
|
"node": NODE_NAME, "gpus": GPU_IDS, "model": model}
|
||||||
|
try:
|
||||||
|
await _send(ws, "worker_hello", _hello())
|
||||||
|
n = 0
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(WORKER_PING_INTERVAL_S)
|
||||||
|
n += 1
|
||||||
|
busy = bool(busy_fn()) if busy_fn else False
|
||||||
|
await _send(ws, "worker_ping", {"instanceId": INSTANCE_ID, "busy": busy})
|
||||||
|
if n % 3 == 0: # ~30s worker_hello wiederholen (wie der Satellit)
|
||||||
|
await _send(ws, "worker_hello", _hello())
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
return # Socket tot → still beenden; run_loop reconnectet + startet neu
|
||||||
|
|
||||||
|
|
||||||
async def run_loop(sessions: SessionManager) -> None:
|
async def run_loop(sessions: SessionManager) -> None:
|
||||||
use_tls = RVS_TLS
|
use_tls = RVS_TLS
|
||||||
retry_s = 2
|
retry_s = 2
|
||||||
tls_fallback_tried = False
|
tls_fallback_tried = False
|
||||||
while True:
|
while True:
|
||||||
scheme = "wss" if use_tls else "ws"
|
scheme = "wss" if use_tls else "ws"
|
||||||
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
|
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||||
try:
|
try:
|
||||||
logger.info("Verbinde zu RVS: %s", masked)
|
logger.info("Verbinde zu RVS: %s", masked)
|
||||||
@@ -687,19 +754,60 @@ async def run_loop(sessions: SessionManager) -> None:
|
|||||||
sessions.attach_ws(ws)
|
sessions.attach_ws(ws)
|
||||||
await _broadcast_status(ws, "ready", model=VOXTRAL_MODEL)
|
await _broadcast_status(ws, "ready", model=VOXTRAL_MODEL)
|
||||||
await _send(ws, "config_request", {"service": "voxtral"})
|
await _send(ws, "config_request", {"service": "voxtral"})
|
||||||
async for raw in ws:
|
ping_task = asyncio.create_task(_worker_register(
|
||||||
|
ws, model=VOXTRAL_MODEL,
|
||||||
|
busy_fn=lambda: bool(sessions._sessions)))
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
raw = await asyncio.wait_for(ws.recv(), timeout=RX_STALE_S)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("Kein RVS-Traffic seit %ds — Verbindung halb-tot, reconnect", RX_STALE_S)
|
||||||
|
raise ConnectionError("rvs-stale")
|
||||||
try:
|
try:
|
||||||
msg = json.loads(raw)
|
msg = json.loads(raw)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
mtype = msg.get("type", "")
|
mtype = msg.get("type", "")
|
||||||
payload = msg.get("payload", {}) or {}
|
payload = msg.get("payload", {}) or {}
|
||||||
|
# Redundanz-Routing: ist die Anfrage gezielt an eine andere
|
||||||
|
# Instanz adressiert, ignorieren. Ohne targetInstance (Feld
|
||||||
|
# fehlt) → wie bisher, jeder Worker nimmt sie an.
|
||||||
|
tgt = payload.get("targetInstance")
|
||||||
|
if tgt and tgt != INSTANCE_ID:
|
||||||
|
continue
|
||||||
|
# Auslastungs-Monitor (node_stats_*) abfangen.
|
||||||
|
if await _stats.handle(ws, mtype, payload, _send):
|
||||||
|
continue
|
||||||
if mtype == "stt_stream_start":
|
if mtype == "stt_stream_start":
|
||||||
sessions.start_session(payload)
|
sessions.start_session(payload)
|
||||||
elif mtype == "stt_audio_chunk":
|
elif mtype == "stt_audio_chunk":
|
||||||
sessions.feed_chunk(payload)
|
sessions.feed_chunk(payload)
|
||||||
elif mtype == "stt_stream_end":
|
elif mtype == "stt_stream_end":
|
||||||
sessions.end_session(payload.get("requestId", ""))
|
sessions.end_session(payload.get("requestId", ""))
|
||||||
|
elif mtype == "stt_transcribe_blob":
|
||||||
|
# One-Shot-Transkription eines PCM-Schnipsels (kein Live-
|
||||||
|
# Stream) — fuer die Wake-Wort-Bestaetigung: die App schickt
|
||||||
|
# den Vor-Trigger-Audio, wir sagen was gesagt wurde, die App
|
||||||
|
# prueft ob "Computer" drin ist. Silero vorgeschaltet:
|
||||||
|
# Musik/Rauschen → leerer Text (nicht bestaetigt).
|
||||||
|
req_id = payload.get("requestId", "")
|
||||||
|
try:
|
||||||
|
pcm = base64.b64decode(payload.get("pcm", ""))
|
||||||
|
audio = pcm_s16le_to_float32(pcm)
|
||||||
|
segs = _speech_segments(audio)
|
||||||
|
if segs is not None and len(segs) == 0:
|
||||||
|
text = ""
|
||||||
|
else:
|
||||||
|
text = (await sessions.runner.transcribe(
|
||||||
|
audio, payload.get("language", "de"))).strip()
|
||||||
|
logger.info("stt_transcribe_blob (%.1fs) → %r",
|
||||||
|
audio.size / 16000.0, text[:60])
|
||||||
|
await _send(ws, "stt_transcribe_result",
|
||||||
|
{"requestId": req_id, "text": text})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("stt_transcribe_blob fehlgeschlagen: %s", exc)
|
||||||
|
await _send(ws, "stt_transcribe_result",
|
||||||
|
{"requestId": req_id, "text": "", "error": str(exc)[:200]})
|
||||||
elif mtype == "voice_id_status_request":
|
elif mtype == "voice_id_status_request":
|
||||||
req_id = payload.get("requestId", "")
|
req_id = payload.get("requestId", "")
|
||||||
try:
|
try:
|
||||||
@@ -747,6 +855,10 @@ async def run_loop(sessions: SessionManager) -> None:
|
|||||||
"AN" if SPEAKER_ID_ENABLED else "AUS")
|
"AN" if SPEAKER_ID_ENABLED else "AUS")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("RVS-Verbindung verloren: %s — retry in %ds", e, retry_s)
|
logger.warning("RVS-Verbindung verloren: %s — retry in %ds", e, retry_s)
|
||||||
|
try:
|
||||||
|
ping_task.cancel()
|
||||||
|
except NameError:
|
||||||
|
pass
|
||||||
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
||||||
use_tls = False
|
use_tls = False
|
||||||
tls_fallback_tried = True
|
tls_fallback_tried = True
|
||||||
@@ -765,6 +877,7 @@ async def main() -> None:
|
|||||||
await loop.run_in_executor(None, runner.load) # Modell laden (blockierend)
|
await loop.run_in_executor(None, runner.load) # Modell laden (blockierend)
|
||||||
sessions = SessionManager(runner)
|
sessions = SessionManager(runner)
|
||||||
logger.info("Voxtral-Bridge startet — Modell=%s", VOXTRAL_MODEL)
|
logger.info("Voxtral-Bridge startet — Modell=%s", VOXTRAL_MODEL)
|
||||||
|
asyncio.create_task(_stats.run_sampler()) # Auslastungs-Sampler (Stage E)
|
||||||
await asyncio.gather(run_loop(sessions), sessions.run_endpointer())
|
await asyncio.gather(run_loop(sessions), sessions.run_endpointer())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""
|
||||||
|
ARIA Node-Stats — Auslastungs-Monitor pro Box (GPU + optional Tokens).
|
||||||
|
|
||||||
|
Identische Kopie in jedem Worker-Build-Context (f5tts/whisper/voxtral/llm-adapter),
|
||||||
|
weil jeder Worker ein eigener Docker-Build-Context ist.
|
||||||
|
|
||||||
|
Aufgaben:
|
||||||
|
- Sampler-Loop (alle SAMPLE_SEC): nvidia-smi-Auslastung + Token-Delta → Ringpuffer
|
||||||
|
(persistent als JSON auf der Box). Laeuft unabhaengig vom Modal.
|
||||||
|
- Live-Stream: bei node_stats_stream_start jede Sekunde rohes nvidia-smi + Werte
|
||||||
|
senden (bis stop / Auto-Timeout).
|
||||||
|
- History-Request + Reset (Besen).
|
||||||
|
|
||||||
|
Reicht `handle(ws, mtype, payload)` in die Worker-Message-Loop ein; gibt True
|
||||||
|
zurueck, wenn die Nachricht eine node_stats_*-Nachricht war.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
SAMPLE_SEC = int(os.getenv("STATS_SAMPLE_SEC", "15"))
|
||||||
|
HISTORY_CAP = int(os.getenv("STATS_HISTORY_CAP", "500")) # ~2h bei 15s
|
||||||
|
STREAM_MAX_SEC = int(os.getenv("STATS_STREAM_MAX_SEC", "300"))
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_cmd(*args, timeout=8) -> str:
|
||||||
|
"""Fuehrt ein Kommando aus, gibt stdout (str) zurueck; '' bei Fehler."""
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*args,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||||
|
return (out or b"").decode("utf-8", "replace")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class NodeStats:
|
||||||
|
def __init__(self, instance_id: str, node_name: str, history_path: str,
|
||||||
|
token_getter=None, logger=None):
|
||||||
|
self.instance_id = instance_id
|
||||||
|
self.node_name = node_name
|
||||||
|
self.history_path = history_path
|
||||||
|
self.token_getter = token_getter # callable -> kumulative Token-Zahl (oder None)
|
||||||
|
self.log = logger
|
||||||
|
self.samples = self._load()
|
||||||
|
self._last_tokens = self._tokens_now()
|
||||||
|
self._stream_task = None
|
||||||
|
|
||||||
|
# ── Persistenz ──────────────────────────────────────────
|
||||||
|
def _load(self) -> list:
|
||||||
|
try:
|
||||||
|
with open(self.history_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _persist(self) -> None:
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(self.history_path) or ".", exist_ok=True)
|
||||||
|
tmp = self.history_path + ".tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
json.dump(self.samples[-HISTORY_CAP:], f)
|
||||||
|
os.replace(tmp, self.history_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _tokens_now(self) -> int:
|
||||||
|
try:
|
||||||
|
return int(self.token_getter()) if self.token_getter else 0
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# ── nvidia-smi ──────────────────────────────────────────
|
||||||
|
async def _query_gpu(self) -> dict:
|
||||||
|
"""Aggregierte GPU-Werte ueber alle sichtbaren Karten."""
|
||||||
|
out = await _run_cmd(
|
||||||
|
"nvidia-smi",
|
||||||
|
"--query-gpu=utilization.gpu,memory.used,memory.total",
|
||||||
|
"--format=csv,noheader,nounits")
|
||||||
|
utils, used, total = [], 0, 0
|
||||||
|
for line in out.strip().splitlines():
|
||||||
|
parts = [p.strip() for p in line.split(",")]
|
||||||
|
if len(parts) < 3:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
utils.append(float(parts[0]))
|
||||||
|
used += float(parts[1])
|
||||||
|
total += float(parts[2])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
|
||||||
|
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
|
||||||
|
|
||||||
|
async def _nvidia_smi_text(self) -> str:
|
||||||
|
txt = await _run_cmd("nvidia-smi")
|
||||||
|
return txt or "nvidia-smi nicht verfuegbar"
|
||||||
|
|
||||||
|
# ── Sampler (Verlauf) ───────────────────────────────────
|
||||||
|
async def run_sampler(self) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
g = await self._query_gpu()
|
||||||
|
now_tok = self._tokens_now()
|
||||||
|
dtok = max(0, now_tok - self._last_tokens)
|
||||||
|
self._last_tokens = now_tok
|
||||||
|
self.samples.append({
|
||||||
|
"ts": int(time.time()),
|
||||||
|
"gpu": g["gpu"], "memUsed": g["memUsed"],
|
||||||
|
"memTotal": g["memTotal"], "tokens": dtok,
|
||||||
|
})
|
||||||
|
if len(self.samples) > HISTORY_CAP:
|
||||||
|
self.samples = self.samples[-HISTORY_CAP:]
|
||||||
|
self._persist()
|
||||||
|
except Exception as e:
|
||||||
|
if self.log:
|
||||||
|
self.log.debug("node_stats sample fehlgeschlagen: %s", e)
|
||||||
|
await asyncio.sleep(SAMPLE_SEC)
|
||||||
|
|
||||||
|
# ── Live-Stream ─────────────────────────────────────────
|
||||||
|
async def _stream(self, ws, send) -> None:
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
while time.time() - t0 < STREAM_MAX_SEC:
|
||||||
|
g = await self._query_gpu()
|
||||||
|
smi = await self._nvidia_smi_text()
|
||||||
|
await send(ws, "node_stats", {
|
||||||
|
"instanceId": self.instance_id, "node": self.node_name,
|
||||||
|
"nvidiaSmi": smi, **g,
|
||||||
|
})
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Dispatch ────────────────────────────────────────────
|
||||||
|
async def handle(self, ws, mtype: str, payload: dict, send) -> bool:
|
||||||
|
if mtype == "node_stats_stream_start":
|
||||||
|
if self._stream_task and not self._stream_task.done():
|
||||||
|
self._stream_task.cancel()
|
||||||
|
self._stream_task = asyncio.create_task(self._stream(ws, send))
|
||||||
|
return True
|
||||||
|
if mtype == "node_stats_stream_stop":
|
||||||
|
if self._stream_task:
|
||||||
|
self._stream_task.cancel()
|
||||||
|
self._stream_task = None
|
||||||
|
return True
|
||||||
|
if mtype == "node_stats_history_request":
|
||||||
|
await send(ws, "node_stats_history", {
|
||||||
|
"instanceId": self.instance_id, "node": self.node_name,
|
||||||
|
"samples": self.samples[-HISTORY_CAP:],
|
||||||
|
"tokenCapable": self.token_getter is not None,
|
||||||
|
"sampleSec": SAMPLE_SEC,
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
if mtype == "node_stats_reset":
|
||||||
|
self.samples = []
|
||||||
|
self._persist()
|
||||||
|
await send(ws, "node_stats_reset_done",
|
||||||
|
{"instanceId": self.instance_id, "node": self.node_name})
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -17,6 +17,6 @@ RUN pip3 install --no-cache-dir torch==2.3.1 torchaudio==2.3.1 \
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip3 install --no-cache-dir -r requirements.txt
|
RUN pip3 install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY bridge.py speaker_id.py ./
|
COPY bridge.py speaker_id.py node_stats.py ./
|
||||||
|
|
||||||
CMD ["python3", "bridge.py"]
|
CMD ["python3", "bridge.py"]
|
||||||
|
|||||||
+67
-5
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
ARIA Whisper Bridge — laeuft auf der Gamebox (RTX 3060).
|
ARIA Whisper Bridge — laeuft auf der AI-Box (RTX 3060).
|
||||||
|
|
||||||
Zwei Modi:
|
Zwei Modi:
|
||||||
|
|
||||||
@@ -59,6 +59,23 @@ WHISPER_DEVICE = os.getenv("WHISPER_DEVICE", "cuda")
|
|||||||
WHISPER_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "float16")
|
WHISPER_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "float16")
|
||||||
WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "de")
|
WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "de")
|
||||||
|
|
||||||
|
# ── Compute-Fleet: Worker-Identitaet & Registrierung ──────────────
|
||||||
|
# Meldet sich bei der aria-bridge (worker_hello) + periodischer worker_ping.
|
||||||
|
NODE_NAME = os.getenv("NODE_NAME", "node").strip() or "node"
|
||||||
|
GPU_IDS = os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip()
|
||||||
|
WORKER_SERVICE = "whisper"
|
||||||
|
INSTANCE_ID = f"{WORKER_SERVICE}@{NODE_NAME}"
|
||||||
|
WORKER_PING_INTERVAL_S = int(os.getenv("WORKER_PING_INTERVAL_S", "10"))
|
||||||
|
# Empfangs-Watchdog: kommt in RX_STALE_S kein Broadcast rein (ein echter Raum hat
|
||||||
|
# staendig Traffic, z.B. sat_hello alle 25s / Brain-Polling), gilt die Verbindung
|
||||||
|
# als halb-tot (Caddy pongt die WS-Pings selbst) -> Zwangs-Reconnect.
|
||||||
|
RX_STALE_S = int(os.getenv("RX_STALE_S", "60"))
|
||||||
|
|
||||||
|
# ── Auslastungs-Monitor (Stage E) ──────────────────────────
|
||||||
|
import node_stats
|
||||||
|
STATS_PATH = os.getenv("STATS_PATH", f"/root/.cache/huggingface/aria_stats_{WORKER_SERVICE}.json")
|
||||||
|
_stats = node_stats.NodeStats(INSTANCE_ID, NODE_NAME, STATS_PATH, logger=logger)
|
||||||
|
|
||||||
ALLOWED_MODELS = {"tiny", "base", "small", "medium", "large-v3"}
|
ALLOWED_MODELS = {"tiny", "base", "small", "medium", "large-v3"}
|
||||||
|
|
||||||
# Streaming-Parameter (Defaults — koennen pro Session vom App-Payload ueberschrieben werden)
|
# Streaming-Parameter (Defaults — koennen pro Session vom App-Payload ueberschrieben werden)
|
||||||
@@ -284,7 +301,7 @@ async def _send(ws, mtype: str, payload: dict) -> None:
|
|||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
# DEBUG-LOG ueber RVS → /shared/logs/app.log
|
# DEBUG-LOG ueber RVS → /shared/logs/app.log
|
||||||
#
|
#
|
||||||
# Stefan's Gamebox ist Windows, kein SSH → wir brauchen Whisper-Bridge-
|
# Stefan's AI-Box ist Windows, kein SSH → wir brauchen Whisper-Bridge-
|
||||||
# Logs ueber den gleichen Pfad wie die App: app_log-Messages via RVS,
|
# Logs ueber den gleichen Pfad wie die App: app_log-Messages via RVS,
|
||||||
# aria-bridge schreibt sie in /shared/logs/app.log. Diagnostic / App-
|
# aria-bridge schreibt sie in /shared/logs/app.log. Diagnostic / App-
|
||||||
# Logs-Tab zeigen sie dann mit platform="whisper".
|
# Logs-Tab zeigen sie dann mit platform="whisper".
|
||||||
@@ -822,6 +839,30 @@ async def _broadcast_status(ws, state: str, **extra) -> None:
|
|||||||
await _send(ws, "service_status", payload)
|
await _send(ws, "service_status", payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def _worker_register(ws, *, model: str = "", busy_fn=None) -> None:
|
||||||
|
"""Meldet diesen Worker bei der aria-bridge an (worker_hello) und haelt die
|
||||||
|
Flotten-Registry per periodischem worker_ping frisch. worker_hello wird alle
|
||||||
|
~30s WIEDERHOLT (wie der Satellit), damit ein neu gestartetes Diagnostic/
|
||||||
|
Bridge uns lernt — RVS spielt hellos nicht nach."""
|
||||||
|
def _hello():
|
||||||
|
return {"instanceId": INSTANCE_ID, "service": WORKER_SERVICE,
|
||||||
|
"node": NODE_NAME, "gpus": GPU_IDS, "model": model}
|
||||||
|
try:
|
||||||
|
await _send(ws, "worker_hello", _hello())
|
||||||
|
n = 0
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(WORKER_PING_INTERVAL_S)
|
||||||
|
n += 1
|
||||||
|
busy = bool(busy_fn()) if busy_fn else False
|
||||||
|
await _send(ws, "worker_ping", {"instanceId": INSTANCE_ID, "busy": busy})
|
||||||
|
if n % 3 == 0:
|
||||||
|
await _send(ws, "worker_hello", _hello())
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
return # Socket tot → still beenden; run_loop reconnectet + startet neu
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
# WS-LOOP
|
# WS-LOOP
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
@@ -833,7 +874,7 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
scheme = "wss" if use_tls else "ws"
|
scheme = "wss" if use_tls else "ws"
|
||||||
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
|
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||||
try:
|
try:
|
||||||
logger.info("Verbinde zu RVS: %s", masked)
|
logger.info("Verbinde zu RVS: %s", masked)
|
||||||
@@ -855,21 +896,37 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
|
|||||||
logger.info("Initial: sende config_request an aria-bridge")
|
logger.info("Initial: sende config_request an aria-bridge")
|
||||||
await _send(ws, "config_request", {"service": "whisper"})
|
await _send(ws, "config_request", {"service": "whisper"})
|
||||||
# Startup-Marker — App-Logs zeigen damit ob Streaming-Code
|
# Startup-Marker — App-Logs zeigen damit ob Streaming-Code
|
||||||
# ueberhaupt aktiv ist (Stefan baut auf Gamebox via PS,
|
# ueberhaupt aktiv ist (Stefan baut auf AI-Box via PS,
|
||||||
# Build/Restart kann unbeabsichtigt alte Version weiterfahren).
|
# Build/Restart kann unbeabsichtigt alte Version weiterfahren).
|
||||||
await _debug_log(ws, "boot",
|
await _debug_log(ws, "boot",
|
||||||
"whisper-bridge online — streaming-mode ENABLED, debug-log ON")
|
"whisper-bridge online — streaming-mode ENABLED, debug-log ON")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Initial-Handshake crashed: %s", e)
|
logger.exception("Initial-Handshake crashed: %s", e)
|
||||||
asyncio.create_task(_initial_handshake())
|
asyncio.create_task(_initial_handshake())
|
||||||
|
ping_task = asyncio.create_task(_worker_register(
|
||||||
|
ws, model=(runner.model_size or WHISPER_MODEL),
|
||||||
|
busy_fn=lambda: bool(sessions._sessions)))
|
||||||
|
|
||||||
async for raw in ws:
|
while True:
|
||||||
|
try:
|
||||||
|
raw = await asyncio.wait_for(ws.recv(), timeout=RX_STALE_S)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("Kein RVS-Traffic seit %ds — Verbindung halb-tot, reconnect", RX_STALE_S)
|
||||||
|
raise ConnectionError("rvs-stale")
|
||||||
try:
|
try:
|
||||||
msg = json.loads(raw)
|
msg = json.loads(raw)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
mtype = msg.get("type", "")
|
mtype = msg.get("type", "")
|
||||||
payload = msg.get("payload", {}) or {}
|
payload = msg.get("payload", {}) or {}
|
||||||
|
# Redundanz-Routing: gezielt an eine andere Instanz adressiert
|
||||||
|
# → ignorieren. Ohne targetInstance → wie bisher.
|
||||||
|
tgt = payload.get("targetInstance")
|
||||||
|
if tgt and tgt != INSTANCE_ID:
|
||||||
|
continue
|
||||||
|
# Auslastungs-Monitor (node_stats_*) abfangen.
|
||||||
|
if await _stats.handle(ws, mtype, payload, _send):
|
||||||
|
continue
|
||||||
|
|
||||||
if mtype == "stt_request":
|
if mtype == "stt_request":
|
||||||
req_id = payload.get("requestId", "?")
|
req_id = payload.get("requestId", "?")
|
||||||
@@ -1038,6 +1095,10 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Verbindung verloren: %s", e)
|
logger.warning("Verbindung verloren: %s", e)
|
||||||
sessions.detach_ws()
|
sessions.detach_ws()
|
||||||
|
try:
|
||||||
|
ping_task.cancel()
|
||||||
|
except NameError:
|
||||||
|
pass
|
||||||
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
||||||
logger.info("TLS-Verbindung fehlgeschlagen — Fallback auf ws://")
|
logger.info("TLS-Verbindung fehlgeschlagen — Fallback auf ws://")
|
||||||
use_tls = False
|
use_tls = False
|
||||||
@@ -1058,6 +1119,7 @@ async def main() -> None:
|
|||||||
# Endpointer-Loop nebenbei laufen lassen — er pruefst _ws is None und
|
# Endpointer-Loop nebenbei laufen lassen — er pruefst _ws is None und
|
||||||
# schlaeft solange das nicht gesetzt ist.
|
# schlaeft solange das nicht gesetzt ist.
|
||||||
asyncio.create_task(sessions.run_endpointer())
|
asyncio.create_task(sessions.run_endpointer())
|
||||||
|
asyncio.create_task(_stats.run_sampler()) # Auslastungs-Sampler (Stage E)
|
||||||
await run_loop(runner, sessions)
|
await run_loop(runner, sessions)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""
|
||||||
|
ARIA Node-Stats — Auslastungs-Monitor pro Box (GPU + optional Tokens).
|
||||||
|
|
||||||
|
Identische Kopie in jedem Worker-Build-Context (f5tts/whisper/voxtral/llm-adapter),
|
||||||
|
weil jeder Worker ein eigener Docker-Build-Context ist.
|
||||||
|
|
||||||
|
Aufgaben:
|
||||||
|
- Sampler-Loop (alle SAMPLE_SEC): nvidia-smi-Auslastung + Token-Delta → Ringpuffer
|
||||||
|
(persistent als JSON auf der Box). Laeuft unabhaengig vom Modal.
|
||||||
|
- Live-Stream: bei node_stats_stream_start jede Sekunde rohes nvidia-smi + Werte
|
||||||
|
senden (bis stop / Auto-Timeout).
|
||||||
|
- History-Request + Reset (Besen).
|
||||||
|
|
||||||
|
Reicht `handle(ws, mtype, payload)` in die Worker-Message-Loop ein; gibt True
|
||||||
|
zurueck, wenn die Nachricht eine node_stats_*-Nachricht war.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
SAMPLE_SEC = int(os.getenv("STATS_SAMPLE_SEC", "15"))
|
||||||
|
HISTORY_CAP = int(os.getenv("STATS_HISTORY_CAP", "500")) # ~2h bei 15s
|
||||||
|
STREAM_MAX_SEC = int(os.getenv("STATS_STREAM_MAX_SEC", "300"))
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_cmd(*args, timeout=8) -> str:
|
||||||
|
"""Fuehrt ein Kommando aus, gibt stdout (str) zurueck; '' bei Fehler."""
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*args,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||||
|
return (out or b"").decode("utf-8", "replace")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class NodeStats:
|
||||||
|
def __init__(self, instance_id: str, node_name: str, history_path: str,
|
||||||
|
token_getter=None, logger=None):
|
||||||
|
self.instance_id = instance_id
|
||||||
|
self.node_name = node_name
|
||||||
|
self.history_path = history_path
|
||||||
|
self.token_getter = token_getter # callable -> kumulative Token-Zahl (oder None)
|
||||||
|
self.log = logger
|
||||||
|
self.samples = self._load()
|
||||||
|
self._last_tokens = self._tokens_now()
|
||||||
|
self._stream_task = None
|
||||||
|
|
||||||
|
# ── Persistenz ──────────────────────────────────────────
|
||||||
|
def _load(self) -> list:
|
||||||
|
try:
|
||||||
|
with open(self.history_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _persist(self) -> None:
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(self.history_path) or ".", exist_ok=True)
|
||||||
|
tmp = self.history_path + ".tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
json.dump(self.samples[-HISTORY_CAP:], f)
|
||||||
|
os.replace(tmp, self.history_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _tokens_now(self) -> int:
|
||||||
|
try:
|
||||||
|
return int(self.token_getter()) if self.token_getter else 0
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# ── nvidia-smi ──────────────────────────────────────────
|
||||||
|
async def _query_gpu(self) -> dict:
|
||||||
|
"""Aggregierte GPU-Werte ueber alle sichtbaren Karten."""
|
||||||
|
out = await _run_cmd(
|
||||||
|
"nvidia-smi",
|
||||||
|
"--query-gpu=utilization.gpu,memory.used,memory.total",
|
||||||
|
"--format=csv,noheader,nounits")
|
||||||
|
utils, used, total = [], 0, 0
|
||||||
|
for line in out.strip().splitlines():
|
||||||
|
parts = [p.strip() for p in line.split(",")]
|
||||||
|
if len(parts) < 3:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
utils.append(float(parts[0]))
|
||||||
|
used += float(parts[1])
|
||||||
|
total += float(parts[2])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
|
||||||
|
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
|
||||||
|
|
||||||
|
async def _nvidia_smi_text(self) -> str:
|
||||||
|
txt = await _run_cmd("nvidia-smi")
|
||||||
|
return txt or "nvidia-smi nicht verfuegbar"
|
||||||
|
|
||||||
|
# ── Sampler (Verlauf) ───────────────────────────────────
|
||||||
|
async def run_sampler(self) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
g = await self._query_gpu()
|
||||||
|
now_tok = self._tokens_now()
|
||||||
|
dtok = max(0, now_tok - self._last_tokens)
|
||||||
|
self._last_tokens = now_tok
|
||||||
|
self.samples.append({
|
||||||
|
"ts": int(time.time()),
|
||||||
|
"gpu": g["gpu"], "memUsed": g["memUsed"],
|
||||||
|
"memTotal": g["memTotal"], "tokens": dtok,
|
||||||
|
})
|
||||||
|
if len(self.samples) > HISTORY_CAP:
|
||||||
|
self.samples = self.samples[-HISTORY_CAP:]
|
||||||
|
self._persist()
|
||||||
|
except Exception as e:
|
||||||
|
if self.log:
|
||||||
|
self.log.debug("node_stats sample fehlgeschlagen: %s", e)
|
||||||
|
await asyncio.sleep(SAMPLE_SEC)
|
||||||
|
|
||||||
|
# ── Live-Stream ─────────────────────────────────────────
|
||||||
|
async def _stream(self, ws, send) -> None:
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
while time.time() - t0 < STREAM_MAX_SEC:
|
||||||
|
g = await self._query_gpu()
|
||||||
|
smi = await self._nvidia_smi_text()
|
||||||
|
await send(ws, "node_stats", {
|
||||||
|
"instanceId": self.instance_id, "node": self.node_name,
|
||||||
|
"nvidiaSmi": smi, **g,
|
||||||
|
})
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Dispatch ────────────────────────────────────────────
|
||||||
|
async def handle(self, ws, mtype: str, payload: dict, send) -> bool:
|
||||||
|
if mtype == "node_stats_stream_start":
|
||||||
|
if self._stream_task and not self._stream_task.done():
|
||||||
|
self._stream_task.cancel()
|
||||||
|
self._stream_task = asyncio.create_task(self._stream(ws, send))
|
||||||
|
return True
|
||||||
|
if mtype == "node_stats_stream_stop":
|
||||||
|
if self._stream_task:
|
||||||
|
self._stream_task.cancel()
|
||||||
|
self._stream_task = None
|
||||||
|
return True
|
||||||
|
if mtype == "node_stats_history_request":
|
||||||
|
await send(ws, "node_stats_history", {
|
||||||
|
"instanceId": self.instance_id, "node": self.node_name,
|
||||||
|
"samples": self.samples[-HISTORY_CAP:],
|
||||||
|
"tokenCapable": self.token_getter is not None,
|
||||||
|
"sampleSec": SAMPLE_SEC,
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
if mtype == "node_stats_reset":
|
||||||
|
self.samples = []
|
||||||
|
self._persist()
|
||||||
|
await send(ws, "node_stats_reset_done",
|
||||||
|
{"instanceId": self.instance_id, "node": self.node_name})
|
||||||
|
return True
|
||||||
|
return False
|
||||||
Reference in New Issue
Block a user