Compare commits
90
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f5234ee62 | ||
|
|
28e58089aa | ||
|
|
c1a90102f8 | ||
|
|
ba531adc74 | ||
|
|
04d29b256e | ||
|
|
e6e07f672a | ||
|
|
d32e7e59c3 | ||
|
|
4f8e65b76b | ||
|
|
a2605a2802 | ||
|
|
0588a8d9b3 | ||
|
|
be202b1fa1 | ||
|
|
1ad85fb687 | ||
|
|
558afee239 | ||
|
|
b2fd8d8953 | ||
|
|
737915e267 | ||
|
|
1140934e15 | ||
|
|
29bf3370b4 | ||
|
|
6b5d847615 | ||
|
|
145c64e67f | ||
|
|
6cb29a28ce | ||
|
|
df5cd3be99 | ||
|
|
4125ab8160 | ||
|
|
edd0b4823f | ||
|
|
d4e3a73e89 | ||
|
|
9d93c3fe3d | ||
|
|
39f7947da9 | ||
|
|
855cc0333e | ||
|
|
2cac1cad0a | ||
|
|
f89e7ac36d | ||
|
|
cb1f571ad0 | ||
|
|
179fe53143 | ||
|
|
b73e89e9bb | ||
|
|
2b4428d823 | ||
|
|
74fcf7fd03 | ||
|
|
c7761284c7 | ||
|
|
223692d4c7 | ||
|
|
a99ea32664 | ||
|
|
4e1ab95684 | ||
|
|
bb7a19f818 | ||
|
|
6ffbc77387 | ||
|
|
3893c89d5f | ||
|
|
9f741df10e | ||
|
|
22f5f5c954 | ||
|
|
e3f6b3626d | ||
|
|
67ea68514a | ||
|
|
d6cca88455 | ||
|
|
d0d06defb1 | ||
|
|
056637ab7a | ||
|
|
ee654a6f6c | ||
|
|
f1c042c60a | ||
|
|
042fd63d33 | ||
|
|
3a908d246a | ||
|
|
7f864bb431 | ||
|
|
3474d2eb6f | ||
|
|
3df35d2293 | ||
|
|
fc198f90da | ||
|
|
387b14a8ef | ||
|
|
18147eb24d | ||
|
|
8d3637079c | ||
|
|
23d104a7c3 | ||
|
|
51985f7c2e | ||
|
|
f854303e7b | ||
|
|
39426dc70d | ||
|
|
99e4b63a06 | ||
|
|
254031bb7d | ||
|
|
eab43f5ea2 | ||
|
|
f6f2b128c4 | ||
|
|
e39599ecea | ||
|
|
e255d7a659 | ||
|
|
ebd25a1cc6 | ||
|
|
633ed06adf | ||
|
|
4f68a2a411 | ||
|
|
5689474c03 | ||
|
|
70fbc164f4 | ||
|
|
603ce93197 | ||
|
|
7022cd3c24 | ||
|
|
daba5c0457 | ||
|
|
e96b97b1b9 | ||
|
|
fcd73d8171 | ||
|
|
d0b00d5c36 | ||
|
|
9d63a73135 | ||
|
|
9981f0523d | ||
|
|
31444f1442 | ||
|
|
5398640078 | ||
|
|
5a00441966 | ||
|
|
dd71b0ce39 | ||
|
|
da36580994 | ||
|
|
7fd945ad65 | ||
|
|
3267b9a3cc | ||
|
|
672d921227 |
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(ssh root@10.0.0.1 \"ls -la /root/ARIA-AGENT/aria-shared/logs/\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@
|
||||
# Werte pro Maschine selbst pflegen.
|
||||
.claude/*.env
|
||||
!.claude/*.env.example
|
||||
# Lokale Claude-Permission-Allowlist mit echten Server-IPs — nicht ins (oeffentliche) Repo.
|
||||
.claude/settings.json
|
||||
|
||||
# brain-import/ ist nur ein Drop-Folder: Stefan packt MDs rein wenn er
|
||||
# was migrieren will, klickt im Diagnostic „Migration aus brain-import/",
|
||||
|
||||
+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")
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -35,18 +35,17 @@ ARIA hat zwei Rollen:
|
||||
│ WebSocket Tunnel │ WebSocket Tunnel
|
||||
▼ ▼
|
||||
┌─────────────────────────────────┐
|
||||
│ Gamebox (Windows + WSL2) │
|
||||
│ RTX 3060, Docker Desktop │
|
||||
│ Compute-Node(s) (NVIDIA GPU) │
|
||||
│ beliebig viele, je per .env │
|
||||
│ konfiguriert (COMPOSE_PROFILES) │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ aria-f5tts-bridge │ │
|
||||
│ │ F5-TTS Voice Cloning │ │
|
||||
│ │ PCM-Streaming an die App │ │
|
||||
│ ├──────────────────────────┤ │
|
||||
│ │ aria-whisper-bridge │ │
|
||||
│ │ Faster-Whisper CUDA │ │
|
||||
│ │ STT in fast-Echtzeit │ │
|
||||
│ │ aria-voxtral-bridge │ │ Profil: voxtral (Default-STT)
|
||||
│ │ aria-f5tts-bridge │ │ Profil: f5tts (TTS)
|
||||
│ │ aria-llm-adapter+swap │ │ Profil: llm (lokales LLM)
|
||||
│ │ aria-whisper-bridge │ │ Profil: whisper (STT-Fallback)
|
||||
│ └──────────────────────────┘ │
|
||||
│ Beide teilen ./voices Volume │
|
||||
│ Aufteilbar: 1 Node pro Dienst │
|
||||
│ ODER All-in-One. GPU per *_GPU. │
|
||||
│ xtts/docker-compose.yml │
|
||||
└─────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
@@ -95,12 +94,17 @@ ARIA hat zwei Rollen:
|
||||
|-----|----|-----|
|
||||
| RVS | Rechenzentrum | `cd rvs && 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` |
|
||||
| Host-Agent(en) 💻 (optional) | Direkt auf einem Rechner | `cd host-agent && ./build.sh` → Binary + `.env` auf den Rechner, starten (Details unten & in [`host-agent/README.md`](host-agent/README.md)) |
|
||||
| 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).
|
||||
> 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 +139,8 @@ RVS_PORT=443
|
||||
RVS_TLS=true
|
||||
RVS_TLS_FALLBACK=true
|
||||
|
||||
# Pairing-Token: Verbindet App, Bridge, Diagnostic und Gamebox im gleichen RVS-Room
|
||||
# MUSS auf allen Geraeten identisch sein (ARIA-VM, Gaming-PC, App)
|
||||
# Pairing-Token: Verbindet App, Bridge, Diagnostic und Compute-Nodes im gleichen RVS-Room
|
||||
# MUSS auf allen Geraeten identisch sein (ARIA-VM, Compute-Nodes, App)
|
||||
RVS_TOKEN= # ./generate-token.sh
|
||||
```
|
||||
|
||||
@@ -304,28 +308,28 @@ Danach wird der Proxy gepatcht:
|
||||
|
||||
## Voice Bridge
|
||||
|
||||
Die Bridge verbindet die Android App mit ARIA und orchestriert die GPU-Services
|
||||
auf der Gamebox.
|
||||
Die Bridge verbindet die Android App mit ARIA und orchestriert die GPU-Dienste
|
||||
auf den Compute-Nodes.
|
||||
|
||||
**Nachrichtenfluss:**
|
||||
```
|
||||
Text: App → RVS → Bridge → aria-brain (HTTP)
|
||||
Audio: App → RVS → Bridge → stt_request (RVS) → whisper-bridge (Gamebox)
|
||||
→ stt_response → Bridge → aria-brain
|
||||
Audio: App → RVS → STT-Node (voxtral/whisper) direkt (Streaming)
|
||||
→ stt_endpoint/stt_stream_done → Bridge → aria-brain
|
||||
Fallback bei Timeout: lokales faster-whisper (CPU)
|
||||
Datei: App → RVS → Bridge → /shared/uploads/ → aria-brain (mit Pfad)
|
||||
|
||||
aria-brain → Antwort → Bridge → RVS → App
|
||||
→ xtts_request (RVS) → f5tts-bridge
|
||||
→ xtts_request (RVS) → f5tts-Node
|
||||
→ audio_pcm Stream → RVS → App AudioTrack
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
- **STT primaer remote**: aria-bridge sendet `stt_request` an die Gamebox-Whisper
|
||||
(faster-whisper CUDA, fast Echtzeit). 45s Timeout, dann Fallback auf lokales
|
||||
CPU-Whisper. Modell-Wahl in Diagnostic, Hot-Swap via config-Broadcast.
|
||||
- **TTS via F5-TTS**: aria-f5tts-bridge auf der Gamebox. Voice Cloning mit
|
||||
- **STT primaer remote**: die App streamt Audio direkt an einen STT-Node
|
||||
(Voxtral-3B default, faster-whisper Fallback-Profil), fast Echtzeit. Timeout →
|
||||
Fallback auf lokales CPU-Whisper. Modell-Wahl in Diagnostic, Hot-Swap via config.
|
||||
- **TTS via F5-TTS**: aria-f5tts-bridge auf einem Compute-Node. Voice Cloning mit
|
||||
Referenz-Audio + automatisch transkribiertem Referenz-Text.
|
||||
- **Text-Cleanup**: `<voice>...</voice>` Tag bevorzugt; Markdown, Code,
|
||||
Einheiten und URLs werden TTS-gerecht aufbereitet. Dezimalzahlen werden
|
||||
@@ -486,7 +490,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
- **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%.
|
||||
- **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
|
||||
- **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
|
||||
@@ -515,7 +519,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
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
@@ -655,7 +659,7 @@ Der Update-Flow:
|
||||
App (Mikrofon) → AAC/MP4 Aufnahme → Base64 → RVS → Bridge
|
||||
Bridge: FFmpeg (16kHz PCM) → Whisper STT → Text → aria-brain
|
||||
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
|
||||
```
|
||||
|
||||
@@ -797,36 +801,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).
|
||||
Verbindet sich ueber RVS mit der ARIA-Infrastruktur — kein VPN noetig, funktioniert
|
||||
ueber verschiedene Netze hinweg.
|
||||
Die GPU-Dienste laufen auf einem oder mehreren separaten Rechnern mit NVIDIA GPU.
|
||||
Jeder **Compute-Node** verbindet sich ueber RVS mit der ARIA-Infrastruktur — kein
|
||||
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)
|
||||
├── aria-f5tts-bridge F5-TTS Voice Cloning + RVS-Relay
|
||||
│ Hoert auf xtts_request, streamt audio_pcm
|
||||
├── aria-whisper-bridge faster-whisper auf CUDA (float16)
|
||||
│ Hoert auf stt_request, antwortet mit stt_response
|
||||
└── ./voices/ Geteilt zwischen beiden:
|
||||
{name}.wav — Referenz-Audio (~6-10s)
|
||||
{name}.txt — Referenz-Text (auto via Whisper)
|
||||
STT-Box COMPOSE_PROFILES=voxtral VOXTRAL_GPU=0
|
||||
TTS-Box COMPOSE_PROFILES=f5tts F5TTS_GPU=0
|
||||
LLM-Box COMPOSE_PROFILES=llm LLM_GPU=0
|
||||
-- kleine Karte: Whisper (klein) statt Voxtral, neben F5-TTS --
|
||||
Klein-Box COMPOSE_PROFILES=whisper,f5tts WHISPER_GPU=0 F5TTS_GPU=0
|
||||
── oder All-in-One ──
|
||||
AI-Box COMPOSE_PROFILES=voxtral,f5tts,llm VOXTRAL_GPU=1 F5TTS_GPU=0 LLM_GPU=0
|
||||
|
||||
↕ RVS (Rechenzentrum, WebSocket Relay)
|
||||
|
||||
ARIA-VM
|
||||
└── aria-bridge: STT primaer remote (45s Timeout, dann lokaler CPU-Fallback)
|
||||
TTS via xtts_request → audio_pcm Stream
|
||||
└── aria-bridge: orchestriert TTS/LLM (xtts_request/llm_request),
|
||||
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)
|
||||
- NVIDIA Container Toolkit
|
||||
- GPU mit mindestens 6GB VRAM (Whisper-large + F5-TTS gemeinsam)
|
||||
Die STT-Node teilt sich das `./voices/`-Volume mit F5-TTS nur, wenn beide auf
|
||||
demselben Node laufen (Referenz-Text-Transkription beim Voice-Upload). Auf
|
||||
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!**
|
||||
|
||||
### Setup
|
||||
@@ -834,13 +859,17 @@ ARIA-VM
|
||||
```bash
|
||||
cd xtts
|
||||
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
|
||||
# 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
|
||||
und muessen nur einmal geladen werden.
|
||||
Die Modelle liegen im Bind-Mount `./hf-cache/` (bzw. `./models/` fuer LLM-GGUFs)
|
||||
und muessen pro Node nur einmal geladen werden.
|
||||
|
||||
### Features
|
||||
|
||||
@@ -862,7 +891,7 @@ In der Diagnostic unter Einstellungen → Sprachausgabe:
|
||||
- **TTS aktiv**: Global An/Aus
|
||||
- **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).
|
||||
|
||||
### Stimme klonen
|
||||
@@ -908,6 +937,75 @@ dem Cache wiederverwendet.
|
||||
|
||||
---
|
||||
|
||||
## Host-Agenten 💻 — Direktzugriff auf einen Rechner
|
||||
|
||||
Ein **Host-Agent** läuft als **Standalone-Binary direkt auf einem Rechner**
|
||||
(Linux) und verbindet sich **ausgehend** zum RVS (gleicher Token). Damit steuert
|
||||
ARIA diesen Rechner direkt — auch wenn er sonst aus dem Netz **nicht erreichbar**
|
||||
ist (hinter NAT/Firewall, kein offener Port). Unterschied zum Satelliten: der
|
||||
Satellit ist ein LAN-Gateway (entdeckt/steuert *andere* Geräte); der Host-Agent
|
||||
steuert den Rechner, auf dem er *läuft*.
|
||||
|
||||
**Fähigkeiten** (ARIA-Tools `host_list` / `host_exec` / `host_read` /
|
||||
`host_write` / `host_info` / `host_screenshot`): Shell-Kommandos (optional
|
||||
`sudo`), Datei lesen/schreiben, System-Info (CPU/RAM/Disk/Uptime), Screenshot
|
||||
(ARIA öffnet ihn mit ihrem Read-Tool und *sieht* den Bildschirm; erscheint zudem
|
||||
inline im Chat).
|
||||
|
||||
### 1. Binary bauen (portabel, Linux x86_64)
|
||||
|
||||
```bash
|
||||
cd host-agent
|
||||
./build.sh # braucht Docker; erzeugt dist/aria-host-agent (~15 MB)
|
||||
```
|
||||
|
||||
Gebaut wird via PyInstaller in einem bullseye-Container (altes glibc) → läuft auf
|
||||
möglichst vielen Distributionen. Keine Runtime auf dem Ziel nötig.
|
||||
|
||||
### 2. Auf dem Ziel-Rechner installieren
|
||||
|
||||
```bash
|
||||
# dist/aria-host-agent auf den Rechner kopieren, dann:
|
||||
cp .env.example .env # RVS-Zugang + CONTROL_ENABLED=true eintragen
|
||||
chmod +x aria-host-agent && ./aria-host-agent
|
||||
```
|
||||
|
||||
**Als systemd-Dienst** (Dauerbetrieb) — der Installer kopiert Binary + `.env`
|
||||
an ihre Plätze und richtet den Dienst ein:
|
||||
|
||||
```bash
|
||||
sudo ./install-service.sh /pfad/zur/.env # .env-Pfad direkt
|
||||
sudo ./install-service.sh # oder: ncurses-Dateidialog (dialog)
|
||||
```
|
||||
|
||||
→ Binary nach `/usr/local/bin`, `.env` nach `/etc/aria-host-agent/.env` (0600),
|
||||
Unit installiert + `enable --now`. Danach `journalctl -u aria-host-agent -f`.
|
||||
|
||||
### TLS / SNI — Agent im selben Netz wie der RVS
|
||||
|
||||
Verbindet der Agent direkt auf die **interne RVS-IP** (statt über den externen
|
||||
Hostnamen per NAT-Hairpin), scheitert TLS sonst am fehlenden Cert für die IP
|
||||
(`tlsv1 alert internal error`). Dann in der `.env`: `RVS_HOST=<interne-ip>` +
|
||||
`RVS_SNI=<zert-name>` (z.B. `example.com`). Gilt genauso für Satelliten
|
||||
und Compute-Nodes im RZ-Netz (`RVS_SNI` in deren `.env`).
|
||||
|
||||
### sudo
|
||||
|
||||
Der Agent wählt automatisch: **root** → direkt · `SUDO_PASSWORD` in der `.env`
|
||||
→ `sudo -S` · **`SUDO_NOPASSWD=true`** → `sudo -n` (Live-ISO / passwortloses
|
||||
sudo, z.B. Linux Mint vom Stick) · sonst klare Fehlermeldung.
|
||||
|
||||
### Sicherheit
|
||||
|
||||
Reagiert nur auf den eigenen RVS-Raum (Token) und nur bei `CONTROL_ENABLED=true`;
|
||||
keine offenen Ports; alle Kommandos werden geloggt. Gibt **vollen** Rechnerzugriff
|
||||
— nur auf Maschinen einsetzen, denen du ARIA anvertraust. Details:
|
||||
[`host-agent/README.md`](host-agent/README.md).
|
||||
|
||||
> Sichtbar in der Diagnostic unter **Satelliten → Host-Agenten 💻**.
|
||||
|
||||
---
|
||||
|
||||
## Docker Volumes
|
||||
|
||||
| Volume / Bind | Pfad im Container | Zweck |
|
||||
@@ -1024,7 +1122,7 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
||||
- [x] Pre-Roll-Buffer einstellbar in App-Settings
|
||||
- [x] Decimal-zu-Worte fuer TTS + generisches Acronym-Buchstabieren
|
||||
- [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] 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)
|
||||
@@ -1079,7 +1177,7 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
||||
- [x] App: Chat-Suche mit Next/Prev Navigation statt Filter
|
||||
- [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] **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] **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
|
||||
@@ -1106,4 +1204,4 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
||||
- [ ] Desktop Client (Tauri)
|
||||
- [ ] bKVM Remote IT-Support
|
||||
- [ ] Custom-`.onnx`-Upload fuer Wake-Word ueber Diagnostic (ohne App-Rebuild)
|
||||
- [ ] Claude Vision direkt (Bildanalyse ohne Dateipfad-Umweg)
|
||||
- [x] Bildanalyse / Vision — ARIA sieht App-Uploads & Screenshots via Read-Tool (`/shared/uploads/` ist im Proxy-Container gemountet). „Direkt ohne Dateipfad" waere reine Politur (ein Read weniger) und ist bewusst NICHT geplant.
|
||||
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20402
|
||||
versionName "0.2.4.2"
|
||||
versionCode 20500
|
||||
versionName "0.2.5.0"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
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).
|
||||
// Loesung: in dieser Phase keine Detections an JS weiterleiten.
|
||||
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()
|
||||
@@ -106,6 +111,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
||||
private val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings
|
||||
private var consecutiveAboveThreshold: Int = 0
|
||||
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
|
||||
private var recordingStartedMs: Long = 0L
|
||||
|
||||
@@ -430,6 +442,36 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
||||
embBuffer.clear()
|
||||
consecutiveAboveThreshold = 0
|
||||
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() {
|
||||
@@ -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)")
|
||||
return
|
||||
}
|
||||
val preTriggerB64 = snapshotPreTrigger()
|
||||
val params = com.facebook.react.bridge.Arguments.createMap().apply {
|
||||
putString("model", modelName)
|
||||
if (preTriggerB64 != null) putString("preTriggerPcm", preTriggerB64)
|
||||
}
|
||||
try {
|
||||
reactApplicationContext
|
||||
@@ -466,6 +510,14 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
||||
read += n
|
||||
}
|
||||
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 {
|
||||
processChunk(buf)
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.2.4.2",
|
||||
"version": "0.2.5.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -34,13 +34,10 @@ interface FileUploadProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Unterstuetzte Dateitypen
|
||||
const SUPPORTED_TYPES = [
|
||||
DocumentPicker.types.images,
|
||||
DocumentPicker.types.pdf,
|
||||
DocumentPicker.types.docx,
|
||||
DocumentPicker.types.plainText,
|
||||
];
|
||||
// Alle Dateitypen zulassen — Stefan will ueber die Bueroklammer jede Datei
|
||||
// hochladen koennen, nicht nur Bilder/Dokumente. Die Komponente verarbeitet
|
||||
// beliebige Typen ohnehin (Base64 + application/octet-stream als Fallback).
|
||||
const SUPPORTED_TYPES = [DocumentPicker.types.allFiles];
|
||||
|
||||
// --- Komponente ---
|
||||
|
||||
@@ -112,7 +109,7 @@ const FileUpload: React.FC<FileUploadProps> = ({ onFileSelected, onCancel }) =>
|
||||
<TouchableOpacity style={styles.pickButton} onPress={pickFile} activeOpacity={0.7}>
|
||||
<Text style={styles.pickIcon}>{'\uD83D\uDCC1'}</Text>
|
||||
<Text style={styles.pickText}>Datei ausw\u00E4hlen</Text>
|
||||
<Text style={styles.pickHint}>JPG, PNG, PDF, DOCX, TXT</Text>
|
||||
<Text style={styles.pickHint}>Alle Dateitypen</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
// Vorschau und Senden
|
||||
|
||||
@@ -185,7 +185,10 @@ function stripSystemHints(text: string): string {
|
||||
if (!m) break;
|
||||
out = out.slice(m[0].length);
|
||||
}
|
||||
return out;
|
||||
// Bestand die Nachricht NUR aus Klammer-Bloecken (z.B. "[Tool-Loop-Limit ...]"
|
||||
// oder "[Fehler: ...]"), waere sie jetzt leer → dann das ORIGINAL zeigen. Sonst
|
||||
// haette der User eine leere Bubble. Fehler-/Meta-Meldungen sollen sichtbar sein.
|
||||
return out.trim() ? out : text;
|
||||
}
|
||||
/** Sekunden → "M:SS" fuer die Sprachnachricht-Dauer. */
|
||||
function formatDur(sec: number): string {
|
||||
@@ -357,7 +360,7 @@ const ChatScreen: React.FC = () => {
|
||||
// folgende identische Events (z.B. zwei 'thinking' hintereinander) den
|
||||
// Stream zumuellen. Eigentlich seltener Fall, aber billig zu pruefen.
|
||||
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 [serviceBannerDismissed, setServiceBannerDismissed] = useState(false);
|
||||
// Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button)
|
||||
@@ -384,6 +387,14 @@ const ChatScreen: React.FC = () => {
|
||||
// stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest
|
||||
// es. Default true (Konversation). false = Einzelaktion/Skill-Antwort.
|
||||
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).
|
||||
const bargeInEnabledRef = useRef<boolean>(false);
|
||||
|
||||
@@ -1406,8 +1417,39 @@ const ChatScreen: React.FC = () => {
|
||||
// ner Bestaetigung) landete im 30s-Fenster. Jetzt: einzelne Befehle enden
|
||||
// sofort (zurueck aufs Wake-Word), nur echte Gespraeche lauschen weiter.
|
||||
converseRef.current = (message.payload as any).converse === true;
|
||||
const _wakeOff = (message.payload as any).wake_off === true;
|
||||
const _wakeOn = (message.payload as any).wake_on === true;
|
||||
const _isSilent = (message.payload as any).speak === false;
|
||||
if (_isSilent) {
|
||||
if (_wakeOn) {
|
||||
// "Wake-Word an" per Text/Aufnahme-Button → Listener wieder starten
|
||||
// (gleicher Weg wie toggleWakeWord-on). Geht auch wenn das Ohr taub war,
|
||||
// weil der Befehl NICHT ueber "Computer" kam.
|
||||
(async () => {
|
||||
try {
|
||||
const started = await wakeWordService.start();
|
||||
setWakeWordActive(started);
|
||||
console.log('[Chat] Wake-Word per Befehl AN gestartet:', started);
|
||||
} catch (e) {
|
||||
console.warn('[Chat] Wake-Word AN fehlgeschlagen:', e);
|
||||
}
|
||||
})();
|
||||
} else if (_wakeOff) {
|
||||
// ARIA hat "Wake-Word aus" per Sprache bekommen → Listener KOMPLETT
|
||||
// stoppen (Mikro frei, echte Ruhe). Gleicher Weg wie der Ohr-Button
|
||||
// (toggleWakeWord-off). Wieder-An nur ueber den Button (dann taub).
|
||||
(async () => {
|
||||
try {
|
||||
if (audioService.isStreamingRecording()) {
|
||||
await audioService.cancelStreamingRecording('wake-off-voice');
|
||||
} else {
|
||||
await audioService.stopRecording();
|
||||
}
|
||||
} catch {}
|
||||
try { await wakeWordService.stop(); } catch {}
|
||||
setWakeWordActive(false);
|
||||
console.log('[Chat] Wake-Word per Sprachbefehl AUS — Ohr-Button zum Wieder-Anmachen');
|
||||
})();
|
||||
} else if (_isSilent) {
|
||||
// Steuerbefehl (speak=false) ist ausgefuehrt und wird NICHT vorgelesen.
|
||||
// Ohne TTS feuert onPlaybackFinished nie — der Mikro-/Konversations-
|
||||
// Lifecycle muss hier selbst weitergeschaltet werden, sonst haengt das Ohr.
|
||||
@@ -1552,7 +1594,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
|
||||
// soll wissen dass er Bilder/Stimmen jetzt nutzen kann ohne in den
|
||||
// Banner gucken zu muessen.
|
||||
@@ -1659,8 +1701,12 @@ const ChatScreen: React.FC = () => {
|
||||
// Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive).
|
||||
// converse=false (Skill-/Einzelantwort, z.B. 'was laeuft gerade') → auch
|
||||
// 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';
|
||||
wakeWordService.endConversation(bg || !converseRef.current).catch(() => {});
|
||||
const bgForcesArmed = bg && !wakeWordService.isBgWakeEnabled();
|
||||
wakeWordService.endConversation(bgForcesArmed || !converseRef.current).catch(() => {});
|
||||
});
|
||||
return () => unsubPlayback();
|
||||
}, []);
|
||||
@@ -1760,12 +1806,24 @@ const ChatScreen: React.FC = () => {
|
||||
!(m.audioRequestId === ev.audioRequestId
|
||||
&& m.text.includes('Spracheingabe wird verarbeitet'))));
|
||||
}
|
||||
// Kein Re-Arm mehr: nach ARIAs Antwort gab es EIN Stille-Fenster (=
|
||||
// Stille-Toleranz). Kam nichts, ist Schluss → zurück aufs Wake-Word.
|
||||
// Kein 30s-Nachlauschen. (speaker_mismatch/no-speech landen beide hier.)
|
||||
// Passiv-Lauschen: leeres Endpoint (no-speech / Silero-Musik / speaker_
|
||||
// mismatch). NICHT sofort beenden — solange die Stille-Toleranz ab
|
||||
// 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') {
|
||||
console.log('[Chat] Passive-Listen: leeres Endpoint — Ende, zurueck aufs Wake-Word');
|
||||
wakeWordService.exitPassiveListening('timeout').catch(() => {});
|
||||
const elapsed = Date.now() - passiveListenStartRef.current;
|
||||
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 {
|
||||
wakeWordService.endConversation();
|
||||
if (!wakeWordService.isActive()) setWakeWordActive(false);
|
||||
@@ -1777,8 +1835,14 @@ const ChatScreen: React.FC = () => {
|
||||
// geschaltet (nach endConversation). Wir starten eine streaming-Aufnahme
|
||||
// OHNE User-Bubble + ohne wake-ready-Sound. Speaker-ID-Gating in der
|
||||
// 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');
|
||||
// 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();
|
||||
});
|
||||
|
||||
@@ -2228,7 +2292,7 @@ const ChatScreen: React.FC = () => {
|
||||
|
||||
// Aufraeumen von "verarbeitet"-Placeholder die nie ein STT-Result bekommen
|
||||
// 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.
|
||||
// Bei 5min Aufnahme = 6 min Wait, bei 5s Aufnahme = 65s. Sicher genug damit
|
||||
// langsame STTs nicht versehentlich aufgeraeumt werden.
|
||||
@@ -2999,7 +3063,7 @@ const ChatScreen: React.FC = () => {
|
||||
</TouchableOpacity>
|
||||
</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);
|
||||
if (entries.length === 0 || serviceBannerDismissed) return null;
|
||||
|
||||
@@ -114,6 +114,8 @@ import wakeWordService, {
|
||||
saveWakeThreshold,
|
||||
loadBgWakeEnabled,
|
||||
saveBgWakeEnabled,
|
||||
loadWakeConfirmEnabled,
|
||||
saveWakeConfirmEnabled,
|
||||
} from '../services/wakeword';
|
||||
import ModeSelector from '../components/ModeSelector';
|
||||
import QRScanner from '../components/QRScanner';
|
||||
@@ -216,6 +218,8 @@ const SettingsScreen: React.FC = () => {
|
||||
const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT);
|
||||
// Hintergrund-Wake: auch bei gesperrtem Bildschirm auf das Wake-Wort hoeren. Default aus.
|
||||
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 [xttsVoice, setXttsVoice] = useState('');
|
||||
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
|
||||
@@ -341,6 +345,7 @@ const SettingsScreen: React.FC = () => {
|
||||
isWakeReadySoundEnabled().then(setWakeReadySound);
|
||||
loadWakeThreshold().then(setWakeThreshold).catch(() => {});
|
||||
loadBgWakeEnabled().then(setBgWake).catch(() => {});
|
||||
loadWakeConfirmEnabled().then(setWakeConfirm).catch(() => {});
|
||||
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
|
||||
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
|
||||
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
|
||||
@@ -1921,6 +1926,28 @@ const SettingsScreen: React.FC = () => {
|
||||
/>
|
||||
</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.toggleHint}>
|
||||
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 {}
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);
|
||||
@@ -350,6 +416,9 @@ class AudioService {
|
||||
// lich Chunks einer alten Session in eine neue mischen.
|
||||
private streamRequestId: 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
|
||||
// worden? Wird auf false gesetzt beim startStreamingRecording, auf true beim
|
||||
// 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)}`;
|
||||
this.streamRequestId = requestId;
|
||||
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.streamEndpointFired = false;
|
||||
this.recordingStartTime = Date.now();
|
||||
@@ -1113,6 +1191,7 @@ class AudioService {
|
||||
requestId: sessionId,
|
||||
pcm: String(e?.pcm || ''),
|
||||
seq: Number(e?.seq || 0),
|
||||
targetInstance: this.streamTargetInstance,
|
||||
});
|
||||
});
|
||||
this.streamPcmErrorSub = emitter.addListener('PcmStreamError', (e: any) => {
|
||||
@@ -1148,6 +1227,7 @@ class AudioService {
|
||||
hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000,
|
||||
sampleRate: 16000,
|
||||
projectId: opts.projectId || '',
|
||||
targetInstance: this.streamTargetInstance,
|
||||
});
|
||||
|
||||
// No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer.
|
||||
@@ -1197,7 +1277,7 @@ class AudioService {
|
||||
if (!reqId) return;
|
||||
const audioReqId = this.streamAudioRequestId;
|
||||
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) {
|
||||
console.warn('[Audio] stt_stream_end senden fehlgeschlagen:', e);
|
||||
}
|
||||
@@ -1232,7 +1312,7 @@ class AudioService {
|
||||
if (!reqId) return;
|
||||
const audioReqId = this.streamAudioRequestId;
|
||||
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 {}
|
||||
this._cleanupStreamLocal(`cancel:${reason}`);
|
||||
// Listener feuern damit ChatScreen reagieren kann (endConversation etc.)
|
||||
|
||||
@@ -87,6 +87,27 @@ export async function saveBgWakeEnabled(enabled: boolean): Promise<void> {
|
||||
} 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
|
||||
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
|
||||
* 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
|
||||
* bei Aenderung in den Einstellungen via setBgWakeEnabled() aktualisiert. */
|
||||
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
|
||||
* WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS
|
||||
* resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite
|
||||
@@ -241,15 +265,19 @@ class WakeWordService {
|
||||
try {
|
||||
const threshold = await loadWakeThreshold();
|
||||
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);
|
||||
// Subscribe nur einmal
|
||||
if (!this.eventSub) {
|
||||
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');
|
||||
this.onWakeDetected().catch(err =>
|
||||
console.warn('[WakeWord] onWakeDetected crashed:', err));
|
||||
// payload.preTriggerPcm (base64 s16le 16kHz) fuer die Bestaetigung —
|
||||
// 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;
|
||||
@@ -348,6 +376,18 @@ class WakeWordService {
|
||||
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
|
||||
* als Schutz gegen den AudioFocus-/AudioTrack-Spike direkt nach dem Resume.
|
||||
* 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');
|
||||
}
|
||||
|
||||
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
|
||||
private async onWakeDetected(): Promise<void> {
|
||||
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten.
|
||||
* 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) {
|
||||
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(()=>{});
|
||||
@@ -405,6 +447,22 @@ class WakeWordService {
|
||||
// Kein erneutes setState — wir bleiben in 'conversing'.
|
||||
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');
|
||||
// Direkt feuern — KEIN setTimeout. Im Hintergrund (Display aus) parkt
|
||||
// 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
|
||||
* "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im
|
||||
* Native-Modul verhindert dass ARIAs eigene Stimme triggert.
|
||||
|
||||
+431
-14
@@ -20,6 +20,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
@@ -132,6 +133,34 @@ WEB_SEARCH_TOOL = {
|
||||
|
||||
# Meta-Tool: ARIA kann selbst neue Skills bauen
|
||||
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",
|
||||
"function": {
|
||||
@@ -831,7 +860,7 @@ META_TOOLS = [
|
||||
"function": {
|
||||
"name": "flux_generate",
|
||||
"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, "
|
||||
"Konzept-Skizzen, Memes. Render dauert 20-90s — kuendige es Stefan "
|
||||
"kurz an, dann ist er nicht ueberrascht.\n\n"
|
||||
@@ -1156,9 +1185,11 @@ META_TOOLS = [
|
||||
"description": (
|
||||
"Zeigt welche ARIA-Satelliten (Aussenposten-Container in FREMDEN Netzen, "
|
||||
"z.B. 'Buero') gerade ONLINE sind und was sie koennen (discover / "
|
||||
"dial.launch / wol / http). Nutze das ZUERST, wenn Stefan etwas 'im "
|
||||
"Buero' / 'im Netz X' / 'auf dem <Geraet> dort' machen will — so weisst "
|
||||
"Du welche Netze erreichbar sind."
|
||||
"dial.launch / wol / http). Nutze das ZUERST, wenn es um ein Geraet/einen "
|
||||
"Host in einem Netz geht, auf dem Du nicht direkt sitzt (Zuhause, Buero, "
|
||||
"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": {}},
|
||||
},
|
||||
@@ -1169,8 +1200,11 @@ META_TOOLS = [
|
||||
"name": "satellite_devices",
|
||||
"description": (
|
||||
"Listet die Geraete im Netz eines Satelliten (Fire TV, Chromecast, "
|
||||
"Smart-TVs, Drucker, NAS, Hosts ...). Nutze es um herauszufinden welches "
|
||||
"Geraet gemeint ist, BEVOR Du satellite_command aufrufst."
|
||||
"Smart-TVs, Drucker, Switches, Router, APs, NAS, Hosts ...). Nutze es um "
|
||||
"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": {
|
||||
"type": "object",
|
||||
@@ -1190,8 +1224,32 @@ META_TOOLS = [
|
||||
"Beispiel YouTube-Video auf Fire TV: action='dial.launch', "
|
||||
"device='Fire TV', params={'app':'YouTube','v':'<videoId>'}. Weitere "
|
||||
"Aktionen: 'wol' (params={'mac':'...'}) zum Aufwecken, "
|
||||
"'http.get'/'http.post' (params={'url':'...'}) fuer lokale Webhooks. "
|
||||
"Geht nur, wenn der Satellit Steuerung erlaubt (siehe satellite_list)."
|
||||
"'snmp.printer' (params={'ip':'<drucker-ip>'}) — BEVORZUGT fuer Drucker-"
|
||||
"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). 'ssh.exec' (params={'ip':'...','cmd':'...'}) "
|
||||
"— fuehrt EIN Shell-Kommando per SSH auf einem Geraet aus (Server, Pi, NAS) "
|
||||
"und gibt exit_code + stdout (gefenstert: contains/offset/max_chars) + "
|
||||
"stderr zurueck; braucht hinterlegte SSH-Credentials (Benutzer + Passwort "
|
||||
"oder Key). Fuer Geraete mit hinterlegten Zugangsdaten "
|
||||
"(community/v3/Login/SSH) 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": {
|
||||
"type": "object",
|
||||
@@ -1205,6 +1263,127 @@ META_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "host_list",
|
||||
"description": (
|
||||
"Zeigt die ARIA-Host-Agenten (Rechner, auf denen ein Agent DIREKT "
|
||||
"laeuft und sich per RVS meldet) die ONLINE sind + was sie koennen "
|
||||
"(exec/read/write/info/screenshot/ui_dump; Android-Agenten koennen "
|
||||
"screenshot+ui_dump). Ein Host-Agent gibt Dir vollen "
|
||||
"Zugriff auf GENAU DIESEN Rechner — auch wenn er hinter NAT/Firewall "
|
||||
"sitzt. Nutze das, wenn Stefan etwas 'auf meinem Laptop/PC/Server X' "
|
||||
"machen will, das kein Geraet im Netz ist."
|
||||
),
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "host_exec",
|
||||
"description": (
|
||||
"Fuehrt EIN Shell-Kommando auf einem Host-Agenten aus (bash -lc). "
|
||||
"Gibt exit_code + stdout (gefenstert: contains/offset/max_chars) + "
|
||||
"stderr. Fuer Root-Rechte sudo=true setzen (Agent nutzt root/"
|
||||
"SUDO_PASSWORD/NOPASSWD automatisch). Sei vorsichtig — das ist "
|
||||
"vollwertiger Rechnerzugriff."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {"type": "string", "description": "Host-ID/Name aus host_list."},
|
||||
"cmd": {"type": "string", "description": "Shell-Kommando."},
|
||||
"sudo": {"type": "boolean", "description": "Mit Root-Rechten ausfuehren."},
|
||||
"timeout": {"type": "number", "description": "max. Laufzeit in Sekunden (Default 60)."},
|
||||
},
|
||||
"required": ["host", "cmd"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "host_read",
|
||||
"description": "Liest eine Datei von einem Host-Agenten (Text). Fuer Binaerdateien liefert der Agent Base64.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {"type": "string", "description": "Host-ID/Name."},
|
||||
"path": {"type": "string", "description": "Absoluter Pfad auf dem Rechner."},
|
||||
},
|
||||
"required": ["host", "path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "host_write",
|
||||
"description": "Schreibt Text in eine Datei auf einem Host-Agenten (ueberschreibt; append=true haengt an).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {"type": "string", "description": "Host-ID/Name."},
|
||||
"path": {"type": "string", "description": "Zielpfad."},
|
||||
"text": {"type": "string", "description": "Inhalt."},
|
||||
"append": {"type": "boolean", "description": "Anhaengen statt ueberschreiben."},
|
||||
},
|
||||
"required": ["host", "path", "text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "host_info",
|
||||
"description": "System-Info eines Host-Agenten: OS, CPU/RAM/Disk-Auslastung, Uptime, IP.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"host": {"type": "string", "description": "Host-ID/Name."}},
|
||||
"required": ["host"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "host_screenshot",
|
||||
"description": (
|
||||
"Macht ein Bildschirmfoto auf einem Host-Agenten (nur wenn dort eine "
|
||||
"grafische Session laeuft). Liefert einen Datei-Pfad unter /shared/uploads/. "
|
||||
"Du KANNST den Screenshot selbst ansehen: oeffne den Pfad mit deinem "
|
||||
"Read-Tool (Bild wird gerendert) — so beantwortest Du 'was ist auf meinem "
|
||||
"Bildschirm'. Und schreibe den Pfad als [FILE: ...]-Marker in die Antwort, "
|
||||
"damit die App das Bild inline zeigt."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"host": {"type": "string", "description": "Host-ID/Name."}},
|
||||
"required": ["host"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "host_ui_dump",
|
||||
"description": (
|
||||
"Liest die sichtbaren Bedienelemente eines Host-Agenten als strukturierte "
|
||||
"Liste (Text, Beschriftung, Klasse, Bildschirm-Position x/y, Rahmen, ob "
|
||||
"klickbar/editierbar). Vor allem fuer Android-Agenten: ergaenzt "
|
||||
"host_screenshot — der Screenshot zeigt Dir das Bild, ui_dump liefert die "
|
||||
"exakten Element-Texte und Koordinaten, um spaeter gezielt zu tippen. Setzt "
|
||||
"auf dem Geraet eine aktive Bedienungshilfe voraus."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"host": {"type": "string", "description": "Host-ID/Name."}},
|
||||
"required": ["host"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -1442,6 +1621,59 @@ def _user_wants_conversation_continue(text: str) -> bool:
|
||||
return bool(_CONTINUE_CONVERSATION_RE.search(_strip_leading_hint_blocks(text)))
|
||||
|
||||
|
||||
# ── Wake-Word AUS per Sprachbefehl ──────────────────────────────────────────
|
||||
# "Wake-Word aus", "mach das Ohr aus", "hoer auf zuzuhoeren", "geh schlafen" …
|
||||
# → die App stoppt den Wake-Word-Listener KOMPLETT (Mikro frei, echte Ruhe).
|
||||
# Wieder-An geht nur ueber den App-Button (bewusst, weil dann taub). Reiner
|
||||
# Steuerbefehl: still (speak=false), kein Weiterlauschen (converse=false).
|
||||
_WAKE_NOUN = r"(?:wake[\s-]?word|wakeword|ohr(?:en)?|mikro(?:fon)?|zuh[oö]r\w*|lausch\w*)"
|
||||
_WAKE_OFF_VERB = (r"(?:aus(?:schalten|stellen)?|abschalten|abstellen|deaktivier\w*|"
|
||||
r"beenden|beende|stopp?\w*|schlafen|ruhe)")
|
||||
_WAKE_OFF_RE = re.compile(
|
||||
rf"\b{_WAKE_NOUN}\b[^.!?]{{0,20}}\b{_WAKE_OFF_VERB}\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+nicht\s+mehr\s+zu"
|
||||
# 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,
|
||||
)
|
||||
|
||||
|
||||
def _user_wants_wake_off(text: str) -> bool:
|
||||
"""True, wenn der User den Wake-Word-Listener per Sprache abschalten will."""
|
||||
if not text:
|
||||
return False
|
||||
return bool(_WAKE_OFF_RE.search(_strip_leading_hint_blocks(text)))
|
||||
|
||||
|
||||
# ── Wake-Word AN per Befehl (Text ODER manueller Aufnahme-Button) ────────────
|
||||
# Gegenstueck zu wake_off. Das "Wieder-An" per Stimme geht NICHT ueber "Computer"
|
||||
# (das hoert ja nicht mehr), aber ueber eine Text-Nachricht oder den manuellen
|
||||
# Aufnahme-Button — beide laufen unabhaengig vom Wake-Word-Listener. Die App
|
||||
# startet den Listener dann wieder (wakeWordService.start()).
|
||||
_WAKE_ON_VERB = r"(?:an(?:schalten|machen|stellen)?|einschalten|aktivier\w*|starte\w*|reaktivier\w*)"
|
||||
_WAKE_ON_RE = re.compile(
|
||||
rf"\b{_WAKE_NOUN}\b[^.!?]{{0,20}}\b{_WAKE_ON_VERB}\b"
|
||||
rf"|\b(?:aktivier\w*|reaktivier\w*|starte\w*)\b[^.!?]{{0,15}}\b{_WAKE_NOUN}\b"
|
||||
rf"|h[oö]r\s+(?:mir\s+)?wieder\s+zu"
|
||||
rf"|(?:wieder|erneut)\s+(?:zu\s*)?(?:h[oö]r|lausch)\w*"
|
||||
rf"|wach\s+auf|aufwachen",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _user_wants_wake_on(text: str) -> bool:
|
||||
"""True, wenn der User den Wake-Word-Listener per Befehl wieder anschalten will."""
|
||||
if not text:
|
||||
return False
|
||||
return bool(_WAKE_ON_RE.search(_strip_leading_hint_blocks(text)))
|
||||
|
||||
|
||||
def _normalize_for_fast_match(text: str) -> str:
|
||||
norm = _strip_leading_hint_blocks(text).lower()
|
||||
norm = _fold_umlauts(norm)
|
||||
@@ -1637,9 +1869,12 @@ class Agent:
|
||||
return False
|
||||
|
||||
# Kuratierte Tool-Auswahl fuers lokale Tier (B1b): web_search (local-only,
|
||||
# SearXNG) + memory_search/trigger_timer (aus META_TOOLS) + Spotify-Skill.
|
||||
# Bewusst klein (Speed + Sicherheit); alles andere → Claude.
|
||||
_LOCAL_TOOL_NAMES = {"memory_search", "trigger_timer"}
|
||||
# SearXNG) + memory_search/trigger_timer (aus META_TOOLS) + Spotify-Skill +
|
||||
# die Satelliten-Tools (Augen/Haende in fremden Netzen — auch das lokale Tier
|
||||
# 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:
|
||||
tools = [WEB_SEARCH_TOOL]
|
||||
@@ -1829,7 +2064,10 @@ class Agent:
|
||||
|
||||
# ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ──
|
||||
|
||||
MAX_TOOL_ITERATIONS = 8 # Schutz vor Endlos-Loops
|
||||
# Schutz vor Endlos-Loops, aber hoch genug fuer echte agentische Arbeit
|
||||
# (Host-Agent-Exploration, Multi-Datei-Tasks brauchen viele Tool-Runden).
|
||||
# 8 war zu knapp — ARIA brach mitten in laufender Arbeit ab. Env-tunebar.
|
||||
MAX_TOOL_ITERATIONS = int(os.getenv("MAX_TOOL_ITERATIONS", "20"))
|
||||
|
||||
def chat(self, user_message: str, source: str = "",
|
||||
project_id: Optional[str] = None,
|
||||
@@ -1865,6 +2103,27 @@ class Agent:
|
||||
active_project_id = (project_id or "").strip()
|
||||
active_project = projects_mod.get_project(active_project_id) if active_project_id else None
|
||||
|
||||
# Wake-Word AUS per Sprache: reiner Steuerbefehl, KEIN Claude/Fast-Path
|
||||
# noetig. Still (speak=false) + kein Weiterlauschen (converse=false). Das
|
||||
# tatsaechliche Stoppen des Listeners macht die App anhand von wake_off in
|
||||
# der Antwort (ChatOut) — hier signalisieren wir es nur. Wieder-An: App-Button.
|
||||
if _user_wants_wake_off(user_message):
|
||||
reply = "Ohr aus. Sag 'Wake-Word an' (per Text oder Aufnahme-Knopf) oder tipp den Ohr-Button, wenn ich wieder lauschen soll. 🔇"
|
||||
self.conversation.add("user", user_message, source=source,
|
||||
project_id=active_project_id)
|
||||
self.conversation.add("assistant", reply, project_id=active_project_id)
|
||||
logger.info("[wake-off] Sprachbefehl erkannt — App stoppt Listener")
|
||||
return reply, "wake-off", False, False, False
|
||||
|
||||
# Wake-Word AN per Befehl (Text/Aufnahme-Button): App startet den Listener.
|
||||
if _user_wants_wake_on(user_message):
|
||||
reply = "Ohr wieder an — ich lausche auf 'Computer'. 👂"
|
||||
self.conversation.add("user", user_message, source=source,
|
||||
project_id=active_project_id)
|
||||
self.conversation.add("assistant", reply, project_id=active_project_id)
|
||||
logger.info("[wake-on] Befehl erkannt — App startet Listener")
|
||||
return reply, "wake-on", False, False, False
|
||||
|
||||
# Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
|
||||
# Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain
|
||||
# iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz.
|
||||
@@ -2063,6 +2322,7 @@ class Agent:
|
||||
# Konversation bleiben gesprochen.
|
||||
self._claude_turn_speak = True
|
||||
self._claude_turn_converse = True # Default Gespraech; run_*-Skill setzt es
|
||||
self._ear_control = None # ear_control-Tool: 'off'|'on' oder None
|
||||
try:
|
||||
for iteration in range(self.MAX_TOOL_ITERATIONS):
|
||||
result = self.proxy.chat_full(messages, tools=tools,
|
||||
@@ -2106,7 +2366,10 @@ class Agent:
|
||||
break
|
||||
else:
|
||||
# Loop-Limit erreicht
|
||||
final_reply = "[Tool-Loop-Limit erreicht — ARIA hat zu viele Tool-Calls gemacht ohne fertig zu werden]"
|
||||
final_reply = (
|
||||
f"Ich hab die Aufgabe nach {self.MAX_TOOL_ITERATIONS} Arbeitsschritten "
|
||||
f"gestoppt, damit ich nicht endlos weiterlaufe — war aber noch mittendrin. "
|
||||
f"Sag mir, ob ich weitermachen oder es anders angehen soll.")
|
||||
logger.warning("Tool-Loop hit MAX_TOOL_ITERATIONS=%d", self.MAX_TOOL_ITERATIONS)
|
||||
|
||||
if not final_reply:
|
||||
@@ -2176,8 +2439,16 @@ class Agent:
|
||||
converse = False
|
||||
elif _wants_continue:
|
||||
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).
|
||||
return (final_reply, "claude", speak, converse, awaiting_reply)
|
||||
return (final_reply, answered_by, speak, converse, awaiting_reply)
|
||||
|
||||
# ── Tool-Dispatcher ───────────────────────────────────────
|
||||
|
||||
@@ -2285,6 +2556,140 @@ class Agent:
|
||||
except Exception as exc:
|
||||
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
|
||||
|
||||
def _dispatch_host(self, name: str, arguments: dict) -> str:
|
||||
"""host_list / host_exec / host_read / host_write / host_info /
|
||||
host_screenshot — via Bridge (/internal/host*) → RVS → Host-Agent."""
|
||||
import base64 as _b64
|
||||
|
||||
def _post(path: str, body: dict, timeout: float) -> dict:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(f"{BRIDGE_URL}{path}", data=data, method="POST",
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8", "ignore"))
|
||||
try:
|
||||
if name == "host_list":
|
||||
result = _post("/internal/host-list", {}, 10)
|
||||
hosts = result.get("hosts") or []
|
||||
if not hosts:
|
||||
return ("Gerade ist kein Host-Agent online. (Ein Host-Agent laeuft "
|
||||
"direkt auf einem Rechner und erlaubt Dir, ihn zu steuern — "
|
||||
"auch hinter NAT/Firewall.)")
|
||||
lines = []
|
||||
for h in hosts:
|
||||
status = "online" if h.get("online") else "offline"
|
||||
caps = ", ".join(h.get("caps") or [])
|
||||
lines.append(f"- {h.get('name')} (id={h.get('hostId')}, {status}, "
|
||||
f"{h.get('os', '')}; kann: {caps})")
|
||||
return "Host-Agenten (Rechner):\n" + "\n".join(lines)
|
||||
|
||||
host = (arguments.get("host") or "").strip()
|
||||
if not host:
|
||||
return "FEHLER: host ist Pflicht (siehe host_list)."
|
||||
|
||||
if name == "host_exec":
|
||||
cmd = (arguments.get("cmd") or "").strip()
|
||||
if not cmd:
|
||||
return "FEHLER: cmd ist Pflicht."
|
||||
params = {"cmd": cmd}
|
||||
if arguments.get("sudo"):
|
||||
params["sudo"] = True
|
||||
timeout = float(arguments.get("timeout") or 60)
|
||||
result = _post("/internal/host", {"host": host, "action": "exec",
|
||||
"params": params, "timeout": timeout + 10},
|
||||
timeout + 20)
|
||||
if not result.get("ok"):
|
||||
return f"FEHLER: {result.get('error')}"
|
||||
r = result.get("result") or {}
|
||||
note = f"exit={r.get('exit_code')}"
|
||||
if r.get("truncated"):
|
||||
note += f" (stdout gekuerzt, {r.get('total_chars')} Zeichen gesamt)"
|
||||
out = f"[{note}]\n{r.get('body', '')}"
|
||||
stderr = (r.get("stderr") or "").strip()
|
||||
if stderr:
|
||||
out += f"\n--- stderr ---\n{stderr[:1500]}"
|
||||
return out
|
||||
|
||||
if name == "host_read":
|
||||
path = (arguments.get("path") or "").strip()
|
||||
result = _post("/internal/host", {"host": host, "action": "read",
|
||||
"params": {"path": path}}, 30)
|
||||
if not result.get("ok"):
|
||||
return f"FEHLER: {result.get('error')}"
|
||||
r = result.get("result") or {}
|
||||
try:
|
||||
text = _b64.b64decode(r.get("base64", "")).decode("utf-8", "replace")
|
||||
except Exception:
|
||||
text = "(Binaerdatei — nicht als Text darstellbar)"
|
||||
trunc = " (gekuerzt)" if r.get("truncated") else ""
|
||||
return f"{r.get('path')} ({r.get('size')} Bytes){trunc}:\n{text[:8000]}"
|
||||
|
||||
if name == "host_write":
|
||||
text = arguments.get("text") or ""
|
||||
params = {"path": (arguments.get("path") or "").strip(),
|
||||
"base64": _b64.b64encode(text.encode("utf-8")).decode("ascii"),
|
||||
"append": bool(arguments.get("append"))}
|
||||
result = _post("/internal/host", {"host": host, "action": "write",
|
||||
"params": params}, 30)
|
||||
if not result.get("ok"):
|
||||
return f"FEHLER: {result.get('error')}"
|
||||
r = result.get("result") or {}
|
||||
return f"OK — {r.get('bytes')} Bytes nach {r.get('path')} geschrieben."
|
||||
|
||||
if name == "host_info":
|
||||
result = _post("/internal/host", {"host": host, "action": "info",
|
||||
"params": {}}, 20)
|
||||
if not result.get("ok"):
|
||||
return f"FEHLER: {result.get('error')}"
|
||||
return f"System-Info {host}:\n" + json.dumps(result.get("result") or {},
|
||||
ensure_ascii=False, indent=2)
|
||||
|
||||
if name == "host_screenshot":
|
||||
result = _post("/internal/host", {"host": host, "action": "screenshot",
|
||||
"params": {}}, 30)
|
||||
if not result.get("ok"):
|
||||
return f"FEHLER: {result.get('error')}"
|
||||
r = result.get("result") or {}
|
||||
# Wie flux_generate: PNG nach /shared/uploads/ (nur dieser Pfad
|
||||
# matcht den [FILE:]-Marker) -> Bridge broadcastet file_from_aria,
|
||||
# App/Diagnostic zeigen es inline im Chat.
|
||||
try:
|
||||
d = "/shared/uploads"
|
||||
os.makedirs(d, exist_ok=True)
|
||||
safe = re.sub(r"[^a-zA-Z0-9_-]+", "-", host).strip("-") or "host"
|
||||
fp = os.path.join(d, f"aria_screenshot_{safe}_{int(time.time())}.png")
|
||||
with open(fp, "wb") as f:
|
||||
f.write(_b64.b64decode(r.get("base64", "")))
|
||||
except Exception as exc:
|
||||
return f"Screenshot erstellt ({r.get('bytes')} Bytes), Speichern fehlgeschlagen: {exc}"
|
||||
return (
|
||||
f"OK — Screenshot von {host} erstellt ({r.get('bytes', 0) // 1024} KB).\n"
|
||||
f"path: {fp}\n\n"
|
||||
f"UM DEN BILDSCHIRM ZU SEHEN: Oeffne die Datei mit deinem Read-Tool "
|
||||
f"(Read {fp}) — Du bekommst das Bild dann angezeigt und kannst "
|
||||
f"beschreiben/entscheiden, was darauf zu sehen ist.\n"
|
||||
f"UM ES STEFAN ZU ZEIGEN: Schreibe den Pfad EXAKT als Marker "
|
||||
f"[FILE: {fp}] in deine Antwort — dann erscheint das Bild inline im Chat."
|
||||
)
|
||||
|
||||
if name == "host_ui_dump":
|
||||
result = _post("/internal/host", {"host": host, "action": "ui_dump",
|
||||
"params": {}}, 20)
|
||||
if not result.get("ok"):
|
||||
return f"FEHLER: {result.get('error')}"
|
||||
r = result.get("result") or {}
|
||||
nodes = r.get("nodes") or []
|
||||
return (
|
||||
f"UI-Baum von {host} (App: {r.get('package', '?')}, "
|
||||
f"{r.get('count', len(nodes))} Elemente). Jeder Eintrag hat x/y = "
|
||||
f"Mittelpunkt zum Antippen:\n"
|
||||
+ json.dumps(nodes, ensure_ascii=False, indent=2)
|
||||
)
|
||||
|
||||
return f"FEHLER: unbekanntes Host-Tool {name}"
|
||||
except Exception as exc:
|
||||
return f"FEHLER: Host/Bridge nicht erreichbar: {exc}"
|
||||
|
||||
def _dispatch_tool(self, name: str, arguments: dict, project_id: str = "") -> str:
|
||||
"""Fuehrt einen Tool-Call aus und gibt ein kurzes Text-Resultat zurueck.
|
||||
Niemals werfen — Fehler werden als Text-Resultat reportet damit Claude
|
||||
@@ -2772,6 +3177,15 @@ class Agent:
|
||||
f"WICHTIG: Schreibe in deiner Antwort an Stefan den Pfad EXAKT als "
|
||||
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":
|
||||
query = (arguments.get("query") or "").strip()
|
||||
if not query:
|
||||
@@ -3068,6 +3482,9 @@ class Agent:
|
||||
return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat."
|
||||
if name in ("satellite_list", "satellite_devices", "satellite_command"):
|
||||
return self._dispatch_satellite(name, arguments)
|
||||
if name in ("host_list", "host_exec", "host_read", "host_write",
|
||||
"host_info", "host_screenshot", "host_ui_dump"):
|
||||
return self._dispatch_host(name, arguments)
|
||||
if name == "vm_register":
|
||||
pid = (project_id or "").strip()
|
||||
if not pid:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
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
|
||||
|
||||
Analog zum Claude-`proxy_client`, nur ueber die Bridge (die ist der RVS-Client;
|
||||
|
||||
@@ -676,6 +676,11 @@ class ChatOut(BaseModel):
|
||||
# Task fertig ist)? Dann pausiert die App die Projekt-Queue und leitet die
|
||||
# naechste Eingabe als Antwort weiter, statt sie als neuen Auftrag anzustellen.
|
||||
awaiting_reply: bool = False
|
||||
# Der User hat per Sprache "Wake-Word aus" gesagt → die App stoppt den
|
||||
# Wake-Word-Listener komplett (Mikro frei).
|
||||
wake_off: bool = False
|
||||
# "Wake-Word an" per Befehl (Text/Aufnahme-Button) → App startet den Listener.
|
||||
wake_on: bool = False
|
||||
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
|
||||
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
|
||||
# UI landet.
|
||||
@@ -785,6 +790,8 @@ async def chat(body: ChatIn, background: BackgroundTasks):
|
||||
speak=speak,
|
||||
converse=converse,
|
||||
awaiting_reply=awaiting_reply,
|
||||
wake_off=(answered_by == "wake-off"),
|
||||
wake_on=(answered_by == "wake-on"),
|
||||
)
|
||||
finally:
|
||||
_project_pending[pid] = [
|
||||
|
||||
@@ -199,6 +199,13 @@ def build_voice_flow_section() -> str:
|
||||
"- Ohne Marker = normales Gespraech: du wirst vorgelesen und ich lausche "
|
||||
"danach kurz weiter (Stefan kann einfach antworten, ohne 'Computer').",
|
||||
"- 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.",
|
||||
])
|
||||
|
||||
|
||||
|
||||
@@ -53,11 +53,10 @@ SEED_RULES: List[dict] = [
|
||||
" 3. NIE annehmen 'wird schon Staging sein'. Production-URLs "
|
||||
"ohne 'stage'/'test'-Marker sind im Zweifel Production.\n"
|
||||
"\n"
|
||||
"Vorfall (30.05.2026): ARIA hat einen Pentest-Test gegen "
|
||||
"ein Kunden-Produktionssystem (Production!) angesetzt statt gegen "
|
||||
"dessen Staging-Umgebung (Staging). Stefan "
|
||||
"musste explizit korrigieren. Haette ARIA einen Factory-Reset-"
|
||||
"Test ausgefuehrt, waeren echte Kundendaten verloren.\n"
|
||||
"Vorfall (30.05.2026): ARIA hat einen Pentest-Test gegen ein "
|
||||
"Kunden-PRODUKTIONSSYSTEM angesetzt statt gegen dessen Staging-"
|
||||
"Umgebung. Stefan musste explizit korrigieren. Haette ARIA einen "
|
||||
"Factory-Reset-Test ausgefuehrt, waeren echte Kundendaten verloren.\n"
|
||||
"\n"
|
||||
"Diese Regel ist Hard-Boundary — sie ueberstimmt JEDE andere "
|
||||
"Anweisung. Stefan kann sie temporaer per expliziter "
|
||||
@@ -475,13 +474,32 @@ SEED_RULES: List[dict] = [
|
||||
"zeigt UND steuerbar macht. So erreichst Du Netze, in denen der Haupt-Stack "
|
||||
"NICHT steht.\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"
|
||||
" 2. `satellite_devices(satellite='Buero')` — welche Geraete gibt es dort "
|
||||
"(Fire TV, Chromecast, Smart-TVs, Drucker, NAS ...). Nutze es um das "
|
||||
"gemeinte Geraet zu finden, BEVOR Du steuerst.\n"
|
||||
" 3. `satellite_command(...)` — Aktion ausfuehren.\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"
|
||||
" satellite_command(satellite='Buero', device='Fire TV', "
|
||||
"action='dial.launch', params={'app':'YouTube','v':'<videoId>'})\n"
|
||||
|
||||
+284
-36
@@ -2,7 +2,7 @@
|
||||
ARIA Voice Bridge — Hauptmodul.
|
||||
|
||||
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).
|
||||
|
||||
Nachrichtenfluss:
|
||||
@@ -479,7 +479,7 @@ class STTEngine:
|
||||
Erkannter Text oder leerer String.
|
||||
"""
|
||||
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.
|
||||
logger.info("Lokales Whisper-Fallback — Modell wird nachgeladen...")
|
||||
try:
|
||||
@@ -654,7 +654,7 @@ class ARIABridge:
|
||||
self._seen_client_msg_ids: "OrderedDict[str, float]" = OrderedDict()
|
||||
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.xtts_voice = ""
|
||||
self._f5tts_config: dict = {}
|
||||
@@ -686,7 +686,7 @@ class ARIABridge:
|
||||
except (TypeError, ValueError):
|
||||
self._persistent_xtts_speed = None
|
||||
# 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)
|
||||
for k in ("f5ttsModel", "f5ttsCkptFile", "f5ttsVocabFile",
|
||||
"f5ttsCfgStrength", "f5ttsNfeStep"):
|
||||
@@ -746,7 +746,7 @@ class ARIABridge:
|
||||
# Gleiche Logik fuer die Wiedergabegeschwindigkeit (F5-TTS speed-Param,
|
||||
# App-Setting aria_tts_speed, 1.0 = normal).
|
||||
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).
|
||||
self._pending_stt: dict[str, asyncio.Future] = {}
|
||||
# whisper-bridge service_status: True wenn ready, False/None wenn loading/unbekannt.
|
||||
@@ -766,7 +766,17 @@ class ARIABridge:
|
||||
# requestId → Future (sat_devices / sat_result), analog _pending_flux.
|
||||
self._satellites: dict[str, dict] = {}
|
||||
self._pending_sat: dict[str, asyncio.Future] = {}
|
||||
# FLUX-Render-Requests die aktuell auf Antwort der flux-bridge (Gamebox) warten.
|
||||
# Host-Agenten (Direktzugriff auf einen Rechner). hostId → {name, os,
|
||||
# caps, control, last_seen}. Registrierung via host_hello/host_ping.
|
||||
# _pending_host: requestId → Future (host_result), analog _pending_sat.
|
||||
self._hosts: dict[str, dict] = {}
|
||||
self._pending_host: dict[str, asyncio.Future] = {}
|
||||
# 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).
|
||||
self._pending_flux: dict[str, asyncio.Future] = {}
|
||||
# flux-bridge service_status: True wenn ready. Render-Timeouts werden
|
||||
@@ -774,7 +784,7 @@ class ARIABridge:
|
||||
self._remote_flux_ready: bool = False
|
||||
# Lokales LLM (Plan B): requestId → Future mit dem llm_response-Payload.
|
||||
# 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.
|
||||
self._pending_llm: dict[str, asyncio.Future] = {}
|
||||
# User-Message-Counter fuer Auto-Compact. Bei zu langer Konversation
|
||||
@@ -810,7 +820,7 @@ class ARIABridge:
|
||||
logger.info("ARIA Voice Bridge startet...")
|
||||
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
|
||||
# antwortet. Das spart RAM auf der VM und Startup-Zeit.
|
||||
|
||||
@@ -1622,6 +1632,11 @@ class ARIABridge:
|
||||
# die Projekt-Queue und leitet die naechste Eingabe als Antwort auf
|
||||
# DIESE Rueckfrage weiter, statt sie als neuen Auftrag anzustellen.
|
||||
"awaiting_reply": bool(payload.get("awaiting_reply", False)) if isinstance(payload, dict) else False,
|
||||
# User hat "Wake-Word aus" gesagt → App stoppt den Listener komplett
|
||||
# (Mikro frei).
|
||||
"wake_off": bool(payload.get("wake_off", False)) if isinstance(payload, dict) else False,
|
||||
# "Wake-Word an" (Text/Aufnahme-Button) → App startet den Listener.
|
||||
"wake_on": bool(payload.get("wake_on", False)) if isinstance(payload, dict) else False,
|
||||
},
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
@@ -1674,20 +1689,27 @@ class ARIABridge:
|
||||
if len(self._xtts_request_to_message) > 100:
|
||||
oldest = next(iter(self._xtts_request_to_message))
|
||||
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({
|
||||
"type": "xtts_request",
|
||||
"payload": {
|
||||
"text": tts_text,
|
||||
"voice": xtts_voice,
|
||||
"speed": xtts_speed,
|
||||
"language": "de",
|
||||
"requestId": xtts_request_id,
|
||||
"messageId": message_id,
|
||||
},
|
||||
"payload": tts_payload,
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
logger.info("[core] XTTS-Request gesendet (voice=%s, speed=%.2fx): '%s'",
|
||||
xtts_voice or "default", xtts_speed, tts_text[:60])
|
||||
logger.info("[core] XTTS-Request gesendet (voice=%s, speed=%.2fx, target=%s): '%s'",
|
||||
xtts_voice or "default", xtts_speed, tts_target or "(broadcast)", tts_text[:60])
|
||||
except Exception as e:
|
||||
logger.error("[core] XTTS-Request fehlgeschlagen: %s — kein Audio", e)
|
||||
|
||||
@@ -1742,7 +1764,7 @@ class ARIABridge:
|
||||
"""Broadcastet die aktuelle voice_config.json einmalig nach RVS-Connect.
|
||||
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
@@ -2017,6 +2039,10 @@ class ARIABridge:
|
||||
# Stellt ARIA eine blockierende Rueckfrage? Dann pausiert die App die
|
||||
# Projekt-Queue und leitet die naechste Eingabe als Antwort weiter.
|
||||
awaiting_reply = bool(data.get("awaiting_reply", False))
|
||||
# User hat per Sprache "Wake-Word aus" gesagt → App stoppt den Listener.
|
||||
wake_off = bool(data.get("wake_off", False))
|
||||
# "Wake-Word an" (Text/Aufnahme-Button) → App startet den Listener wieder.
|
||||
wake_on = bool(data.get("wake_on", False))
|
||||
|
||||
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
|
||||
# damit sie in der UI vor der Reply auftauchen
|
||||
@@ -2108,7 +2134,9 @@ class ARIABridge:
|
||||
"answeredBy": answered_by,
|
||||
"speak": speak,
|
||||
"converse": converse,
|
||||
"awaiting_reply": awaiting_reply})
|
||||
"awaiting_reply": awaiting_reply,
|
||||
"wake_off": wake_off,
|
||||
"wake_on": wake_on})
|
||||
except Exception:
|
||||
logger.exception("[brain] _process_core_response Fehler")
|
||||
await self._emit_activity("idle", "", project_id=project_id)
|
||||
@@ -2179,7 +2207,7 @@ class ARIABridge:
|
||||
await self._broadcast_current_mode()
|
||||
|
||||
# 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
|
||||
# ihren Hard-Defaults).
|
||||
asyncio.create_task(self._broadcast_persisted_config())
|
||||
@@ -2540,7 +2568,7 @@ class ARIABridge:
|
||||
elif msg_type == "config":
|
||||
# Konfiguration von App/Diagnostic empfangen + persistent speichern.
|
||||
# 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.
|
||||
changed = False
|
||||
if "ttsEnabled" in payload:
|
||||
@@ -2564,7 +2592,7 @@ class ARIABridge:
|
||||
new_model = payload["whisperModel"]
|
||||
allowed = {"tiny", "base", "small", "medium", "large-v3"}
|
||||
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)
|
||||
self.stt_engine.model_size = new_model
|
||||
self.stt_engine.model = None
|
||||
@@ -2783,8 +2811,18 @@ class ARIABridge:
|
||||
"message": payload.get("message", ""),
|
||||
"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")
|
||||
# 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",
|
||||
line["level"], line["scope"], line["message"][:120])
|
||||
except Exception as exc:
|
||||
@@ -3425,7 +3463,7 @@ class ARIABridge:
|
||||
return
|
||||
|
||||
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", "")
|
||||
future = self._pending_llm.get(request_id)
|
||||
if future is None or future.done():
|
||||
@@ -3434,7 +3472,7 @@ class ARIABridge:
|
||||
return
|
||||
|
||||
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
|
||||
# im 'loading' steckt, geben wir der Bridge mehr Zeit (Modell-Download
|
||||
# kann 1-2 Min dauern), statt nach 45s lokal zu fallbacken.
|
||||
@@ -3517,6 +3555,96 @@ class ARIABridge:
|
||||
self._satellites[sid]["caps"], self._satellites[sid]["control"])
|
||||
return
|
||||
|
||||
elif msg_type == "host_hello":
|
||||
hid = (payload.get("hostId") or "").strip()
|
||||
if hid:
|
||||
new = hid not in self._hosts
|
||||
self._hosts[hid] = {
|
||||
"hostId": hid,
|
||||
"name": payload.get("name") or hid,
|
||||
"os": payload.get("os") or "",
|
||||
"caps": payload.get("caps") or [],
|
||||
"control": bool(payload.get("control")),
|
||||
"last_seen": time.time(),
|
||||
}
|
||||
if new:
|
||||
logger.info("[host] Agent online: %s (%s) caps=%s control=%s",
|
||||
hid, self._hosts[hid]["name"],
|
||||
self._hosts[hid]["caps"], self._hosts[hid]["control"])
|
||||
return
|
||||
|
||||
elif msg_type == "host_ping":
|
||||
hid = (payload.get("hostId") or "").strip()
|
||||
if hid and hid in self._hosts:
|
||||
self._hosts[hid]["last_seen"] = time.time()
|
||||
return
|
||||
|
||||
elif msg_type == "host_result":
|
||||
req_id = payload.get("requestId", "")
|
||||
future = self._pending_host.get(req_id)
|
||||
if future is not None and not future.done():
|
||||
future.set_result(payload)
|
||||
hid = (payload.get("hostId") or "").strip()
|
||||
if hid and hid in self._hosts:
|
||||
self._hosts[hid]["last_seen"] = time.time()
|
||||
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"):
|
||||
req_id = payload.get("requestId", "")
|
||||
future = self._pending_sat.get(req_id)
|
||||
@@ -3542,11 +3670,11 @@ class ARIABridge:
|
||||
else:
|
||||
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:
|
||||
# ready=True → 45s reicht selbst fuer lange Audios
|
||||
# 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_LOADING_S = 300.0
|
||||
|
||||
@@ -3899,15 +4027,15 @@ class ARIABridge:
|
||||
_FLUX_TIMEOUT_LOADING_S = 900.0 # 15 min beim allerersten Mal (Modell-Download)
|
||||
|
||||
# ── Local-LLM-Roundtrip: Brain → Bridge → RVS → llm-adapter → zurueck ──
|
||||
# Qwen3 auf der Gamebox antwortet auf kurze Turns in <1 s. Grosszuegiger
|
||||
# Timeout deckt Kaltstart / laengere Antworten / Netz-Jitter (Gamebox@home)
|
||||
# Qwen3 auf der AI-Box antwortet auf kurze Turns in <1 s. Grosszuegiger
|
||||
# Timeout deckt Kaltstart / laengere Antworten / Netz-Jitter (AI-Box@home)
|
||||
# ab. Bei Timeout faellt der Router im Brain per Escalation auf Claude.
|
||||
_LLM_TIMEOUT_S = 30.0
|
||||
|
||||
async def _local_llm(self, messages: list, max_tokens: int = 512,
|
||||
temperature: float = 0.7, stop=None, tools=None,
|
||||
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.
|
||||
Rueckgabe: {ok, content, tool_calls, model, elapsedMs} oder {ok:False, error}."""
|
||||
if self.ws_rvs is None:
|
||||
@@ -3932,8 +4060,15 @@ class ARIABridge:
|
||||
req_payload["tools"] = tools
|
||||
if model:
|
||||
req_payload["model"] = model
|
||||
logger.info("[rvs] llm_request → llm-adapter (id=%s, msgs=%d, max_tokens=%d, tools=%d, model=%s)",
|
||||
request_id[:8], len(messages), max_tokens, len(tools) if tools else 0, model or "-")
|
||||
# Redundanz/Multitasking: freie llm-Instanz gezielt adressieren, die
|
||||
# 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({
|
||||
"type": "llm_request",
|
||||
"payload": req_payload,
|
||||
@@ -3944,7 +4079,7 @@ class ARIABridge:
|
||||
try:
|
||||
result = await asyncio.wait_for(future, timeout=self._LLM_TIMEOUT_S)
|
||||
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"):
|
||||
err = (result or {}).get("error") if isinstance(result, dict) else "leeres Resultat"
|
||||
return {"ok": False, "error": err or "llm-adapter Fehler"}
|
||||
@@ -4424,6 +4559,27 @@ class ARIABridge:
|
||||
elif method == "POST" and path == "/internal/satellite-list":
|
||||
# Brain fragt: welche Satelliten/Netze sind online + Capabilities.
|
||||
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/host-list":
|
||||
# Brain fragt: welche Host-Agenten (Rechner) sind online + Capabilities.
|
||||
await _send_response(writer, 200, {"ok": True, "hosts": self._host_list()})
|
||||
elif method == "POST" and path == "/internal/host":
|
||||
# Brain-Tool: Kommando an einen Host-Agenten.
|
||||
# body: {host, action, params?, timeout?}
|
||||
try:
|
||||
data = json.loads(body.decode("utf-8", "ignore"))
|
||||
except Exception as exc:
|
||||
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
|
||||
return
|
||||
result = await self._host_request(
|
||||
host=str(data.get("host") or ""),
|
||||
action=str(data.get("action") or ""),
|
||||
params=data.get("params") if isinstance(data.get("params"), dict) else {},
|
||||
timeout=float(data.get("timeout") or 60.0),
|
||||
)
|
||||
await _send_response(writer, 200, result)
|
||||
elif method == "POST" and path == "/internal/satellite":
|
||||
# Brain-Tool: Discovery oder Command an einen Satelliten.
|
||||
# body: {op:'discover'|'command', satellite, device?, action?, params?}
|
||||
@@ -4444,7 +4600,7 @@ class ARIABridge:
|
||||
await _send_response(writer, 200, result)
|
||||
elif method == "POST" and path == "/internal/flux-generate":
|
||||
# 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
|
||||
# /shared/uploads/ und melden Pfad + Render-Stats zurueck.
|
||||
# Brain referenziert das Bild dann mit [FILE:]-Marker in
|
||||
@@ -4481,7 +4637,7 @@ class ARIABridge:
|
||||
await _send_response(writer, status, result)
|
||||
elif method == "POST" and path == "/internal/local-llm":
|
||||
# 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.
|
||||
try:
|
||||
data = json.loads(body.decode("utf-8", "ignore"))
|
||||
@@ -4673,6 +4829,60 @@ class ARIABridge:
|
||||
})
|
||||
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 = "",
|
||||
device: str = "", action: str = "",
|
||||
params: Optional[dict] = None,
|
||||
@@ -4709,6 +4919,44 @@ class ARIABridge:
|
||||
finally:
|
||||
self._pending_sat.pop(request_id, None)
|
||||
|
||||
def _host_list(self) -> list[dict]:
|
||||
"""Bekannte Host-Agenten (frisch = in den letzten 5 Min gesehen)."""
|
||||
now = time.time()
|
||||
return [{
|
||||
"hostId": h["hostId"], "name": h.get("name") or h["hostId"],
|
||||
"os": h.get("os") or "", "caps": h.get("caps") or [],
|
||||
"control": bool(h.get("control")),
|
||||
"online": (now - h.get("last_seen", 0)) < 300,
|
||||
} for h in self._hosts.values()]
|
||||
|
||||
async def _host_request(self, host: str = "", action: str = "",
|
||||
params: Optional[dict] = None,
|
||||
timeout: float = 60.0) -> dict:
|
||||
"""Schickt host_command an einen Host-Agenten (via RVS) und wartet auf
|
||||
host_result. Muster identisch zu _satellite_request."""
|
||||
if self.ws_rvs is None:
|
||||
return {"ok": False, "error": "RVS-Verbindung nicht aktiv"}
|
||||
request_id = str(uuid.uuid4())
|
||||
loop = asyncio.get_event_loop()
|
||||
future: asyncio.Future = loop.create_future()
|
||||
self._pending_host[request_id] = future
|
||||
try:
|
||||
msg = {"type": "host_command",
|
||||
"payload": {"requestId": request_id, "host": host,
|
||||
"action": action, "params": params or {}},
|
||||
"timestamp": int(time.time() * 1000)}
|
||||
ok = await self._send_to_rvs(msg)
|
||||
if not ok:
|
||||
return {"ok": False, "error": "Host-Request konnte nicht gesendet werden"}
|
||||
result = await asyncio.wait_for(future, timeout=timeout)
|
||||
return result if isinstance(result, dict) else {"ok": False, "error": "ungueltige Antwort"}
|
||||
except asyncio.TimeoutError:
|
||||
return {"ok": False, "error": f"Host '{host or 'all'}' antwortet nicht (Timeout)."}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
finally:
|
||||
self._pending_host.pop(request_id, None)
|
||||
|
||||
async def _delete_chat_message(self, ts: int) -> dict:
|
||||
"""Entfernt eine Bubble: aus chat_backup.jsonl + Brain conversation,
|
||||
broadcastet chat_message_deleted via RVS.
|
||||
|
||||
+922
-267
File diff suppressed because it is too large
Load Diff
+310
-6
@@ -349,6 +349,66 @@ function loadLocalModels() {
|
||||
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 ───────────────────────────────────────────
|
||||
// Jeder Eintrag map[absoluter_pfad] = project_id (leer = Hauptchat).
|
||||
// Wird vom files-list-Endpoint + files-set-project gepflegt.
|
||||
@@ -490,6 +550,97 @@ function broadcastSatellites() {
|
||||
broadcast({ type: "sat_update", satellites: satelliteList() });
|
||||
}
|
||||
|
||||
// ── Host-Agenten: Direktzugriff auf einen Rechner ────────────────
|
||||
const hosts = new Map(); // hostId → {hostId, name, os, caps, control, last_seen}
|
||||
|
||||
function hostList() {
|
||||
const now = Date.now();
|
||||
return Array.from(hosts.values()).map(h => ({
|
||||
hostId: h.hostId, name: h.name, os: h.os, caps: h.caps, control: h.control,
|
||||
online: (now - (h.last_seen || 0)) < 300000,
|
||||
}));
|
||||
}
|
||||
|
||||
function broadcastHosts() {
|
||||
broadcast({ type: "host_update", hosts: hostList() });
|
||||
}
|
||||
|
||||
// ── 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 ─────────────────────────
|
||||
|
||||
async function connectGateway() {
|
||||
@@ -929,12 +1080,90 @@ function connectRVS(forcePlain) {
|
||||
});
|
||||
broadcastSatellites();
|
||||
}
|
||||
} else if (msg.type === "host_hello") {
|
||||
// Ein Host-Agent (Direktzugriff auf einen Rechner) meldet sich.
|
||||
const p = msg.payload || {};
|
||||
if (p.hostId) {
|
||||
const wasKnown = hosts.has(p.hostId);
|
||||
hosts.set(p.hostId, {
|
||||
hostId: p.hostId, name: p.name || p.hostId, os: p.os || "",
|
||||
caps: p.caps || [], control: !!p.control, last_seen: Date.now(),
|
||||
});
|
||||
if (!wasKnown) broadcastHosts();
|
||||
else hosts.get(p.hostId).last_seen = Date.now();
|
||||
}
|
||||
} else if (msg.type === "host_ping") {
|
||||
const p = msg.payload || {};
|
||||
if (p.hostId && hosts.has(p.hostId)) hosts.get(p.hostId).last_seen = Date.now();
|
||||
} else if (msg.type === "rooms_info") {
|
||||
// Antwort des RVS auf rooms_query → an den Browser (Raum-Diagnose).
|
||||
broadcast({ type: "rooms_info", payload: msg.payload || {} });
|
||||
} else if (msg.type === "sat_devices") {
|
||||
// Antwort eines Satelliten auf sat_discover → Geraeteliste an Browser.
|
||||
const p = msg.payload || {};
|
||||
if (p.satellite && satellites.has(p.satellite)) satellites.get(p.satellite).last_seen = Date.now();
|
||||
broadcast({ type: "sat_devices", satellite: p.satellite || "",
|
||||
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") {
|
||||
// Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
|
||||
// weiterreichen, damit der Thinking-Indikator im Chat erscheint.
|
||||
@@ -981,7 +1210,7 @@ function connectRVS(forcePlain) {
|
||||
}
|
||||
broadcast({ type: "voice_ready", payload: msg.payload });
|
||||
} 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
|
||||
const svc = msg.payload?.service || "?";
|
||||
const state = msg.payload?.state || "?";
|
||||
@@ -996,6 +1225,12 @@ function connectRVS(forcePlain) {
|
||||
log("info", "rvs", `service_status ${svc} ${state}${model ? ` (${model})` : ""}`);
|
||||
}
|
||||
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) {
|
||||
// PCM-Chunks einer laufenden Voice-Preview — sammeln + WAV bauen
|
||||
_handlePreviewChunk(msg.payload);
|
||||
@@ -1040,7 +1275,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;
|
||||
const proto = RVS_TLS === "true" ? "wss" : "ws";
|
||||
const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`;
|
||||
@@ -1048,7 +1283,7 @@ function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs) {
|
||||
const timeout = setTimeout(() => {
|
||||
try { freshWs.close(); } catch (_) {}
|
||||
clientWs.send(JSON.stringify({ type: expectType, payload: { voices: [], error: "Timeout" }, timestamp: Date.now() }));
|
||||
}, 15000);
|
||||
}, timeoutMs);
|
||||
freshWs.on("open", () => {
|
||||
freshWs.send(JSON.stringify({ type: sendType, payload: sendPayload, timestamp: Date.now() }));
|
||||
});
|
||||
@@ -1738,6 +1973,20 @@ const server = http.createServer((req, res) => {
|
||||
} else if (req.url === "/api/local-models-list" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
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") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(readLocalLlmConfig()));
|
||||
@@ -2505,6 +2754,10 @@ wss.on("connection", (ws) => {
|
||||
if (currentDiskStatus) ws.send(JSON.stringify(currentDiskStatus));
|
||||
// Aktuell bekannte Satelliten mitgeben (RVS replayt sat_hello nicht).
|
||||
ws.send(JSON.stringify({ type: "sat_update", satellites: satelliteList() }));
|
||||
// Aktuell bekannte Host-Agenten mitgeben.
|
||||
ws.send(JSON.stringify({ type: "host_update", hosts: hostList() }));
|
||||
// Aktuell bekannte Compute-Worker mitgeben (RVS replayt worker_hello nicht).
|
||||
ws.send(JSON.stringify({ type: "worker_update", workers: workerList() }));
|
||||
|
||||
ws.on("message", (raw) => {
|
||||
try {
|
||||
@@ -2536,11 +2789,35 @@ wss.on("connection", (ws) => {
|
||||
} else if (msg.action === "sat_list") {
|
||||
// Browser will die aktuelle Satelliten-Liste.
|
||||
ws.send(JSON.stringify({ type: "sat_update", satellites: satelliteList() }));
|
||||
} else if (msg.action === "host_list") {
|
||||
// Browser will die aktuelle Host-Agenten-Liste.
|
||||
ws.send(JSON.stringify({ type: "host_update", hosts: hostList() }));
|
||||
} else if (msg.action === "rooms_query") {
|
||||
// Browser will die RVS-Raum-Diagnose — via persistente rvsWs anfragen,
|
||||
// die Antwort (rooms_info) kommt auf derselben Verbindung zurueck.
|
||||
sendToRVS_raw({ type: "rooms_query", payload: {}, timestamp: Date.now() });
|
||||
} 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") {
|
||||
// Browser triggert einen Geraete-Scan auf einem Satelliten.
|
||||
sendToRVS_raw({ type: "sat_discover",
|
||||
payload: { satellite: msg.satellite || "", force: true },
|
||||
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") {
|
||||
testProxy(msg.text);
|
||||
} else if (msg.action === "check_proxy_auth") {
|
||||
@@ -2568,7 +2845,7 @@ wss.on("connection", (ws) => {
|
||||
// Datei von Diagnostic an Bridge via RVS senden
|
||||
sendToRVS_raw({
|
||||
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(),
|
||||
});
|
||||
log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`);
|
||||
@@ -2611,9 +2888,11 @@ wss.on("connection", (ws) => {
|
||||
// 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);
|
||||
} 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() });
|
||||
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") {
|
||||
// Bubble loeschen — Bridge raeumt chat_backup.jsonl + Brain-conversation
|
||||
// + broadcastet chat_message_deleted via RVS.
|
||||
@@ -2702,6 +2981,31 @@ wss.on("connection", (ws) => {
|
||||
// Sessions- und Brain-File-Viewer entfernt — Sessions sind raus, Memory
|
||||
// laeuft jetzt komplett ueber die Vector-DB im aria-brain (siehe Gehirn-Tab).
|
||||
// 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") {
|
||||
handleRestartSession(ws);
|
||||
// ── Einstellungen ──
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# FLUX.1-dev Bildgenerierung — Architektur & Stand
|
||||
|
||||
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
|
||||
RVS verbindet und auf seinen Request-Typ lauscht.
|
||||
|
||||
@@ -23,7 +23,7 @@ aria-bridge ── send_to_core ──▶ aria-brain
|
||||
RVS
|
||||
│ fanout
|
||||
▼
|
||||
flux-bridge (Gamebox)
|
||||
flux-bridge (AI-Box)
|
||||
│ FluxPipeline.from_pretrained(...)
|
||||
│ pipeline(prompt, width, height, steps, guidance).images[0]
|
||||
│ 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
|
||||
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
|
||||
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**
|
||||
(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.
|
||||
|
||||
## 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-
|
||||
Klasse, solides Deutsch, Apache-2.0. Alt.: Mistral Small 3 7B (schneller,
|
||||
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`).
|
||||
- **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.)
|
||||
|
||||
## 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:
|
||||
|
||||
- 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
|
||||
sich mit dem RVS-Token, lauscht auf `llm_request`, ruft lokal llama-server,
|
||||
schickt `llm_response` (korreliert per requestId) zurück.
|
||||
@@ -115,7 +115,7 @@ mit Ziel lokal.
|
||||
|
||||
## 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").
|
||||
- **B1 — Router + lokale Tools:** Heuristik Tier-1/2 + Escalation, schlanke
|
||||
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 +
|
||||
Broadcast-Kollisions-Vermeidung (nicht 2× dieselbe Faehigkeit). = Mini-Nomad.
|
||||
|
||||
**Empfehlung:** Fuer 2 Gameboxen NICHT bauen — statische Platzierung reicht
|
||||
(Gamebox1=LLM, Gamebox2=Voice). Dynamisches Laden/Entladen zum VRAM-Freimachen
|
||||
**Empfehlung:** Fuer 2 AI-Boxen NICHT bauen — statische Platzierung reicht
|
||||
(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
|
||||
billigen Heartbeat-Teil; fuer echte Orchestrierung Docker Swarm / Nomad nehmen
|
||||
statt selbst einen Scheduler zu bauen.
|
||||
@@ -227,7 +227,7 @@ lohnt nicht):
|
||||
einen Heartbeat via RVS (Host, GPU-Util, VRAM frei/belegt, laufende
|
||||
GPU-Container). Diagnostic zeigt pro Host VRAM-Balken + Dienste + „Host X hat
|
||||
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.
|
||||
Ohne Orchestrator.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
#!/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
|
||||
flux_response mit base64-PNG zurueck an die aria-bridge. Diese speichert
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# ─── ARIA Host-Agent — Konfiguration ───────────────────────────────
|
||||
# Kopiere diese Datei nach .env (neben die Binary) und passe sie an.
|
||||
|
||||
# RVS-Zugang (identisch zum Haupt-Stack — gleicher Raum/Token, damit ARIA
|
||||
# diesen Rechner erreicht). Werte aus der Haupt-.env.
|
||||
RVS_HOST=rvs.example.de
|
||||
RVS_PORT=443
|
||||
RVS_TLS=true
|
||||
RVS_TLS_FALLBACK=true # bei TLS-Fehlschlag einmal auf ws:// zurueckfallen
|
||||
RVS_TOKEN=
|
||||
# RVS_SNI: nur noetig, wenn RVS_HOST eine IP ist (Agent im selben Netz wie der
|
||||
# RVS, direkt auf die interne IP). Dann hier den Zertifikats-/Hostnamen angeben,
|
||||
# damit der TLS-Handshake (SNI) passt. Sonst leer lassen.
|
||||
# RVS_HOST=10.0.0.2
|
||||
# RVS_SNI=example.com
|
||||
RVS_SNI=
|
||||
|
||||
# ─── Identitaet dieses Rechners ────────────────────────────────────
|
||||
# HOST_ID = technisch eindeutig (a-z0-9-_), Default = Hostname-Slug.
|
||||
# HOST_NAME = menschlicher Name, so spricht ARIA den Rechner an ("Stefans Laptop").
|
||||
HOST_ID=
|
||||
HOST_NAME=
|
||||
|
||||
# ─── Steuerung (Sicherheit!) ───────────────────────────────────────
|
||||
# MUSS auf true, sonst fuehrt der Agent nichts aus (reiner Idle-Client).
|
||||
CONTROL_ENABLED=true
|
||||
|
||||
# ─── sudo ──────────────────────────────────────────────────────────
|
||||
# Reihenfolge: 1) Agent laeuft als root -> braucht kein sudo. 2) SUDO_PASSWORD
|
||||
# gesetzt -> sudo -S mit Passwort. 3) SUDO_NOPASSWD=true -> sudo -n (Live-ISO /
|
||||
# passwortloses sudo, z.B. Linux Mint vom Stick). 4) sonst schlaegt sudo fehl.
|
||||
SUDO_PASSWORD=
|
||||
SUDO_NOPASSWD=false
|
||||
|
||||
# ─── Limits (optional) ─────────────────────────────────────────────
|
||||
EXEC_TIMEOUT=60 # max. Laufzeit eines Kommandos (s)
|
||||
OUT_MAX_CHARS=20000 # stdout-Ausschnitt (offset/max_chars pro Request)
|
||||
FILE_MAX_BYTES=10485760 # max. Datei-Transfer (10 MB)
|
||||
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
build/
|
||||
dist/
|
||||
.buildenv/
|
||||
*.spec
|
||||
__pycache__/
|
||||
@@ -0,0 +1,24 @@
|
||||
# Baut die Host-Agent-Binary mit PyInstaller in einem Container mit ALTEM glibc
|
||||
# (bullseye, glibc 2.31), damit die Onefile-Binary auf moeglichst vielen Linux-
|
||||
# Distributionen laeuft (glibc ist abwaerts-, nicht aufwaertskompatibel).
|
||||
FROM python:3.11-slim-bullseye
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# PyInstaller braucht objdump aus binutils (im slim-Image nicht enthalten).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends binutils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir pyinstaller
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY host_agent.py .
|
||||
|
||||
# Onefile-Binary; psutil-Hidden-Imports werden von PyInstaller erkannt.
|
||||
RUN pyinstaller --onefile --name aria-host-agent \
|
||||
--collect-all psutil \
|
||||
host_agent.py
|
||||
|
||||
# Ergebnis liegt in /build/dist/aria-host-agent
|
||||
CMD ["sh", "-c", "cp /build/dist/aria-host-agent /out/ && echo 'Binary -> /out/aria-host-agent'"]
|
||||
@@ -0,0 +1,36 @@
|
||||
# Baut die Windows-.exe des Host-Agents AUF LINUX — via Wine + Windows-Python +
|
||||
# PyInstaller. tobix/pywine bringt Wine + Windows-Python 3.11 mit (PyInstaller
|
||||
# kann NICHT cross-compilen, deshalb der Wine-Umweg).
|
||||
#
|
||||
# Zusaetzlich baut NSIS ein setup.exe, das die Agent-.exe installiert, eine .env
|
||||
# in C:\ProgramData\ARIA-Host-Agent anlegt (falls keine da ist) und den Agent als
|
||||
# automatisch startenden Windows-Dienst (via nssm) einrichtet.
|
||||
FROM tobix/pywine:3.11
|
||||
|
||||
ARG VERSION=0.0.0
|
||||
|
||||
# NSIS (Installer-Compiler, laeuft nativ auf Linux) + Tools fuer nssm.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nsis curl unzip ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
# nssm — Non-Sucking Service Manager (public domain): macht aus der Konsolen-.exe
|
||||
# einen sauberen Windows-Dienst (die .exe selbst spricht das SCM nicht).
|
||||
RUN curl -fsSL https://nssm.cc/release/nssm-2.24.zip -o /tmp/nssm.zip \
|
||||
&& unzip -q /tmp/nssm.zip -d /tmp \
|
||||
&& cp /tmp/nssm-2.24/win64/nssm.exe ./nssm.exe \
|
||||
&& rm -rf /tmp/nssm*
|
||||
|
||||
COPY requirements.txt host_agent.py ./
|
||||
COPY windows/installer.nsi ./
|
||||
|
||||
# Windows-Python-Deps + PyInstaller, dann die Onefile-.exe bauen.
|
||||
RUN wine pip install --no-cache-dir -r requirements.txt pyinstaller
|
||||
RUN wine pyinstaller --onefile --name aria-host-agent --collect-all psutil host_agent.py \
|
||||
&& cp dist/aria-host-agent.exe ./aria-host-agent.exe \
|
||||
&& makensis -DVERSION=${VERSION} installer.nsi
|
||||
|
||||
# Beide Artefakte rausreichen (dist/ wird vom build-win.sh als Volume gemountet).
|
||||
CMD ["bash","-lc","cp dist/aria-host-agent.exe /out/ && cp aria-host-agent-setup.exe /out/ && echo 'OK -> /out/aria-host-agent.exe + /out/aria-host-agent-setup.exe'"]
|
||||
@@ -0,0 +1,178 @@
|
||||
# ARIA Host-Agent
|
||||
|
||||
Ein schlanker Agent, der **direkt auf einem Rechner** läuft und ARIA erlaubt,
|
||||
diesen Rechner zu steuern — auch wenn er sonst aus dem Netz **nicht erreichbar**
|
||||
ist (hinter NAT/Firewall, kein offener Port). Der Agent verbindet sich
|
||||
**ausgehend** zum RVS (gleicher Token wie der Rest von ARIA).
|
||||
|
||||
Unterschied zum **Satelliten**: der Satellit entdeckt und steuert *andere*
|
||||
Geräte in einem LAN; der Host-Agent steuert *den Rechner, auf dem er läuft*.
|
||||
|
||||
## Fähigkeiten
|
||||
|
||||
| Aktion | Was |
|
||||
|--------------|-----|
|
||||
| `exec` | Shell-Kommando ausführen (optional `sudo`), stdout/stderr/exit |
|
||||
| `read` | Datei lesen (Base64, mit Offset/Limit) |
|
||||
| `write` | Datei schreiben/anhängen (Base64 oder Text) |
|
||||
| `info` | OS, CPU/RAM/Disk-Auslastung, Uptime, IP |
|
||||
| `screenshot` | Bildschirmfoto (X11: scrot/maim · Wayland: grim) |
|
||||
|
||||
ARIA nutzt diese über die Brain-Tools `host_list` / `host_exec` / `host_read` /
|
||||
`host_write` / `host_info` / `host_screenshot`.
|
||||
|
||||
## Plattformen
|
||||
|
||||
Eine Codebasis, läuft auf **Linux, macOS und Windows** (der Agent wählt Shell,
|
||||
Screenshot-Methode und Root/Admin-Check je OS automatisch):
|
||||
|
||||
| | exec | Root/Admin | Screenshot |
|
||||
|---|---|---|---|
|
||||
| **Linux** | `bash -lc` | sudo (`SUDO_PASSWORD`/`SUDO_NOPASSWD`) / root | grim (Wayland) · scrot/maim (X11) |
|
||||
| **macOS** | `bash -lc` | sudo (wie Linux) | `screencapture` (Bordmittel) |
|
||||
| **Windows** | PowerShell | Agent **als Administrator** starten (kein sudo) | PowerShell/System.Drawing (Bordmittel) |
|
||||
|
||||
PyInstaller kann **nicht cross-kompilieren** — jede Binary wird auf ihrem OS gebaut.
|
||||
|
||||
## Bauen
|
||||
|
||||
**Linux (portabel, empfohlen)** — Docker-Container mit altem glibc:
|
||||
```bash
|
||||
./build.sh # -> dist/aria-host-agent (~15 MB, läuft auf vielen Distros)
|
||||
```
|
||||
|
||||
**Linux/macOS ohne Docker** — PyInstaller direkt (linkt gegen lokales glibc/OS):
|
||||
```bash
|
||||
./build-native.sh # -> dist/aria-host-agent
|
||||
```
|
||||
|
||||
**Windows — nativ** (auf einem Windows-Rechner, Python 3 im PATH nötig):
|
||||
```bat
|
||||
build-native.bat REM -> dist\aria-host-agent.exe
|
||||
```
|
||||
|
||||
**Windows — aus Docker heraus (auf Linux!), inkl. Installer** — Wine baut die
|
||||
`.exe`, NSIS packt ein `setup.exe`, das den Agent als Windows-Dienst einrichtet:
|
||||
```bash
|
||||
./build-win.sh [version]
|
||||
# -> dist/aria-host-agent.exe (Konsolen-Binary)
|
||||
# -> dist/aria-host-agent-setup.exe (Installer: Dienst + .env in ProgramData)
|
||||
```
|
||||
Der erste Lauf zieht das `tobix/pywine`-Image (~1–2 GB) und richtet die Wine-
|
||||
Python-Umgebung ein — das dauert; Folge-Builds sind schnell. PyInstaller kann
|
||||
nicht cross-compilen, deshalb der Wine-Umweg. **macOS geht so NICHT** (Apple
|
||||
lässt sich nicht legal aus Docker bauen) — dort `./build-native.sh` auf einem Mac.
|
||||
|
||||
### Docker scheitert? (Live-ISO / overlayfs-Root)
|
||||
|
||||
Wenn `build.sh` mit `failed to mount … overlayfs … invalid argument` abbricht,
|
||||
läufst du wahrscheinlich auf einem **Live-System** (Live-ISO). Dessen Root ist
|
||||
selbst ein overlayfs, und Dockers `overlay2`-Treiber kann kein Overlay-auf-
|
||||
Overlay stapeln. Zwei Auswege:
|
||||
|
||||
- **Empfohlen:** Binary auf einem normal installierten Linux bauen (`./build.sh`)
|
||||
und nur die fertige `dist/aria-host-agent` aufs Live-System kopieren. Die
|
||||
Binary ist portabel — Ziel braucht weder Docker noch Python.
|
||||
- **Nativ bauen (ohne Docker):**
|
||||
```bash
|
||||
sudo apt install -y python3-pip python3-venv
|
||||
./build-native.sh
|
||||
```
|
||||
Achtung: nativ gebaut linkt die Binary gegen das glibc **dieser** Maschine —
|
||||
sie läuft dann nur auf Systemen mit gleichem oder neuerem glibc.
|
||||
|
||||
## Installieren
|
||||
|
||||
1. `dist/aria-host-agent` auf den Ziel-Rechner kopieren.
|
||||
2. `.env.example` → `.env` daneben, RVS-Zugang + `CONTROL_ENABLED=true` eintragen.
|
||||
3. Starten: `chmod +x aria-host-agent && ./aria-host-agent`
|
||||
|
||||
### Als systemd-Dienst (empfohlen für Dauerbetrieb)
|
||||
|
||||
Der Installer kopiert Binary + `.env` an ihre Plätze und richtet den Dienst ein:
|
||||
|
||||
```bash
|
||||
# .env-Pfad direkt übergeben:
|
||||
sudo ./install-service.sh /pfad/zur/.env
|
||||
|
||||
# ODER ohne Argument -> ncurses-Dateidialog (dialog) zum Auswählen der .env:
|
||||
sudo ./install-service.sh
|
||||
```
|
||||
|
||||
Er legt ab:
|
||||
- Binary → `/usr/local/bin/aria-host-agent`
|
||||
- `.env` → `/etc/aria-host-agent/.env` (Rechte `0600`, enthält Token/Passwörter)
|
||||
- Unit → `/etc/systemd/system/aria-host-agent.service`, dann `enable --now`.
|
||||
|
||||
Danach: `systemctl status aria-host-agent` · `journalctl -u aria-host-agent -f`.
|
||||
Die Binary sucht er unter `dist/aria-host-agent` bzw. `./aria-host-agent` (oder
|
||||
2. Argument). Braucht `dialog` für den Dateibrowser (bietet die Installation an).
|
||||
|
||||
### Windows-Dienst (setup.exe)
|
||||
|
||||
`aria-host-agent-setup.exe` (aus `build-win.sh` oder dem Gitea-Release) als
|
||||
Administrator ausführen. Der Installer:
|
||||
- kopiert die `.exe` nach `%ProgramFiles%\ARIA Host-Agent`,
|
||||
- legt `%ProgramData%\ARIA-Host-Agent\.env` an (nur falls noch keine da ist),
|
||||
- richtet über **nssm** den Dienst **ARIA Host-Agent** ein (Autostart) und startet ihn.
|
||||
|
||||
Danach die `.env` unter `%ProgramData%\ARIA-Host-Agent\` mit RVS-Zugang +
|
||||
`CONTROL_ENABLED=true` füllen und den Dienst neu starten (`services.msc` →
|
||||
*ARIA Host-Agent*, oder `nssm restart ARIAHostAgent`). Deinstallation über
|
||||
*Apps & Features* → *ARIA Host-Agent* (die `.env` in ProgramData bleibt erhalten).
|
||||
|
||||
## Release (Binaries als Gitea-Assets)
|
||||
|
||||
`release_agent.sh <version>` baut alles Docker-Baubare und hängt es als
|
||||
**Release-Asset** an den Tag `agent-v<version>` — nichts landet im Git-Tree:
|
||||
|
||||
```bash
|
||||
./release_agent.sh 0.2.0 # Linux + Android + Windows (Wine)
|
||||
SKIP_WINDOWS=1 ./release_agent.sh 0.2.0 # ohne Windows (schneller)
|
||||
```
|
||||
|
||||
Assets: `aria-host-agent-linux-x64`, `aria-host-agent-android-agent-v<v>.apk`,
|
||||
`aria-host-agent-windows.exe`, `aria-host-agent-windows-setup.exe`. **macOS** ist
|
||||
nicht Docker-baubar — auf einem Mac `./build-native.sh` laufen lassen und das
|
||||
Ergebnis vor dem Release nach `dist/aria-host-agent-macos` legen, dann nimmt das
|
||||
Skript es automatisch mit. Gitea-Zugang via `.env`/Umgebung (`GITEA_URL`,
|
||||
`GITEA_REPO`, `GITEA_USER`), Kennwort wird abgefragt.
|
||||
|
||||
## TLS / SNI — Agent im selben Netz wie der RVS
|
||||
|
||||
Steht der Rechner im **selben Netz wie der RVS** (z.B. Rechenzentrum) und soll
|
||||
direkt auf dessen **interne IP** verbinden (kein NAT-Hairpin über den externen
|
||||
Hostnamen), scheitert der TLS-Handshake sonst an `tlsv1 alert internal error`
|
||||
(Caddy hat kein Zertifikat für die IP). Lösung — in der `.env`:
|
||||
|
||||
```
|
||||
RVS_HOST=10.0.0.2 # interne RVS-IP
|
||||
RVS_SNI=example.com # Name, für den das Caddy-Zert gilt
|
||||
```
|
||||
|
||||
Der Agent verbindet dann auf die IP, präsentiert aber den Namen im TLS-SNI.
|
||||
Zuhause / im Normalfall `RVS_SNI` leer lassen und den Hostnamen als `RVS_HOST`.
|
||||
|
||||
## sudo
|
||||
|
||||
Vier Fälle, der Agent wählt automatisch:
|
||||
|
||||
1. **Agent läuft als root** (z.B. systemd `User=root`) → volle Rechte, kein sudo nötig.
|
||||
2. `SUDO_PASSWORD=…` in der `.env` → `sudo -S` mit Passwort.
|
||||
3. `SUDO_NOPASSWD=true` → `sudo -n` (Live-ISO / passwortloses sudo, z.B. Linux
|
||||
Mint vom Stick).
|
||||
4. sonst → sudo-Kommandos scheitern mit klarer Meldung.
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- Reagiert **nur** auf den eigenen RVS-Raum (Token) und **nur**, wenn
|
||||
`CONTROL_ENABLED=true`.
|
||||
- Keine offenen Ports (reiner ausgehender Client).
|
||||
- Alle Kommandos werden geloggt.
|
||||
- Der Agent gibt **vollen** Zugriff auf den Rechner — nur auf Maschinen
|
||||
einsetzen, denen du ARIA anvertraust.
|
||||
|
||||
## Hinweis Screenshot
|
||||
|
||||
Als Systemdienst fehlt die grafische Session. Für `screenshot` den Agent in der
|
||||
Desktop-Session starten (Autostart) oder `DISPLAY`/`XAUTHORITY` in der Unit setzen.
|
||||
@@ -0,0 +1,11 @@
|
||||
build/
|
||||
.gradle/
|
||||
dist/
|
||||
*.apk
|
||||
local.properties
|
||||
.idea/
|
||||
*.iml
|
||||
captures/
|
||||
|
||||
# Signaturschluessel — NUR lokal, niemals ins oeffentliche Repo (Backup machen!)
|
||||
aria-agent.keystore
|
||||
@@ -0,0 +1,32 @@
|
||||
# Baut die Android-Agent-APK (Debug, auto-signiert -> direkt installierbar).
|
||||
# APK-Builds gehen nur unter Linux — deshalb im Container.
|
||||
FROM eclipse-temurin:17-jdk-jammy
|
||||
|
||||
ARG GRADLE_VERSION=8.5
|
||||
ARG CMDLINE_TOOLS=11076708
|
||||
ENV ANDROID_SDK_ROOT=/opt/android-sdk
|
||||
ENV ANDROID_HOME=/opt/android-sdk
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
unzip wget ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Gradle
|
||||
RUN wget -q https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip -O /tmp/g.zip \
|
||||
&& unzip -q /tmp/g.zip -d /opt && rm /tmp/g.zip
|
||||
|
||||
# Android cmdline-tools + SDK
|
||||
RUN mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools \
|
||||
&& wget -q https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip -O /tmp/c.zip \
|
||||
&& unzip -q /tmp/c.zip -d ${ANDROID_SDK_ROOT}/cmdline-tools && rm /tmp/c.zip \
|
||||
&& mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest
|
||||
|
||||
ENV PATH="/opt/gradle-${GRADLE_VERSION}/bin:${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools:${PATH}"
|
||||
|
||||
RUN yes | sdkmanager --licenses >/dev/null 2>&1 || true
|
||||
RUN sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0" >/dev/null 2>&1
|
||||
|
||||
WORKDIR /project
|
||||
COPY . /project
|
||||
|
||||
CMD ["bash","-lc","gradle --no-daemon assembleDebug && cp app/build/outputs/apk/debug/app-debug.apk /out/aria-android-agent.apk && echo 'OK -> /out/aria-android-agent.apk'"]
|
||||
@@ -0,0 +1,194 @@
|
||||
# ARIA Android-Agent
|
||||
|
||||
Ein **nativer Android-Agent** (eigene APK), der ARIA erlaubt, ein Smartphone
|
||||
**fernzusteuern** — inkl. Bedienen fremder App-UIs (z.B. „ARIA, richte auf dem
|
||||
Handy ein E-Mail-Konto ein"). Gegenstück zum Desktop-`host-agent` (Linux/macOS/
|
||||
Windows), aber Android ist kein Unix-Shell-System — deshalb ein **anderes
|
||||
Action-Set** (UI-Automation statt beliebiger Shell-Kommandos).
|
||||
|
||||
Verbindet sich wie die ARIA-App **ausgehend** zum RVS (gleicher Token/Raum),
|
||||
Verbindungs-Setup per **QR-Scan oder manueller Eingabe**. Taucht in der
|
||||
Diagnostic unter **Satelliten → Host-Agenten 💻** auf (`host_hello` mit
|
||||
`os="Android …"` + Android-Caps).
|
||||
|
||||
## Warum nativ (Kotlin), nicht Termux/RN
|
||||
|
||||
- **UI-Automation** (fremde Apps bedienen) geht auf Android nur über einen
|
||||
**AccessibilityService** — den kann nur eine native App bereitstellen.
|
||||
- **Screenshots** einer laufenden Session: **MediaProjection** (native).
|
||||
- **Dauerbetrieb**: Foreground-Service mit Notification (native).
|
||||
- Termux gäbe nur Shell + `termux-api` (SMS/Anruf/Standort …), **kein** Bedienen
|
||||
anderer App-UIs. Für „E-Mail-Konto durchklicken" reicht das nicht.
|
||||
|
||||
Tech: **Kotlin**, OkHttp-WebSocket (RVS-Client), CameraX/ML-Kit (QR),
|
||||
AccessibilityService (Input), MediaProjection (Screenshot). Build via Docker
|
||||
(Android-SDK + Gradle) → APK. Nur Linux baut APKs (Docker), Deploy manuell.
|
||||
|
||||
## Action-Set (host_command → host_result)
|
||||
|
||||
Android-spezifisch (statt exec/read/write des Desktop-Agents):
|
||||
|
||||
| Action | Was |
|
||||
|---|---|
|
||||
| `screenshot` | Bildschirmfoto (MediaProjection) — ARIA *sieht* den Schirm |
|
||||
| `ui_dump` | Sichtbare UI als Baum (Texte, Buttons, Felder + Koordinaten) — ARIAs „Augen" für gezieltes Tippen |
|
||||
| `ui_tap` | Tippen (x,y ODER auf ein Element aus ui_dump) |
|
||||
| `ui_text` | Text in das fokussierte/angegebene Feld schreiben |
|
||||
| `ui_swipe` | Wischen/Scrollen |
|
||||
| `ui_key` | Systemtasten (BACK, HOME, ENTER …) |
|
||||
| `app_launch` | App per Paketname starten (z.B. E-Mail-App) |
|
||||
| `app_list` | installierte Apps auflisten |
|
||||
| `info` | Gerät: Modell, Android-Version, Akku, Netz, IP |
|
||||
| `notify` | Benachrichtigung anzeigen |
|
||||
| *(später)* | `sms_send`, `call`, `location`, `clipboard` (je nach Bedarf + Berechtigung) |
|
||||
|
||||
ARIA-Flow „E-Mail einrichten": `app_launch` (Mail-App) → `screenshot`/`ui_dump`
|
||||
(sehen, was da ist) → `ui_tap`/`ui_text` (durchklicken) → wieder `ui_dump` prüfen,
|
||||
bis fertig. Genau das agentische Muster wie beim Endian-Fix, nur mit Handy-UI.
|
||||
|
||||
## Dauerbetrieb — Foreground-Service vs. Push (FCM)
|
||||
|
||||
Der Agent muss **immer erreichbar** sein, obwohl Android Hintergrundprozesse
|
||||
aggressiv killt (Doze, App-Standby, OEM-Batterie-Manager wie Xiaomi/Huawei).
|
||||
Zwei Wege, deine WhatsApp-Intuition trifft ins Schwarze:
|
||||
|
||||
**A) Foreground-Service (persistente WebSocket)** — der einfache Start:
|
||||
- Dauerhafte RVS-Verbindung + Foreground-Notification („Agent aktiv").
|
||||
- Braucht: `FOREGROUND_SERVICE`, **Akku-Optimierung ausnehmen**
|
||||
(`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` — User whitelistet die App),
|
||||
`RECEIVE_BOOT_COMPLETED` + BootReceiver (Neustart nach Reboot), Auto-Reconnect
|
||||
(haben wir im Protokoll schon).
|
||||
- **Reutzt unser bestehendes `host_hello`/`host_command`/`host_result` 1:1.**
|
||||
- Nachteil: etwas Akku; manche OEMs killen trotzdem → „Autostart" manuell erlauben.
|
||||
|
||||
**B) Self-hosted Push (KEIN Google!)** — genau wie WhatsApp, aber auf eigenem Server:
|
||||
- **UnifiedPush + self-hosted ntfy**: Auf dem ARIA-Server läuft **ntfy** (freier,
|
||||
self-hostbarer Push-Server). Der Agent nutzt **UnifiedPush** (offener Standard,
|
||||
de-Google-Welt/F-Droid) mit dem ntfy-Distributor auf dem Handy. Will ARIA etwas,
|
||||
POSTet die Bridge/RVS an ntfy → weckt die App → sie holt den Befehl vom RVS,
|
||||
arbeitet, antwortet. **Läuft auch auf Custom-ROMs OHNE Google Play Services.**
|
||||
- Akkuschonend wie FCM, aber ohne jede Google-Abhängigkeit. Nur ein Dienst mehr
|
||||
(ntfy) im Stack + der Push-Auslöser serverseitig.
|
||||
|
||||
**C) FCM (Google) — optional:** Wer ein Stock-Android mit Play Services hat und
|
||||
Googles Push-Kanal will, kann FCM statt ntfy nehmen (bester Akku auf GMS-Geräten).
|
||||
Braucht Firebase-Projekt + Play Services. **Nur eine Option, keine Pflicht.**
|
||||
|
||||
**Custom-ROM ohne Google:** → Weg **A** (eigener Socket) oder **B** (self-hosted
|
||||
ntfy). Beide brauchen KEIN Google. Für Stefans dediziertes Ziel-Handy ist **A**
|
||||
sogar oft die einfachste Dauerlösung (unser eigener „Push" über den RVS-Socket).
|
||||
|
||||
**Hybrid (ideal, End-Ausbau):** Im Leerlauf nur Push (max. Akku). Ein Push weckt
|
||||
die App → sie öffnet die RVS-Verbindung, hält sich per Wakelock für die Interaktion
|
||||
wach (mehrere Befehle flüssig, z.B. E-Mail-Setup durchklicken) → schläft nach ein
|
||||
paar Sekunden Ruhe wieder ein. So WhatsApp-Akku UND schnelle Multi-Befehl-Sessions.
|
||||
Der Push kommt dabei von **B (self-hosted ntfy)** oder C (FCM) — freie Wahl.
|
||||
Erste Push-Latenz aus tiefem Doze ~1–3 s; danach bleibt der Socket die Session offen.
|
||||
|
||||
**Empfehlung:** Meilenstein 1–3 mit **A (Foreground-Service)** — läuft sofort und
|
||||
nutzt alles Vorhandene, damit wir schnell einen funktionierenden Agenten haben, ganz
|
||||
ohne externe Dienste. Dann **Push-Hybrid mit self-hosted ntfy (B)** als Akku-Ausbau —
|
||||
KEIN Google. FCM (C) nur optional für Stock-Android. Action-Set/Protokoll bleiben
|
||||
identisch, nur der Wecker ändert sich.
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- Reagiert nur auf den eigenen RVS-Raum (Token); Setup per QR/manuell.
|
||||
- **CONTROL_ENABLED**-Schalter in der App (Default AUS) — erst wenn Stefan es
|
||||
bewusst aktiviert, führt der Agent Aktionen aus.
|
||||
- AccessibilityService + MediaProjection müssen vom User **explizit** in den
|
||||
Android-Einstellungen freigegeben werden (kein stiller Zugriff möglich).
|
||||
- Alle Aktionen werden protokolliert (In-App-Log + optional an ARIA).
|
||||
- Voller Gerätezugriff — nur auf eigenen/anvertrauten Geräten nutzen.
|
||||
|
||||
## Meilensteine
|
||||
|
||||
1. **✅ Verbinden + sichtbar** — Gradle-Projekt, AndroidManifest, RVS-WS-Client,
|
||||
Foreground-Service, Connect-UI (QR-Scan + manuell), `host_hello`/`host_ping`.
|
||||
→ Agent erscheint in der Diagnostic. `info` funktioniert.
|
||||
2. **✅ Sehen** — MediaProjection-Screenshot (`ScreenCapturer`, gleicher
|
||||
`{format,bytes,base64}`-Vertrag wie der Desktop-Agent → `host_screenshot`) +
|
||||
`ui_dump` (`AriaAccessibilityService`, nur lesend → Brain-Tool `host_ui_dump`).
|
||||
Freigabe einmalig in der App: „Bildschirm-Zugriff erlauben" + „Bedienungshilfe
|
||||
öffnen". → ARIA sieht den Schirm und liest die UI-Elemente mit Koordinaten.
|
||||
3. **Steuern** — `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` über den
|
||||
AccessibilityService. → ARIA bedient Apps (E-Mail-Setup).
|
||||
4. **Feinschliff** — `info`/`app_list`/`notify`, Build-Härtung, `release.sh`
|
||||
(Version-Param → Gitea-Release-Asset, wie die App).
|
||||
|
||||
## Bauen
|
||||
|
||||
APK-Builds laufen **nur unter Linux** — deshalb im Docker-Container. Du brauchst
|
||||
nichts Android-spezifisches installiert, **nur Docker**. Android-SDK, Gradle und
|
||||
Build-Tools zieht der Container selbst (`Dockerfile.build`).
|
||||
|
||||
```bash
|
||||
cd host-agent/android
|
||||
./build.sh
|
||||
```
|
||||
|
||||
`build.sh` baut das Image `aria-android-agent-build` und lässt darin
|
||||
`gradle assembleDebug` laufen. Ergebnis:
|
||||
|
||||
```
|
||||
host-agent/android/dist/aria-android-agent.apk
|
||||
```
|
||||
|
||||
Das ist ein **Debug-APK**: auto-signiert mit dem Android-Debug-Key, also direkt
|
||||
installierbar — ohne eigenen Keystore, ohne Play Store.
|
||||
|
||||
### Auf dem Handy installieren
|
||||
|
||||
1. `dist/aria-android-agent.apk` aufs Zielgerät kopieren (USB, Cloud, `adb install
|
||||
dist/aria-android-agent.apk`, …).
|
||||
2. Antippen → Android fragt nach **„Unbekannte Quellen / Aus dieser Quelle
|
||||
installieren erlauben"** → erlauben.
|
||||
3. App öffnen → verbinden (QR/manuell), „Steuerung erlauben" an, für M2 zusätzlich
|
||||
„Bildschirm-Zugriff erlauben" + „Bedienungshilfe öffnen".
|
||||
|
||||
### Was der erste Build kostet
|
||||
|
||||
Der **erste** Lauf lädt viel (JDK-Image, Gradle, Android-SDK, Dependencies) und
|
||||
dauert entsprechend — mehrere Minuten. Folge-Builds sind schnell (Docker-Layer +
|
||||
Gradle-Cache im Image). Häufige Stolperer:
|
||||
|
||||
- **Docker fehlt / kein Zugriff** → `docker`-Rechte prüfen (`docker ps`).
|
||||
- **Overlay-on-Overlay auf einem Live-ISO** („invalid argument" beim Image-Bau) —
|
||||
gleiches Problem wie beim Desktop-Agent auf dem Mint-Live-System; von einer
|
||||
installierten Linux-Kiste bauen.
|
||||
- **Kotlin-/Manifest-Fehler** beim allerersten Bau eines neuen Meilensteins: den
|
||||
Gradle-Fehler posten, das glätten wir schnell.
|
||||
|
||||
### Version setzen
|
||||
|
||||
Bis es `release.sh` gibt, wird die Version in `app/build.gradle` gepflegt
|
||||
(`versionCode` / `versionName`). Aktuell `1` / `0.2.0` (M1+M2).
|
||||
|
||||
## Release
|
||||
|
||||
Das APK ist **kein** Teil des Git-Trees (blaeht sonst die History dauerhaft auf) —
|
||||
es wird als **Release-Asset** an einen Tag gehaengt. Das macht `release_agent.sh`
|
||||
(liegt eine Ebene hoeher, in `host-agent/`):
|
||||
|
||||
```bash
|
||||
cd host-agent
|
||||
./release_agent.sh 0.2.0
|
||||
```
|
||||
|
||||
Das Skript (wie die `release.sh` der Haupt-App, Version als Parameter):
|
||||
|
||||
- setzt die Version (`host_agent.py` → `AGENT_VERSION`, `app/build.gradle` →
|
||||
`versionName`/`versionCode`),
|
||||
- baut **Linux-Binary + Android-APK** per Docker,
|
||||
- committet den Version-Bump, taggt **`agent-v<version>`** (eigener Namespace,
|
||||
kollidiert nicht mit den App-Tags `v<version>`) und pusht,
|
||||
- legt ein Gitea-Release an und laedt die Assets hoch:
|
||||
`aria-host-agent-linux-x64`, `aria-host-agent-android-agent-v<version>.apk`,
|
||||
optional `-macos` / `-windows.exe` (falls in `host-agent/dist/` vorgebaut).
|
||||
|
||||
Gitea-Zugang (`GITEA_URL`, `GITEA_REPO`, `GITEA_USER`) kommt aus der Umgebung oder
|
||||
einer `.env`; das Kennwort wird interaktiv abgefragt. **Binaries landen unter
|
||||
„Releases", nie im Tree.**
|
||||
|
||||
> Status: **M1 + M2 fertig** (verbinden, `info`, `screenshot`, `ui_dump`),
|
||||
> `release_agent.sh` vorhanden. Als Nächstes Meilenstein 3 (Steuern).
|
||||
@@ -0,0 +1,57 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'de.hackersoft.ariaagent'
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId 'de.hackersoft.ariaagent'
|
||||
minSdk 26
|
||||
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
|
||||
versionCode 3
|
||||
versionName '0.0.0.3'
|
||||
}
|
||||
|
||||
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
|
||||
// Android neue APKs als Update derselben App akzeptiert (sonst "Konflikt mit
|
||||
// bestehendem Paket"). Die Keystore-Datei liegt NUR lokal (gitignored, nicht
|
||||
// im oeffentlichen Repo) — UNBEDINGT sichern, sonst brechen kuenftige Updates.
|
||||
def ariaKeystore = rootProject.file('aria-agent.keystore')
|
||||
signingConfigs {
|
||||
aria {
|
||||
if (ariaKeystore.exists()) {
|
||||
storeFile ariaKeystore
|
||||
storePassword 'ariaagent'
|
||||
keyAlias 'aria'
|
||||
keyPassword 'ariaagent'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
if (ariaKeystore.exists()) signingConfig signingConfigs.aria
|
||||
}
|
||||
release {
|
||||
minifyEnabled false
|
||||
if (ariaKeystore.exists()) signingConfig signingConfigs.aria
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0' // RVS-WebSocket
|
||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0' // QR-Scan (FOSS, kein Google-Dienst)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.AppCompat.DayNight">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".AgentService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name=".AriaAccessibilityService"
|
||||
android:exported="false"
|
||||
android:label="ARIA Host-Agent"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/accessibility_config" />
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.hackersoft.ariaagent
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
|
||||
/** Verbindungs-/Agent-Einstellungen (in SharedPreferences persistiert). */
|
||||
data class AgentConfig(
|
||||
var host: String = "",
|
||||
var port: Int = 443,
|
||||
var tls: Boolean = true,
|
||||
var token: String = "",
|
||||
var name: String = "",
|
||||
var controlEnabled: Boolean = false,
|
||||
) {
|
||||
companion object {
|
||||
private const val PREFS = "aria_agent"
|
||||
|
||||
fun load(ctx: Context): AgentConfig {
|
||||
val p = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
return AgentConfig(
|
||||
host = p.getString("host", "") ?: "",
|
||||
port = p.getInt("port", 443),
|
||||
tls = p.getBoolean("tls", true),
|
||||
token = p.getString("token", "") ?: "",
|
||||
name = p.getString("name", "") ?: "",
|
||||
controlEnabled = p.getBoolean("control", false),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun save(ctx: Context) {
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().apply {
|
||||
putString("host", host)
|
||||
putInt("port", port)
|
||||
putBoolean("tls", tls)
|
||||
putString("token", token)
|
||||
putString("name", name)
|
||||
putBoolean("control", controlEnabled)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun isValid(): Boolean = host.isNotBlank() && token.isNotBlank()
|
||||
|
||||
fun displayName(): String = if (name.isNotBlank()) name else Build.MODEL
|
||||
|
||||
/** Stabile, technische Host-ID (a-z0-9_-), wie beim Desktop-Agent. */
|
||||
fun hostId(): String {
|
||||
val base = displayName().lowercase()
|
||||
.replace(Regex("[^a-z0-9_-]+"), "-").trim('-')
|
||||
return base.ifBlank { "android" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package de.hackersoft.ariaagent
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
/**
|
||||
* Foreground-Service (Weg A): haelt die RVS-Verbindung dauerhaft, damit ARIA
|
||||
* den Agenten jederzeit erreicht. Persistente Notification + Auto-Reconnect.
|
||||
*/
|
||||
class AgentService : Service() {
|
||||
|
||||
private var rvs: RvsClient? = null
|
||||
|
||||
companion object {
|
||||
private const val CH = "aria_agent"
|
||||
private const val NOTIF_ID = 1
|
||||
const val ACTION_STATUS = "de.hackersoft.ariaagent.STATUS"
|
||||
const val ACTION_PROJECTION = "de.hackersoft.ariaagent.PROJECTION"
|
||||
const val EXTRA_RESULT_CODE = "resultCode"
|
||||
const val EXTRA_RESULT_DATA = "resultData"
|
||||
|
||||
@Volatile var status: String = "gestoppt"
|
||||
@Volatile var connected: Boolean = false
|
||||
|
||||
fun start(ctx: Context) {
|
||||
val i = Intent(ctx, AgentService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= 26) ctx.startForegroundService(i) else ctx.startService(i)
|
||||
}
|
||||
|
||||
fun stop(ctx: Context) {
|
||||
ctx.stopService(Intent(ctx, AgentService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
startForeground(NOTIF_ID, buildNotification("startet …"))
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// WICHTIG: Jeder startForegroundService()-Aufruf MUSS binnen ~5s mit
|
||||
// startForeground() beantwortet werden — sonst crasht Android den Prozess
|
||||
// (ForegroundServiceDidNotStartInTimeException). Der Projection-Intent kommt
|
||||
// per startForegroundService, obwohl der Dienst schon laeuft -> hier IMMER
|
||||
// zuerst startForeground aufrufen (idempotent).
|
||||
startForeground(NOTIF_ID, buildNotification(status))
|
||||
|
||||
if (intent?.action == ACTION_PROJECTION) {
|
||||
val code = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
|
||||
@Suppress("DEPRECATION")
|
||||
val data = intent.getParcelableExtra<Intent>(EXTRA_RESULT_DATA)
|
||||
if (code != 0 && data != null) {
|
||||
try {
|
||||
ScreenCapturer.start(applicationContext, code, data)
|
||||
updateNotification("$status · Bildschirm-Zugriff aktiv")
|
||||
} catch (e: Throwable) {
|
||||
ScreenCapturer.lastError = e.message ?: e.javaClass.simpleName
|
||||
updateNotification("Bildschirm-Fehler: ${ScreenCapturer.lastError}")
|
||||
}
|
||||
}
|
||||
if (rvs == null) startRvs() // Dienst war frisch -> Verbindung nachziehen
|
||||
return START_STICKY
|
||||
}
|
||||
return startRvs()
|
||||
}
|
||||
|
||||
/** (Re-)Startet die RVS-Verbindung anhand der gespeicherten Config. */
|
||||
private fun startRvs(): Int {
|
||||
val cfg = AgentConfig.load(this)
|
||||
if (!cfg.isValid()) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
rvs?.stop()
|
||||
rvs = RvsClient(applicationContext, cfg) { conn, msg ->
|
||||
connected = conn
|
||||
status = msg
|
||||
updateNotification(msg)
|
||||
sendBroadcast(Intent(ACTION_STATUS).setPackage(packageName))
|
||||
}
|
||||
rvs?.start()
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
rvs?.stop()
|
||||
ScreenCapturer.stop()
|
||||
connected = false
|
||||
status = "gestoppt"
|
||||
sendBroadcast(Intent(ACTION_STATUS).setPackage(packageName))
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun createChannel() {
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
val ch = NotificationChannel(CH, "ARIA Agent", NotificationManager.IMPORTANCE_LOW)
|
||||
ch.setShowBadge(false)
|
||||
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
|
||||
.createNotificationChannel(ch)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(text: String): Notification {
|
||||
val pi = PendingIntent.getActivity(
|
||||
this, 0, Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
return NotificationCompat.Builder(this, CH)
|
||||
.setContentTitle("ARIA Host-Agent")
|
||||
.setContentText(text)
|
||||
.setSmallIcon(R.drawable.ic_launcher)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(pi)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun updateNotification(text: String) {
|
||||
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
|
||||
.notify(NOTIF_ID, buildNotification(text))
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package de.hackersoft.ariaagent
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.graphics.Rect
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Bedienungshilfe-Dienst (Meilenstein 2 — nur LESEND).
|
||||
*
|
||||
* Liefert einen strukturierten Baum der sichtbaren Bildschirm-Elemente: Text,
|
||||
* Beschriftung (contentDescription), Klasse, Bildschirm-Position (Mittelpunkt +
|
||||
* Rahmen) und Flags (klickbar/editierbar/ankreuzbar). ARIA nutzt das ergaenzend
|
||||
* zum Screenshot, um Ziele exakt zu benennen. Tippen/Text folgt in M3.
|
||||
*
|
||||
* Der Nutzer schaltet den Dienst einmalig unter Einstellungen > Bedienungshilfen
|
||||
* frei. Er fuehrt hier nichts autonom aus — reagiert nur auf `dump()`.
|
||||
*/
|
||||
class AriaAccessibilityService : AccessibilityService() {
|
||||
|
||||
override fun onServiceConnected() {
|
||||
instance = this
|
||||
}
|
||||
|
||||
override fun onUnbind(intent: android.content.Intent?): Boolean {
|
||||
if (instance === this) instance = null
|
||||
return super.onUnbind(intent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (instance === this) instance = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) { /* passiv */ }
|
||||
override fun onInterrupt() { /* passiv */ }
|
||||
|
||||
/** Strukturierter Baum des aktiven Fensters. Shape: {ok, result:{package,count,nodes[]}}. */
|
||||
fun dump(): JSONObject {
|
||||
val root = rootInActiveWindow
|
||||
?: return JSONObject().put("ok", false)
|
||||
.put("error", "Kein aktives Fenster lesbar (Bildschirm evtl. aus oder gesperrt).")
|
||||
val nodes = JSONArray()
|
||||
try {
|
||||
walk(root, nodes, 0)
|
||||
} finally {
|
||||
@Suppress("DEPRECATION") try { root.recycle() } catch (_: Exception) {}
|
||||
}
|
||||
val result = JSONObject()
|
||||
.put("package", root.packageName?.toString() ?: "")
|
||||
.put("count", nodes.length())
|
||||
.put("nodes", nodes)
|
||||
return JSONObject().put("ok", true).put("result", result)
|
||||
}
|
||||
|
||||
private fun walk(node: AccessibilityNodeInfo?, out: JSONArray, depth: Int) {
|
||||
if (node == null || depth > 40 || out.length() >= 400) return
|
||||
val text = node.text?.toString()?.trim()
|
||||
val desc = node.contentDescription?.toString()?.trim()
|
||||
val cls = node.className?.toString()?.substringAfterLast('.')
|
||||
val interesting = !text.isNullOrBlank() || !desc.isNullOrBlank() ||
|
||||
node.isClickable || node.isEditable || node.isCheckable
|
||||
if (interesting) {
|
||||
val r = Rect()
|
||||
node.getBoundsInScreen(r)
|
||||
val o = JSONObject()
|
||||
if (!text.isNullOrBlank()) o.put("text", text)
|
||||
if (!desc.isNullOrBlank()) o.put("desc", desc)
|
||||
if (cls != null) o.put("cls", cls)
|
||||
if (node.isClickable) o.put("clickable", true)
|
||||
if (node.isEditable) o.put("editable", true)
|
||||
if (node.isCheckable) o.put("checked", node.isChecked)
|
||||
o.put("x", r.centerX())
|
||||
o.put("y", r.centerY())
|
||||
o.put("bounds", "${r.left},${r.top},${r.right},${r.bottom}")
|
||||
out.put(o)
|
||||
}
|
||||
for (i in 0 until node.childCount) {
|
||||
walk(node.getChild(i), out, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
var instance: AriaAccessibilityService? = null
|
||||
|
||||
val available: Boolean get() = instance != null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.hackersoft.ariaagent
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
/** Startet den Agent-Service nach dem Booten wieder (wenn konfiguriert). */
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
|
||||
if (AgentConfig.load(ctx).isValid()) {
|
||||
AgentService.start(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package de.hackersoft.ariaagent
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.SwitchCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import org.json.JSONObject
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var host: EditText
|
||||
private lateinit var port: EditText
|
||||
private lateinit var token: EditText
|
||||
private lateinit var name: EditText
|
||||
private lateinit var tls: SwitchCompat
|
||||
private lateinit var control: SwitchCompat
|
||||
private lateinit var statusView: TextView
|
||||
|
||||
private val scan = registerForActivityResult(ScanContract()) { res ->
|
||||
res.contents?.let { applyQr(it) }
|
||||
}
|
||||
private val camPerm = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) launchScan() else toast("Kamera-Berechtigung nötig für QR-Scan")
|
||||
}
|
||||
private val notifPerm = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
|
||||
private val projection = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { res ->
|
||||
if (res.resultCode == Activity.RESULT_OK && res.data != null) {
|
||||
val i = Intent(this, AgentService::class.java)
|
||||
.setAction(AgentService.ACTION_PROJECTION)
|
||||
.putExtra(AgentService.EXTRA_RESULT_CODE, res.resultCode)
|
||||
.putExtra(AgentService.EXTRA_RESULT_DATA, res.data)
|
||||
ContextCompat.startForegroundService(this, i)
|
||||
toast("Bildschirm-Zugriff aktiv — ARIA kann jetzt Screenshots machen")
|
||||
} else {
|
||||
toast("Bildschirm-Zugriff abgelehnt")
|
||||
}
|
||||
}
|
||||
|
||||
private val statusReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, i: Intent?) = refreshStatus()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
host = findViewById(R.id.host)
|
||||
port = findViewById(R.id.port)
|
||||
token = findViewById(R.id.token)
|
||||
name = findViewById(R.id.name)
|
||||
tls = findViewById(R.id.tls)
|
||||
control = findViewById(R.id.control)
|
||||
statusView = findViewById(R.id.status)
|
||||
|
||||
findViewById<Button>(R.id.btnScan).setOnClickListener {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||
== PackageManager.PERMISSION_GRANTED) launchScan()
|
||||
else camPerm.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
findViewById<Button>(R.id.btnConnect).setOnClickListener { saveAndConnect() }
|
||||
findViewById<Button>(R.id.btnStop).setOnClickListener {
|
||||
AgentService.stop(this)
|
||||
refreshStatus()
|
||||
}
|
||||
findViewById<Button>(R.id.btnScreen).setOnClickListener {
|
||||
if (!AgentService.connected && !AgentConfig.load(this).isValid()) {
|
||||
toast("Erst verbinden, dann Bildschirm-Zugriff erlauben")
|
||||
return@setOnClickListener
|
||||
}
|
||||
val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
projection.launch(mpm.createScreenCaptureIntent())
|
||||
}
|
||||
findViewById<Button>(R.id.btnAccessibility).setOnClickListener {
|
||||
try {
|
||||
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||
toast("'ARIA Host-Agent' in der Liste einschalten")
|
||||
} catch (_: Exception) {
|
||||
toast("Bedienungshilfe-Einstellungen nicht gefunden")
|
||||
}
|
||||
}
|
||||
|
||||
loadIntoUi(AgentConfig.load(this))
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 33 &&
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
notifPerm.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val filter = IntentFilter(AgentService.ACTION_STATUS)
|
||||
ContextCompat.registerReceiver(this, statusReceiver, filter,
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
refreshStatus()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
try { unregisterReceiver(statusReceiver) } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun launchScan() {
|
||||
val o = ScanOptions()
|
||||
.setBeepEnabled(false)
|
||||
.setOrientationLocked(false)
|
||||
.setPrompt("ARIA-Verbindungs-QR scannen")
|
||||
scan.launch(o)
|
||||
}
|
||||
|
||||
private fun applyQr(content: String) {
|
||||
try {
|
||||
val j = JSONObject(content)
|
||||
host.setText(j.optString("host"))
|
||||
port.setText((if (j.has("port")) j.optInt("port", 443) else 443).toString())
|
||||
token.setText(j.optString("token"))
|
||||
if (j.has("tls")) tls.isChecked = j.optBoolean("tls", true)
|
||||
toast("QR übernommen — jetzt 'Speichern & Verbinden'")
|
||||
} catch (_: Exception) {
|
||||
toast("Das ist kein ARIA-Verbindungs-QR")
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadIntoUi(c: AgentConfig) {
|
||||
host.setText(c.host)
|
||||
port.setText(c.port.toString())
|
||||
token.setText(c.token)
|
||||
name.setText(c.name)
|
||||
tls.isChecked = c.tls
|
||||
control.isChecked = c.controlEnabled
|
||||
}
|
||||
|
||||
private fun saveAndConnect() {
|
||||
val c = AgentConfig(
|
||||
host = host.text.toString().trim(),
|
||||
port = port.text.toString().trim().toIntOrNull() ?: 443,
|
||||
tls = tls.isChecked,
|
||||
token = token.text.toString().trim(),
|
||||
name = name.text.toString().trim(),
|
||||
controlEnabled = control.isChecked,
|
||||
)
|
||||
if (!c.isValid()) {
|
||||
toast("Host und Token sind Pflicht")
|
||||
return
|
||||
}
|
||||
c.save(this)
|
||||
AgentService.start(this)
|
||||
toast("Agent gestartet")
|
||||
refreshStatus()
|
||||
}
|
||||
|
||||
private fun refreshStatus() {
|
||||
val dot = if (AgentService.connected) "🟢" else "🔴"
|
||||
statusView.text = "Status: ${AgentService.status} $dot"
|
||||
}
|
||||
|
||||
private fun toast(m: String) = Toast.makeText(this, m, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package de.hackersoft.ariaagent
|
||||
|
||||
import android.content.Context
|
||||
import android.os.BatteryManager
|
||||
import android.os.Build
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* RVS-WebSocket-Client (Meilenstein 1). Spricht dasselbe Protokoll wie der
|
||||
* Desktop-Agent: meldet sich per host_hello, haelt sich per host_ping frisch,
|
||||
* beantwortet host_command -> host_result.
|
||||
*
|
||||
* M1-Aktionen: nur `info`. screenshot/ui_* folgen in M2/M3.
|
||||
*/
|
||||
class RvsClient(
|
||||
private val appCtx: Context,
|
||||
private val config: AgentConfig,
|
||||
private val onStatus: (connected: Boolean, msg: String) -> Unit,
|
||||
) {
|
||||
private val client = OkHttpClient.Builder()
|
||||
.pingInterval(20, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS) // Server-Push: nie lesen-timeouten
|
||||
.build()
|
||||
|
||||
private var ws: WebSocket? = null
|
||||
@Volatile private var running = false
|
||||
private var pingThread: Thread? = null
|
||||
|
||||
private val caps = listOf("info", "screenshot", "ui_dump") // M3: ui_tap/ui_text/…
|
||||
|
||||
fun start() {
|
||||
running = true
|
||||
connect()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
pingThread?.interrupt()
|
||||
try { ws?.close(1000, "bye") } catch (_: Exception) {}
|
||||
ws = null
|
||||
}
|
||||
|
||||
private fun url(): String {
|
||||
val proto = if (config.tls) "wss" else "ws"
|
||||
return "$proto://${config.host}:${config.port}?token=${config.token}"
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
if (!running) return
|
||||
onStatus(false, "verbinde …")
|
||||
val req = Request.Builder().url(url()).build()
|
||||
ws = client.newWebSocket(req, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
onStatus(true, "verbunden")
|
||||
sendHello(webSocket)
|
||||
startPing(webSocket)
|
||||
}
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
handle(webSocket, text)
|
||||
}
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
onStatus(false, "getrennt: ${t.message ?: "?"}")
|
||||
reconnectLater()
|
||||
}
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
onStatus(false, "geschlossen")
|
||||
reconnectLater()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun reconnectLater() {
|
||||
pingThread?.interrupt()
|
||||
if (!running) return
|
||||
Thread {
|
||||
try { Thread.sleep(3000) } catch (_: InterruptedException) { return@Thread }
|
||||
connect()
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun send(webSocket: WebSocket, type: String, payload: JSONObject) {
|
||||
val o = JSONObject()
|
||||
o.put("type", type)
|
||||
o.put("payload", payload)
|
||||
o.put("timestamp", System.currentTimeMillis())
|
||||
try { webSocket.send(o.toString()) } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun sendHello(webSocket: WebSocket) {
|
||||
val p = JSONObject()
|
||||
p.put("hostId", config.hostId())
|
||||
p.put("name", config.displayName())
|
||||
p.put("os", "Android ${Build.VERSION.RELEASE} (${Build.MODEL})")
|
||||
p.put("caps", JSONArray(caps))
|
||||
p.put("control", config.controlEnabled)
|
||||
send(webSocket, "host_hello", p)
|
||||
}
|
||||
|
||||
private fun startPing(webSocket: WebSocket) {
|
||||
pingThread?.interrupt()
|
||||
pingThread = Thread {
|
||||
while (running && !Thread.currentThread().isInterrupted) {
|
||||
try { Thread.sleep(25000) } catch (_: InterruptedException) { break }
|
||||
val p = JSONObject().put("hostId", config.hostId())
|
||||
send(webSocket, "host_ping", p)
|
||||
sendHello(webSocket) // Re-announce (RVS replayt hellos nicht)
|
||||
}
|
||||
}.also { it.start() }
|
||||
}
|
||||
|
||||
private fun handle(webSocket: WebSocket, text: String) {
|
||||
val msg = try { JSONObject(text) } catch (_: Exception) { return }
|
||||
if (msg.optString("type") != "host_command") return
|
||||
val payload = msg.optJSONObject("payload") ?: JSONObject()
|
||||
|
||||
val target = payload.optString("host").ifBlank { payload.optString("hostId") }
|
||||
if (target.isNotBlank()
|
||||
&& !target.equals(config.hostId(), true)
|
||||
&& !target.equals(config.displayName(), true)) return
|
||||
|
||||
val action = payload.optString("action")
|
||||
val result: JSONObject = when {
|
||||
!config.controlEnabled ->
|
||||
err("Steuerung ist in der Agent-App deaktiviert (Schalter 'Steuerung erlauben').")
|
||||
action == "info" -> doInfo()
|
||||
action == "screenshot" -> doScreenshot()
|
||||
action == "ui_dump" -> doUiDump()
|
||||
action in listOf("ui_tap", "ui_text", "ui_swipe", "ui_key",
|
||||
"app_launch", "app_list", "notify") ->
|
||||
err("Aktion '$action' kommt in Meilenstein 3 (noch nicht implementiert).")
|
||||
else -> err("Aktion '$action' unbekannt.")
|
||||
}
|
||||
result.put("requestId", payload.optString("requestId"))
|
||||
result.put("hostId", config.hostId())
|
||||
result.put("action", action)
|
||||
send(webSocket, "host_result", result)
|
||||
}
|
||||
|
||||
private fun err(m: String): JSONObject = JSONObject().put("ok", false).put("error", m)
|
||||
|
||||
/** Bildschirmfoto — selber Vertrag wie der Desktop-Agent: {format,bytes,base64}. */
|
||||
private fun doScreenshot(): JSONObject {
|
||||
if (!ScreenCapturer.active) {
|
||||
val why = ScreenCapturer.lastError?.let { " (letzter Fehler: $it)" } ?: ""
|
||||
return err("Bildschirm-Zugriff nicht erlaubt. In der Agent-App auf dem Handy " +
|
||||
"einmalig 'Bildschirm-Zugriff erlauben' antippen.$why")
|
||||
}
|
||||
val png = ScreenCapturer.capture()
|
||||
?: return err("Screenshot fehlgeschlagen (kein Frame). Ist der Bildschirm an?")
|
||||
val b64 = android.util.Base64.encodeToString(png, android.util.Base64.NO_WRAP)
|
||||
val res = JSONObject().put("format", "png").put("bytes", png.size).put("base64", b64)
|
||||
return JSONObject().put("ok", true).put("result", res)
|
||||
}
|
||||
|
||||
/** Sichtbare Bedienelemente als Baum (Bedienungshilfe). */
|
||||
private fun doUiDump(): JSONObject {
|
||||
val svc = AriaAccessibilityService.instance
|
||||
?: return err("Bedienungshilfe nicht aktiv. In der Agent-App 'Bedienungshilfe " +
|
||||
"öffnen' antippen und 'ARIA Host-Agent' einschalten.")
|
||||
return svc.dump()
|
||||
}
|
||||
|
||||
private fun doInfo(): JSONObject {
|
||||
val res = JSONObject()
|
||||
res.put("host", config.displayName())
|
||||
res.put("model", Build.MODEL)
|
||||
res.put("manufacturer", Build.MANUFACTURER)
|
||||
res.put("android", Build.VERSION.RELEASE)
|
||||
res.put("sdk", Build.VERSION.SDK_INT)
|
||||
try {
|
||||
val bm = appCtx.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
|
||||
res.put("battery_percent", bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY))
|
||||
} catch (_: Exception) {}
|
||||
return JSONObject().put("ok", true).put("result", res)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package de.hackersoft.ariaagent
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PixelFormat
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.hardware.display.VirtualDisplay
|
||||
import android.media.Image
|
||||
import android.media.ImageReader
|
||||
import android.media.projection.MediaProjection
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.WindowManager
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* Bildschirm-Aufnahme via MediaProjection (Meilenstein 2 — "sehen").
|
||||
*
|
||||
* Der Nutzer erlaubt den Zugriff EINMALIG in der Agent-App (System-Dialog).
|
||||
* Danach laeuft ein stiller VirtualDisplay -> ImageReader, aus dem `capture()`
|
||||
* bei Bedarf das aktuelle Bild als PNG zieht. Kein Google-Dienst.
|
||||
*
|
||||
* Der Zugriff geht bei App-Kill / Neustart verloren und muss neu erlaubt werden
|
||||
* (Android-Sicherheit — Projection-Token ist nicht persistierbar).
|
||||
*/
|
||||
object ScreenCapturer {
|
||||
private var projection: MediaProjection? = null
|
||||
private var reader: ImageReader? = null
|
||||
private var vdisplay: VirtualDisplay? = null
|
||||
private var handlerThread: HandlerThread? = null
|
||||
private var handler: Handler? = null
|
||||
private var w = 0
|
||||
private var h = 0
|
||||
private var dpi = 0
|
||||
|
||||
/** Letzter Init-/Capture-Fehler (fuer die Fehlermeldung an ARIA). */
|
||||
@Volatile
|
||||
var lastError: String? = null
|
||||
|
||||
val active: Boolean
|
||||
@Synchronized get() = projection != null
|
||||
|
||||
@Synchronized
|
||||
fun start(ctx: Context, resultCode: Int, data: Intent) {
|
||||
stop()
|
||||
val mpm = ctx.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
val mp = mpm.getMediaProjection(resultCode, data) ?: run {
|
||||
lastError = "getMediaProjection lieferte null"
|
||||
return
|
||||
}
|
||||
|
||||
val metrics = DisplayMetrics()
|
||||
val wm = ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
@Suppress("DEPRECATION")
|
||||
wm.defaultDisplay.getRealMetrics(metrics)
|
||||
w = metrics.widthPixels
|
||||
h = metrics.heightPixels
|
||||
dpi = metrics.densityDpi
|
||||
|
||||
handlerThread = HandlerThread("aria-capture").also { it.start() }
|
||||
handler = Handler(handlerThread!!.looper)
|
||||
|
||||
// Ab Android 14 Pflicht VOR createVirtualDisplay; frueher unschaedlich.
|
||||
mp.registerCallback(object : MediaProjection.Callback() {
|
||||
override fun onStop() { stop() }
|
||||
}, handler)
|
||||
|
||||
val ir = ImageReader.newInstance(w, h, PixelFormat.RGBA_8888, 2)
|
||||
reader = ir
|
||||
// AUTO_MIRROR = Standard-Flag fuer MediaProjection-Capture (die Projection
|
||||
// selbst autorisiert die Aufnahme, kein Sonderrecht noetig).
|
||||
vdisplay = mp.createVirtualDisplay(
|
||||
"aria-screen", w, h, dpi,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
ir.surface, null, handler,
|
||||
)
|
||||
projection = mp
|
||||
lastError = null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
try { vdisplay?.release() } catch (_: Exception) {}
|
||||
try { reader?.close() } catch (_: Exception) {}
|
||||
try { projection?.stop() } catch (_: Exception) {}
|
||||
try { handlerThread?.quitSafely() } catch (_: Exception) {}
|
||||
vdisplay = null
|
||||
reader = null
|
||||
projection = null
|
||||
handlerThread = null
|
||||
handler = null
|
||||
}
|
||||
|
||||
/** PNG-Bytes des aktuellen Bildschirms, oder null. Wartet kurz auf einen Frame. */
|
||||
fun capture(): ByteArray? {
|
||||
val r = reader ?: return null
|
||||
var image: Image? = null
|
||||
var tries = 0
|
||||
while (tries < 20) {
|
||||
image = r.acquireLatestImage()
|
||||
if (image != null) break
|
||||
try { Thread.sleep(80) } catch (_: InterruptedException) {}
|
||||
tries++
|
||||
}
|
||||
if (image == null) return null
|
||||
return try {
|
||||
val plane = image.planes[0]
|
||||
val buffer = plane.buffer
|
||||
val pixelStride = plane.pixelStride
|
||||
val rowStride = plane.rowStride
|
||||
val rowPadding = rowStride - pixelStride * w
|
||||
val padded = Bitmap.createBitmap(
|
||||
w + (if (pixelStride > 0) rowPadding / pixelStride else 0),
|
||||
h, Bitmap.Config.ARGB_8888,
|
||||
)
|
||||
padded.copyPixelsFromBuffer(buffer)
|
||||
val cropped = if (rowPadding == 0) padded else Bitmap.createBitmap(padded, 0, 0, w, h)
|
||||
val scaled = downscale(cropped, 1280)
|
||||
val out = ByteArrayOutputStream()
|
||||
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
if (scaled !== cropped) scaled.recycle()
|
||||
if (cropped !== padded) cropped.recycle()
|
||||
padded.recycle()
|
||||
out.toByteArray()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} finally {
|
||||
try { image?.close() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun downscale(src: Bitmap, maxSide: Int): Bitmap {
|
||||
val longSide = maxOf(src.width, src.height)
|
||||
if (longSide <= maxSide) return src
|
||||
val scale = maxSide.toFloat() / longSide
|
||||
return Bitmap.createScaledBitmap(
|
||||
src, (src.width * scale).toInt(), (src.height * scale).toInt(), true,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#0D0D1A"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#3FFF9F"
|
||||
android:pathData="M54,26 m-26,0 a26,26 0 1,0 52,0 a26,26 0 1,0 -52,0 Z M54,26 m-13,0 a13,13 0 1,1 26,0 a13,13 0 1,1 -26,0 Z" />
|
||||
<path
|
||||
android:fillColor="#3FFF9F"
|
||||
android:pathData="M52,58 h4 v24 h-4 z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,128 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/app_name"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:paddingBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Status: —"
|
||||
android:paddingBottom="16dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnScan"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="QR-Code scannen" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="— oder manuell —"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="4dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/host"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="RVS-Host (z.B. rvs.example.com)"
|
||||
android:inputType="textUri" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/port"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="Port"
|
||||
android:text="443"
|
||||
android:inputType="number" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/token"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="RVS-Token"
|
||||
android:inputType="textNoSuggestions" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="Anzeigename (optional, z.B. 'Stefans Handy')"
|
||||
android:inputType="text" />
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
android:id="@+id/tls"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="TLS (wss)"
|
||||
android:checked="true"
|
||||
android:paddingTop="12dp" />
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
android:id="@+id/control"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Steuerung erlauben (Aktionen ausführen)"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="16dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnConnect"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Speichern & Verbinden" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnStop"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Agent stoppen"
|
||||
android:paddingTop="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Sehen (Meilenstein 2)"
|
||||
android:textStyle="bold"
|
||||
android:paddingTop="24dp"
|
||||
android:paddingBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Damit ARIA Screenshots machen und die Oberfläche lesen kann. Beides einmalig freigeben."
|
||||
android:textSize="13sp"
|
||||
android:paddingBottom="8dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnScreen"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Bildschirm-Zugriff erlauben" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnAccessibility"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Bedienungshilfe öffnen"
|
||||
android:paddingTop="8dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">ARIA Host-Agent</string>
|
||||
<string name="accessibility_desc">Erlaubt ARIA, die sichtbaren Bildschirm-Elemente zu lesen (Text und Position), um Dich fernzusteuern. Nur aktiv, wenn Du \'Steuerung erlauben\' eingeschaltet hast.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged"
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:accessibilityFlags="flagRetrieveInteractiveWindows|flagReportViewIds"
|
||||
android:canRetrieveWindowContent="true"
|
||||
android:notificationTimeout="100"
|
||||
android:description="@string/accessibility_desc" />
|
||||
@@ -0,0 +1,5 @@
|
||||
// Root-Build. Plugin-Versionen zentral, in den Modulen nur angewandt.
|
||||
plugins {
|
||||
id 'com.android.application' version '8.2.2' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '1.9.22' apply false
|
||||
}
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Baut die Android-Agent-APK per Docker (Android-SDK + Gradle).
|
||||
# ./build.sh
|
||||
# Ergebnis: dist/aria-android-agent.apk -> aufs Handy kopieren + installieren
|
||||
# ("Unbekannte Quellen erlauben").
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
mkdir -p dist
|
||||
docker build -f Dockerfile.build -t aria-android-agent-build .
|
||||
docker run --rm -v "$(pwd)/dist:/out" aria-android-agent-build
|
||||
echo
|
||||
echo "Fertig: dist/aria-android-agent.apk"
|
||||
@@ -0,0 +1,5 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
org.gradle.caching=true
|
||||
@@ -0,0 +1,16 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = "aria-android-agent"
|
||||
include(":app")
|
||||
@@ -0,0 +1,30 @@
|
||||
# systemd-Unit fuer den ARIA Host-Agent.
|
||||
#
|
||||
# Installation:
|
||||
# sudo cp aria-host-agent /usr/local/bin/
|
||||
# sudo mkdir -p /etc/aria-host-agent && sudo cp .env /etc/aria-host-agent/.env
|
||||
# sudo cp aria-host-agent.service /etc/systemd/system/
|
||||
# sudo systemctl enable --now aria-host-agent
|
||||
#
|
||||
# Als root (User=root): Kommandos haben volle Rechte, kein sudo/Passwort noetig.
|
||||
# Fuer einen normalen User: User=<name> setzen und in der .env SUDO_PASSWORD
|
||||
# oder SUDO_NOPASSWD konfigurieren.
|
||||
#
|
||||
# HINWEIS Screenshot: als Systemdienst fehlt die grafische Session (DISPLAY/
|
||||
# WAYLAND_DISPLAY). Fuer host_screenshot den Agent stattdessen in der Desktop-
|
||||
# Session starten (Autostart) oder DISPLAY/XAUTHORITY in der Unit setzen.
|
||||
[Unit]
|
||||
Description=ARIA Host-Agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/etc/aria-host-agent
|
||||
ExecStart=/usr/local/bin/aria-host-agent
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,28 @@
|
||||
@echo off
|
||||
REM ARIA Host-Agent — Windows-Build mit PyInstaller (kein Docker).
|
||||
REM Voraussetzung: Python 3 installiert und im PATH (python.org, "Add to PATH").
|
||||
REM
|
||||
REM build-native.bat
|
||||
REM
|
||||
REM Ergebnis: dist\aria-host-agent.exe (Onefile). Danach .env danebenlegen
|
||||
REM (siehe .env.example) und starten. Fuer Admin-Rechte die .exe per Rechtsklick
|
||||
REM "Als Administrator ausfuehren".
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
python -m venv .buildenv || goto :err
|
||||
call .buildenv\Scripts\activate.bat
|
||||
python -m pip install --quiet --upgrade pip
|
||||
python -m pip install --quiet pyinstaller -r requirements.txt || goto :err
|
||||
pyinstaller --onefile --name aria-host-agent --collect-all psutil host_agent.py || goto :err
|
||||
call deactivate
|
||||
|
||||
echo.
|
||||
echo Fertig: dist\aria-host-agent.exe
|
||||
echo .env danebenlegen (siehe .env.example), dann starten (ggf. als Administrator).
|
||||
goto :eof
|
||||
|
||||
:err
|
||||
echo.
|
||||
echo FEHLER beim Bauen. Ist Python 3 installiert und im PATH? (python --version)
|
||||
exit /b 1
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# Baut die Host-Agent-Binary OHNE Docker — direkt mit PyInstaller.
|
||||
# Nutze das, wenn Docker nicht geht (z.B. Live-ISO mit overlayfs-Root, wo
|
||||
# Dockers overlay2-Treiber kein Overlay-auf-Overlay stapeln kann).
|
||||
#
|
||||
# sudo apt install -y python3-pip python3-venv # falls noch nicht da
|
||||
# ./build-native.sh
|
||||
#
|
||||
# WICHTIG: Nativ gebaut linkt die Binary gegen das glibc DIESER Maschine. Sie
|
||||
# laeuft dann nur auf Systemen mit glibc >= dem hier. Fuer breite Kompatibilitaet
|
||||
# lieber ./build.sh (Docker/bullseye) auf einem normalen Rechner nutzen.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
python3 -m venv .buildenv
|
||||
# shellcheck disable=SC1091
|
||||
. .buildenv/bin/activate
|
||||
pip install --quiet --upgrade pip
|
||||
pip install --quiet pyinstaller -r requirements.txt
|
||||
pyinstaller --onefile --name aria-host-agent --collect-all psutil host_agent.py
|
||||
deactivate
|
||||
|
||||
echo
|
||||
echo "Fertig: dist/aria-host-agent"
|
||||
echo ".env danebenlegen (siehe .env.example), dann: chmod +x aria-host-agent && ./aria-host-agent"
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Baut die Windows-.exe + setup.exe des Host-Agents AUF LINUX (Wine im Docker).
|
||||
# ./build-win.sh [version]
|
||||
# Ergebnis:
|
||||
# dist/aria-host-agent.exe (Konsolen-Binary)
|
||||
# dist/aria-host-agent-setup.exe (Installer: richtet Windows-Dienst ein)
|
||||
#
|
||||
# Hinweis: Der erste Lauf zieht das tobix/pywine-Image (~1-2 GB) + baut die
|
||||
# Wine-Umgebung — das dauert. Folge-Builds sind schnell.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
VERSION="${1:-0.0.0}"
|
||||
mkdir -p dist
|
||||
docker build -f Dockerfile.win --build-arg VERSION="$VERSION" -t aria-host-agent-win .
|
||||
docker run --rm -v "$(pwd)/dist:/out" aria-host-agent-win
|
||||
echo
|
||||
echo "Fertig: dist/aria-host-agent.exe + dist/aria-host-agent-setup.exe"
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Baut die portable Host-Agent-Binary (Linux x86_64) via Docker + PyInstaller.
|
||||
# Ergebnis: ./dist/aria-host-agent (Onefile, ~15 MB, keine Runtime noetig).
|
||||
#
|
||||
# ./build.sh
|
||||
#
|
||||
# Danach auf den Ziel-Rechner kopieren, .env danebenlegen und starten:
|
||||
# ./aria-host-agent
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
mkdir -p dist
|
||||
docker build -f Dockerfile.build -t aria-host-agent-build .
|
||||
docker run --rm -v "$(pwd)/dist:/out" aria-host-agent-build
|
||||
|
||||
echo
|
||||
echo "Fertig: dist/aria-host-agent"
|
||||
echo "Auf den Ziel-Rechner kopieren, .env danebenlegen (siehe .env.example), dann:"
|
||||
echo " chmod +x aria-host-agent && ./aria-host-agent"
|
||||
@@ -0,0 +1,526 @@
|
||||
"""
|
||||
ARIA Host-Agent — Direktzugriff auf EINEN Rechner.
|
||||
|
||||
Laeuft direkt auf dem Ziel-Rechner (Linux) und verbindet sich AUSGEHEND als
|
||||
RVS-Client in Stefans Raum (gleicher Token). Damit kann ARIA diesen Rechner
|
||||
direkt steuern, auch wenn er sonst aus dem Netz nicht erreichbar ist (hinter
|
||||
NAT/Firewall, kein offener Port). Anders als der Satellit (der ein LAN
|
||||
entdeckt/steuert) ist beim Agent das "Geraet" der Rechner selbst.
|
||||
|
||||
Als reine Binary verteilbar (PyInstaller onefile) + .env fuer die Zugangsdaten.
|
||||
|
||||
Faehigkeiten (host_command → host_result):
|
||||
exec Shell-Kommando ausfuehren (optional sudo)
|
||||
read/write Datei lesen/schreiben (Base64)
|
||||
info OS / CPU / RAM / Disk / Uptime / Netz
|
||||
screenshot Bildschirmfoto (X11/Wayland, wenn grafische Session da ist)
|
||||
|
||||
Sicherheit: reagiert nur auf den eigenen RVS-Raum (Token) und nur, wenn
|
||||
CONTROL_ENABLED=true. Alles wird geloggt. Keine offenen Ports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import websockets
|
||||
|
||||
# ─── Plattform-Weichen (Linux / macOS / Windows) ────────────────────
|
||||
IS_WINDOWS = os.name == "nt"
|
||||
IS_MAC = sys.platform == "darwin"
|
||||
|
||||
|
||||
def _is_admin() -> bool:
|
||||
"""root (Unix) bzw. Administrator (Windows)."""
|
||||
try:
|
||||
return os.geteuid() == 0 # Unix (Linux/macOS)
|
||||
except AttributeError:
|
||||
try:
|
||||
import ctypes
|
||||
return ctypes.windll.shell32.IsUserAnAdmin() != 0 # Windows
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [host-agent] %(levelname)s %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("host-agent")
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
"""Laedt eine .env neben der Binary/dem Script (oder im CWD) in os.environ.
|
||||
Bereits gesetzte Werte gewinnen. Kein python-dotenv noetig."""
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
# PyInstaller-onefile: __file__ liegt im Temp-Extract-Dir, NICHT beim .exe/
|
||||
# Binary — deshalb zusaetzlich sys.executable-Ordner (echter Binary-Ort) und
|
||||
# das CWD (z.B. der ProgramData-Ordner, den der Windows-Dienst als AppDir nutzt).
|
||||
exe_dir = os.path.dirname(os.path.abspath(sys.executable))
|
||||
seen = set()
|
||||
candidates = [os.path.join(d, ".env") for d in (exe_dir, here, os.getcwd())]
|
||||
for path in [p for p in candidates if not (p in seen or seen.add(p))]:
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, val = line.partition("=")
|
||||
key = key.strip()
|
||||
if key.startswith("export "):
|
||||
key = key[len("export "):].strip()
|
||||
val = val.strip()
|
||||
if val[:1] in ("'", '"'):
|
||||
q = val[0]
|
||||
end = val.find(q, 1)
|
||||
val = val[1:end] if end != -1 else val[1:]
|
||||
else:
|
||||
m = re.search(r"\s+#", val)
|
||||
if m:
|
||||
val = val[:m.start()]
|
||||
val = val.strip()
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = val
|
||||
except Exception as exc:
|
||||
logger.warning(".env laden fehlgeschlagen (%s): %s", path, exc)
|
||||
break
|
||||
|
||||
|
||||
_load_dotenv()
|
||||
|
||||
|
||||
# ─── Konfiguration ──────────────────────────────────────────────────
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
v = os.environ.get(name)
|
||||
if v is None:
|
||||
return default
|
||||
return v.strip().lower() in ("1", "true", "yes", "on", "ja")
|
||||
|
||||
|
||||
def _default_id() -> str:
|
||||
host = socket.gethostname() or "host"
|
||||
slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", host).strip("-").lower()
|
||||
return slug or "host"
|
||||
|
||||
|
||||
RVS_HOST = os.environ.get("RVS_HOST", "")
|
||||
RVS_PORT = int(os.environ.get("RVS_PORT", "443") or "443")
|
||||
RVS_TLS = _env_bool("RVS_TLS", True)
|
||||
# Bei TLS-Fehlschlag einmal auf ws:// zurueckfallen (wie die Compute-Bridges).
|
||||
# Hilft nur, wenn der RVS plaintext erreichbar ist; gegen Caddy-TLS bleibt wss.
|
||||
RVS_TLS_FALLBACK = _env_bool("RVS_TLS_FALLBACK", True)
|
||||
RVS_TOKEN = os.environ.get("RVS_TOKEN", "")
|
||||
# TLS-Hostname (SNI + Zertifikatspruefung), falls RVS_HOST eine IP ist — z.B. der
|
||||
# Agent laeuft im selben Netz wie der RVS und verbindet direkt auf die interne IP,
|
||||
# das Caddy-Zertifikat gilt aber fuer den Namen. Dann: RVS_HOST=<interne-ip>,
|
||||
# RVS_SNI=<zert-name>. Leer = SNI = RVS_HOST (Normalfall).
|
||||
RVS_SNI = os.environ.get("RVS_SNI", "").strip()
|
||||
|
||||
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
|
||||
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
|
||||
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
|
||||
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
|
||||
if RVS_TLS and RVS_SNI:
|
||||
import socket as _socket
|
||||
_orig_getaddrinfo = _socket.getaddrinfo
|
||||
def _sni_getaddrinfo(host, *a, **k):
|
||||
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
|
||||
_socket.getaddrinfo = _sni_getaddrinfo
|
||||
|
||||
|
||||
HOST_ID = (os.environ.get("HOST_ID") or _default_id()).strip()
|
||||
HOST_NAME = (os.environ.get("HOST_NAME") or HOST_ID).strip()
|
||||
|
||||
# Steuerung ist der Sinn des Agents — aber bewusst opt-in (Sicherheit).
|
||||
CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False)
|
||||
|
||||
# sudo: 1) Agent laeuft als root -> direkt. 2) SUDO_PASSWORD gesetzt -> sudo -S.
|
||||
# 3) SUDO_NOPASSWD=true (Live-ISO / NOPASSWD-sudoers) -> sudo -n. 4) sonst Fehler.
|
||||
SUDO_PASSWORD = os.environ.get("SUDO_PASSWORD", "")
|
||||
SUDO_NOPASSWD = _env_bool("SUDO_NOPASSWD", False)
|
||||
|
||||
EXEC_TIMEOUT = float(os.environ.get("EXEC_TIMEOUT", "60") or "60")
|
||||
# Ausgabe-Fenster (wie beim Satelliten): grosse stdout klein halten fuers RVS.
|
||||
OUT_MAX_CHARS = int(os.environ.get("OUT_MAX_CHARS", "20000") or "20000")
|
||||
OUT_MAX_CHARS_HARD = int(os.environ.get("OUT_MAX_CHARS_HARD", "200000") or "200000")
|
||||
# Datei-Transfer-Limit (Base64 durchs RVS).
|
||||
FILE_MAX_BYTES = int(os.environ.get("FILE_MAX_BYTES", str(10 * 1024 * 1024)) or str(10 * 1024 * 1024))
|
||||
|
||||
# Version (wird von release_agent.sh beim Release gesetzt).
|
||||
AGENT_VERSION = "0.0.0.3"
|
||||
|
||||
HEARTBEAT_SEC = 25
|
||||
CAPS = ["exec", "read", "write", "info", "screenshot"]
|
||||
|
||||
|
||||
# ─── Text-/Zahl-Helfer ──────────────────────────────────────────────
|
||||
|
||||
def _to_int(s):
|
||||
try:
|
||||
return int(str(s).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _to_float(s):
|
||||
try:
|
||||
return float(str(s).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _window_text(text: str, params: dict) -> dict:
|
||||
"""contains-Zeilenfilter + offset/max_chars-Fenster. Gibt body + Metadaten."""
|
||||
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:
|
||||
text = "\n".join(ln for ln in text.splitlines()
|
||||
if any(t in ln.lower() for t in terms))
|
||||
offset = max(0, _to_int(params.get("offset")) or 0)
|
||||
max_chars = _to_int(params.get("max_chars")) or OUT_MAX_CHARS
|
||||
max_chars = max(1, min(max_chars, OUT_MAX_CHARS_HARD))
|
||||
body = text[offset:offset + max_chars]
|
||||
return {"body": body, "total_chars": total, "filtered": bool(contains),
|
||||
"offset": offset, "returned_chars": len(body),
|
||||
"truncated": offset + len(body) < len(text)}
|
||||
|
||||
|
||||
# ─── Aktionen ───────────────────────────────────────────────────────
|
||||
|
||||
def _wrap_sudo(cmd: str, use_sudo: bool):
|
||||
"""Baut die Argv (OS-abhaengige Shell) + optional Root/Admin-Rechte.
|
||||
Gibt (argv, stdin_data) oder (None, fehlertext)."""
|
||||
if IS_WINDOWS:
|
||||
# PowerShell; kein sudo. Fuer Admin-Rechte muss der Agent SELBST als
|
||||
# Administrator laufen (UAC) — dann hat 'sudo:true' bereits volle Rechte.
|
||||
if use_sudo and not _is_admin():
|
||||
return None, ("Windows kennt kein sudo. Starte den Agent als "
|
||||
"Administrator ('Als Administrator ausfuehren'), dann "
|
||||
"laufen Kommandos mit vollen Rechten.")
|
||||
return ["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd], None
|
||||
# Unix: Linux + macOS (bash vorhanden; macOS-sudo verhaelt sich wie Linux)
|
||||
if not use_sudo or _is_admin():
|
||||
return ["bash", "-lc", cmd], None
|
||||
if SUDO_PASSWORD:
|
||||
return ["sudo", "-S", "-p", "", "bash", "-lc", cmd], SUDO_PASSWORD + "\n"
|
||||
if SUDO_NOPASSWD:
|
||||
return ["sudo", "-n", "bash", "-lc", cmd], None
|
||||
return None, ("sudo verlangt ein Passwort. Setze SUDO_PASSWORD in der .env, "
|
||||
"oder SUDO_NOPASSWD=true (Live-ISO / passwortloses sudo), oder "
|
||||
"starte den Agent als root.")
|
||||
|
||||
|
||||
def _do_exec(params: dict) -> dict:
|
||||
cmd = params.get("cmd") or params.get("command") or ""
|
||||
if not cmd:
|
||||
return {"ok": False, "error": "cmd (Kommando) erforderlich."}
|
||||
argv, stdin_data = _wrap_sudo(cmd, bool(params.get("sudo")))
|
||||
if argv is None:
|
||||
return {"ok": False, "error": stdin_data}
|
||||
timeout = _to_float(params.get("timeout")) or EXEC_TIMEOUT
|
||||
try:
|
||||
r = subprocess.run(argv, input=stdin_data, capture_output=True,
|
||||
text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"ok": False, "error": f"Timeout nach {timeout:.0f}s."}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"exec fehlgeschlagen: {exc}"}
|
||||
win = _window_text(r.stdout, params)
|
||||
return {"ok": True, "result": {"exit_code": r.returncode,
|
||||
"stderr": (r.stderr or "")[:4000], **win}}
|
||||
|
||||
|
||||
def _do_read(params: dict) -> dict:
|
||||
path = params.get("path") or ""
|
||||
if not path:
|
||||
return {"ok": False, "error": "path erforderlich."}
|
||||
p = Path(path).expanduser()
|
||||
if not p.is_file():
|
||||
return {"ok": False, "error": f"Datei nicht gefunden: {path}"}
|
||||
size = p.stat().st_size
|
||||
offset = max(0, _to_int(params.get("offset")) or 0)
|
||||
max_bytes = _to_int(params.get("max_bytes")) or FILE_MAX_BYTES
|
||||
max_bytes = max(1, min(max_bytes, FILE_MAX_BYTES))
|
||||
try:
|
||||
with p.open("rb") as f:
|
||||
f.seek(offset)
|
||||
data = f.read(max_bytes)
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"Lesen fehlgeschlagen: {exc}"}
|
||||
return {"ok": True, "result": {
|
||||
"path": str(p), "size": size, "offset": offset,
|
||||
"returned_bytes": len(data), "truncated": offset + len(data) < size,
|
||||
"base64": base64.b64encode(data).decode("ascii"),
|
||||
}}
|
||||
|
||||
|
||||
def _do_write(params: dict) -> dict:
|
||||
path = params.get("path") or ""
|
||||
if not path:
|
||||
return {"ok": False, "error": "path erforderlich."}
|
||||
b64 = params.get("base64")
|
||||
text = params.get("text")
|
||||
if b64 is None and text is None:
|
||||
return {"ok": False, "error": "base64 ODER text erforderlich."}
|
||||
try:
|
||||
data = base64.b64decode(b64) if b64 is not None else str(text).encode("utf-8")
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"base64 ungueltig: {exc}"}
|
||||
p = Path(path).expanduser()
|
||||
try:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
mode = "ab" if params.get("append") else "wb"
|
||||
with p.open(mode) as f:
|
||||
f.write(data)
|
||||
if params.get("chmod"):
|
||||
os.chmod(p, int(str(params["chmod"]), 8))
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"Schreiben fehlgeschlagen: {exc}"}
|
||||
return {"ok": True, "result": {"path": str(p), "bytes": len(data)}}
|
||||
|
||||
|
||||
def _do_info(params: dict) -> dict:
|
||||
info = {
|
||||
"host": HOST_NAME, "hostname": socket.gethostname(),
|
||||
"agent_version": AGENT_VERSION,
|
||||
"os": platform.platform(), "kernel": platform.release(),
|
||||
"arch": platform.machine(), "python": platform.python_version(),
|
||||
"user": os.environ.get("USER") or os.environ.get("USERNAME") or "",
|
||||
"is_root": _is_admin(),
|
||||
}
|
||||
try:
|
||||
import psutil
|
||||
info["cpu_percent"] = psutil.cpu_percent(interval=0.3)
|
||||
info["cpu_count"] = psutil.cpu_count()
|
||||
vm = psutil.virtual_memory()
|
||||
info["ram_used_mb"] = round(vm.used / 1024 / 1024)
|
||||
info["ram_total_mb"] = round(vm.total / 1024 / 1024)
|
||||
info["ram_percent"] = vm.percent
|
||||
du = psutil.disk_usage("/")
|
||||
info["disk_used_gb"] = round(du.used / 1024 / 1024 / 1024, 1)
|
||||
info["disk_total_gb"] = round(du.total / 1024 / 1024 / 1024, 1)
|
||||
info["disk_percent"] = du.percent
|
||||
info["uptime_s"] = round(time.time() - psutil.boot_time())
|
||||
info["load_avg"] = list(os.getloadavg()) if hasattr(os, "getloadavg") else None
|
||||
except Exception:
|
||||
# Fallback ohne psutil: das Noetigste aus os/shutil.
|
||||
try:
|
||||
info["load_avg"] = list(os.getloadavg())
|
||||
except Exception:
|
||||
info["load_avg"] = None
|
||||
try:
|
||||
total, used, free = shutil.disk_usage("/")
|
||||
info["disk_used_gb"] = round(used / 1024 ** 3, 1)
|
||||
info["disk_total_gb"] = round(total / 1024 ** 3, 1)
|
||||
except Exception:
|
||||
pass
|
||||
# Primaere IP (best effort).
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
info["primary_ip"] = s.getsockname()[0]
|
||||
s.close()
|
||||
except Exception:
|
||||
info["primary_ip"] = ""
|
||||
return {"ok": True, "result": info}
|
||||
|
||||
|
||||
def _do_screenshot(params: dict) -> dict:
|
||||
"""Bildschirmfoto — OS-abhaengig. Windows: PowerShell/System.Drawing;
|
||||
macOS: screencapture; Linux: grim (Wayland) / scrot/maim/import (X11).
|
||||
Braucht eine aktive grafische Session."""
|
||||
import tempfile
|
||||
tmp = os.path.join(tempfile.gettempdir(), f"aria_shot_{int(time.time())}.png")
|
||||
candidates = []
|
||||
if IS_WINDOWS:
|
||||
ps = ("Add-Type -AssemblyName System.Windows.Forms,System.Drawing;"
|
||||
"$b=[System.Windows.Forms.SystemInformation]::VirtualScreen;"
|
||||
"$bmp=New-Object System.Drawing.Bitmap $b.Width,$b.Height;"
|
||||
"$g=[System.Drawing.Graphics]::FromImage($bmp);"
|
||||
"$g.CopyFromScreen($b.Location,[System.Drawing.Point]::Empty,$b.Size);"
|
||||
f"$bmp.Save('{tmp}');$g.Dispose();$bmp.Dispose()")
|
||||
candidates.append(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps])
|
||||
elif IS_MAC:
|
||||
candidates.append(["screencapture", "-x", tmp]) # -x = ohne Ton
|
||||
else:
|
||||
if os.environ.get("WAYLAND_DISPLAY") and shutil.which("grim"):
|
||||
candidates.append(["grim", tmp])
|
||||
for tool, argv in (("scrot", ["scrot", "-o", tmp]),
|
||||
("maim", ["maim", tmp]),
|
||||
("gnome-screenshot", ["gnome-screenshot", "-f", tmp]),
|
||||
("import", ["import", "-window", "root", tmp])):
|
||||
if shutil.which(tool):
|
||||
candidates.append(argv)
|
||||
if not candidates:
|
||||
return {"ok": False, "error":
|
||||
"Kein Screenshot-Tool gefunden. Linux: grim (Wayland) oder "
|
||||
"scrot/maim (X11) installieren. (Windows/macOS nutzen Bordmittel.)"}
|
||||
last_err = ""
|
||||
for argv in candidates:
|
||||
try:
|
||||
r = subprocess.run(argv, capture_output=True, text=True, timeout=15)
|
||||
if r.returncode == 0 and os.path.isfile(tmp) and os.path.getsize(tmp) > 0:
|
||||
with open(tmp, "rb") as f:
|
||||
b = f.read()
|
||||
os.remove(tmp)
|
||||
return {"ok": True, "result": {"format": "png", "bytes": len(b),
|
||||
"base64": base64.b64encode(b).decode("ascii")}}
|
||||
last_err = (r.stderr or r.stdout or "").strip()[:200]
|
||||
except Exception as exc:
|
||||
last_err = str(exc)[:200]
|
||||
return {"ok": False, "error": f"Screenshot fehlgeschlagen ({last_err}). "
|
||||
"Laeuft der Agent in derselben grafischen Session?"}
|
||||
|
||||
|
||||
ACTIONS = {
|
||||
"exec": _do_exec, "read": _do_read, "write": _do_write,
|
||||
"info": _do_info, "screenshot": _do_screenshot,
|
||||
}
|
||||
|
||||
|
||||
# ─── RVS-Client ─────────────────────────────────────────────────────
|
||||
|
||||
class HostAgent:
|
||||
def __init__(self) -> None:
|
||||
self.ws = None
|
||||
|
||||
def _for_me(self, payload: dict) -> bool:
|
||||
"""Command gilt uns, wenn kein host-Feld gesetzt ist (Broadcast) oder es
|
||||
auf unsere ID/Name passt."""
|
||||
target = (payload.get("host") or payload.get("hostId") or "").strip()
|
||||
if not target:
|
||||
return True
|
||||
return target.lower() in (HOST_ID.lower(), HOST_NAME.lower())
|
||||
|
||||
async def _send(self, message: dict) -> None:
|
||||
if self.ws is None:
|
||||
return
|
||||
try:
|
||||
await self.ws.send(json.dumps(message))
|
||||
except Exception as exc:
|
||||
logger.warning("Senden fehlgeschlagen: %s", exc)
|
||||
|
||||
async def _hello(self, log: bool = False) -> None:
|
||||
if log:
|
||||
logger.info("host_hello: id=%s name=%s caps=%s control=%s",
|
||||
HOST_ID, HOST_NAME, ",".join(CAPS), CONTROL_ENABLED)
|
||||
await self._send({"type": "host_hello", "payload": {
|
||||
"hostId": HOST_ID, "name": HOST_NAME, "os": platform.platform(),
|
||||
"version": AGENT_VERSION, "caps": CAPS, "control": CONTROL_ENABLED,
|
||||
}, "timestamp": int(time.time() * 1000)})
|
||||
|
||||
async def _heartbeat(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(HEARTBEAT_SEC)
|
||||
await self._send({"type": "host_ping", "payload": {"hostId": HOST_ID},
|
||||
"timestamp": int(time.time() * 1000)})
|
||||
await self._hello()
|
||||
|
||||
async def _handle(self, raw: str) -> None:
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except Exception:
|
||||
return
|
||||
if msg.get("type") != "host_command":
|
||||
return
|
||||
payload = msg.get("payload") or {}
|
||||
if not self._for_me(payload):
|
||||
return
|
||||
req_id = payload.get("requestId", "")
|
||||
action = (payload.get("action") or "").strip()
|
||||
params = payload.get("params") or {}
|
||||
if not CONTROL_ENABLED:
|
||||
result = {"ok": False, "error": "Steuerung ist deaktiviert (CONTROL_ENABLED=false)."}
|
||||
elif action not in ACTIONS:
|
||||
result = {"ok": False, "error": f"Aktion '{action}' unbekannt (bekannt: {', '.join(ACTIONS)})."}
|
||||
else:
|
||||
logger.info("[cmd] %s params=%s", action,
|
||||
{k: str(v)[:60] for k, v in params.items() if k not in ("base64",)})
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
result = await loop.run_in_executor(None, ACTIONS[action], params)
|
||||
except Exception as exc:
|
||||
result = {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
|
||||
await self._send({"type": "host_result",
|
||||
"payload": {"requestId": req_id, "hostId": HOST_ID,
|
||||
"action": action, **result},
|
||||
"timestamp": int(time.time() * 1000)})
|
||||
|
||||
async def run(self) -> None:
|
||||
if not RVS_HOST or not RVS_TOKEN:
|
||||
logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).")
|
||||
return
|
||||
backoff = 1
|
||||
# use_tls kann bei Fehlschlag einmal auf ws:// fallen (RVS_TLS_FALLBACK),
|
||||
# danach wieder zurueck auf RVS_TLS (kein Sticky-Fallback).
|
||||
use_tls = RVS_TLS
|
||||
tls_fallback_tried = False
|
||||
connect_kwargs = {"max_size": 16 * 1024 * 1024,
|
||||
"ping_interval": 20, "ping_timeout": 20}
|
||||
while True:
|
||||
proto = "wss" if use_tls else "ws"
|
||||
# Bei TLS + RVS_SNI: URI nutzt den HOSTNAMEN (Host-Header/SNI/Cert),
|
||||
# getaddrinfo mappt ihn auf die IP in RVS_HOST. Bei ws:// direkt die IP.
|
||||
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
|
||||
url = f"{proto}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||
fallback = False
|
||||
try:
|
||||
logger.info("Verbinde mit RVS %s://%s:%s%s …", proto, uri_host, RVS_PORT,
|
||||
f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
|
||||
async with websockets.connect(url, **connect_kwargs) as ws:
|
||||
self.ws = ws
|
||||
backoff = 1
|
||||
tls_fallback_tried = False
|
||||
await self._hello(log=True)
|
||||
hb = asyncio.create_task(self._heartbeat())
|
||||
try:
|
||||
async for raw in ws:
|
||||
await self._handle(raw)
|
||||
finally:
|
||||
hb.cancel()
|
||||
except Exception as exc:
|
||||
logger.warning("RVS-Verbindung verloren: %s", exc)
|
||||
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
||||
logger.info("TLS fehlgeschlagen — Fallback auf ws://")
|
||||
use_tls = False
|
||||
tls_fallback_tried = True
|
||||
fallback = True
|
||||
finally:
|
||||
self.ws = None
|
||||
if fallback:
|
||||
continue # sofort erneut mit ws://
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 30)
|
||||
use_tls = RVS_TLS # kein Sticky-Fallback
|
||||
tls_fallback_tried = False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logger.info("ARIA Host-Agent startet — id=%s name=%s control=%s root=%s",
|
||||
HOST_ID, HOST_NAME, CONTROL_ENABLED, _is_admin())
|
||||
try:
|
||||
asyncio.run(HostAgent().run())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# ARIA Host-Agent — Service-Installer (systemd).
|
||||
#
|
||||
# Installiert die Binary nach /usr/local/bin, die .env nach /etc/aria-host-agent
|
||||
# und richtet den systemd-Dienst ein (enable + start).
|
||||
#
|
||||
# Nutzung:
|
||||
# sudo ./install-service.sh /pfad/zur/.env # .env-Pfad direkt uebergeben
|
||||
# sudo ./install-service.sh # ncurses-Dateidialog (dialog)
|
||||
#
|
||||
# Die Binary wird unter ./dist/aria-host-agent oder ./aria-host-agent erwartet
|
||||
# (vorher ./build.sh oder ./build-native.sh ausfuehren), oder als 2. Argument.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BIN_DST="/usr/local/bin/aria-host-agent"
|
||||
ETC_DIR="/etc/aria-host-agent"
|
||||
UNIT_DST="/etc/systemd/system/aria-host-agent.service"
|
||||
|
||||
die() { echo "FEHLER: $*" >&2; exit 1; }
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || die "Bitte mit sudo/root ausfuehren: sudo $0 $*"
|
||||
|
||||
# ── Binary finden (Arg 2 > dist/ > ./) ──────────────────────────────
|
||||
BIN_SRC="${2:-}"
|
||||
if [ -z "$BIN_SRC" ]; then
|
||||
if [ -x "$SCRIPT_DIR/dist/aria-host-agent" ]; then BIN_SRC="$SCRIPT_DIR/dist/aria-host-agent"
|
||||
elif [ -x "$SCRIPT_DIR/aria-host-agent" ]; then BIN_SRC="$SCRIPT_DIR/aria-host-agent"
|
||||
fi
|
||||
fi
|
||||
[ -n "$BIN_SRC" ] && [ -f "$BIN_SRC" ] || die "Binary nicht gefunden. Erst bauen (./build.sh) oder als 2. Argument uebergeben."
|
||||
|
||||
# ── .env bestimmen: Arg 1, sonst ncurses-Dateidialog ────────────────
|
||||
ENV_SRC="${1:-}"
|
||||
if [ -z "$ENV_SRC" ]; then
|
||||
if ! command -v dialog >/dev/null 2>&1; then
|
||||
echo "Kein .env-Pfad uebergeben und 'dialog' ist nicht installiert."
|
||||
read -r -p "dialog jetzt installieren (apt)? [j/N] " a
|
||||
case "$a" in
|
||||
j|J|y|Y) (apt-get update && apt-get install -y dialog) || die "dialog-Installation fehlgeschlagen — .env-Pfad bitte als Argument uebergeben." ;;
|
||||
*) die "Ohne dialog bitte den .env-Pfad als Argument uebergeben: sudo $0 /pfad/zur/.env" ;;
|
||||
esac
|
||||
fi
|
||||
# dialog --fselect: Dateibrowser; --stdout gibt die Auswahl auf stdout.
|
||||
START_DIR="${SUDO_USER:+/home/$SUDO_USER/}"
|
||||
[ -d "$START_DIR" ] || START_DIR="$SCRIPT_DIR/"
|
||||
ENV_SRC="$(dialog --stdout --title 'ARIA Host-Agent — .env auswaehlen' \
|
||||
--fselect "$START_DIR" 14 72)" || true
|
||||
clear
|
||||
[ -n "$ENV_SRC" ] || die "Abgebrochen — keine .env ausgewaehlt."
|
||||
fi
|
||||
[ -f "$ENV_SRC" ] || die ".env nicht gefunden: $ENV_SRC"
|
||||
|
||||
echo "Binary : $BIN_SRC"
|
||||
echo ".env : $ENV_SRC"
|
||||
|
||||
# ── Installieren ────────────────────────────────────────────────────
|
||||
install -m 0755 "$BIN_SRC" "$BIN_DST"
|
||||
install -d -m 0755 "$ETC_DIR"
|
||||
install -m 0600 "$ENV_SRC" "$ETC_DIR/.env" # 0600: enthaelt Token/Passwoerter
|
||||
|
||||
if [ -f "$SCRIPT_DIR/aria-host-agent.service" ]; then
|
||||
install -m 0644 "$SCRIPT_DIR/aria-host-agent.service" "$UNIT_DST"
|
||||
else
|
||||
die "aria-host-agent.service nicht gefunden neben dem Installer."
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now aria-host-agent
|
||||
|
||||
echo
|
||||
echo "✓ Installiert & gestartet."
|
||||
echo " Status: systemctl status aria-host-agent"
|
||||
echo " Log: journalctl -u aria-host-agent -f"
|
||||
echo " .env: $ETC_DIR/.env (aendern -> systemctl restart aria-host-agent)"
|
||||
Executable
+150
@@ -0,0 +1,150 @@
|
||||
#!/bin/bash
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# ARIA Host-Agent — Release Script
|
||||
# Baut die auf Linux+Docker moeglichen Artefakte und haengt sie als
|
||||
# Gitea-Release-Assets an einen Tag. NICHTS wandert in den Git-Tree —
|
||||
# Binaries leben nur unter "Releases" (blaeht clone/History nicht auf).
|
||||
#
|
||||
# Verwendung: ./release_agent.sh <version> (z.B. ./release_agent.sh 0.2.0)
|
||||
#
|
||||
# Artefakte:
|
||||
# - Linux-x64-Binary (Docker, PyInstaller) -> immer
|
||||
# - Android-APK (Docker, Gradle) -> immer
|
||||
# - macOS / Windows (falls in dist/ vorgebaut) -> optional
|
||||
# (mac/win koennen auf Linux nicht cross-gebaut werden -> build-native.*
|
||||
# auf dem jeweiligen OS laufen lassen, Ergebnis nach host-agent/dist/ legen)
|
||||
#
|
||||
# Eigener Tag-Namespace agent-v<version> (kollidiert NICHT mit den
|
||||
# App-Tags v<version>).
|
||||
#
|
||||
# Gitea-Zugang (GITEA_URL, GITEA_REPO, GITEA_USER) wird aus der Umgebung
|
||||
# oder aus host-agent/.env bzw. der Repo-Wurzel-.env gelesen; das Kennwort
|
||||
# wird interaktiv abgefragt.
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")" # host-agent/
|
||||
SCRIPT_DIR="$(pwd)"
|
||||
ROOT_DIR="$(cd .. && pwd)"
|
||||
|
||||
# ── Farben ───────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
|
||||
# ── Parameter ────────────────────────────────────────────────────────
|
||||
VERSION=${1:?"Usage: ./release_agent.sh <version> (z.B. 0.2.0)"}
|
||||
TAG="agent-v$VERSION"
|
||||
|
||||
# ── Gitea-Konfiguration (env > host-agent/.env > Repo-Wurzel-.env) ────
|
||||
[ -f "$SCRIPT_DIR/.env" ] && source "$SCRIPT_DIR/.env"
|
||||
[ -f "$ROOT_DIR/.env" ] && source "$ROOT_DIR/.env"
|
||||
|
||||
GITEA_URL="${GITEA_URL:?"GITEA_URL nicht gesetzt (in .env oder als Umgebungsvariable)"}"
|
||||
GITEA_REPO="${GITEA_REPO:?"GITEA_REPO nicht gesetzt (z.B. stefan/aria-agent)"}"
|
||||
GITEA_USER="${GITEA_USER:-$(echo "$GITEA_REPO" | cut -d'/' -f1)}"
|
||||
|
||||
echo -e "${CYAN}╔═══════════════════════════════════════════╗${NC}"
|
||||
echo -e "${CYAN}║ ARIA Host-Agent Release — ${TAG}$(printf '%*s' $((14 - ${#TAG})) '')║${NC}"
|
||||
echo -e "${CYAN}╚═══════════════════════════════════════════╝${NC}\n"
|
||||
|
||||
# ── Kennwort ─────────────────────────────────────────────────────────
|
||||
echo -e "${YELLOW}Gitea-Login: ${GITEA_USER}${NC}"
|
||||
read -s -p "Gitea-Kennwort: " GITEA_PASS; echo ""
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -u "${GITEA_USER}:${GITEA_PASS}" "$GITEA_URL/api/v1/user")
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo -e "${RED}Login fehlgeschlagen (HTTP $HTTP_CODE). Kennwort korrekt?${NC}"; exit 1
|
||||
fi
|
||||
echo -e " ${GREEN}✓${NC} Login erfolgreich\n"
|
||||
|
||||
# ── Versionsnummern setzen ───────────────────────────────────────────
|
||||
echo -e "${GREEN}[1/5] Version auf $VERSION setzen...${NC}"
|
||||
# Desktop-Agent
|
||||
sed -i "s/^AGENT_VERSION = \"[^\"]*\"/AGENT_VERSION = \"$VERSION\"/" host_agent.py
|
||||
echo -e " ${GREEN}✓${NC} host_agent.py → AGENT_VERSION $VERSION"
|
||||
# Android: versionName + versionCode (aus Version berechnen; 3- oder 4-stellig)
|
||||
IFS='.' read -ra VP <<< "$VERSION"
|
||||
V1=${VP[0]:-0}; V2=${VP[1]:-0}; V3=${VP[2]:-0}; V4=${VP[3]:-0}
|
||||
VERSION_CODE=$((V1 * 1000000 + V2 * 10000 + V3 * 100 + V4)); [ "$VERSION_CODE" -lt 1 ] && VERSION_CODE=1
|
||||
sed -i "s/versionName '[^']*'/versionName '$VERSION'/" android/app/build.gradle
|
||||
sed -i "s/versionCode [0-9]*/versionCode $VERSION_CODE/" android/app/build.gradle
|
||||
echo -e " ${GREEN}✓${NC} android/app/build.gradle → versionName $VERSION, versionCode $VERSION_CODE\n"
|
||||
|
||||
# ── Bauen (Docker) ───────────────────────────────────────────────────
|
||||
echo -e "${GREEN}[2/5] Linux-Binary + Android-APK bauen (Docker)...${NC}"
|
||||
./build.sh >/dev/null
|
||||
LINUX_BIN="$SCRIPT_DIR/dist/aria-host-agent"
|
||||
[ -f "$LINUX_BIN" ] || { echo -e "${RED}Linux-Binary fehlt: $LINUX_BIN${NC}"; exit 1; }
|
||||
echo -e " ${GREEN}✓${NC} Linux-Binary ($(du -h "$LINUX_BIN" | cut -f1))"
|
||||
|
||||
( cd android && ./build.sh >/dev/null )
|
||||
APK="$SCRIPT_DIR/android/dist/aria-android-agent.apk"
|
||||
[ -f "$APK" ] || { echo -e "${RED}APK fehlt: $APK${NC}"; exit 1; }
|
||||
echo -e " ${GREEN}✓${NC} Android-APK ($(du -h "$APK" | cut -f1))"
|
||||
|
||||
# Windows (.exe + setup.exe) via Wine im Docker. Dauert (pywine-Image ~1-2 GB) —
|
||||
# mit SKIP_WINDOWS=1 ./release_agent.sh <v> ueberspringbar.
|
||||
if [ "${SKIP_WINDOWS:-0}" = "1" ]; then
|
||||
echo -e " ${YELLOW}Windows-Build uebersprungen (SKIP_WINDOWS=1)${NC}"
|
||||
else
|
||||
echo -e " ${CYAN}…${NC} Windows-.exe + setup.exe bauen (Wine, kann dauern)"
|
||||
if ./build-win.sh "$VERSION" >/dev/null 2>&1; then
|
||||
echo -e " ${GREEN}✓${NC} Windows-.exe + setup.exe"
|
||||
else
|
||||
echo -e " ${YELLOW}Windows-Build fehlgeschlagen — Release laeuft ohne Windows weiter.${NC}"
|
||||
echo -e " ${YELLOW}(Einzeln testen: ./build-win.sh $VERSION)${NC}"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Asset-Liste aufbauen: "lokaler_pfad::asset-name"
|
||||
ASSETS=(
|
||||
"$LINUX_BIN::aria-host-agent-linux-x64"
|
||||
"$APK::aria-host-agent-android-$TAG.apk"
|
||||
)
|
||||
# Native Artefakte (nur wenn vorhanden): Windows aus build-win.sh, macOS
|
||||
# vorgebaut (build-native.sh auf einem Mac -> dist/aria-host-agent-macos legen).
|
||||
[ -f "$SCRIPT_DIR/dist/aria-host-agent-macos" ] && ASSETS+=("$SCRIPT_DIR/dist/aria-host-agent-macos::aria-host-agent-macos")
|
||||
[ -f "$SCRIPT_DIR/dist/aria-host-agent.exe" ] && ASSETS+=("$SCRIPT_DIR/dist/aria-host-agent.exe::aria-host-agent-windows.exe")
|
||||
[ -f "$SCRIPT_DIR/dist/aria-host-agent-setup.exe" ] && ASSETS+=("$SCRIPT_DIR/dist/aria-host-agent-setup.exe::aria-host-agent-windows-setup.exe")
|
||||
|
||||
# ── Git-Tag ──────────────────────────────────────────────────────────
|
||||
echo -e "${GREEN}[3/5] Git-Tag $TAG...${NC}"
|
||||
git add host_agent.py android/app/build.gradle
|
||||
git commit -m "release(agent): bump to $VERSION" 2>/dev/null || echo -e " ${YELLOW}Keine Aenderungen zum Committen${NC}"
|
||||
if git rev-parse "$TAG" &>/dev/null; then
|
||||
echo -e " ${YELLOW}Tag $TAG existiert bereits — überspringe${NC}"
|
||||
else
|
||||
git tag "$TAG"; echo -e " ${GREEN}✓${NC} Tag $TAG erstellt"
|
||||
fi
|
||||
git push origin main "$TAG"
|
||||
echo -e " ${GREEN}✓${NC} Tag gepusht\n"
|
||||
|
||||
# ── Gitea-Release ────────────────────────────────────────────────────
|
||||
echo -e "${GREEN}[4/5] Gitea-Release anlegen...${NC}"
|
||||
BODY=$(printf 'ARIA Host-Agent %s\n\nDesktop (Linux) + Android-APK. Auf das Zielgeraet kopieren, siehe README.' "$TAG")
|
||||
BODY_JSON=$(printf '%s' "$BODY" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))' 2>/dev/null || printf '"%s"' "$BODY")
|
||||
RESP=$(curl -s -X POST "$GITEA_URL/api/v1/repos/$GITEA_REPO/releases" \
|
||||
-u "${GITEA_USER}:${GITEA_PASS}" -H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"Host-Agent $TAG\",\"body\":$BODY_JSON,\"draft\":false,\"prerelease\":false}")
|
||||
RELEASE_ID=$(echo "$RESP" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||
if [ -z "$RELEASE_ID" ]; then echo -e "${RED}Release fehlgeschlagen:${NC}\n$RESP"; exit 1; fi
|
||||
echo -e " ${GREEN}✓${NC} Release #$RELEASE_ID erstellt\n"
|
||||
|
||||
# ── Assets hochladen ─────────────────────────────────────────────────
|
||||
echo -e "${GREEN}[5/5] Assets hochladen (${#ASSETS[@]})...${NC}"
|
||||
for entry in "${ASSETS[@]}"; do
|
||||
path="${entry%%::*}"; name="${entry##*::}"
|
||||
UP=$(curl -s -X POST \
|
||||
"$GITEA_URL/api/v1/repos/$GITEA_REPO/releases/$RELEASE_ID/assets?name=$name" \
|
||||
-u "${GITEA_USER}:${GITEA_PASS}" -F "attachment=@${path}")
|
||||
if echo "$UP" | grep -q '"name"'; then
|
||||
echo -e " ${GREEN}✓${NC} $name"
|
||||
else
|
||||
echo -e " ${RED}✗ $name fehlgeschlagen:${NC} $UP"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔═══════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Host-Agent $TAG ist live!${NC}"
|
||||
echo -e "${GREEN}║${NC} $GITEA_URL/$GITEA_REPO/releases/tag/$TAG"
|
||||
echo -e "${GREEN}╚═══════════════════════════════════════════════════╝${NC}"
|
||||
@@ -0,0 +1,2 @@
|
||||
websockets>=12.0
|
||||
psutil>=5.9 # System-Info (CPU/RAM/Disk/Uptime) fuer host_info
|
||||
@@ -0,0 +1,83 @@
|
||||
; ARIA Host-Agent — Windows-Installer (NSIS, auf Linux mit makensis gebaut).
|
||||
;
|
||||
; Installiert die Agent-.exe nach %ProgramFiles%\ARIA Host-Agent, legt eine .env
|
||||
; in %ProgramData%\ARIA-Host-Agent an (nur falls noch keine da ist — User-Config
|
||||
; bleibt erhalten) und richtet einen automatisch startenden Windows-Dienst via
|
||||
; nssm ein. Der Dienst laeuft mit AppDirectory = ProgramData-Ordner, damit der
|
||||
; Agent die .env von dort (aus dem CWD) liest.
|
||||
|
||||
!ifndef VERSION
|
||||
!define VERSION "0.0.0"
|
||||
!endif
|
||||
!define SVC "ARIAHostAgent"
|
||||
|
||||
Name "ARIA Host-Agent ${VERSION}"
|
||||
OutFile "aria-host-agent-setup.exe"
|
||||
InstallDir "$PROGRAMFILES64\ARIA Host-Agent"
|
||||
RequestExecutionLevel admin
|
||||
Unicode true
|
||||
ShowInstDetails show
|
||||
ShowUninstDetails show
|
||||
|
||||
Var DataDir
|
||||
|
||||
Page directory
|
||||
Page instfiles
|
||||
UninstPage uninstConfirm
|
||||
UninstPage instfiles
|
||||
|
||||
Section "Install"
|
||||
SetOutPath "$INSTDIR"
|
||||
File "aria-host-agent.exe"
|
||||
File "nssm.exe"
|
||||
|
||||
; ProgramData-Ordner fuer die .env bestimmen
|
||||
ReadEnvStr $0 "ProgramData"
|
||||
StrCmp $0 "" 0 +2
|
||||
StrCpy $0 "$PROFILE" ; Fallback, falls %ProgramData% fehlt
|
||||
StrCpy $DataDir "$0\ARIA-Host-Agent"
|
||||
CreateDirectory "$DataDir"
|
||||
|
||||
; .env nur schreiben, wenn noch keine existiert (User-Config nicht ueberschreiben)
|
||||
IfFileExists "$DataDir\.env" env_done 0
|
||||
FileOpen $1 "$DataDir\.env" w
|
||||
FileWrite $1 "# ARIA Host-Agent — Konfiguration (dieser Windows-Dienst liest diese Datei).$\r$\n"
|
||||
FileWrite $1 "# Nach dem Aendern den Dienst neu starten: services.msc -> ARIA Host-Agent.$\r$\n"
|
||||
FileWrite $1 "RVS_HOST=rvs.example.de$\r$\n"
|
||||
FileWrite $1 "RVS_PORT=443$\r$\n"
|
||||
FileWrite $1 "RVS_TLS=true$\r$\n"
|
||||
FileWrite $1 "RVS_TLS_FALLBACK=true$\r$\n"
|
||||
FileWrite $1 "RVS_TOKEN=$\r$\n"
|
||||
FileWrite $1 "RVS_SNI=$\r$\n"
|
||||
FileWrite $1 "HOST_NAME=$\r$\n"
|
||||
FileWrite $1 "CONTROL_ENABLED=true$\r$\n"
|
||||
FileClose $1
|
||||
env_done:
|
||||
|
||||
; Dienst (neu) einrichten — evtl. alten sauber entfernen, dann installieren
|
||||
nsExec::ExecToLog '"$INSTDIR\nssm.exe" stop ${SVC}'
|
||||
nsExec::ExecToLog '"$INSTDIR\nssm.exe" remove ${SVC} confirm'
|
||||
nsExec::ExecToLog '"$INSTDIR\nssm.exe" install ${SVC} "$INSTDIR\aria-host-agent.exe"'
|
||||
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} AppDirectory "$DataDir"'
|
||||
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} DisplayName "ARIA Host-Agent"'
|
||||
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} Description "ARIA-Fernsteuerung dieses Rechners (RVS-Agent)."'
|
||||
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} Start SERVICE_AUTO_START'
|
||||
nsExec::ExecToLog '"$INSTDIR\nssm.exe" start ${SVC}'
|
||||
|
||||
; Uninstaller + Eintrag unter "Apps & Features"
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent" "DisplayName" "ARIA Host-Agent"
|
||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent" "DisplayVersion" "${VERSION}"
|
||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent" "UninstallString" '"$INSTDIR\uninstall.exe"'
|
||||
SectionEnd
|
||||
|
||||
Section "Uninstall"
|
||||
nsExec::ExecToLog '"$INSTDIR\nssm.exe" stop ${SVC}'
|
||||
nsExec::ExecToLog '"$INSTDIR\nssm.exe" remove ${SVC} confirm'
|
||||
Delete "$INSTDIR\aria-host-agent.exe"
|
||||
Delete "$INSTDIR\nssm.exe"
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
RMDir "$INSTDIR"
|
||||
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent"
|
||||
; Die .env in %ProgramData%\ARIA-Host-Agent bleibt bewusst erhalten (User-Config).
|
||||
SectionEnd
|
||||
@@ -183,9 +183,9 @@ Wichtige Mechanismen:
|
||||
- [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] 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] aria-bridge: STT primaer remote (Gamebox), Fallback lokal nach 45s Timeout
|
||||
- [x] Whisper-Modell hot-swap auf Gamebox via config-Broadcast aus Diagnostic
|
||||
- [x] Whisper STT auf die AI-Box ausgelagert (faster-whisper CUDA, float16) — neuer aria-whisper-bridge Container
|
||||
- [x] aria-bridge: STT primaer remote (AI-Box), Fallback lokal nach 45s Timeout
|
||||
- [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] 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)
|
||||
@@ -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] 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] App Chat-Suche umgebaut: Highlight + Next/Prev statt Filter
|
||||
- [x] App Pinch-Zoom in Bildern rewriten (Multi-Touch-Race-Bugs)
|
||||
@@ -399,7 +399,7 @@ Skills mit Tool-Use.
|
||||
### Architektur
|
||||
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
|
||||
- [ ] 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
|
||||
- [ ] Heartbeat (periodische Selbst-Checks)
|
||||
- [ ] Lokales LLM als Waechter (Triage vor Claude-Call)
|
||||
|
||||
+46
-1
@@ -63,7 +63,7 @@ const ALLOWED_TYPES = new Set([
|
||||
"agent_stream",
|
||||
"oauth_callback",
|
||||
// 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_request", "llm_response", "llm_partial",
|
||||
// Workspace-Desktop (Code-Projekte): Live-Code-Editor (CodeMirror in der App)
|
||||
@@ -80,6 +80,28 @@ const ALLOWED_TYPES = new Set([
|
||||
// sat_hello, liefern Geraete-Inventar (sat_devices) auf sat_discover und
|
||||
// fuehren Aktionen aus (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",
|
||||
// Host-Agenten: ein Agent laeuft direkt auf einem Rechner, meldet sich mit
|
||||
// host_hello/host_ping und fuehrt host_command aus (exec/read/write/info/
|
||||
// screenshot) -> host_result. ARIA steuert so Rechner auch hinter NAT.
|
||||
"host_hello", "host_ping", "host_command", "host_result",
|
||||
// Raum-Diagnose: Diagnostic fragt die aktuellen RVS-Raeume ab (rooms_query),
|
||||
// RVS antwortet direkt mit rooms_info (Fingerprint + Laenge + Client-Zahl je
|
||||
// Raum). Deckt Token-Prefix-Kollisionen auf (2 Raeume, gleicher 8-Zeichen-Log).
|
||||
"rooms_query", "rooms_info",
|
||||
// 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> }
|
||||
@@ -331,6 +353,29 @@ function registerClient(ws, token) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Raum-Diagnose: direkt an den anfragenden Client antworten (nicht relay'en).
|
||||
// Zeigt ALLE Raeume mit token8 (wie im Log), einem laengeren Fingerprint und
|
||||
// der Token-Laenge — so werden Prefix-Kollisionen (2 Raeume, gleicher 8-Zeichen-
|
||||
// Log, aber verschiedene volle Tokens) sofort sichtbar. KEINE vollen Tokens.
|
||||
if (msg.type === "rooms_query") {
|
||||
const crypto = require("crypto");
|
||||
const out = [];
|
||||
for (const [tok, room] of rooms) {
|
||||
let live = 0;
|
||||
for (const c of room.clients) if (c.readyState === 1) live++;
|
||||
out.push({
|
||||
token8: tok.slice(0, 8),
|
||||
fp: crypto.createHash("sha256").update(tok).digest("hex").slice(0, 12),
|
||||
len: tok.length,
|
||||
clients: live,
|
||||
you: tok === ws._token,
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => b.clients - a.clients);
|
||||
ws.send(JSON.stringify({ type: "rooms_info", payload: { rooms: out }, timestamp: Date.now() }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Update-Download: APK als Base64 ueber WebSocket senden
|
||||
if (msg.type === "update_download") {
|
||||
const apkInfo = getLatestAPK();
|
||||
|
||||
+39
-2
@@ -6,7 +6,14 @@
|
||||
RVS_HOST=rvs.example.de
|
||||
RVS_PORT=443
|
||||
RVS_TLS=true
|
||||
RVS_TLS_FALLBACK=true # bei TLS-Fehlschlag einmal auf ws:// zurueckfallen
|
||||
RVS_TOKEN=
|
||||
# RVS_SNI: nur noetig, wenn RVS_HOST eine IP ist (Satellit im selben Netz wie der
|
||||
# RVS, direkt auf die interne IP). Dann hier den Zertifikats-/Hostnamen angeben,
|
||||
# damit der TLS-Handshake (SNI) passt. Sonst leer lassen.
|
||||
# RVS_HOST=10.0.0.2
|
||||
# RVS_SNI=example.com
|
||||
RVS_SNI=
|
||||
|
||||
# ─── Identitaet / Adresse dieses Satelliten ────────────────────────
|
||||
# SATELLITE_ID = technisch eindeutig (a-z0-9-_), Default = Hostname-Slug.
|
||||
@@ -24,9 +31,39 @@ CONTROL_ENABLED=true
|
||||
# Erlaubte Steuer-Aktionen (kommagetrennt). Alles andere wird abgelehnt.
|
||||
# dial.launch App-Launch via DIAL (z.B. YouTube-Video auf Fire TV / Smart-TV)
|
||||
# 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
|
||||
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)
|
||||
# ssh.exec Kommando per SSH ausfuehren (params: ip, cmd; Auth aus Creds 'ssh')
|
||||
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec
|
||||
|
||||
# ─── 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) ───────────────────────────────────
|
||||
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
|
||||
|
||||
# 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 .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
@@ -48,6 +48,11 @@ python satellite.py # liest .env automatisch
|
||||
den Satelliten erreicht). `SATELLITE_LOCATION` ist der Name, über den ARIA das Netz
|
||||
anspricht („Büro").
|
||||
|
||||
**Satellit im selben Netz wie der RVS** (z.B. Rechenzentrum, direkt auf die
|
||||
interne IP statt NAT-Hairpin): `RVS_HOST=<interne-ip>` + **`RVS_SNI=<zert-name>`**
|
||||
(der Name, für den das Caddy-Zertifikat gilt). Ohne das scheitert TLS an
|
||||
`tlsv1 alert internal error`. Zuhause / normal: `RVS_SNI` leer lassen.
|
||||
|
||||
**Kontrolle:** im Log/Diagnostic muss `primary_ip` im Ziel-LAN liegen
|
||||
(`192.168.0.x`) — steht da `192.168.65.x` oder `172.x`, sitzt der Satellit im
|
||||
falschen (Docker-)Netz.
|
||||
|
||||
@@ -21,3 +21,6 @@ services:
|
||||
network_mode: host
|
||||
env_file: .env
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Persistenter Credential-Store (verschluesselt) + Schluesseldatei.
|
||||
- ./data:/data
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
websockets>=12.0
|
||||
zeroconf>=0.131.0
|
||||
requests>=2.31.0
|
||||
cryptography>=42.0 # Verschluesselung des Geraete-Credential-Stores (Fernet)
|
||||
paramiko>=3.4 # SSH-Client fuer ssh.exec (Passwort ODER Key)
|
||||
|
||||
+737
-11
@@ -35,6 +35,7 @@ import re
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import websockets
|
||||
@@ -105,7 +106,25 @@ def _default_id() -> str:
|
||||
RVS_HOST = os.environ.get("RVS_HOST", "")
|
||||
RVS_PORT = int(os.environ.get("RVS_PORT", "443") or "443")
|
||||
RVS_TLS = _env_bool("RVS_TLS", True)
|
||||
# Bei TLS-Fehlschlag einmal auf ws:// zurueckfallen (wie die Compute-Bridges).
|
||||
RVS_TLS_FALLBACK = _env_bool("RVS_TLS_FALLBACK", True)
|
||||
RVS_TOKEN = os.environ.get("RVS_TOKEN", "")
|
||||
# TLS-Hostname (SNI + Zertifikatspruefung), falls RVS_HOST eine IP ist — z.B. ein
|
||||
# Satellit im selben Netz wie der RVS, der direkt auf die interne IP verbindet
|
||||
# (das Caddy-Zertifikat gilt aber fuer den Namen). Leer = SNI = RVS_HOST.
|
||||
RVS_SNI = os.environ.get("RVS_SNI", "").strip()
|
||||
|
||||
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
|
||||
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
|
||||
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
|
||||
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
|
||||
if RVS_TLS and RVS_SNI:
|
||||
import socket as _socket
|
||||
_orig_getaddrinfo = _socket.getaddrinfo
|
||||
def _sni_getaddrinfo(host, *a, **k):
|
||||
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
|
||||
_socket.getaddrinfo = _sni_getaddrinfo
|
||||
|
||||
|
||||
SATELLITE_ID = (os.environ.get("SATELLITE_ID") or _default_id()).strip()
|
||||
SATELLITE_LOCATION = (os.environ.get("SATELLITE_LOCATION") or SATELLITE_ID).strip()
|
||||
@@ -113,7 +132,9 @@ SATELLITE_LOCATION = (os.environ.get("SATELLITE_LOCATION") or SATELLITE_ID).stri
|
||||
CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False)
|
||||
CONTROL_ALLOWLIST = [
|
||||
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,ssh.exec").split(",")
|
||||
if a.strip()
|
||||
]
|
||||
|
||||
@@ -121,6 +142,118 @@ 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")
|
||||
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")
|
||||
|
||||
# SSH (ssh.exec): Verbindungs-/Kommando-Timeout. Auth aus dem Credential-Store
|
||||
# (Typ 'ssh': user + pass ODER key). Default-Port 22, pro Request/Cred ueberschreibbar.
|
||||
SSH_TIMEOUT_SEC = float(os.environ.get("SSH_TIMEOUT_SEC", "20") or "20")
|
||||
|
||||
# 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", "ssh") if entry.get(t)]
|
||||
out.append({"ip": ip, "types": types})
|
||||
return out
|
||||
|
||||
HEARTBEAT_SEC = 25
|
||||
|
||||
# mDNS-Servicetypen, die fuer ARIA interessant sind.
|
||||
@@ -420,6 +553,18 @@ async def _control(action: str, params: dict, devices: list[dict]) -> dict:
|
||||
return await loop.run_in_executor(None, _do_wol, params)
|
||||
if action in ("http.get", "http.post"):
|
||||
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)
|
||||
if action == "ssh.exec":
|
||||
return await loop.run_in_executor(None, _do_ssh, params)
|
||||
return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
|
||||
@@ -471,15 +616,484 @@ def _do_wol(params: dict) -> dict:
|
||||
return {"ok": True, "result": f"Wake-on-LAN an {mac} gesendet."}
|
||||
|
||||
|
||||
def _window_text(text: str, params: dict) -> dict:
|
||||
"""Schneidet grosse Text-Ausgaben zu: optionaler contains-Zeilenfilter, dann
|
||||
offset/max_chars-Fenster. Gibt body + Metadaten (total/returned/truncated)."""
|
||||
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:
|
||||
text = "\n".join(ln for ln in text.splitlines()
|
||||
if any(t in ln.lower() for t in terms))
|
||||
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]
|
||||
return {"body": body, "total_chars": total, "filtered": bool(contains),
|
||||
"offset": offset, "returned_chars": len(body),
|
||||
"truncated": offset + len(body) < len(text)}
|
||||
|
||||
|
||||
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
|
||||
url = params.get("url") or ""
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return {"ok": False, "error": "url (http/https) erforderlich."}
|
||||
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"),
|
||||
headers=params.get("headers"), timeout=6)
|
||||
return {"ok": True, "result": {"status": r.status_code, "body": r.text[:2000]}}
|
||||
headers=params.get("headers"), auth=auth,
|
||||
timeout=HTTP_TIMEOUT_SEC)
|
||||
return {"ok": True, "result": {"status": r.status_code, **_window_text(r.text, params)}}
|
||||
|
||||
|
||||
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}
|
||||
|
||||
|
||||
# ─── SSH ───────────────────────────────────────────────────────────
|
||||
|
||||
def _ssh_load_key(key_str: str):
|
||||
"""Laedt einen privaten Schluessel aus einem String (RSA/Ed25519/ECDSA/DSS)."""
|
||||
import io
|
||||
import paramiko
|
||||
for cls in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey, paramiko.DSSKey):
|
||||
try:
|
||||
return cls.from_private_key(io.StringIO(key_str))
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _do_ssh(params: dict) -> dict:
|
||||
"""Fuehrt EIN Kommando per SSH auf einem Geraet aus. params: {ip|host, cmd,
|
||||
user?, pass?, key?, port?}. Auth bevorzugt aus dem Credential-Store (Typ 'ssh').
|
||||
Grosse Ausgaben werden wie bei http.get gefenstert (contains/offset/max_chars)."""
|
||||
import paramiko
|
||||
host = _snmp_target(params)
|
||||
if not host:
|
||||
return {"ok": False, "error": "ip/host erforderlich."}
|
||||
cmd = params.get("cmd") or params.get("command") or ""
|
||||
if not cmd:
|
||||
return {"ok": False, "error": "cmd (Kommando) erforderlich."}
|
||||
creds = _creds_for(host).get("ssh", {})
|
||||
user = str(params.get("user") or creds.get("user") or "")
|
||||
if not user:
|
||||
return {"ok": False, "error": "SSH-Benutzer fehlt (Credentials fuer diese IP setzen)."}
|
||||
port = _to_int(params.get("port") or creds.get("port")) or 22
|
||||
password = params.get("pass") or params.get("password") or creds.get("pass")
|
||||
key_str = params.get("key") or creds.get("key")
|
||||
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
kwargs = {"hostname": host, "port": port, "username": user,
|
||||
"timeout": SSH_TIMEOUT_SEC, "banner_timeout": SSH_TIMEOUT_SEC,
|
||||
"auth_timeout": SSH_TIMEOUT_SEC, "allow_agent": False,
|
||||
"look_for_keys": False}
|
||||
if key_str:
|
||||
pkey = _ssh_load_key(str(key_str))
|
||||
if pkey is None:
|
||||
return {"ok": False, "error": "Privater SSH-Key nicht lesbar (Format?)."}
|
||||
kwargs["pkey"] = pkey
|
||||
if password:
|
||||
kwargs["password"] = str(password)
|
||||
client.connect(**kwargs)
|
||||
stdin, stdout, stderr = client.exec_command(cmd, timeout=SSH_TIMEOUT_SEC)
|
||||
exit_code = stdout.channel.recv_exit_status()
|
||||
out = stdout.read().decode("utf-8", "ignore")
|
||||
err = stderr.read().decode("utf-8", "ignore")
|
||||
except paramiko.AuthenticationException:
|
||||
return {"ok": False, "error": "SSH-Auth fehlgeschlagen (User/Passwort/Key pruefen)."}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"SSH fehlgeschlagen: {exc}"}
|
||||
finally:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
win = _window_text(out, params)
|
||||
return {"ok": True, "result": {"host": host, "exit_code": exit_code,
|
||||
"stderr": err[:4000], **win}}
|
||||
|
||||
|
||||
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 ────────────────────────────────────────────────────────
|
||||
@@ -531,13 +1145,42 @@ class Satellite:
|
||||
ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
|
||||
arp = await loop.run_in_executor(None, _discover_arp)
|
||||
self._devices = _merge_devices(mdns, ssdp, arp)
|
||||
if SNMP_DISCOVERY:
|
||||
await self._enrich_snmp(self._devices)
|
||||
self._devices_ts = time.time()
|
||||
logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d)",
|
||||
len(self._devices), len(mdns), len(ssdp), len(arp))
|
||||
n_snmp = sum(1 for d in self._devices if d.get("snmpCapable"))
|
||||
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:
|
||||
self._scanning = False
|
||||
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:
|
||||
if self.ws is None:
|
||||
return
|
||||
@@ -597,7 +1240,12 @@ class Satellite:
|
||||
params = payload.get("params") or {}
|
||||
if payload.get("device") and "device" not in params:
|
||||
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)
|
||||
await self._send({
|
||||
"type": "sat_result",
|
||||
@@ -605,6 +1253,63 @@ class Satellite:
|
||||
"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", "ssh"):
|
||||
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:
|
||||
while True:
|
||||
try:
|
||||
@@ -626,17 +1331,29 @@ class Satellite:
|
||||
if not RVS_HOST or not RVS_TOKEN:
|
||||
logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).")
|
||||
return
|
||||
_creds_load()
|
||||
asyncio.create_task(self._periodic_scan())
|
||||
backoff = 1
|
||||
# use_tls kann bei Fehlschlag einmal auf ws:// fallen (RVS_TLS_FALLBACK),
|
||||
# danach wieder zurueck auf RVS_TLS (kein Sticky-Fallback). Bei TLS+RVS_SNI
|
||||
# nutzt die URI den HOSTNAMEN (Host-Header/SNI/Cert), getaddrinfo mappt ihn
|
||||
# auf die IP in RVS_HOST.
|
||||
use_tls = RVS_TLS
|
||||
tls_fallback_tried = False
|
||||
connect_kwargs = {"max_size": 8 * 1024 * 1024,
|
||||
"ping_interval": 20, "ping_timeout": 20}
|
||||
while True:
|
||||
proto = "wss" if RVS_TLS else "ws"
|
||||
url = f"{proto}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||
proto = "wss" if use_tls else "ws"
|
||||
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
|
||||
url = f"{proto}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||
fallback = False
|
||||
try:
|
||||
logger.info("Verbinde mit RVS %s://%s:%s …", proto, RVS_HOST, RVS_PORT)
|
||||
async with websockets.connect(url, max_size=8 * 1024 * 1024,
|
||||
ping_interval=20, ping_timeout=20) as ws:
|
||||
logger.info("Verbinde mit RVS %s://%s:%s%s …", proto, uri_host, RVS_PORT,
|
||||
f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
|
||||
async with websockets.connect(url, **connect_kwargs) as ws:
|
||||
self.ws = ws
|
||||
backoff = 1
|
||||
tls_fallback_tried = False
|
||||
await self._hello(log=True)
|
||||
hb = asyncio.create_task(self._heartbeat())
|
||||
try:
|
||||
@@ -646,10 +1363,19 @@ class Satellite:
|
||||
hb.cancel()
|
||||
except Exception as exc:
|
||||
logger.warning("RVS-Verbindung verloren: %s", exc)
|
||||
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
||||
logger.info("TLS fehlgeschlagen — Fallback auf ws://")
|
||||
use_tls = False
|
||||
tls_fallback_tried = True
|
||||
fallback = True
|
||||
finally:
|
||||
self.ws = None
|
||||
if fallback:
|
||||
continue
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 30)
|
||||
use_tls = RVS_TLS
|
||||
tls_fallback_tried = False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
+50
-3
@@ -1,11 +1,58 @@
|
||||
# ════════════════════════════════════════════════
|
||||
# ARIA XTTS v2 — Konfiguration
|
||||
# Kopieren nach .env und anpassen
|
||||
# ARIA Compute-Node — Konfiguration
|
||||
# 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=example.com
|
||||
RVS_PORT=444
|
||||
RVS_TLS=true
|
||||
RVS_TLS_FALLBACK=true
|
||||
RVS_TOKEN=dein_token_hier
|
||||
# RVS_SNI: nur noetig, wenn die Box im SELBEN Netz wie der RVS steht und direkt
|
||||
# auf dessen interne IP verbinden soll (kein NAT-Hairpin ueber den externen
|
||||
# Hostnamen). Dann RVS_HOST=<interne-ip> und hier den Zertifikats-/Hostnamen, fuer
|
||||
# den Caddy sein Cert hat. Zuhause / normal: leer lassen.
|
||||
# RVS_HOST=10.0.0.2
|
||||
# RVS_SNI=example.com
|
||||
RVS_SNI=
|
||||
|
||||
# ─── 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
|
||||
startklaren GPU-Satelliten-Host für den `xtts`-Stack (STT/TTS/LLM).
|
||||
Der `xtts`-Stack sind ARIAs GPU-Dienste (STT: Voxtral/Whisper · TTS: F5-TTS ·
|
||||
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
|
||||
|
||||
@@ -20,13 +25,13 @@ Alles **idempotent** — mehrfach ausführbar.
|
||||
|
||||
```bash
|
||||
git clone <repo> ARIA-AGENT
|
||||
cd ARIA-AGENT/ai-box
|
||||
cd ARIA-AGENT/xtts
|
||||
|
||||
sudo ./bootstrap.sh
|
||||
# → Wenn der Treiber frisch installiert wurde: einmal neu starten, dann nochmal:
|
||||
sudo reboot
|
||||
# … nach dem Boot:
|
||||
cd ARIA-AGENT/ai-box
|
||||
cd ARIA-AGENT/xtts
|
||||
sudo ./bootstrap.sh --up --token <DEIN_RVS_TOKEN>
|
||||
```
|
||||
|
||||
@@ -53,6 +58,10 @@ GPU-Test + Stack-Start.
|
||||
24-GB-Karte: `docker compose stop whisper-bridge && docker compose --profile voxtral up -d --build`.
|
||||
- **Erster Start lädt Modelle** (mehrere GB via HuggingFace nach `xtts/hf-cache`
|
||||
+ `xtts/models`) — genug Platz (1 TB NVMe ✓) und etwas Geduld.
|
||||
- **Box im selben Netz wie der RVS** (z.B. Rechenzentrum, direkt auf die interne
|
||||
IP statt NAT-Hairpin): in `xtts/.env` `RVS_HOST=<interne-ip>` + **`RVS_SNI=<zert-name>`**
|
||||
(Name, für den das Caddy-Zert gilt). Ohne das scheitert TLS an `tlsv1 alert
|
||||
internal error`. Zuhause / normal: `RVS_SNI` leer lassen.
|
||||
|
||||
## Verifizieren
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# GPU-Satelliten-Host fuer den xtts-Stack (Voxtral/Whisper STT, F5-TTS, lokales LLM).
|
||||
#
|
||||
# Ablauf nach `git clone`:
|
||||
# cd ARIA-AGENT/ai-box
|
||||
# cd ARIA-AGENT/xtts
|
||||
# sudo ./bootstrap.sh # richtet Treiber + Docker + NVIDIA-Toolkit ein
|
||||
# # (falls Treiber frisch installiert: einmal `sudo reboot`, dann Script erneut)
|
||||
# sudo ./bootstrap.sh --up # dazu: xtts-Stack (Whisper+F5+LLM) hochziehen
|
||||
@@ -122,7 +122,7 @@ EOF
|
||||
ok "nvidia-driver aus backports installiert: ${NEWV}"
|
||||
echo
|
||||
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}"
|
||||
exit 0
|
||||
else
|
||||
+101
-98
@@ -1,45 +1,50 @@
|
||||
# ════════════════════════════════════════════════
|
||||
# ARIA Gamebox Stack — GPU F5-TTS + Whisper STT
|
||||
# Laeuft auf dem Gaming-PC (RTX 3060)
|
||||
# Verbindet sich zum RVS fuer TTS/STT-Requests
|
||||
# ARIA Compute-Node — GPU-Dienste (STT / TTS / LLM)
|
||||
#
|
||||
# FLUX-Bildgenerierung liegt im /flux Verzeichnis im Repo-Root —
|
||||
# eigener Compose-Stack, kann auch auf einer anderen Maschine laufen.
|
||||
# KEINE feste "AI-Box" mehr: dieser Stack laeuft auf beliebig vielen
|
||||
# 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:
|
||||
# - Docker Desktop mit WSL2
|
||||
# - NVIDIA Container Toolkit
|
||||
# - .env mit RVS-Verbindungsdaten
|
||||
# - Docker + NVIDIA Container Toolkit (registriert die `nvidia`-Runtime)
|
||||
# - .env mit RVS-Verbindungsdaten, COMPOSE_PROFILES, NODE_NAME, *_GPU
|
||||
#
|
||||
# Start: docker compose up -d
|
||||
# Start: docker compose up -d (liest COMPOSE_PROFILES aus der .env)
|
||||
# ════════════════════════════════════════════════
|
||||
|
||||
services:
|
||||
|
||||
# ─── F5-TTS Bridge (GPU) ──────────────────────
|
||||
# Ersetzt den frueheren XTTS-Stack. Empfaengt xtts_request via RVS,
|
||||
# rendert via F5-TTS mit Voice-Cloning, streamt PCM an die App.
|
||||
# Voice-Upload: speichert WAV und laesst whisper-bridge den Referenz-
|
||||
# text transkribieren — der User muss nichts eintippen.
|
||||
# Empfaengt xtts_request via RVS, rendert via F5-TTS mit Voice-Cloning,
|
||||
# streamt PCM an die App. Voice-Upload: speichert WAV und laesst eine
|
||||
# STT-Bridge den Referenztext transkribieren — der User tippt nichts.
|
||||
f5tts-bridge:
|
||||
build: ./f5tts
|
||||
container_name: aria-f5tts-bridge
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
device_ids: ["0"] # TTS → GPU 0 (8 GB; F5 ist klein)
|
||||
capabilities: [gpu]
|
||||
profiles: ["f5tts"] # startet nur mit COMPOSE_PROFILES=…f5tts…
|
||||
runtime: nvidia
|
||||
volumes:
|
||||
- ./voices:/voices # WAV + TXT Referenz
|
||||
- ./hf-cache:/root/.cache/huggingface # HF-Cache als Bind-Mount.
|
||||
# Direkt sichtbar im xtts/hf-cache/,
|
||||
# einfach manuell zu loeschen, kein
|
||||
# Docker-Desktop .vhdx Bloat.
|
||||
# Wird mit whisper-bridge geteilt.
|
||||
# Wird mit STT-Bridges geteilt.
|
||||
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,
|
||||
# nfe_step, Custom-Checkpoint) kommen ueber Diagnostic via RVS-config.
|
||||
- RVS_HOST=${RVS_HOST}
|
||||
@@ -51,27 +56,22 @@ services:
|
||||
- VOICES_DIR=/voices
|
||||
restart: unless-stopped
|
||||
|
||||
# ─── Whisper STT (GPU) ────────────────────────
|
||||
# Faster-Whisper auf der Gamebox statt auf der VM (CPU) —
|
||||
# deutlich schneller. Verbindet sich selbst per WebSocket an
|
||||
# den RVS und nimmt dort stt_request Nachrichten der aria-bridge
|
||||
# entgegen, antwortet mit stt_response. Zusaetzlich nutzt die
|
||||
# f5tts-bridge Whisper intern fuer die Referenz-Transkription bei
|
||||
# Voice-Uploads. Laedt das Modell beim Start vor; auf Config-
|
||||
# Broadcasts (Diagnostic → whisperModel) wird zur Laufzeit hot-
|
||||
# swapped.
|
||||
# ─── Whisper STT (GPU) — opt-in Fallback-STT ──
|
||||
# Faster-Whisper auf CUDA. Verbindet sich selbst per WebSocket an den RVS
|
||||
# und nimmt stt_request / stt_stream_* Nachrichten entgegen. Zusaetzlich
|
||||
# nutzt die f5tts-bridge Whisper intern fuer die Referenz-Transkription bei
|
||||
# Voice-Uploads. Modell-Hot-Swap via Diagnostic (config-Broadcast).
|
||||
# Nur EINEN STT-Provider pro Node laufen lassen (voxtral ODER whisper) —
|
||||
# sonst beantworten beide dieselbe Anfrage doppelt.
|
||||
whisper-bridge:
|
||||
build: ./whisper
|
||||
container_name: aria-whisper-bridge
|
||||
profiles: ["whisper"] # Fallback-STT — startet nur mit --profile whisper
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
device_ids: ["1"] # STT/groesstes Modell → GPU 1 (12 GB; spaeter Voxtral-STT-3B ~9 GB)
|
||||
capabilities: [gpu]
|
||||
profiles: ["whisper"] # startet nur mit COMPOSE_PROFILES=…whisper…
|
||||
runtime: nvidia
|
||||
environment:
|
||||
- NVIDIA_VISIBLE_DEVICES=${WHISPER_GPU:-1}
|
||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
- NODE_NAME=${NODE_NAME:-node}
|
||||
- RVS_HOST=${RVS_HOST}
|
||||
- RVS_PORT=${RVS_PORT:-443}
|
||||
- RVS_TLS=${RVS_TLS:-true}
|
||||
@@ -91,74 +91,22 @@ services:
|
||||
# Container-Restarts.
|
||||
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 ─────────
|
||||
# Ersetzt whisper als STT. Laeuft auf Treiber 550/CUDA 12.4 (torch cu124, KEIN
|
||||
# Treiber-Upgrade noetig). Modell Voxtral-Mini-3B-2507 (~9 GB bf16) → GPU 1.
|
||||
# Startet bei jedem `docker compose up -d`. Whisper ist der opt-in Fallback
|
||||
# (Profil "whisper") — beide zusammen wuerden stt_* doppelt beantworten, also
|
||||
# immer nur EINEN STT laufen lassen.
|
||||
# Voxtral-Mini-3B-2507 (~9 GB bf16). Laeuft auf Treiber 550/CUDA 12.4
|
||||
# (torch cu124, KEIN Treiber-Upgrade noetig). Whisper ist der opt-in
|
||||
# Fallback — immer nur EINEN STT-Provider pro Node aktiv haben.
|
||||
voxtral-bridge:
|
||||
build: ./voxtral
|
||||
container_name: aria-voxtral-bridge
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
device_ids: ["1"] # 12-GB-Karte (STT-3B ~9 GB); GPU 0 (8 GB) bleibt fuer F5/LLM
|
||||
capabilities: [gpu]
|
||||
profiles: ["voxtral"] # startet nur mit COMPOSE_PROFILES=…voxtral…
|
||||
runtime: nvidia
|
||||
volumes:
|
||||
- ./hf-cache:/root/.cache/huggingface # gleicher Modell-Cache wie whisper/f5
|
||||
- ./voice-id:/voice-id # Speaker-Fingerprint (wie whisper)
|
||||
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_PORT=${RVS_PORT:-443}
|
||||
- RVS_TLS=${RVS_TLS:-true}
|
||||
@@ -169,3 +117,58 @@ services:
|
||||
- HUGGING_FACE_HUB_TOKEN=${HF_TOKEN:-} # falls das Modell HF-gated ist
|
||||
- PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True # weniger VRAM-Fragmentierung
|
||||
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 && \
|
||||
pip3 install --no-cache-dir -c /tmp/torch-constraint.txt -r requirements.txt
|
||||
|
||||
COPY node_stats.py .
|
||||
COPY bridge.py .
|
||||
|
||||
CMD ["python3", "bridge.py"]
|
||||
|
||||
+106
-10
@@ -1,6 +1,6 @@
|
||||
#!/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
|
||||
16-bit PCM Chunks als audio_pcm Nachrichten zurueck an die App.
|
||||
@@ -51,6 +51,21 @@ RVS_PORT = int(os.getenv("RVS_PORT", "443"))
|
||||
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
|
||||
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
|
||||
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
||||
# TLS-Hostname (SNI + Cert), falls RVS_HOST eine IP ist (Box im selben Netz wie
|
||||
# der RVS, direkt auf die interne IP). Leer = SNI = RVS_HOST.
|
||||
RVS_SNI = os.getenv("RVS_SNI", "").strip()
|
||||
|
||||
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
|
||||
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
|
||||
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
|
||||
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
|
||||
if RVS_TLS and RVS_SNI:
|
||||
import socket as _socket
|
||||
_orig_getaddrinfo = _socket.getaddrinfo
|
||||
def _sni_getaddrinfo(host, *a, **k):
|
||||
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
|
||||
_socket.getaddrinfo = _sni_getaddrinfo
|
||||
|
||||
|
||||
# F5-TTS Konfiguration
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
@@ -60,6 +75,24 @@ RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
||||
# f5ttsCkptFile, f5ttsVocabFile, f5ttsCfgStrength, f5ttsNfeStep).
|
||||
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_CKPT_FILE = "" # leer = Default-Checkpoint von HF
|
||||
DEFAULT_F5TTS_VOCAB_FILE = "" # leer = Default-Vocab vom Modell
|
||||
@@ -378,7 +411,7 @@ async def _send(ws, mtype: str, payload: dict) -> None:
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
# unterschiedlichen Hosts laufen. Logs ueber RVS heisst: ein Pfad.
|
||||
#
|
||||
@@ -460,13 +493,16 @@ _tts_queue: asyncio.Queue[tuple] = asyncio.Queue()
|
||||
|
||||
async def _tts_worker(ws, runner: F5Runner) -> None:
|
||||
"""Serialisiert Synthesen — GPU kann sonst OOM gehen."""
|
||||
global _tts_busy
|
||||
while True:
|
||||
text, voice, request_id, message_id, language, speed = await _tts_queue.get()
|
||||
_tts_busy = True
|
||||
try:
|
||||
await _do_tts(ws, runner, text, voice, request_id, message_id, language, speed)
|
||||
except Exception:
|
||||
logger.exception("TTS-Worker Fehler")
|
||||
finally:
|
||||
_tts_busy = False
|
||||
_tts_queue.task_done()
|
||||
|
||||
|
||||
@@ -663,6 +699,18 @@ async def handle_voice_upload(ws, payload: dict) -> None:
|
||||
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:
|
||||
try:
|
||||
voices = []
|
||||
@@ -699,13 +747,16 @@ async def handle_delete_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."""
|
||||
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:
|
||||
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
|
||||
try:
|
||||
wav, txt = voice_paths(name)
|
||||
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
|
||||
import io, tarfile
|
||||
buf = io.BytesIO()
|
||||
@@ -715,10 +766,10 @@ async def handle_export_voice(ws, payload: dict) -> None:
|
||||
tar.add(txt, arcname=txt.name)
|
||||
data = base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
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:
|
||||
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:
|
||||
@@ -808,6 +859,31 @@ async def _broadcast_status(ws, state: str, **extra) -> None:
|
||||
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:
|
||||
use_tls = RVS_TLS
|
||||
retry_s = 2
|
||||
@@ -816,12 +892,14 @@ async def run_loop(runner: F5Runner) -> None:
|
||||
|
||||
while True:
|
||||
scheme = "wss" if use_tls else "ws"
|
||||
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
|
||||
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
|
||||
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||
|
||||
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
|
||||
try:
|
||||
logger.info("Verbinde zu RVS: %s", masked)
|
||||
async with websockets.connect(url, ping_interval=20, ping_timeout=10, max_size=50 * 1024 * 1024) as ws:
|
||||
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
|
||||
async with websockets.connect(url, **connect_kwargs) as ws:
|
||||
logger.info("RVS verbunden")
|
||||
retry_s = 2
|
||||
tls_fallback_tried = False
|
||||
@@ -855,15 +933,31 @@ async def run_loop(runner: F5Runner) -> None:
|
||||
|
||||
# TTS-Worker fuer diese Verbindung starten
|
||||
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:
|
||||
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:
|
||||
msg = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
mtype = msg.get("type", "")
|
||||
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":
|
||||
try:
|
||||
@@ -958,6 +1052,7 @@ async def run_loop(runner: F5Runner) -> None:
|
||||
_last_diag_voice = ""
|
||||
finally:
|
||||
worker.cancel()
|
||||
ping_task.cancel()
|
||||
try:
|
||||
await worker
|
||||
except asyncio.CancelledError:
|
||||
@@ -985,6 +1080,7 @@ async def main() -> None:
|
||||
sys.exit(1)
|
||||
VOICES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
runner = F5Runner()
|
||||
asyncio.create_task(_stats.run_sampler()) # Auslastungs-Sampler (Stage E)
|
||||
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
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY node_stats.py .
|
||||
COPY 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
|
||||
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).
|
||||
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
|
||||
@@ -35,7 +35,7 @@ umstellen + Container neu — Ein-Zeilen-Wechsel, kein Code.
|
||||
Modelle) ist ein geplanter Folge-Baustein via `llama-swap` — siehe
|
||||
`docs/plan-local-llm-router.md`.
|
||||
|
||||
## Start (auf der Gamebox)
|
||||
## Start (auf der AI-Box)
|
||||
|
||||
```bash
|
||||
cd xtts
|
||||
|
||||
+280
-14
@@ -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
|
||||
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
|
||||
`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.
|
||||
|
||||
Env:
|
||||
@@ -43,10 +43,38 @@ RVS_PORT = os.getenv("RVS_PORT", "443").strip()
|
||||
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
|
||||
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
|
||||
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
||||
# TLS-Hostname (SNI + Cert), falls RVS_HOST eine IP ist (Box im selben Netz wie
|
||||
# der RVS, direkt auf die interne IP). Leer = SNI = RVS_HOST.
|
||||
RVS_SNI = os.getenv("RVS_SNI", "").strip()
|
||||
|
||||
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
|
||||
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
|
||||
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
|
||||
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
|
||||
if RVS_TLS and RVS_SNI:
|
||||
import socket as _socket
|
||||
_orig_getaddrinfo = _socket.getaddrinfo
|
||||
def _sni_getaddrinfo(host, *a, **k):
|
||||
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
|
||||
_socket.getaddrinfo = _sni_getaddrinfo
|
||||
|
||||
|
||||
LLAMA_URL = os.getenv("LLAMA_URL", "http://llama:8081").rstrip("/")
|
||||
LLM_MODEL = os.getenv("LLM_MODEL", "qwen3-8b")
|
||||
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
|
||||
# <think>-Block und liefert (bei kleinem max_tokens) leeren/abgeschnittenen
|
||||
# content, ausserdem 3x langsamer. ARIAs schnelles Tier will KEIN Grübeln
|
||||
@@ -56,6 +84,94 @@ LLM_TIMEOUT_SEC = float(os.getenv("LLM_TIMEOUT_SEC", "60"))
|
||||
# empfindlich reagiert: LLM_DISABLE_THINKING=false setzen.
|
||||
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:
|
||||
try:
|
||||
@@ -99,11 +215,17 @@ async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
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 {
|
||||
"ok": True,
|
||||
"content": msg.get("content") or "",
|
||||
"tool_calls": msg.get("tool_calls") or None,
|
||||
"usage": data.get("usage"),
|
||||
"usage": usage,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("llama.cpp-Call fehlgeschlagen: %s", e)
|
||||
@@ -124,7 +246,63 @@ async def _emit_llm_status(ws, state: str, model: str, **extra) -> None:
|
||||
{"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:
|
||||
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
|
||||
req_id = payload.get("requestId", "")
|
||||
messages = payload.get("messages") or []
|
||||
@@ -177,6 +355,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:
|
||||
if not RVS_HOST:
|
||||
logger.error("RVS_HOST nicht gesetzt — Abbruch")
|
||||
@@ -185,35 +418,68 @@ async def _run() -> None:
|
||||
logger.error("RVS_TOKEN nicht gesetzt — Abbruch")
|
||||
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
|
||||
retry_s = 2
|
||||
tls_fallback_tried = False
|
||||
|
||||
while True:
|
||||
scheme = "wss" if use_tls else "ws"
|
||||
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
|
||||
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
|
||||
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 16 * 1024 * 1024}
|
||||
try:
|
||||
logger.info("Verbinde zu RVS: %s (llama=%s)", masked, LLAMA_URL)
|
||||
async with websockets.connect(
|
||||
url, ping_interval=20, ping_timeout=10, max_size=16 * 1024 * 1024
|
||||
) as ws:
|
||||
logger.info("Verbinde zu RVS: %s%s (llama=%s)", masked,
|
||||
f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "", LLAMA_URL)
|
||||
async with websockets.connect(url, **connect_kwargs) as ws:
|
||||
logger.info("RVS verbunden — llm-adapter online")
|
||||
retry_s = 2
|
||||
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:
|
||||
msg = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
if msg.get("type") != "llm_request":
|
||||
continue
|
||||
mtype = msg.get("type")
|
||||
payload = msg.get("payload", {}) or {}
|
||||
# Jede Anfrage nebenlaeufig — llama.cpp serialisiert intern,
|
||||
# aber wir blockieren so nicht den Empfang weiterer Messages.
|
||||
asyncio.create_task(_handle_llm_request(ws, payload))
|
||||
# Redundanz-Routing: gezielt an eine andere Instanz adressiert
|
||||
# → ignorieren. Ohne targetInstance → wie bisher (jeder nimmt).
|
||||
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:
|
||||
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:
|
||||
tls_fallback_tried = True
|
||||
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
|
||||
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 && \
|
||||
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"]
|
||||
|
||||
+290
-16
@@ -53,11 +53,45 @@ RVS_PORT = int(os.getenv("RVS_PORT", "443"))
|
||||
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
|
||||
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
|
||||
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
||||
# TLS-Hostname (SNI + Cert), falls RVS_HOST eine IP ist (Box im selben Netz wie
|
||||
# der RVS, direkt auf die interne IP). Leer = SNI = RVS_HOST.
|
||||
RVS_SNI = os.getenv("RVS_SNI", "").strip()
|
||||
|
||||
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
|
||||
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
|
||||
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
|
||||
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
|
||||
if RVS_TLS and RVS_SNI:
|
||||
import socket as _socket
|
||||
_orig_getaddrinfo = _socket.getaddrinfo
|
||||
def _sni_getaddrinfo(host, *a, **k):
|
||||
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
|
||||
_socket.getaddrinfo = _sni_getaddrinfo
|
||||
|
||||
|
||||
VOXTRAL_MODEL = os.getenv("VOXTRAL_MODEL", "mistralai/Voxtral-Mini-3B-2507")
|
||||
VOXTRAL_LANGUAGE = os.getenv("VOXTRAL_LANGUAGE", "de")
|
||||
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_DEFAULT_ENDPOINT_MS = 2400
|
||||
STREAM_DEFAULT_HARD_CAP_MS = 300000
|
||||
@@ -94,6 +128,78 @@ _HALLUCINATION_RE = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Kollabiert unmittelbar wiederholte Phrasen (Voxtral-Repetition-Loop) auf EINE
|
||||
# Kopie. Zweites Netz hinter no_repeat_ngram in der Generation. Phrase 5-80 Zeichen,
|
||||
# 3+ mal hintereinander → eine. Kurze legitime Doppelungen ('ja ja', 'sehr sehr')
|
||||
# bleiben (Unit < 5 Zeichen bzw. < 3 Wiederholungen).
|
||||
_REPEAT_RE = re.compile(r"(.{5,80}?)(?:\s*\1){2,}", re.IGNORECASE | re.DOTALL)
|
||||
|
||||
|
||||
def _collapse_repetitions(text: str) -> str:
|
||||
if not text:
|
||||
return text
|
||||
out = text
|
||||
for _ in range(3): # mehrfach fuer verschachtelte/ungleiche Loops
|
||||
new = _REPEAT_RE.sub(r"\1", out)
|
||||
if new == out:
|
||||
break
|
||||
out = new
|
||||
return out.strip()
|
||||
|
||||
|
||||
# ── Silero VAD: echte Sprach-Erkennung VOR dem Transkribieren ──────────────
|
||||
# Der Muster-Filter oben kennt nur spezifische Artefakte. Generische Phantome
|
||||
# ("Ich bin ein guter Mann" aus Fast-Stille) kann ein Text-Regex nicht fangen —
|
||||
# aber ein VAD schon, weil es am AUDIO entscheidet, nicht am Text. Silero trennt
|
||||
# Sprache zuverlaessig von Stille / Rauschen / MUSIK. Kein Speech-Segment →
|
||||
# no-speech → nicht transkribieren → kein Phantom (und Musik/Instrumental fliegt
|
||||
# gleich mit raus).
|
||||
# FAIL-OPEN: klappt das VAD nicht (Import/Load/Inferenz), wird trotzdem normal
|
||||
# transkribiert. Die STT darf NIE komplett sterben (Speaker-ID-Lektion).
|
||||
SILERO_VAD_ENABLED = os.getenv("SILERO_VAD_ENABLED", "true").lower() in ("1", "true", "yes")
|
||||
SILERO_VAD_THRESHOLD = float(os.getenv("SILERO_VAD_THRESHOLD", "0.5"))
|
||||
SILERO_MIN_SPEECH_MS = int(os.getenv("SILERO_MIN_SPEECH_MS", "150"))
|
||||
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}
|
||||
|
||||
|
||||
def _speech_segments(audio_f32):
|
||||
"""Silero-VAD-Sprachsegmente (Liste von {start,end} Sample-Indizes) im
|
||||
16kHz-float32-Audio. Rueckgabe:
|
||||
[] → kein Speech (Stille/Rauschen/Musik) → Phantom-Verdacht, verwerfen.
|
||||
[...] → Speech vorhanden.
|
||||
None → VAD nicht verfuegbar → fail-open (Aufrufer transkribiert normal)."""
|
||||
if not SILERO_VAD_ENABLED or _vad_state["failed"]:
|
||||
return None
|
||||
if _vad_state["model"] is None:
|
||||
try:
|
||||
from silero_vad import load_silero_vad, get_speech_timestamps
|
||||
_vad_state["model"] = load_silero_vad()
|
||||
_vad_state["get_ts"] = get_speech_timestamps
|
||||
logger.info("Silero VAD geladen (threshold=%.2f, min_speech=%dms)",
|
||||
SILERO_VAD_THRESHOLD, SILERO_MIN_SPEECH_MS)
|
||||
except Exception:
|
||||
logger.exception("Silero VAD Laden fehlgeschlagen — dauerhaft aus (fail-open)")
|
||||
_vad_state["failed"] = True
|
||||
return None
|
||||
try:
|
||||
import torch as _torch
|
||||
segs = _vad_state["get_ts"](
|
||||
_torch.from_numpy(audio_f32), _vad_state["model"],
|
||||
sampling_rate=16000, threshold=SILERO_VAD_THRESHOLD,
|
||||
min_speech_duration_ms=SILERO_MIN_SPEECH_MS,
|
||||
)
|
||||
return segs or []
|
||||
except Exception:
|
||||
logger.exception("Silero VAD Inferenz fehlgeschlagen — dieser Turn fail-open")
|
||||
return None
|
||||
|
||||
# Speaker-ID Gating global an/aus. DEFAULT AUS (fail-open) — die "nur meine Stimme"-
|
||||
# Pruefung ist ein BEWUSSTER Schalter, kein Automatismus: ein einziger schlechter
|
||||
# Enroll darf nie die ganze STT lahmlegen (genau das ist passiert). Wird per config-
|
||||
@@ -162,7 +268,20 @@ class VoxtralRunner:
|
||||
with torch.no_grad():
|
||||
# hoch genug fuer lange Diktate (stoppt eh am EOS); 512 hat
|
||||
# mehrminutige Aufnahmen abgeschnitten.
|
||||
outputs = model.generate(**inputs, max_new_tokens=4096)
|
||||
# Repetition-Bremse: Voxtral kippt bei Stille/Rauschen am Ende
|
||||
# gern in eine Schleife und wiederholt einen Satz zig-mal
|
||||
# ("Vergiss das, das ist nur... Vergiss das, das ist nur..."
|
||||
# x15). no_repeat_ngram_size=4 laesst die ERSTE echte Nennung
|
||||
# durch, verbietet aber die exakte 4-Gramm-Wiederholung → Loop
|
||||
# bricht ab; repetition_penalty daempft zusaetzlich. Beides mild,
|
||||
# damit normale Sprache (auch mal ein doppeltes Wort) unberuehrt
|
||||
# bleibt.
|
||||
outputs = model.generate(
|
||||
**inputs,
|
||||
max_new_tokens=4096,
|
||||
no_repeat_ngram_size=4,
|
||||
repetition_penalty=1.15,
|
||||
)
|
||||
trimmed = outputs[:, inputs.input_ids.shape[1]:]
|
||||
text = proc.batch_decode(trimmed, skip_special_tokens=True)
|
||||
return (text[0] if text else "").strip()
|
||||
@@ -201,6 +320,7 @@ class StreamSession:
|
||||
last_growth_at: float = 0.0
|
||||
last_transcribe_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
|
||||
closed: bool = False
|
||||
endpoint_sent: bool = False
|
||||
@@ -426,26 +546,79 @@ class SessionManager:
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"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:
|
||||
self._update_noise_floor(sess, rms)
|
||||
# No-Speech-Timeout: wurde die GANZE Zeit KEINE Stimme erkannt
|
||||
# (last_voice_at==0), feuert der normale Endpoint unten NIE — der braucht
|
||||
# last_voice_at>0. Ohne das bleibt ein reines Stille-Fenster offen bis
|
||||
# Hardcap/manuellem Stop → genau Stefans Repro: "die Stille-Ende wird nie
|
||||
# erreicht, stop ich selbst ist es weg". Nach endpoint_ms Stille ab Start
|
||||
# schliessen wir das Fenster selbst als no-speech (leer, lautlos, zurueck
|
||||
# aufs Wake-Word). voiced_frames==0 → _finalize verwirft ohne Transkript,
|
||||
# also KEIN Phantom.
|
||||
if sess.last_voice_at == 0 and (now - sess.started_at) * 1000.0 >= sess.endpoint_ms:
|
||||
await self._finalize(sess, "no_speech")
|
||||
return
|
||||
# Endpoint: hat der User schon gesprochen UND ist es seit endpoint_ms still?
|
||||
if sess.last_voice_at > 0 and (now - sess.last_voice_at) * 1000.0 >= sess.endpoint_ms:
|
||||
await self._finalize(sess, "endpoint")
|
||||
|
||||
async def _emit_no_speech(self, sess: "StreamSession", reason_label: str) -> None:
|
||||
"""Leeres no-speech-Endpoint senden + Session droppen (kein Transkript).
|
||||
App re-armt still, zurueck aufs Wake-Word."""
|
||||
if self._ws is not None:
|
||||
payload = {"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "", "reason": reason_label,
|
||||
"durationS": 0.0, "sttMs": 0}
|
||||
await _send(self._ws, "stt_endpoint", payload)
|
||||
await _send(self._ws, "stt_stream_done", {
|
||||
"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "", "reason": reason_label})
|
||||
self.drop(sess.request_id)
|
||||
|
||||
async def _finalize(self, sess: StreamSession, reason: str) -> None:
|
||||
if sess.endpoint_sent:
|
||||
return
|
||||
sess.endpoint_sent = True
|
||||
# Halluzinations-Guard: zu wenig echte Stimme (Stille / kurzer Blip im
|
||||
# Passiv-/Wake-Fenster) → NICHT transkribieren. Voxtral (wie Whisper) baut
|
||||
# aus Fast-Nichts gern einen Fuellsatz ("keine Ahnung" o.ae.), der dann als
|
||||
# PHANTOM-Nachricht ans Brain geht und das Gespraech entgleisen laesst
|
||||
# (Stefans Repro: "kam Nachricht von mir, obwohl ich nichts sagte"). Leeres
|
||||
# Endpoint = no-speech → App re-armt still. Der manuelle Stop (stream_end)
|
||||
# ist ausgenommen: dort hat der User bewusst gesprochen (kurze Woerter ok).
|
||||
if reason != "stream_end" and sess.voiced_frames < STREAM_MIN_VOICED_FRAMES:
|
||||
# aus Fast-Nichts gern einen Fuellsatz ("Die Stadt hat eine Flaeche von
|
||||
# 1,5 km2"), der dann als PHANTOM-Nachricht ans Brain geht und das Gespraech
|
||||
# entgleisen laesst (Stefans Repro: "kam Nachricht von mir, obwohl ich
|
||||
# nichts sagte"). Leeres Endpoint = no-speech → App re-armt still.
|
||||
#
|
||||
# WICHTIG (aus dem ai-box-Log gelernt): die Phantome kommen mit
|
||||
# reason=stream_end — Passiv-/Wake-Fenster enden AUCH per stream_end, wenn
|
||||
# sie auf Stille zumachen. stream_end ist also NICHT gleich "manueller Stop".
|
||||
# Deshalb greift der Guard jetzt auch bei stream_end, aber mit niedrigerer
|
||||
# Schwelle (voiced==0 = gar keine Stimme), damit ein kurzes bewusstes Wort
|
||||
# ('ja', 'stopp') am Aufnahme-Button noch durchgeht, echte Stille aber nicht.
|
||||
_min_voiced = STREAM_MIN_VOICED_FRAMES if reason != "stream_end" else 1
|
||||
if sess.voiced_frames < _min_voiced:
|
||||
logger.info("Stream %s: no-speech (voiced_frames=%d<%d, reason=%s) — leeres Endpoint",
|
||||
sess.request_id[:8], sess.voiced_frames, STREAM_MIN_VOICED_FRAMES, reason)
|
||||
sess.request_id[:8], sess.voiced_frames, _min_voiced, reason)
|
||||
if self._ws is not None:
|
||||
nospeech = {"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
@@ -459,6 +632,28 @@ class SessionManager:
|
||||
self.drop(sess.request_id)
|
||||
return
|
||||
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
|
||||
|
||||
# Silero VAD: ist ueberhaupt echte Sprache im Audio? Das entscheidet am
|
||||
# AUDIO, nicht am Text — faengt also generische Phantome ("Ich bin ein
|
||||
# guter Mann") UND Musik/Rauschen, die der Muster-Filter nicht kennt.
|
||||
# Kein Speech-Segment → no-speech, gar nicht erst transkribieren.
|
||||
# fail-open: segs=None (VAD nicht verfuegbar) → normal weiter.
|
||||
segs = _speech_segments(audio)
|
||||
if segs is not None and len(segs) == 0:
|
||||
logger.info("Stream %s: Silero VAD — keine Sprache (%.1fs, reason=%s) → no-speech",
|
||||
sess.request_id[:8], audio.size / 16000.0, reason)
|
||||
await self._emit_no_speech(sess, f"vad_no_speech:{reason}")
|
||||
return
|
||||
if segs:
|
||||
# Auf die Sprach-Spanne trimmen (Stille-Raender weg → Voxtral
|
||||
# halluziniert an den Enden weniger). Kleiner Pad gegen abgeschnittene
|
||||
# leise Wort-Anfaenge/-Enden.
|
||||
pad = int(SILERO_PAD_MS / 1000.0 * 16000)
|
||||
s0 = max(0, segs[0]["start"] - pad)
|
||||
s1 = min(int(audio.size), segs[-1]["end"] + pad)
|
||||
if s1 > s0 and (s1 - s0) < audio.size:
|
||||
audio = audio[s0:s1]
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
final_text = (await self.runner.transcribe(audio, sess.language)).strip()
|
||||
@@ -467,17 +662,27 @@ class SessionManager:
|
||||
final_text = sess.last_partial
|
||||
stt_ms = int((time.time() - t0) * 1000)
|
||||
duration_s = audio.size / 16000.0
|
||||
# Repetition-Loop einkassieren, falls trotz no_repeat_ngram was durchkam.
|
||||
_collapsed = _collapse_repetitions(final_text)
|
||||
if _collapsed != final_text:
|
||||
logger.info("Stream %s: Repetition-Loop kollabiert (%d→%d Zeichen)",
|
||||
sess.request_id[:8], len(final_text), len(_collapsed))
|
||||
final_text = _collapsed
|
||||
logger.info("Stream %s: FINAL (reason=%s, %.1fs, %dms): %r",
|
||||
sess.request_id[:8], reason, duration_s, stt_ms, final_text[:120])
|
||||
|
||||
# Halluzinations-Filter (2. Netz): leeres/Artefakt-Transkript im borderline-
|
||||
# Band → als no-speech verwerfen statt ein Phantom ("Die Stadt hat eine
|
||||
# Flaeche von 1,5 km2") ans Brain zu schicken. Manueller Stop (stream_end)
|
||||
# ist ausgenommen (bewusst gesprochen, auch kurze Woerter zaehlen).
|
||||
# Flaeche von 1,5 km2") ans Brain zu schicken. Gilt fuer ALLE reasons inkl.
|
||||
# stream_end (dort kamen die realen Phantome!) — aber das borderline-Band
|
||||
# (wenig voiced_frames) schuetzt echte, klar gesprochene Eingaben: eine echte
|
||||
# Geografie-FRAGE hat normale Stimm-Energie (voiced_frames >> Schwelle) und
|
||||
# geht durch; das Phantom aus Stille hat ~0 und wird verworfen. Ein leeres
|
||||
# Transkript wird immer verworfen (nichts gesagt = nichts senden).
|
||||
_clean = final_text.strip(" .,!?…-\t\n\r")
|
||||
_borderline = sess.voiced_frames < STREAM_HALLUC_GUARD_FRAMES
|
||||
_is_phantom = (not _clean) or (_borderline and bool(_HALLUCINATION_RE.search(final_text)))
|
||||
if reason != "stream_end" and _is_phantom:
|
||||
if _is_phantom:
|
||||
logger.info("Stream %s: Halluzination verworfen (voiced_frames=%d<%d, %.1fs, text=%r)",
|
||||
sess.request_id[:8], sess.voiced_frames, STREAM_HALLUC_GUARD_FRAMES,
|
||||
duration_s, final_text[:80])
|
||||
@@ -524,37 +729,101 @@ async def _broadcast_status(ws, state: str, **extra) -> None:
|
||||
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:
|
||||
use_tls = RVS_TLS
|
||||
retry_s = 2
|
||||
tls_fallback_tried = False
|
||||
while True:
|
||||
scheme = "wss" if use_tls else "ws"
|
||||
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
|
||||
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
|
||||
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
|
||||
try:
|
||||
logger.info("Verbinde zu RVS: %s", masked)
|
||||
async with websockets.connect(url, ping_interval=20, ping_timeout=10,
|
||||
max_size=50 * 1024 * 1024) as ws:
|
||||
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
|
||||
async with websockets.connect(url, **connect_kwargs) as ws:
|
||||
logger.info("RVS verbunden")
|
||||
retry_s = 2
|
||||
tls_fallback_tried = False
|
||||
sessions.attach_ws(ws)
|
||||
await _broadcast_status(ws, "ready", model=VOXTRAL_MODEL)
|
||||
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:
|
||||
msg = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
mtype = msg.get("type", "")
|
||||
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":
|
||||
sessions.start_session(payload)
|
||||
elif mtype == "stt_audio_chunk":
|
||||
sessions.feed_chunk(payload)
|
||||
elif mtype == "stt_stream_end":
|
||||
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":
|
||||
req_id = payload.get("requestId", "")
|
||||
try:
|
||||
@@ -602,6 +871,10 @@ async def run_loop(sessions: SessionManager) -> None:
|
||||
"AN" if SPEAKER_ID_ENABLED else "AUS")
|
||||
except Exception as e:
|
||||
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:
|
||||
use_tls = False
|
||||
tls_fallback_tried = True
|
||||
@@ -620,6 +893,7 @@ async def main() -> None:
|
||||
await loop.run_in_executor(None, runner.load) # Modell laden (blockierend)
|
||||
sessions = SessionManager(runner)
|
||||
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())
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -4,6 +4,7 @@ transformers>=4.54
|
||||
mistral-common[audio]>=1.8.1
|
||||
accelerate>=0.30
|
||||
speechbrain>=1.0 # Speaker-ID (ECAPA-TDNN) — nur Stefans Stimme
|
||||
silero-vad>=5.1 # neuronales VAD: echte Sprache vs Stille/Rauschen/Musik
|
||||
soundfile>=0.12
|
||||
librosa>=0.10 # VoxtralProcessor.load_audio_as nutzt librosa zum WAV-Laden
|
||||
numpy>=1.24
|
||||
|
||||
@@ -17,6 +17,6 @@ RUN pip3 install --no-cache-dir torch==2.3.1 torchaudio==2.3.1 \
|
||||
COPY 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"]
|
||||
|
||||
+86
-7
@@ -1,6 +1,6 @@
|
||||
#!/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:
|
||||
|
||||
@@ -53,12 +53,44 @@ RVS_PORT = int(os.getenv("RVS_PORT", "443"))
|
||||
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
|
||||
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
|
||||
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
||||
# TLS-Hostname (SNI + Cert), falls RVS_HOST eine IP ist (Box im selben Netz wie
|
||||
# der RVS, direkt auf die interne IP). Leer = SNI = RVS_HOST.
|
||||
RVS_SNI = os.getenv("RVS_SNI", "").strip()
|
||||
|
||||
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
|
||||
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
|
||||
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
|
||||
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
|
||||
if RVS_TLS and RVS_SNI:
|
||||
import socket as _socket
|
||||
_orig_getaddrinfo = _socket.getaddrinfo
|
||||
def _sni_getaddrinfo(host, *a, **k):
|
||||
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
|
||||
_socket.getaddrinfo = _sni_getaddrinfo
|
||||
|
||||
|
||||
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "small")
|
||||
WHISPER_DEVICE = os.getenv("WHISPER_DEVICE", "cuda")
|
||||
WHISPER_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "float16")
|
||||
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"}
|
||||
|
||||
# Streaming-Parameter (Defaults — koennen pro Session vom App-Payload ueberschrieben werden)
|
||||
@@ -284,7 +316,7 @@ async def _send(ws, mtype: str, payload: dict) -> None:
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 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,
|
||||
# aria-bridge schreibt sie in /shared/logs/app.log. Diagnostic / App-
|
||||
# Logs-Tab zeigen sie dann mit platform="whisper".
|
||||
@@ -822,6 +854,30 @@ async def _broadcast_status(ws, state: str, **extra) -> None:
|
||||
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
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -833,11 +889,13 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
|
||||
|
||||
while True:
|
||||
scheme = "wss" if use_tls else "ws"
|
||||
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
|
||||
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
|
||||
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
|
||||
try:
|
||||
logger.info("Verbinde zu RVS: %s", masked)
|
||||
async with websockets.connect(url, ping_interval=20, ping_timeout=10, max_size=50 * 1024 * 1024) as ws:
|
||||
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
|
||||
async with websockets.connect(url, **connect_kwargs) as ws:
|
||||
logger.info("RVS verbunden")
|
||||
retry_s = 2
|
||||
tls_fallback_tried = False
|
||||
@@ -855,21 +913,37 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
|
||||
logger.info("Initial: sende config_request an aria-bridge")
|
||||
await _send(ws, "config_request", {"service": "whisper"})
|
||||
# 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).
|
||||
await _debug_log(ws, "boot",
|
||||
"whisper-bridge online — streaming-mode ENABLED, debug-log ON")
|
||||
except Exception as e:
|
||||
logger.exception("Initial-Handshake crashed: %s", e)
|
||||
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:
|
||||
msg = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
mtype = msg.get("type", "")
|
||||
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":
|
||||
req_id = payload.get("requestId", "?")
|
||||
@@ -1038,6 +1112,10 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
|
||||
except Exception as e:
|
||||
logger.warning("Verbindung verloren: %s", e)
|
||||
sessions.detach_ws()
|
||||
try:
|
||||
ping_task.cancel()
|
||||
except NameError:
|
||||
pass
|
||||
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
||||
logger.info("TLS-Verbindung fehlgeschlagen — Fallback auf ws://")
|
||||
use_tls = False
|
||||
@@ -1058,6 +1136,7 @@ async def main() -> None:
|
||||
# Endpointer-Loop nebenbei laufen lassen — er pruefst _ws is None und
|
||||
# schlaeft solange das nicht gesetzt ist.
|
||||
asyncio.create_task(sessions.run_endpointer())
|
||||
asyncio.create_task(_stats.run_sampler()) # Auslastungs-Sampler (Stage E)
|
||||
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