Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07df538763 | ||
|
|
5761c5f6b9 | ||
|
|
87755b9c58 | ||
|
|
b6195d3a53 | ||
|
|
b9354283af | ||
|
|
bf1db6cbf9 | ||
|
|
ea159d4ed7 | ||
|
|
dd1e1ac458 | ||
|
|
ddd77c7ad4 | ||
|
|
7c3c932054 | ||
|
|
d6c7502810 | ||
|
|
dc77933014 | ||
|
|
6a6b320f37 | ||
|
|
cc91497923 | ||
|
|
10e7faa92b | ||
|
|
d030cd7bc6 | ||
|
|
36641e6519 | ||
|
|
9b11c16479 | ||
|
|
8f9e3e5c79 | ||
|
|
f1ae18b658 | ||
|
|
27f9399eda | ||
|
|
c131820be0 | ||
|
|
e43b44725b | ||
|
|
586a2bf134 | ||
|
|
ac40afc856 | ||
|
|
ff6d474f29 | ||
|
|
44aef71813 | ||
|
|
27381e7038 | ||
|
|
21d389cc26 | ||
|
|
eae036a9fc | ||
|
|
621b5525a8 | ||
|
|
6da567aee6 | ||
|
|
7071614434 | ||
|
|
8301342728 | ||
|
|
6a8dd662fa | ||
|
|
a48d8442b1 | ||
|
|
47735642f2 | ||
|
|
e15f7a5bc0 | ||
|
|
39f908dd4a | ||
|
|
176997a800 | ||
|
|
5b7e205ede | ||
|
|
47cc1b7f08 | ||
|
|
f4b7148cf9 | ||
|
|
5941be0f21 | ||
|
|
2038b676e6 | ||
|
|
8b84ee3b9c | ||
|
|
c86cd59b5c | ||
|
|
96012dc986 | ||
|
|
80d7f62eaa | ||
|
|
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 | ||
|
|
d984125624 | ||
|
|
51b267c915 | ||
|
|
54dc4498e5 | ||
|
|
a15b8ce51a | ||
|
|
18d8bc5386 | ||
|
|
ff4c1b997a | ||
|
|
7d2fce46cb | ||
|
|
334c5887b0 | ||
|
|
ef51c85cb0 | ||
|
|
d294895583 | ||
|
|
7249e2cf61 | ||
|
|
de34011b93 | ||
|
|
95bce35cab | ||
|
|
4f4c12e6d1 | ||
|
|
3abe13e796 | ||
|
|
0344f249b8 | ||
|
|
ae865a4662 | ||
|
|
9389b5ae06 | ||
|
|
23d3188205 | ||
|
|
560328bebe | ||
|
|
d72518e617 | ||
|
|
0415b2e16d | ||
|
|
f41f7fb585 | ||
|
|
c623901385 | ||
|
|
1b78ea3d8a | ||
|
|
a490513f25 | ||
|
|
d61ce316a6 | ||
|
|
09f7058853 | ||
|
|
fea503a6f3 | ||
|
|
9e7d2309f0 | ||
|
|
766fbe6da6 | ||
|
|
bf1c513191 | ||
|
|
afb7e2c604 | ||
|
|
c9d081d910 | ||
|
|
0bff35d3ef | ||
|
|
e37817ceaa | ||
|
|
c255e18a20 | ||
|
|
de8c241f2d | ||
|
|
4a743737d3 | ||
|
|
bd04305b34 | ||
|
|
b7df891574 | ||
|
|
0b760d44a0 | ||
|
|
ca18502671 | ||
|
|
be9c079360 | ||
|
|
0103cc6a38 | ||
|
|
357f1bad21 | ||
|
|
e3d3c0604b | ||
|
|
94077ec299 | ||
|
|
50e6a6e12f | ||
|
|
4e3dc36bdd | ||
|
|
8cdac41fa5 | ||
|
|
08836a6a59 | ||
|
|
6fe414029b | ||
|
|
3889d72726 | ||
|
|
5700b53b56 | ||
|
|
543b928112 | ||
|
|
7754f2b2ee | ||
|
|
d6d7805509 | ||
|
|
5aa5e2b6c6 | ||
|
|
5bb7b216f7 | ||
|
|
194dffe67e | ||
|
|
7ab2e3c101 | ||
|
|
4eeba3cf5f | ||
|
|
077bb92fd2 | ||
|
|
e5c4d0b46b | ||
|
|
f01677a5a1 | ||
|
|
71bb910672 | ||
|
|
fde8c3e9c0 | ||
|
|
c2ac054227 | ||
|
|
59b09e3491 | ||
|
|
41590c90a8 | ||
|
|
da2e5e36ba | ||
|
|
fad9aceb0e |
@@ -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 20206
|
||||
versionName "0.2.2.6"
|
||||
versionCode 20503
|
||||
versionName "0.2.5.3"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -49,6 +49,28 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Teilen-Ziel: ARIA erscheint im Teilen-Menue anderer Apps (WhatsApp,
|
||||
Galerie, Browser ...). EINE Datei bzw. reiner Text. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/*" />
|
||||
<data android:mimeType="image/*" />
|
||||
<data android:mimeType="video/*" />
|
||||
<data android:mimeType="audio/*" />
|
||||
<data android:mimeType="application/*" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Mehrere Dateien auf einmal. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
<data android:mimeType="video/*" />
|
||||
<data android:mimeType="audio/*" />
|
||||
<data android:mimeType="application/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ariacockpit
|
||||
|
||||
import android.content.Intent
|
||||
import com.facebook.react.ReactActivity
|
||||
import com.facebook.react.ReactActivityDelegate
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||
@@ -19,4 +20,26 @@ class MainActivity : ReactActivity() {
|
||||
*/
|
||||
override fun createReactActivityDelegate(): ReactActivityDelegate =
|
||||
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
|
||||
|
||||
/**
|
||||
* Teilen in die LAUFENDE App (launchMode=singleTask liefert das hier ab).
|
||||
* setIntent() ist wichtig, damit getSharedData() denselben Inhalt sieht, und
|
||||
* das Event weckt die JS-Seite sofort — ohne es wuerde der geteilte Inhalt
|
||||
* erst beim naechsten Kaltstart auftauchen.
|
||||
*/
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
// Best effort: je nach RN-Architektur ist der Kontext anders erreichbar.
|
||||
// Scheitert es, greift der zuverlaessige Zweitweg — die JS-Seite fragt beim
|
||||
// Zurueckkehren in den Vordergrund ohnehin nochmal via getSharedData() nach
|
||||
// (setIntent() oben sorgt dafuer, dass sie denselben Inhalt sieht).
|
||||
try {
|
||||
val ctx = (application as? com.facebook.react.ReactApplication)
|
||||
?.reactNativeHost?.reactInstanceManager?.currentReactContext
|
||||
ShareIntentModule.notifyNewIntent(
|
||||
ctx as? com.facebook.react.bridge.ReactApplicationContext, intent)
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ class MainApplication : Application(), ReactApplication {
|
||||
add(OpenWakeWordPackage())
|
||||
add(PhoneCallPackage())
|
||||
add(BackgroundAudioPackage())
|
||||
add(ShareIntentPackage())
|
||||
}
|
||||
|
||||
override fun getJSMainModuleName(): String = "index"
|
||||
|
||||
@@ -59,7 +59,12 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
||||
// Trigger eingestuft werden kann. Folge: App pausiert beim Oeffnen die Musik,
|
||||
// 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 = 1500L
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.ariacockpit
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.WritableArray
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Teilen-Ziel: nimmt Inhalte entgegen, die aus anderen Apps (WhatsApp, Galerie,
|
||||
* Browser ...) per "Teilen → ARIA" geschickt werden.
|
||||
*
|
||||
* Die geteilten Dateien kommen als content://-URI, auf die nur DIESE App und nur
|
||||
* JETZT zugreifen darf. Deshalb kopieren wir sie sofort in den App-Cache und
|
||||
* geben einen echten Dateipfad zurueck — den kann die JS-Seite wie eine normal
|
||||
* ausgewaehlte Datei weiterverarbeiten (RNFS.readFile → Base64 → Upload).
|
||||
*
|
||||
* Ablauf:
|
||||
* App geschlossen → Intent liegt beim Start an → getSharedData()
|
||||
* App laeuft → onNewIntent (launchMode=singleTask) → Event 'ShareReceived'
|
||||
*/
|
||||
class ShareIntentModule(private val reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
override fun getName(): String = "ShareIntent"
|
||||
|
||||
companion object {
|
||||
private const val EVENT = "ShareReceived"
|
||||
|
||||
/** Vom MainActivity.onNewIntent gerufen, wenn die App schon laeuft. */
|
||||
@JvmStatic
|
||||
fun notifyNewIntent(ctx: ReactApplicationContext?, intent: Intent?) {
|
||||
if (ctx == null || intent == null || !isShare(intent)) return
|
||||
try {
|
||||
val data = extract(ctx, intent)
|
||||
ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit(EVENT, data)
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun isShare(i: Intent): Boolean =
|
||||
i.action == Intent.ACTION_SEND || i.action == Intent.ACTION_SEND_MULTIPLE
|
||||
|
||||
/** Baut {text, items:[{path,name,type,size}]} aus dem Intent. */
|
||||
private fun extract(ctx: ReactApplicationContext, intent: Intent): WritableMap {
|
||||
val out = Arguments.createMap()
|
||||
out.putString("text", intent.getStringExtra(Intent.EXTRA_TEXT) ?: "")
|
||||
|
||||
val uris = mutableListOf<Uri>()
|
||||
if (intent.action == Intent.ACTION_SEND) {
|
||||
@Suppress("DEPRECATION")
|
||||
(intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM))?.let { uris.add(it) }
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
?.forEach { uris.add(it) }
|
||||
}
|
||||
|
||||
val items: WritableArray = Arguments.createArray()
|
||||
for (uri in uris) {
|
||||
copyToCache(ctx, uri)?.let { items.pushMap(it) }
|
||||
}
|
||||
out.putArray("items", items)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Kopiert den geteilten Inhalt in den Cache. Noetig, weil die Leseerlaubnis
|
||||
* fuer die content://-URI nur fuer diesen Intent gilt — spaeter (nach der
|
||||
* Projektauswahl) waere sie womoeglich schon erloschen.
|
||||
*/
|
||||
private fun copyToCache(ctx: ReactApplicationContext, uri: Uri): WritableMap? {
|
||||
return try {
|
||||
val cr = ctx.contentResolver
|
||||
var name = "geteilt"
|
||||
var size = 0L
|
||||
cr.query(uri, null, null, null, null)?.use { c ->
|
||||
val ni = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
val si = c.getColumnIndex(OpenableColumns.SIZE)
|
||||
if (c.moveToFirst()) {
|
||||
if (ni >= 0) c.getString(ni)?.let { name = it }
|
||||
if (si >= 0) size = c.getLong(si)
|
||||
}
|
||||
}
|
||||
val type = cr.getType(uri) ?: "application/octet-stream"
|
||||
val dir = File(ctx.cacheDir, "shared").apply { mkdirs() }
|
||||
// Eindeutiger Name, damit zwei gleichnamige Bilder sich nicht ueberschreiben.
|
||||
val safe = name.replace(Regex("[^A-Za-z0-9._-]"), "_").take(80)
|
||||
val dst = File(dir, "${System.currentTimeMillis()}_$safe")
|
||||
cr.openInputStream(uri)?.use { input ->
|
||||
dst.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: return null
|
||||
|
||||
Arguments.createMap().apply {
|
||||
putString("path", dst.absolutePath)
|
||||
putString("name", name)
|
||||
putString("type", type)
|
||||
putDouble("size", (if (size > 0) size else dst.length()).toDouble())
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt den Inhalt, mit dem die App GESTARTET wurde (App war zu).
|
||||
* Gibt {text, items:[]} zurueck — items leer, wenn nichts geteilt wurde.
|
||||
*/
|
||||
@ReactMethod
|
||||
fun getSharedData(promise: Promise) {
|
||||
try {
|
||||
val activity = currentActivity
|
||||
val intent = activity?.intent
|
||||
if (intent == null || !isShare(intent)) {
|
||||
promise.resolve(Arguments.createMap().apply {
|
||||
putString("text", "")
|
||||
putArray("items", Arguments.createArray())
|
||||
})
|
||||
return
|
||||
}
|
||||
val data = extract(reactContext, intent)
|
||||
// Verbraucht markieren, damit ein Rotieren/Zurueckkehren den Dialog
|
||||
// nicht erneut oeffnet.
|
||||
activity.intent = Intent(Intent.ACTION_MAIN)
|
||||
promise.resolve(data)
|
||||
} catch (e: Throwable) {
|
||||
promise.reject("SHARE_READ_FAILED", e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Aufgeraeumte Kopien loeschen (nach erfolgreichem Senden). */
|
||||
@ReactMethod
|
||||
fun clearCache(promise: Promise) {
|
||||
try {
|
||||
File(reactContext.cacheDir, "shared").listFiles()?.forEach { it.delete() }
|
||||
promise.resolve(true)
|
||||
} catch (e: Throwable) {
|
||||
promise.resolve(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Fuer NativeEventEmitter (RN verlangt diese beiden Methoden).
|
||||
@ReactMethod fun addListener(eventName: String) {}
|
||||
@ReactMethod fun removeListeners(count: Int) {}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ariacockpit
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class ShareIntentPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(ShareIntentModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.2.2.6",
|
||||
"version": "0.2.5.3",
|
||||
"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
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Teilen-Dialog — was aus anderen Apps per "Teilen → ARIA" hereinkommt.
|
||||
*
|
||||
* Ablauf wie gewuenscht: erst sehen WAS geteilt wurde, dann WOHIN (Projekt
|
||||
* oder Hauptchat), dann optional ein Text dazu. Ohne Text geht genauso.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator, FlatList, Image, Modal, StyleSheet, Text,
|
||||
TextInput, TouchableOpacity, View,
|
||||
} from 'react-native';
|
||||
import brainApi, { Project } from '../services/brainApi';
|
||||
|
||||
export interface SharedItem {
|
||||
path: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
items: SharedItem[];
|
||||
/** Text, den die sendende App mitgegeben hat (z.B. geteilter Link). */
|
||||
initialText?: string;
|
||||
onCancel: () => void;
|
||||
/** projectId: '' = Hauptchat. text: optional, darf leer sein. */
|
||||
onSend: (projectId: string, text: string) => void;
|
||||
}
|
||||
|
||||
const fmtSize = (n: number) =>
|
||||
n > 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB`
|
||||
: n > 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`;
|
||||
|
||||
const ShareModal: React.FC<Props> = ({ visible, items, initialText, onCancel, onSend }) => {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pid, setPid] = useState<string | null>(null); // null = noch nichts gewaehlt
|
||||
const [text, setText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setPid(null);
|
||||
setText(initialText || '');
|
||||
setSending(false);
|
||||
setLoading(true);
|
||||
brainApi.listProjects(false)
|
||||
.then(ps => setProjects(ps || []))
|
||||
.catch(() => setProjects([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [visible, initialText]);
|
||||
|
||||
const send = useCallback(() => {
|
||||
if (pid === null || sending) return;
|
||||
setSending(true);
|
||||
onSend(pid, text.trim());
|
||||
}, [pid, text, sending, onSend]);
|
||||
|
||||
const preview = items.find(i => i.type?.startsWith('image/'));
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" transparent onRequestClose={onCancel}>
|
||||
<View style={s.backdrop}>
|
||||
<View style={s.box}>
|
||||
<Text style={s.title}>Mit ARIA teilen</Text>
|
||||
|
||||
{/* Was wird geteilt */}
|
||||
<View style={s.itemsBox}>
|
||||
{preview ? (
|
||||
<Image source={{ uri: 'file://' + preview.path }} style={s.thumb} resizeMode="cover" />
|
||||
) : null}
|
||||
<View style={{ flex: 1 }}>
|
||||
{items.length === 0 ? (
|
||||
<Text style={s.dim}>Nur Text</Text>
|
||||
) : items.slice(0, 3).map((it, i) => (
|
||||
<Text key={i} style={s.itemLine} numberOfLines={1}>
|
||||
{it.name} <Text style={s.dim}>({fmtSize(it.size)})</Text>
|
||||
</Text>
|
||||
))}
|
||||
{items.length > 3 ? (
|
||||
<Text style={s.dim}>+ {items.length - 3} weitere</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Wohin */}
|
||||
<Text style={s.label}>Wohin soll das?</Text>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#FFD60A" style={{ marginVertical: 12 }} />
|
||||
) : (
|
||||
<FlatList
|
||||
style={s.list}
|
||||
data={[{ id: '', name: 'Hauptchat', description: '' } as Project, ...projects]}
|
||||
keyExtractor={p => p.id || '__main__'}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
renderItem={({ item }) => {
|
||||
const sel = pid === item.id;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[s.row, sel && s.rowSel]}
|
||||
onPress={() => setPid(item.id)}>
|
||||
<Text style={[s.rowText, sel && s.rowTextSel]} numberOfLines={1}>
|
||||
{item.id ? '📁 ' : '💬 '}{item.name}
|
||||
</Text>
|
||||
{sel ? <Text style={s.check}>✓</Text> : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Optionaler Text */}
|
||||
<Text style={s.label}>Text dazu <Text style={s.dim}>(optional)</Text></Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
placeholder="z.B. „Schau dir das mal an“ — kann auch leer bleiben"
|
||||
placeholderTextColor="#666680"
|
||||
multiline
|
||||
/>
|
||||
|
||||
<View style={s.actions}>
|
||||
<TouchableOpacity style={s.btnGhost} onPress={onCancel} disabled={sending}>
|
||||
<Text style={s.btnGhostText}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[s.btn, (pid === null || sending) && s.btnDisabled]}
|
||||
onPress={send}
|
||||
disabled={pid === null || sending}>
|
||||
<Text style={s.btnText}>
|
||||
{sending ? 'Senden …' : pid === null ? 'Ziel wählen' : 'Senden'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const s = StyleSheet.create({
|
||||
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.75)', justifyContent: 'flex-end' },
|
||||
box: {
|
||||
backgroundColor: '#0D0D1A', borderTopLeftRadius: 16, borderTopRightRadius: 16,
|
||||
padding: 18, maxHeight: '88%',
|
||||
},
|
||||
title: { color: '#E0E0F0', fontSize: 19, fontWeight: '700', marginBottom: 12 },
|
||||
itemsBox: {
|
||||
flexDirection: 'row', gap: 10, alignItems: 'center',
|
||||
backgroundColor: '#12121E', borderRadius: 10, padding: 10, marginBottom: 14,
|
||||
},
|
||||
thumb: { width: 56, height: 56, borderRadius: 8, backgroundColor: '#1E1E2E' },
|
||||
itemLine: { color: '#E0E0F0', fontSize: 13 },
|
||||
dim: { color: '#8888AA', fontSize: 12 },
|
||||
label: { color: '#8888AA', fontSize: 12, marginBottom: 6 },
|
||||
list: { maxHeight: 190, marginBottom: 14 },
|
||||
row: {
|
||||
flexDirection: 'row', alignItems: 'center', paddingVertical: 10,
|
||||
paddingHorizontal: 12, borderRadius: 8, marginBottom: 4, backgroundColor: '#12121E',
|
||||
},
|
||||
rowSel: { backgroundColor: '#1E2A1E', borderWidth: 1, borderColor: '#3FFF9F' },
|
||||
rowText: { color: '#E0E0F0', fontSize: 14, flex: 1 },
|
||||
rowTextSel: { color: '#3FFF9F', fontWeight: '600' },
|
||||
check: { color: '#3FFF9F', fontSize: 16 },
|
||||
input: {
|
||||
backgroundColor: '#12121E', borderRadius: 8, color: '#E0E0F0',
|
||||
paddingHorizontal: 12, paddingVertical: 10, minHeight: 56,
|
||||
textAlignVertical: 'top', marginBottom: 16, fontSize: 14,
|
||||
},
|
||||
actions: { flexDirection: 'row', gap: 10, justifyContent: 'flex-end' },
|
||||
btnGhost: { paddingVertical: 11, paddingHorizontal: 18 },
|
||||
btnGhostText: { color: '#8888AA', fontSize: 15 },
|
||||
btn: {
|
||||
backgroundColor: '#FFD60A', borderRadius: 8,
|
||||
paddingVertical: 11, paddingHorizontal: 24,
|
||||
},
|
||||
btnDisabled: { backgroundColor: '#3A3A2A' },
|
||||
btnText: { color: '#0D0D1A', fontSize: 15, fontWeight: '700' },
|
||||
});
|
||||
|
||||
export default ShareModal;
|
||||
@@ -35,7 +35,7 @@ import MemoryBrowser from '../components/MemoryBrowser';
|
||||
import ErrorBoundary from '../components/ErrorBoundary';
|
||||
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
||||
import audioService from '../services/audio';
|
||||
import wakeWordService, { loadPassiveListenMs } from '../services/wakeword';
|
||||
import wakeWordService from '../services/wakeword';
|
||||
import ProjectsBrowser from '../components/ProjectsBrowser';
|
||||
import brainApi, { Project as BrainProject } from '../services/brainApi';
|
||||
import projectFocus from '../services/projectFocus';
|
||||
@@ -49,8 +49,10 @@ import updateService from '../services/updater';
|
||||
import VoiceButton from '../components/VoiceButton';
|
||||
import FileUpload, { FileData } from '../components/FileUpload';
|
||||
import CameraUpload, { PhotoData } from '../components/CameraUpload';
|
||||
import ShareModal, { SharedItem } from '../components/ShareModal';
|
||||
import shareIntent from '../services/shareIntent';
|
||||
import MessageText from '../components/MessageText';
|
||||
import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs } from '../services/audio';
|
||||
import { loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio';
|
||||
import Geolocation from '@react-native-community/geolocation';
|
||||
|
||||
// --- Typen ---
|
||||
@@ -93,6 +95,9 @@ interface ChatMessage {
|
||||
* gespiegelt damit wir die EXAKT richtige Placeholder-Bubble ersetzen,
|
||||
* auch wenn mehrere Aufnahmen parallel offen sind. */
|
||||
audioRequestId?: string;
|
||||
/** Laenge der Sprachaufnahme in Sekunden (aus dem stt_endpoint) — fuer die
|
||||
* Dauer-Anzeige an der Voice-Bubble. */
|
||||
durationS?: number;
|
||||
/** Skill-Created-Bubble: ARIA hat einen neuen Skill angelegt */
|
||||
skillCreated?: {
|
||||
name: string;
|
||||
@@ -182,8 +187,19 @@ 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 {
|
||||
const s = Math.max(0, Math.round(sec));
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${m}:${r.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
||||
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
|
||||
|
||||
@@ -334,6 +350,10 @@ const ChatScreen: React.FC = () => {
|
||||
} | null>(null);
|
||||
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
|
||||
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
|
||||
// Teilen-Ziel: Inhalte, die aus anderen Apps hereingereicht wurden.
|
||||
const [shareItems, setShareItems] = useState<SharedItem[]>([]);
|
||||
const [shareText, setShareText] = useState('');
|
||||
const [shareVisible, setShareVisible] = useState(false);
|
||||
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
|
||||
// Multi-Threading: Activity pro Kontext (key = projectId, '' = Hauptchat).
|
||||
// Der Indikator zeigt nur den fokussierten Kontext — nicht global.
|
||||
@@ -346,7 +366,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)
|
||||
@@ -373,6 +393,16 @@ 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);
|
||||
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const messageIdCounter = useRef(0);
|
||||
@@ -651,6 +681,7 @@ const ChatScreen: React.FC = () => {
|
||||
const voice = await AsyncStorage.getItem('aria_xtts_voice');
|
||||
localXttsVoiceRef.current = voice || '';
|
||||
ttsSpeedRef.current = await loadTtsSpeed();
|
||||
bargeInEnabledRef.current = await loadBargeInEnabled();
|
||||
const gps = await AsyncStorage.getItem('aria_gps_enabled');
|
||||
setGpsEnabled(gps === 'true');
|
||||
const hints = await AsyncStorage.getItem('aria_show_hints');
|
||||
@@ -1278,6 +1309,10 @@ const ChatScreen: React.FC = () => {
|
||||
sender: 'user',
|
||||
text: diagText,
|
||||
timestamp: message.timestamp,
|
||||
// projectId MUSS mit: die Chat-Ansicht filtert danach. Ohne das
|
||||
// landete die Nachricht im Hauptchat-Topf und war in jedem Projekt
|
||||
// unsichtbar — man sah nur ARIAs Antwort, nicht die eigene Frage.
|
||||
projectId: (message.payload.projectId as string) || '',
|
||||
}]));
|
||||
}
|
||||
return;
|
||||
@@ -1387,13 +1422,61 @@ const ChatScreen: React.FC = () => {
|
||||
// Fallback mehr: die Bridge schickt speak zuverlaessig mit.
|
||||
// Merken ob nach dem Vorlesen 30s weiterlauschen (Gespraech) oder direkt
|
||||
// stoppen — onPlaybackFinished liest converseRef. Default true.
|
||||
converseRef.current = (message.payload as any).converse !== false;
|
||||
// Passiv-Lauschen (30s) NUR wenn das Brain explizit converse:true schickt.
|
||||
// Vorher default true → jeder Befehl (auch "Spiele Spotify" mit gesproche-
|
||||
// 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 && wakeWordService.isConversing()) {
|
||||
// Klarer Steuerbefehl (Liedersteuerung etc.) = KEINE Konversation →
|
||||
// STOP: direkt zurueck aufs Wake-Word. Kein Gong, keine Aufnahme,
|
||||
// kein 30s-Fenster (skipPassive=true).
|
||||
wakeWordService.endConversation(true).catch(() => {});
|
||||
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.
|
||||
if (converseRef.current) {
|
||||
// Befehlskette laeuft WEITER ([[WEITER]]): Mikro NICHT schliessen,
|
||||
// sondern das passive Lausch-Fenster oeffnen (endConversation(false)),
|
||||
// damit der naechste Kettenbefehl direkt gesprochen werden kann. ARIA
|
||||
// haelt bewusst offen, bis sie [[ENDE]] (converse=false) schickt.
|
||||
if (wakeWordService.isConversing()) {
|
||||
wakeWordService.endConversation(false).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
// Einzelbefehl / [[ENDE]] → ARIA "drueckt selbst Stop": jede offene
|
||||
// Aufnahme schliessen + zurueck aufs Wake-Word, egal in welchem Zustand
|
||||
// (conversing, passives Lauschen ODER offene Streaming-Aufnahme).
|
||||
ariaStopRecording('silent-command').catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1521,7 +1604,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.
|
||||
@@ -1628,8 +1711,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();
|
||||
}, []);
|
||||
@@ -1648,7 +1735,11 @@ const ChatScreen: React.FC = () => {
|
||||
rememberMyRequest(audioRequestId);
|
||||
const wasInterrupted = interruptAriaIfBusy();
|
||||
const location = await getCurrentLocation();
|
||||
const windowMs = await loadConvWindowMs();
|
||||
// EIN Wert regiert: die Stille-Toleranz. Sie gilt sowohl als Pause WÄHREND
|
||||
// des Redens (endpointMs) ALS AUCH als "wenn du nicht anfängst zu reden,
|
||||
// ist Schluss" (noSpeechTimeoutMs). Kein separates 30s-Konversationsfenster
|
||||
// mehr — Stefans Modell: sagst du nichts, greift der Stille-Wert.
|
||||
const sttEndpointMs = await loadSttEndpointMs();
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
@@ -1666,9 +1757,11 @@ const ChatScreen: React.FC = () => {
|
||||
speed: ttsSpeedRef.current,
|
||||
interrupted: wasInterrupted,
|
||||
location: location || null,
|
||||
noSpeechTimeoutMs: windowMs,
|
||||
endpointMs: await loadSttEndpointMs(),
|
||||
hardCapMs: 60000,
|
||||
noSpeechTimeoutMs: sttEndpointMs,
|
||||
endpointMs: sttEndpointMs,
|
||||
// Notbremse 5 min (nicht 1 min) — der Stille-Endpoint beendet normale
|
||||
// Turns eh sofort; der Cap darf lange Diktate nicht mitten drin kappen.
|
||||
hardCapMs: await loadMaxRecordingMs(),
|
||||
projectId: focusedProjectIdRef.current,
|
||||
});
|
||||
import('../services/logger').then(m => m.reportAppDebug('wake.cb', `startStreamingRecording returned ok=${ok}`)).catch(()=>{});
|
||||
@@ -1696,6 +1789,13 @@ const ChatScreen: React.FC = () => {
|
||||
if (ev.text && ev.text.trim()) {
|
||||
console.log('[Chat] STT-Endpoint: %r (reason=%s, %dms, %.1fs Audio)',
|
||||
ev.text.slice(0, 80), ev.reason, ev.sttMs, ev.durationS);
|
||||
// Aufnahme-Dauer an die passende Voice-Bubble haengen (Anzeige). Der
|
||||
// spaetere STT-Text-Update spreadet die Message, die Dauer bleibt.
|
||||
if (ev.audioRequestId && typeof ev.durationS === 'number' && ev.durationS > 0) {
|
||||
const dur = ev.durationS;
|
||||
setMessages(prev => prev.map(m =>
|
||||
m.audioRequestId === ev.audioRequestId ? { ...m, durationS: dur } : m));
|
||||
}
|
||||
// Wenn passive lauschend: User hat tatsaechlich was gesagt → uebergang
|
||||
// zu 'conversing' damit der normale Flow greift (TTS, resume, etc.)
|
||||
if (wakeWordService.getState() === 'listening') {
|
||||
@@ -1712,16 +1812,39 @@ const ChatScreen: React.FC = () => {
|
||||
// dem STT-Text gefuellte Bubble NICHT loeschen, sonst verschwindet die
|
||||
// Sprachnachricht obwohl ARIA schon an der Antwort arbeitet.
|
||||
if (ev.audioRequestId) {
|
||||
setMessages(prev => prev.filter(m =>
|
||||
!(m.audioRequestId === ev.audioRequestId
|
||||
&& m.text.includes('Spracheingabe wird verarbeitet'))));
|
||||
// 'no-response' heisst: die Spracherkennung hat GAR NICHT geantwortet
|
||||
// (Worker neu gestartet / nicht erreichbar) — die Aufnahme ist also
|
||||
// wirklich verloren gegangen, Du hast nicht etwa nichts gesagt. Das
|
||||
// zeigen wir an, statt die Bubble stillschweigend zu entfernen; sonst
|
||||
// wirkt es, als waere die Nachricht nie gesendet worden.
|
||||
const lost = String(ev.reason || '').includes('no-response');
|
||||
setMessages(prev => lost
|
||||
? prev.map(m => (m.audioRequestId === ev.audioRequestId
|
||||
&& m.text.includes('Spracheingabe wird verarbeitet'))
|
||||
? { ...m, text: '⚠️ Aufnahme ging verloren — die Spracherkennung war gerade nicht erreichbar. Bitte nochmal aufnehmen.' }
|
||||
: m)
|
||||
: prev.filter(m =>
|
||||
!(m.audioRequestId === ev.audioRequestId
|
||||
&& m.text.includes('Spracheingabe wird verarbeitet'))));
|
||||
}
|
||||
// Bei Passive-Listen + speaker_mismatch oder no-speech: erneut passiv
|
||||
// lauschen (Timer im wakeword-service laeuft weiter, regelt das Ende).
|
||||
// Sonst endConversation wie bisher.
|
||||
// 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 — naechste passive Aufnahme');
|
||||
startPassiveStreamingRecording();
|
||||
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);
|
||||
@@ -1733,8 +1856,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();
|
||||
});
|
||||
|
||||
@@ -1751,7 +1880,7 @@ const ChatScreen: React.FC = () => {
|
||||
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
rememberMyRequest(audioRequestId);
|
||||
const location = await getCurrentLocation();
|
||||
const windowMs = await loadConvWindowMs();
|
||||
const sttEndpointMs = await loadSttEndpointMs(); // ein Wert für Pause + No-Speech
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
@@ -1769,9 +1898,10 @@ const ChatScreen: React.FC = () => {
|
||||
speed: ttsSpeedRef.current,
|
||||
interrupted: true, // Barge-In → Brain weiss "User hat unterbrochen"
|
||||
location: location || null,
|
||||
noSpeechTimeoutMs: windowMs,
|
||||
endpointMs: await loadSttEndpointMs(),
|
||||
hardCapMs: 60000,
|
||||
noSpeechTimeoutMs: sttEndpointMs,
|
||||
endpointMs: sttEndpointMs,
|
||||
// Notbremse 5 min (s.o.) — lange Diktate nicht bei 1 min abschneiden.
|
||||
hardCapMs: await loadMaxRecordingMs(),
|
||||
projectId: focusedProjectIdRef.current,
|
||||
});
|
||||
if (ok) {
|
||||
@@ -1789,7 +1919,9 @@ const ChatScreen: React.FC = () => {
|
||||
// Prozess nicht killt wenn die App im Hintergrund ist.
|
||||
const unsubTtsStart = audioService.onPlaybackStarted(() => {
|
||||
acquireBackgroundAudio('tts').catch(() => {});
|
||||
if (wakeWordService.isConversing() && wakeWordService.hasWakeWord()) {
|
||||
// Barge-Listening (Mikro waehrend TTS) NUR im Barge-in-Modus. Default aus =
|
||||
// Halb-Duplex: ARIA spricht ungestoert zu Ende, dann erst geht das Mikro auf.
|
||||
if (bargeInEnabledRef.current && wakeWordService.isConversing() && wakeWordService.hasWakeWord()) {
|
||||
wakeWordService.startBargeListening().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -1828,16 +1960,20 @@ const ChatScreen: React.FC = () => {
|
||||
const audioRequestId = `audio_passive_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
rememberMyRequest(audioRequestId);
|
||||
const location = await getCurrentLocation();
|
||||
const passiveMs = await loadPassiveListenMs();
|
||||
// Kein 30s-Passiv-Fenster mehr: nach ARIAs Antwort geht das Mikro auf, und
|
||||
// fängst du nicht innerhalb der Stille-Toleranz an zu reden, ist Schluss →
|
||||
// zurück aufs Wake-Word. Derselbe Wert wie die Pause-Toleranz beim Reden.
|
||||
const sttEndpointMs = await loadSttEndpointMs();
|
||||
const { ok } = await audioService.startStreamingRecording({
|
||||
audioRequestId,
|
||||
voice: localXttsVoiceRef.current,
|
||||
speed: ttsSpeedRef.current,
|
||||
interrupted: false,
|
||||
location: location || null,
|
||||
noSpeechTimeoutMs: Math.min(passiveMs, 30000),
|
||||
endpointMs: await loadSttEndpointMs(),
|
||||
hardCapMs: Math.max(passiveMs + 5000, 35000),
|
||||
noSpeechTimeoutMs: sttEndpointMs,
|
||||
endpointMs: sttEndpointMs,
|
||||
// Lange Antworten nicht kappen (früher 35s → schnitt langes Reden ab).
|
||||
hardCapMs: await loadMaxRecordingMs(),
|
||||
projectId: focusedProjectIdRef.current,
|
||||
});
|
||||
if (!ok) {
|
||||
@@ -2177,7 +2313,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.
|
||||
@@ -2210,15 +2346,16 @@ const ChatScreen: React.FC = () => {
|
||||
advanceQueue(pid);
|
||||
}, [advanceQueue]);
|
||||
|
||||
// Queue-Modus („immer anstellen"): eine neue Sprachnachricht bricht ARIAs
|
||||
// laufende Arbeit NICHT mehr ab. Sie wird — wie Text — angestellt und laeuft
|
||||
// serialisiert (der Brain-Lock pro Projekt reiht /chat-/audio-Turns auf).
|
||||
// Nur das TTS wird akustisch gestoppt, damit das Mikro ARIAs eigene Stimme
|
||||
// nicht mithoert. Explizites Abbrechen laeuft ueber den Stop-Button
|
||||
// (cancelRequest). Rueckgabe = false, weil kein Barge-In/Interrupt mehr.
|
||||
// Nimmt der User das Mikro waehrend ARIA SPRICHT, ist das ein echter Interrupt:
|
||||
// TTS stoppen UND die laufende Brain-Antwort abbrechen (cancel_request). Sonst
|
||||
// produziert das Brain weiter TTS, die ins offene Mikro laeuft → genau der
|
||||
// "Mischmasch" (ARIA antwortet weiter waehrend ich rede). Fuer bewusstes
|
||||
// Nicht-Abbrechen gibt es weiterhin den separaten Zwischenruf-Button (📣).
|
||||
const interruptAriaIfBusy = useCallback(() => {
|
||||
if (audioService.isPlayingAudio()) {
|
||||
audioService.haltAllPlayback('user startet Aufnahme (Queue-Modus, kein Abbruch)');
|
||||
audioService.haltAllPlayback('user startet Aufnahme — Interrupt');
|
||||
rvs.send('cancel_request' as any, { hard: true, source: 'voice-interrupt' });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
@@ -2255,7 +2392,7 @@ const ChatScreen: React.FC = () => {
|
||||
// die Session auch app-seitig haben wir +2s Toleranz.
|
||||
noSpeechTimeoutMs: 0,
|
||||
endpointMs: await loadSttEndpointMs(),
|
||||
hardCapMs: 300000,
|
||||
hardCapMs: await loadMaxRecordingMs(),
|
||||
projectId: focusedProjectIdRef.current,
|
||||
});
|
||||
if (!ok) {
|
||||
@@ -2267,11 +2404,50 @@ const ChatScreen: React.FC = () => {
|
||||
return true;
|
||||
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
|
||||
|
||||
// ARIA schliesst die Aufnahme SELBST — das programmatische Gegenstueck zum
|
||||
// Stop-Button. Aufgerufen nach einem stillen Steuerbefehl (speak=false): der
|
||||
// Befehl ist ausgefuehrt, ARIA hat die Rueckinfo (Skill-Ergebnis) und antwortet
|
||||
// NICHT vorgelesen. Weil ohne TTS kein onPlaybackFinished kommt, muss der
|
||||
// Aufnahme-/Konversations-Zustand hier aktiv aufgeraeumt werden, sonst bleibt
|
||||
// das Ohr haengen bzw. das Aufnahme-Fenster laeuft leer weiter (Stefans
|
||||
// Reproduktion: "spotify play" und das Mikro wartet trotzdem 30s).
|
||||
// Unterschied zum manuellen Stop: der verwirft NICHT, sondern finalisiert die
|
||||
// Aufnahme (User will seinen Satz verarbeitet haben) — hier ist der Befehl
|
||||
// schon durch, ein evtl. offenes Folge-Fenster wird verworfen.
|
||||
const ariaStopRecording = useCallback(async (reason: string): Promise<void> => {
|
||||
converseRef.current = false;
|
||||
// 1) Passiv-Lauschen: sauber beenden (cancelt den Stream selbst, startet
|
||||
// KEINE neue passive Aufnahme).
|
||||
if (wakeWordService.getState() === 'listening') {
|
||||
await wakeWordService.exitPassiveListening('manual').catch(() => {});
|
||||
return;
|
||||
}
|
||||
// 2) Noch offene Streaming-Aufnahme (aktiv / Barge-In) verwerfen.
|
||||
if (audioService.isStreamingRecording()) {
|
||||
await audioService.cancelStreamingRecording(reason).catch(() => {});
|
||||
}
|
||||
// 3) Konversation beenden → zurueck aufs Wake-Word (skipPassive: kein 30s-Fenster).
|
||||
if (wakeWordService.isConversing()) {
|
||||
await wakeWordService.endConversation(true).catch(() => {});
|
||||
} else if (!wakeWordService.isActive()) {
|
||||
setWakeWordActive(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Manueller Aufnahme-Knopf — Stop. Sendet stt_stream_end an Whisper, die
|
||||
// dann ihrerseits den finalen Text als stt_endpoint emittiert. aria-bridge
|
||||
// forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich
|
||||
// endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume.
|
||||
const handleVoiceButtonStop = useCallback(async (): Promise<void> => {
|
||||
// Manueller Stop = endgueltig: auch die NACH der Antwort kommende
|
||||
// onPlaybackFinished darf kein 30s-Passiv-Fenster mehr oeffnen.
|
||||
converseRef.current = false;
|
||||
// Stop = ALLES beenden, vorhersehbar. Spricht ARIA gerade, hart stoppen +
|
||||
// laufende Brain-Antwort abbrechen (sonst "sagt sie ihren letzten Satz").
|
||||
if (audioService.isPlayingAudio()) {
|
||||
audioService.haltAllPlayback('user stop');
|
||||
rvs.send('cancel_request' as any, { hard: true, source: 'voice-stop' });
|
||||
}
|
||||
// Stop WAEHREND des passiven 30s-Lauschens ('listening'): sauber beenden
|
||||
// (zurueck aufs Wake-Word), NICHT den passiven Stream neu starten.
|
||||
// exitPassiveListening cancelt den Stream selbst (via _freeMic) → es feuert
|
||||
@@ -2301,16 +2477,47 @@ const ChatScreen: React.FC = () => {
|
||||
setPendingAttachments(prev => [...prev, { file: photo, isPhoto: true }]);
|
||||
}, []);
|
||||
|
||||
// ── Teilen-Ziel ────────────────────────────────────────────────
|
||||
// Beim Start pruefen (App war zu) und auf spaetere Shares lauschen
|
||||
// (App laeuft schon → onNewIntent im MainActivity).
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const open = (p: { text: string; items: SharedItem[] }) => {
|
||||
if (!alive) return;
|
||||
if (!p.items.length && !p.text) return; // nichts dabei
|
||||
setShareItems(p.items);
|
||||
setShareText(p.text);
|
||||
setShareVisible(true);
|
||||
};
|
||||
shareIntent.getShared().then(open).catch(() => {});
|
||||
const unsub = shareIntent.onShare(open);
|
||||
// Zweitweg: Teilen holt die App in den Vordergrund. Beim Wechsel nach
|
||||
// 'active' nochmal nachfragen — das greift auch, wenn das native Event
|
||||
// (je nach RN-Architektur) nicht durchkommt. getSharedData() markiert den
|
||||
// Intent als verbraucht, es kann also nicht doppelt aufpoppen.
|
||||
const appSub = AppState.addEventListener('change', st => {
|
||||
if (st === 'active') shareIntent.getShared().then(open).catch(() => {});
|
||||
});
|
||||
return () => { alive = false; unsub(); try { appSub.remove(); } catch (_) {} };
|
||||
}, []);
|
||||
|
||||
|
||||
// Alle Pending Anhaenge + Text senden
|
||||
const sendPendingAttachments = useCallback(async (messageText: string) => {
|
||||
if (pendingAttachments.length === 0) return;
|
||||
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', pendingAttachments.length);
|
||||
const sendPendingAttachments = useCallback(async (
|
||||
messageText: string,
|
||||
// Explizite Liste (Teilen-Weg): der State waere beim direkten Aufruf noch
|
||||
// nicht aktualisiert. Ohne Angabe wie bisher die Pending-Liste.
|
||||
explicitAttachments?: { file: any; isPhoto: boolean }[],
|
||||
) => {
|
||||
const atts = explicitAttachments ?? pendingAttachments;
|
||||
if (atts.length === 0) return;
|
||||
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', atts.length);
|
||||
const location = await getCurrentLocation();
|
||||
const msgId = nextId();
|
||||
|
||||
// Alle Attachments fuer die Chat-Nachricht sammeln
|
||||
const attachments: Attachment[] = [];
|
||||
for (const { file, isPhoto } of pendingAttachments) {
|
||||
for (const { file, isPhoto } of atts) {
|
||||
const isImage = isPhoto || (file.type && file.type.startsWith('image/'));
|
||||
const name = isPhoto ? file.fileName : file.name;
|
||||
const base64 = file.base64 || '';
|
||||
@@ -2333,7 +2540,7 @@ const ChatScreen: React.FC = () => {
|
||||
const userMsg: ChatMessage = {
|
||||
id: msgId,
|
||||
sender: 'user',
|
||||
text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
|
||||
text: messageText || `${atts.length} Anhang/Anhaenge`,
|
||||
timestamp: Date.now(),
|
||||
attachments,
|
||||
projectId: activePid,
|
||||
@@ -2346,7 +2553,7 @@ const ChatScreen: React.FC = () => {
|
||||
setMessages(prev => capMessages([...prev, userMsg]));
|
||||
|
||||
// Alle Dateien an RVS senden + auf Disk speichern
|
||||
for (const { file, isPhoto } of pendingAttachments) {
|
||||
for (const { file, isPhoto } of atts) {
|
||||
const name = isPhoto ? file.fileName : file.name;
|
||||
const base64 = file.base64 || '';
|
||||
const mimeType = file.type || '';
|
||||
@@ -2371,6 +2578,10 @@ const ChatScreen: React.FC = () => {
|
||||
size: file.size,
|
||||
base64,
|
||||
projectId: activePid,
|
||||
// Korrelation: dieselbe clientMsgId wie der Text, damit die Bridge
|
||||
// die Datei genau DIESER Nachricht zuordnet — auch wenn Files (fire-
|
||||
// and-forget) und Text (ACK-getrackt, bei Queue verzoegert) desyncen.
|
||||
...(cmid && { clientMsgId: cmid }),
|
||||
...(isPhoto && file.width && { width: file.width, height: file.height }),
|
||||
...(location && { location }),
|
||||
});
|
||||
@@ -2397,6 +2608,33 @@ const ChatScreen: React.FC = () => {
|
||||
// sendPendingAttachments — beide stehen in seinem deps-Array, und wenn es
|
||||
// davor deklariert waere, laendeten sie dort in der Temporal Dead Zone
|
||||
// (tsc TS2448/2454; lief nur zufaellig, weil Babel const→var hebt).
|
||||
/** Geteiltes senden: Ziel setzen, Dateien anhaengen, abschicken. */
|
||||
const handleShareSend = useCallback(async (projectId: string, text: string) => {
|
||||
try {
|
||||
// Chat-Focus aufs gewaehlte Ziel — die Nachricht landet dort, und Stefan
|
||||
// steht nach dem Senden gleich im richtigen Kontext.
|
||||
setFocusedProjectId(projectId);
|
||||
focusedProjectIdRef.current = projectId;
|
||||
|
||||
const files = await shareIntent.toFileData(shareItems);
|
||||
if (files.length) {
|
||||
// Liste explizit uebergeben: der State waere hier noch leer.
|
||||
await sendPendingAttachments(text, files.map(f => ({ file: f, isPhoto: false })));
|
||||
} else if (text) {
|
||||
// Reiner Text (z.B. geteilter Link): ins Eingabefeld, Stefan schickt ab.
|
||||
setInputText(text);
|
||||
}
|
||||
shareIntent.clearShareCache();
|
||||
} catch (e) {
|
||||
console.warn('[Share] Senden fehlgeschlagen:', e);
|
||||
Alert.alert('Teilen fehlgeschlagen', String((e as any)?.message || e));
|
||||
} finally {
|
||||
setShareVisible(false);
|
||||
setShareItems([]);
|
||||
setShareText('');
|
||||
}
|
||||
}, [shareItems, sendPendingAttachments]);
|
||||
|
||||
const sendTextMessage = useCallback(async () => {
|
||||
const text = inputText.trim();
|
||||
|
||||
@@ -2440,6 +2678,24 @@ const ChatScreen: React.FC = () => {
|
||||
}
|
||||
}, [inputText, pendingAttachments, sendPendingAttachments, getCtxState, setCtxState, setCtxQueue, actuallySend]);
|
||||
|
||||
// Zwischenruf: waehrend ARIA arbeitet eine Korrektur MITTEN in den laufenden
|
||||
// Turn schieben — NICHT in die Queue, KEIN Abbruch. Geht als 'interject' ueber
|
||||
// RVS an die Bridge → Proxy → laufender Subprozess (greift es an der naechsten
|
||||
// Tool-Grenze auf). Sichtbar nur, wenn der aktive Kontext gerade arbeitet.
|
||||
const sendInterject = useCallback(() => {
|
||||
const text = inputText.trim();
|
||||
if (!text) return;
|
||||
const activePid = focusedProjectIdRef.current;
|
||||
rvs.send('interject' as any, { projectId: activePid, text });
|
||||
// Lokale Bubble zur Rueckmeldung (laeuft NICHT durch Send/Queue).
|
||||
setMessages(prev => capMessages([...prev, {
|
||||
id: nextId(), sender: 'user', text: `📣 Zwischenruf: ${text}`,
|
||||
timestamp: Date.now(), projectId: activePid,
|
||||
}]));
|
||||
projectDraftsRef.current = { ...projectDraftsRef.current, [activePid]: '' };
|
||||
setInputText('');
|
||||
}, [inputText]);
|
||||
|
||||
// --- Rendering ---
|
||||
|
||||
const renderMessage = ({ item }: { item: ChatMessage }) => {
|
||||
@@ -2625,6 +2881,16 @@ const ChatScreen: React.FC = () => {
|
||||
{att.serverPath ? '(tippen zum Laden)' : '(nicht verfuegbar)'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : att.type === 'audio' ? (
|
||||
<View style={styles.attachmentFile}>
|
||||
<Text style={styles.attachmentFileIcon}>{'🎙'}</Text>
|
||||
<Text style={styles.attachmentFileName} numberOfLines={1}>
|
||||
{att.name || 'Sprachaufnahme'}
|
||||
</Text>
|
||||
{typeof item.durationS === 'number' && item.durationS > 0 ? (
|
||||
<Text style={styles.attachmentFileSize}>{formatDur(item.durationS)}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={styles.attachmentFile}
|
||||
@@ -2876,7 +3142,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;
|
||||
@@ -3214,9 +3480,19 @@ const ChatScreen: React.FC = () => {
|
||||
|
||||
{/* Senden oder Sprache */}
|
||||
{inputText.trim() || pendingAttachments.length > 0 ? (
|
||||
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
|
||||
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
|
||||
</TouchableOpacity>
|
||||
<>
|
||||
{/* Zwischenruf: nur wenn ARIA im aktiven Kontext gerade arbeitet und
|
||||
Text da ist. Schiebt die Korrektur in den laufenden Turn statt
|
||||
sie anzustellen. */}
|
||||
{inputText.trim() && (agentActivityByCtx[focusedProjectId]?.activity || 'idle') !== 'idle' ? (
|
||||
<TouchableOpacity style={styles.interjectButton} onPress={sendInterject} accessibilityLabel="Zwischenruf">
|
||||
<Text style={styles.interjectIcon}>{'\uD83D\uDCE3'}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
|
||||
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<VoiceButton
|
||||
@@ -3496,6 +3772,13 @@ const ChatScreen: React.FC = () => {
|
||||
{/* Datei-Upload Modal */}
|
||||
<Modal visible={showFileUpload} transparent animationType="slide">
|
||||
<View style={styles.modalOverlay}>
|
||||
<ShareModal
|
||||
visible={shareVisible}
|
||||
items={shareItems}
|
||||
initialText={shareText}
|
||||
onCancel={() => { setShareVisible(false); setShareItems([]); setShareText(''); }}
|
||||
onSend={handleShareSend}
|
||||
/>
|
||||
<FileUpload
|
||||
onFileSelected={handleFileSelected}
|
||||
onCancel={() => setShowFileUpload(false)}
|
||||
@@ -3734,6 +4017,18 @@ const styles = StyleSheet.create({
|
||||
sendIcon: {
|
||||
fontSize: 18,
|
||||
},
|
||||
interjectButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#FF9500', // orange — Zwischenruf, klar vom blauen Senden getrennt
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 6,
|
||||
},
|
||||
interjectIcon: {
|
||||
fontSize: 18,
|
||||
},
|
||||
wakeWordBtn: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
|
||||
@@ -63,14 +63,16 @@ import {
|
||||
VAD_SILENCE_MIN_SEC,
|
||||
VAD_SILENCE_MAX_SEC,
|
||||
VAD_SILENCE_STORAGE_KEY,
|
||||
CONV_WINDOW_DEFAULT_SEC,
|
||||
CONV_WINDOW_MIN_SEC,
|
||||
CONV_WINDOW_MAX_SEC,
|
||||
CONV_WINDOW_STORAGE_KEY,
|
||||
STT_ENDPOINT_DEFAULT_MS,
|
||||
STT_ENDPOINT_MIN_MS,
|
||||
STT_ENDPOINT_MAX_MS,
|
||||
STT_ENDPOINT_STORAGE_KEY,
|
||||
MAX_RECORDING_DEFAULT_SEC,
|
||||
MAX_RECORDING_MIN_SEC,
|
||||
MAX_RECORDING_MAX_SEC,
|
||||
MAX_RECORDING_STORAGE_KEY,
|
||||
loadBargeInEnabled,
|
||||
saveBargeInEnabled,
|
||||
VAD_SILENCE_DB_DEFAULT,
|
||||
VAD_SILENCE_DB_MIN,
|
||||
VAD_SILENCE_DB_MAX,
|
||||
@@ -110,9 +112,10 @@ import wakeWordService, {
|
||||
WAKE_THRESHOLD_MAX,
|
||||
loadWakeThreshold,
|
||||
saveWakeThreshold,
|
||||
PASSIVE_LISTEN_DEFAULT_MS,
|
||||
loadPassiveListenMs,
|
||||
savePassiveListenMs,
|
||||
loadBgWakeEnabled,
|
||||
saveBgWakeEnabled,
|
||||
loadWakeConfirmEnabled,
|
||||
saveWakeConfirmEnabled,
|
||||
} from '../services/wakeword';
|
||||
import ModeSelector from '../components/ModeSelector';
|
||||
import QRScanner from '../components/QRScanner';
|
||||
@@ -195,8 +198,12 @@ const SettingsScreen: React.FC = () => {
|
||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||
const [ttsPrerollSec, setTtsPrerollSec] = useState<number>(TTS_PREROLL_DEFAULT_SEC);
|
||||
const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC);
|
||||
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
|
||||
// Aktive Streaming-Pausen-Toleranz (STT_ENDPOINT) — der "Stille-Toleranz"-Regler
|
||||
// steuert jetzt DIESEN Wert (der alte vadSilenceSec war der tote Legacy-dB-Pfad).
|
||||
const [sttEndpointSec, setSttEndpointSec] = useState<number>(STT_ENDPOINT_DEFAULT_MS / 1000);
|
||||
const [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC);
|
||||
// Barge-in: ARIA waehrend ihrer Antwort unterbrechen duerfen. Default aus (Halb-Duplex).
|
||||
const [bargeIn, setBargeIn] = useState<boolean>(false);
|
||||
// null = automatisch (adaptive Baseline), sonst manueller dB-Override
|
||||
const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null);
|
||||
const [showVadInfo, setShowVadInfo] = useState(false);
|
||||
@@ -209,7 +216,10 @@ const SettingsScreen: React.FC = () => {
|
||||
const [wakeStatus, setWakeStatus] = useState<string>('');
|
||||
const [wakeReadySound, setWakeReadySound] = useState<boolean>(true);
|
||||
const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT);
|
||||
const [passiveSec, setPassiveSec] = useState<number>(Math.round(PASSIVE_LISTEN_DEFAULT_MS / 1000));
|
||||
// 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);
|
||||
@@ -298,11 +308,11 @@ const SettingsScreen: React.FC = () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY).then(saved => {
|
||||
AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY).then(saved => {
|
||||
if (saved != null) {
|
||||
const n = parseFloat(saved);
|
||||
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) {
|
||||
setConvWindowSec(n);
|
||||
const n = parseInt(saved, 10);
|
||||
if (isFinite(n) && n >= STT_ENDPOINT_MIN_MS && n <= STT_ENDPOINT_MAX_MS) {
|
||||
setSttEndpointSec(n / 1000);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -314,6 +324,7 @@ const SettingsScreen: React.FC = () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
loadBargeInEnabled().then(setBargeIn).catch(() => {});
|
||||
AsyncStorage.getItem(VAD_SILENCE_DB_OVERRIDE_KEY).then(saved => {
|
||||
if (saved != null && saved !== '') {
|
||||
const n = parseFloat(saved);
|
||||
@@ -333,7 +344,8 @@ const SettingsScreen: React.FC = () => {
|
||||
});
|
||||
isWakeReadySoundEnabled().then(setWakeReadySound);
|
||||
loadWakeThreshold().then(setWakeThreshold).catch(() => {});
|
||||
loadPassiveListenMs().then(ms => setPassiveSec(Math.round(ms / 1000))).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 => {
|
||||
@@ -1651,72 +1663,56 @@ const SettingsScreen: React.FC = () => {
|
||||
{currentSection === 'voice_input' && (<>
|
||||
<Text style={styles.sectionTitle}>Spracheingabe</Text>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.toggleLabel}>Stille-Toleranz</Text>
|
||||
<View style={styles.toggleRow}>
|
||||
<View style={styles.toggleInfo}>
|
||||
<Text style={styles.toggleLabel}>Barge-in (unterbrechen)</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
AUS (empfohlen): ARIA spricht ihre Antwort ZU ENDE, dann geht das
|
||||
Mikro auf — sauber, kein Selbst-Echo, du hoerst sie ganz. AN: du
|
||||
kannst sie waehrend des Sprechens per Wake-Wort unterbrechen.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={bargeIn}
|
||||
onValueChange={(v) => { setBargeIn(v); saveBargeInEnabled(v).catch(() => {}); }}
|
||||
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
|
||||
thumbColor={bargeIn ? '#FFFFFF' : '#666680'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Stille-Toleranz</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Wie lange du eine Sprechpause machen darfst, bevor die Aufnahme
|
||||
automatisch beendet und gesendet wird. Hoeher = mehr Zeit zum
|
||||
Nachdenken; niedriger = schnelleres Senden.
|
||||
Default: {VAD_SILENCE_DEFAULT_SEC.toFixed(1)}s.
|
||||
Nachdenken (z.B. im Auto); niedriger = schnelleres Senden.
|
||||
Default: {(STT_ENDPOINT_DEFAULT_MS / 1000).toFixed(1)}s.
|
||||
</Text>
|
||||
<View style={styles.prerollRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.max(VAD_SILENCE_MIN_SEC, Math.round((vadSilenceSec - 0.5) * 10) / 10);
|
||||
setVadSilenceSec(next);
|
||||
AsyncStorage.setItem(VAD_SILENCE_STORAGE_KEY, String(next));
|
||||
const next = Math.max(STT_ENDPOINT_MIN_MS / 1000, Math.round((sttEndpointSec - 0.5) * 10) / 10);
|
||||
setSttEndpointSec(next);
|
||||
AsyncStorage.setItem(STT_ENDPOINT_STORAGE_KEY, String(Math.round(next * 1000)));
|
||||
}}
|
||||
disabled={vadSilenceSec <= VAD_SILENCE_MIN_SEC}
|
||||
disabled={sttEndpointSec <= STT_ENDPOINT_MIN_MS / 1000}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>−0.5</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.prerollValue}>{vadSilenceSec.toFixed(1)} s</Text>
|
||||
<Text style={styles.prerollValue}>{sttEndpointSec.toFixed(1)} s</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.min(VAD_SILENCE_MAX_SEC, Math.round((vadSilenceSec + 0.5) * 10) / 10);
|
||||
setVadSilenceSec(next);
|
||||
AsyncStorage.setItem(VAD_SILENCE_STORAGE_KEY, String(next));
|
||||
const next = Math.min(STT_ENDPOINT_MAX_MS / 1000, Math.round((sttEndpointSec + 0.5) * 10) / 10);
|
||||
setSttEndpointSec(next);
|
||||
AsyncStorage.setItem(STT_ENDPOINT_STORAGE_KEY, String(Math.round(next * 1000)));
|
||||
}}
|
||||
disabled={vadSilenceSec >= VAD_SILENCE_MAX_SEC}
|
||||
disabled={sttEndpointSec >= STT_ENDPOINT_MAX_MS / 1000}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>+0.5</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Konversations-Fenster</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Im Gespraechsmodus (Ohr-Button): nach ARIA's Antwort hast du so lange
|
||||
Zeit, weiter zu sprechen, bevor die Konversation automatisch beendet wird.
|
||||
Sprichst du nichts → Mikrofon zu.
|
||||
Default: {CONV_WINDOW_DEFAULT_SEC.toFixed(1)}s.
|
||||
</Text>
|
||||
<View style={styles.prerollRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.max(CONV_WINDOW_MIN_SEC, Math.round((convWindowSec - 1) * 10) / 10);
|
||||
setConvWindowSec(next);
|
||||
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
|
||||
}}
|
||||
disabled={convWindowSec <= CONV_WINDOW_MIN_SEC}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>−1</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.prerollValue}>{convWindowSec.toFixed(0)} s</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.min(CONV_WINDOW_MAX_SEC, Math.round((convWindowSec + 1) * 10) / 10);
|
||||
setConvWindowSec(next);
|
||||
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
|
||||
}}
|
||||
disabled={convWindowSec >= CONV_WINDOW_MAX_SEC}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>+1</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Maximale Aufnahmedauer</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Notbremse: nach so vielen Minuten wird die Aufnahme automatisch beendet,
|
||||
@@ -1749,56 +1745,10 @@ const SettingsScreen: React.FC = () => {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={{flexDirection: 'row', alignItems: 'center', marginTop: 24, gap: 8}}>
|
||||
<Text style={styles.toggleLabel}>Stille-Pegel (dB)</Text>
|
||||
<TouchableOpacity onPress={() => setShowVadInfo(true)} style={styles.infoBtn}>
|
||||
<Text style={styles.infoBtnText}>i</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={styles.toggleHint}>
|
||||
Welcher Mikro-Pegel als "Stille" gilt. Standard: automatisch (Baseline aus
|
||||
den ersten 500ms). Manuell setzen wenn Auto nicht zuverlaessig greift.
|
||||
</Text>
|
||||
<View style={styles.prerollRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = vadSilenceDb == null
|
||||
? VAD_SILENCE_DB_DEFAULT - 1
|
||||
: Math.max(VAD_SILENCE_DB_MIN, vadSilenceDb - 1);
|
||||
setVadSilenceDb(next);
|
||||
AsyncStorage.setItem(VAD_SILENCE_DB_OVERRIDE_KEY, String(next));
|
||||
}}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>−1</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.prerollValue}>
|
||||
{vadSilenceDb == null ? 'auto' : `${vadSilenceDb} dB`}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = vadSilenceDb == null
|
||||
? VAD_SILENCE_DB_DEFAULT + 1
|
||||
: Math.min(VAD_SILENCE_DB_MAX, vadSilenceDb + 1);
|
||||
setVadSilenceDb(next);
|
||||
AsyncStorage.setItem(VAD_SILENCE_DB_OVERRIDE_KEY, String(next));
|
||||
}}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>+1</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{vadSilenceDb != null && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setVadSilenceDb(null);
|
||||
AsyncStorage.removeItem(VAD_SILENCE_DB_OVERRIDE_KEY);
|
||||
}}
|
||||
style={{alignSelf: 'center', marginTop: 8, paddingVertical: 6, paddingHorizontal: 12}}
|
||||
>
|
||||
<Text style={{color: '#0096FF', fontSize: 13}}>↻ Auf automatisch zuruecksetzen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{/* "Stille-Pegel (dB)"-Regler entfernt: der aktive Streaming-STT nutzt
|
||||
einen adaptiven Rausch-Boden (automatisch), ein manueller dB-Wert war
|
||||
wirkungslos. Rauschen-als-Wort verhindert das STT-Modell selbst
|
||||
(no_speech_prob-Filter), nicht die dB-Schwelle. */}
|
||||
</View>
|
||||
|
||||
<Modal
|
||||
@@ -1954,38 +1904,58 @@ const SettingsScreen: React.FC = () => {
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden-Fenster (Gespraech)</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Nach einer gesprochenen ARIA-Antwort kannst du so lange einfach
|
||||
weiterreden — ohne Wake-Word — bevor zurueck aufs Wake-Word geschaltet
|
||||
wird. Reine Steuerbefehle (z.B. „nächster Titel") beenden sofort.
|
||||
Default: {Math.round(PASSIVE_LISTEN_DEFAULT_MS / 1000)}s.
|
||||
</Text>
|
||||
<View style={styles.prerollRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.max(10, passiveSec - 5);
|
||||
setPassiveSec(next);
|
||||
savePassiveListenMs(next * 1000);
|
||||
<View style={[styles.toggleRow, {marginTop: 20, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 16}]}>
|
||||
<View style={styles.toggleInfo}>
|
||||
<Text style={styles.toggleLabel}>Auch bei gesperrtem Bildschirm zuhören</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
AUS (empfohlen): das Wake-Wort greift nur, wenn die App offen ist —
|
||||
im Hintergrund sind die meisten „Trigger" Fehlalarme (TV, Husten).
|
||||
AN: ARIA hört auch bei gesperrtem Bildschirm / im Hintergrund auf
|
||||
„{KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS] || wakeKeyword}" — mehr Fehlauslöser möglich.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={bgWake}
|
||||
onValueChange={(val) => {
|
||||
setBgWake(val);
|
||||
saveBgWakeEnabled(val).catch(() => {});
|
||||
wakeWordService.setBgWakeEnabled(val);
|
||||
}}
|
||||
disabled={passiveSec <= 10}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>−5</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.prerollValue}>{passiveSec} s</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = Math.min(60, passiveSec + 5);
|
||||
setPassiveSec(next);
|
||||
savePassiveListenMs(next * 1000);
|
||||
}}
|
||||
disabled={passiveSec >= 60}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>+5</Text>
|
||||
</TouchableOpacity>
|
||||
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
|
||||
thumbColor={bgWake ? '#FFFFFF' : '#666680'}
|
||||
/>
|
||||
</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
|
||||
Wake-Word weiterreden. Fängst du nicht innerhalb der „Stille-Toleranz"
|
||||
(Sektion Spracheingabe) an, geht's zurück aufs Wake-Word. Reine
|
||||
Steuerbefehle beenden sofort. Ein separates Zeitfenster gibt es nicht
|
||||
mehr — es zählt überall derselbe Stille-Wert.
|
||||
</Text>
|
||||
</View>
|
||||
</>)}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* ariaView — Empfaenger der von ARIA komponierten RAEUMLICHEN Ansichten (M1).
|
||||
*
|
||||
* Fluss: ARIA ruft im Brain `present_view` → Brain-Event `aria_view` → Bridge →
|
||||
* RVS `aria_view` → hier gepuffert → WorkspaceCanvas rendert Orb + Karten, die
|
||||
* auf der Flaeche materialisieren.
|
||||
*
|
||||
* Der Service haelt pro Projekt die AKTUELLE View-Spec, damit eine spaet
|
||||
* gemountete Canvas-Kachel sofort den Ist-Stand bekommt. Muster wie
|
||||
* services/codeFile.ts (Singleton, rvs.onMessage).
|
||||
*
|
||||
* Die Karten-Typen sind bewusst offen (string), damit spaetere Renderer (vnc,
|
||||
* chart, file …) ohne Service-Aenderung dazukommen. Der jeweilige Client-Renderer
|
||||
* entscheidet, was er mit einem unbekannten Typ macht (i.d.R. ignorieren).
|
||||
*/
|
||||
|
||||
import rvs, { RVSMessage } from './rvs';
|
||||
|
||||
export type OrbState = 'idle' | 'listening' | 'thinking' | 'speaking' | 'working';
|
||||
|
||||
export interface ViewMarker {
|
||||
lat: number;
|
||||
lon: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ViewCard {
|
||||
type: 'text' | 'image' | 'map' | 'code' | 'list' | string;
|
||||
title?: string;
|
||||
md?: string; // text/list
|
||||
src?: string; // image
|
||||
markers?: ViewMarker[]; // map
|
||||
path?: string; // code
|
||||
lang?: string; // code
|
||||
// Zukuenftige Kartenfelder ohne Service-Aenderung:
|
||||
[k: string]: any;
|
||||
}
|
||||
|
||||
export interface ViewSpec {
|
||||
cards: ViewCard[];
|
||||
orb?: OrbState;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface AriaView {
|
||||
projectId: string;
|
||||
view: ViewSpec;
|
||||
clientMsgId?: string;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
type ViewSub = (v: AriaView) => void;
|
||||
|
||||
class AriaViewService {
|
||||
private views = new Map<string, AriaView>();
|
||||
private subs: ViewSub[] = [];
|
||||
|
||||
constructor() {
|
||||
rvs.onMessage((m) => this.onMessage(m));
|
||||
}
|
||||
|
||||
private onMessage(m: RVSMessage): void {
|
||||
if (m.type !== 'aria_view') return;
|
||||
const p = (m.payload || {}) as any;
|
||||
const raw = (p.view || {}) as any;
|
||||
const cards: ViewCard[] = Array.isArray(raw.cards) ? raw.cards : [];
|
||||
if (cards.length === 0) return; // leere Ansicht ignorieren
|
||||
const view: ViewSpec = {
|
||||
cards,
|
||||
orb: raw.orb || 'speaking',
|
||||
title: raw.title || '',
|
||||
};
|
||||
const projectId: string = p.projectId || '';
|
||||
const entry: AriaView = {
|
||||
projectId,
|
||||
view,
|
||||
clientMsgId: p.clientMsgId || '',
|
||||
ts: Date.now(),
|
||||
};
|
||||
this.views.set(projectId, entry);
|
||||
this.subs.forEach((cb) => {
|
||||
try { cb(entry); } catch {}
|
||||
});
|
||||
}
|
||||
|
||||
/** Aktuelle Ansicht eines Projekts (leer = Hauptchat). */
|
||||
getView(projectId: string): AriaView | undefined {
|
||||
return this.views.get(projectId || '');
|
||||
}
|
||||
|
||||
/** Registriert einen Listener fuer neue Ansichten. */
|
||||
subscribe(cb: ViewSub): () => void {
|
||||
this.subs.push(cb);
|
||||
return () => { this.subs = this.subs.filter((s) => s !== cb); };
|
||||
}
|
||||
|
||||
/** Ansicht eines Projekts verwerfen (z.B. wenn der User sie wegwischt). */
|
||||
clear(projectId: string): void {
|
||||
this.views.delete(projectId || '');
|
||||
}
|
||||
}
|
||||
|
||||
const ariaView = new AriaViewService();
|
||||
export default ariaView;
|
||||
+162
-44
@@ -143,23 +143,100 @@ export const VAD_SILENCE_MIN_SEC = 1.0;
|
||||
export const VAD_SILENCE_MAX_SEC = 8.0;
|
||||
export const VAD_SILENCE_STORAGE_KEY = 'aria_vad_silence_sec';
|
||||
|
||||
// Konversations-Fenster (in Sekunden) — nach ARIA's Antwort hat der User so
|
||||
// lange Zeit, im Gespraechsmodus weiter zu sprechen, ohne dass die Konversation
|
||||
// beendet wird. Sprichst du im Fenster nichts → Konversation aus.
|
||||
export const CONV_WINDOW_DEFAULT_SEC = 8.0;
|
||||
export const CONV_WINDOW_MIN_SEC = 3.0;
|
||||
export const CONV_WINDOW_MAX_SEC = 20.0;
|
||||
export const CONV_WINDOW_STORAGE_KEY = 'aria_conv_window_sec';
|
||||
|
||||
// STT-Endpoint (ms Stille bis "fertig gesprochen"). Zu kurz = schneidet mitten
|
||||
// im Satz ab, besonders im Auto wo man mit Pausen spricht (Reproduktion: die
|
||||
// 11.8s-Frage wurde bei "…ohne dass ein" gekappt). 1500 war zu aggressiv;
|
||||
// 2400 default, im Auto ggf. hoeher. Konfigurierbar in den Settings.
|
||||
// im Satz ab, besonders im Auto oder wenn man zum Nachdenken pausiert. 1500 war
|
||||
// zu aggressiv; 2400 default, bis 8s hoch stellbar (Denkpausen). In den Settings
|
||||
// unter "Stille-Toleranz" konfigurierbar.
|
||||
export const STT_ENDPOINT_DEFAULT_MS = 2400;
|
||||
export const STT_ENDPOINT_MIN_MS = 1000;
|
||||
export const STT_ENDPOINT_MAX_MS = 4000;
|
||||
export const STT_ENDPOINT_MAX_MS = 8000; // bis 8s: genug Zeit zum Ueberlegen
|
||||
export const STT_ENDPOINT_STORAGE_KEY = 'aria_stt_endpoint_ms';
|
||||
|
||||
// Barge-in-Modus: darf man ARIA waehrend ihrer TTS-Antwort unterbrechen (reden)?
|
||||
// Default AUS = sauberes Halb-Duplex (ARIA spricht aus, DANN oeffnet das Mikro —
|
||||
// kein Selbst-Echo, kein Mischmasch). AN = waehrend TTS auf Wake-Wort lauschen.
|
||||
export const BARGE_IN_STORAGE_KEY = 'aria_barge_in_enabled';
|
||||
|
||||
export async function loadBargeInEnabled(): Promise<boolean> {
|
||||
try {
|
||||
return (await AsyncStorage.getItem(BARGE_IN_STORAGE_KEY)) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBargeInEnabled(enabled: boolean): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.setItem(BARGE_IN_STORAGE_KEY, String(enabled));
|
||||
} 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);
|
||||
@@ -189,18 +266,6 @@ export async function loadTtsSpeed(): Promise<number> {
|
||||
return TTS_SPEED_DEFAULT;
|
||||
}
|
||||
|
||||
export async function loadConvWindowMs(): Promise<number> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY);
|
||||
if (raw != null) {
|
||||
const n = parseFloat(raw);
|
||||
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) {
|
||||
return Math.round(n * 1000);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return Math.round(CONV_WINDOW_DEFAULT_SEC * 1000);
|
||||
}
|
||||
|
||||
async function loadVadSilenceMs(): Promise<number> {
|
||||
try {
|
||||
@@ -351,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.
|
||||
@@ -1073,7 +1141,7 @@ class AudioService {
|
||||
* Leer = Hauptchat. Ohne Prefix / Sticky landet die STT-Nachricht damit
|
||||
* automatisch in dem Kontext den Stefan gerade sieht. */
|
||||
projectId?: string;
|
||||
}): Promise<{ requestId: string; ok: boolean }> {
|
||||
}): Promise<{ requestId: string; ok: boolean; error?: string }> {
|
||||
if (this.recordingState !== 'idle') {
|
||||
console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState);
|
||||
return { requestId: '', ok: false };
|
||||
@@ -1087,13 +1155,42 @@ class AudioService {
|
||||
console.warn('[Audio] Keine Mikrofon-Berechtigung');
|
||||
return { requestId: '', ok: false };
|
||||
}
|
||||
// Ohne Verbindung gar nicht erst aufnehmen: rvs.send() verwirft still, wenn
|
||||
// der Socket nicht offen ist (kein Puffer, kein Retry). Genau deshalb fiel
|
||||
// nach App-Resume/Reconnect die ERSTE Aufnahme komplett unter den Tisch —
|
||||
// start, Chunks und end waren weg, die Bubble verschwand, und erst der
|
||||
// zweite Versuch klappte (da war der Reconnect durch). Nachtraeglich
|
||||
// gepuffertes Audio waere sinnlos: die STT-Sitzung laeuft in Echtzeit.
|
||||
// WICHTIG: den SOCKET fragen, nicht den gemerkten Zustand. Der kann
|
||||
// faelschlich 'disconnected' sein, obwohl die Verbindung laeuft (Zombie) —
|
||||
// dann wuerde hier jede Aufnahme abgelehnt, bevor sie beginnt, und der
|
||||
// Nutzer kann gar nicht mehr sprechen.
|
||||
if (!rvs.isSocketOpen()) {
|
||||
console.warn('[Audio] startStreamingRecording: Socket nicht offen (state=%s)',
|
||||
rvs.getState());
|
||||
return { requestId: '', ok: false, error: 'not-connected' };
|
||||
}
|
||||
|
||||
// Laufende Wiedergabe stoppen (damit ARIA sich nicht selbst hoert)
|
||||
this.stopPlayback();
|
||||
|
||||
const requestId = `sttstr_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
this.streamRequestId = requestId;
|
||||
// Zustand SOFORT setzen (nicht erst nach den awaits weiter unten): sonst ist
|
||||
// isStreamingRecording() bereits true, recordingState aber noch 'idle' —
|
||||
// ein Abbruch in diesem Fenster liess den Start danach weiterlaufen und der
|
||||
// Recorder blieb haengen, sodass die NAECHSTE Aufnahme abgelehnt wurde.
|
||||
this.setState('recording');
|
||||
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();
|
||||
@@ -1114,6 +1211,7 @@ class AudioService {
|
||||
requestId: sessionId,
|
||||
pcm: String(e?.pcm || ''),
|
||||
seq: Number(e?.seq || 0),
|
||||
targetInstance: this.streamTargetInstance,
|
||||
});
|
||||
});
|
||||
this.streamPcmErrorSub = emitter.addListener('PcmStreamError', (e: any) => {
|
||||
@@ -1124,6 +1222,31 @@ class AudioService {
|
||||
console.warn('[Audio] PcmStreamChunk-Subscription fehlgeschlagen:', err);
|
||||
}
|
||||
|
||||
// Abbruch waehrend der awaits oben? Dann hier aussteigen, statt eine
|
||||
// Sitzung zu starten, die niemand mehr erwartet.
|
||||
if (this.streamRequestId !== requestId) {
|
||||
console.warn('[Audio] Start abgebrochen (Session gewechselt) — breche ab');
|
||||
return { requestId: '', ok: false, error: 'superseded' };
|
||||
}
|
||||
|
||||
// stt_stream_start MUSS vor dem Recorder raus: sonst treffen die ersten
|
||||
// Chunks (200ms-Takt) auf eine Bridge, die die Sitzung noch nicht kennt,
|
||||
// und werden verworfen — der Wortanfang fehlt, bei kurzen Kommandos bleibt
|
||||
// gar nichts uebrig.
|
||||
rvs.send('stt_stream_start' as any, {
|
||||
requestId,
|
||||
audioRequestId: opts.audioRequestId || '',
|
||||
voice: opts.voice || '',
|
||||
speed: typeof opts.speed === 'number' ? opts.speed : 1.0,
|
||||
interrupted: !!opts.interrupted,
|
||||
location: opts.location || null,
|
||||
endpointMs: typeof opts.endpointMs === 'number' ? opts.endpointMs : STT_ENDPOINT_DEFAULT_MS,
|
||||
hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000,
|
||||
sampleRate: 16000,
|
||||
projectId: opts.projectId || '',
|
||||
targetInstance: this.streamTargetInstance,
|
||||
});
|
||||
|
||||
const started = await PcmStreamRecorder.start();
|
||||
if (!started) {
|
||||
throw new Error('PcmStreamRecorder.start returned false');
|
||||
@@ -1135,34 +1258,29 @@ class AudioService {
|
||||
this._cancelDeferredFocusRelease();
|
||||
AudioFocus?.requestExclusive().catch(() => {});
|
||||
|
||||
this.setState('recording');
|
||||
|
||||
// stt_stream_start — der Whisper-Bridge mitteilen dass jetzt Chunks kommen.
|
||||
rvs.send('stt_stream_start' as any, {
|
||||
requestId,
|
||||
audioRequestId: opts.audioRequestId || '',
|
||||
voice: opts.voice || '',
|
||||
speed: typeof opts.speed === 'number' ? opts.speed : 1.0,
|
||||
interrupted: !!opts.interrupted,
|
||||
location: opts.location || null,
|
||||
endpointMs: typeof opts.endpointMs === 'number' ? opts.endpointMs : 1500,
|
||||
hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000,
|
||||
sampleRate: 16000,
|
||||
projectId: opts.projectId || '',
|
||||
});
|
||||
|
||||
// No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer.
|
||||
// Wenn nach Konversationsfenster kein einziger stt_partial gekommen ist,
|
||||
// hat der User vermutlich nix gesagt → Session beenden.
|
||||
// WICHTIG: Der Aufrufer uebergibt hier die Sprechpausen-Toleranz
|
||||
// (sttEndpointMs, Default 2400ms). Die misst aber ab dem ENDE des
|
||||
// Sprechens — dieser Watchdog laeuft ab dem START der Aufnahme. Bis das
|
||||
// erste Zwischenergebnis zurueck sein KANN, vergehen realistisch 1,3-2,2s
|
||||
// (Mindest-Puffer 600ms + Transkriptions-Takt 700ms + Netzweg hin/zurueck).
|
||||
// Ohne Zuschlag reisst schon ein kurzes Ueberlegen nach dem Gong die
|
||||
// Grenze: die Aufnahme wurde abgebrochen und die Bubble verschwand — erst
|
||||
// der zweite Versuch klappte, weil man da sofort losredet.
|
||||
const NO_SPEECH_GRACE_MS = 2500;
|
||||
const noSpeechMs = Number(opts.noSpeechTimeoutMs || 0);
|
||||
if (noSpeechMs > 0) {
|
||||
const effectiveMs = noSpeechMs + NO_SPEECH_GRACE_MS;
|
||||
this.streamNoSpeechTimer = setTimeout(() => {
|
||||
if (this.streamRequestId === requestId && !this.streamGotPartial) {
|
||||
console.log('[Audio] Stream %s: no-speech nach %dms → cancel',
|
||||
requestId.slice(0, 12), noSpeechMs);
|
||||
requestId.slice(0, 12), effectiveMs);
|
||||
this.cancelStreamingRecording('no-speech').catch(() => {});
|
||||
}
|
||||
}, noSpeechMs);
|
||||
}, effectiveMs);
|
||||
}
|
||||
|
||||
// Hard-Cap als zweite Sicherheitsleine (App-seitig zusaetzlich zur Bridge).
|
||||
@@ -1198,7 +1316,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);
|
||||
}
|
||||
@@ -1233,7 +1351,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.)
|
||||
|
||||
@@ -145,6 +145,17 @@ class GpsTrackingService {
|
||||
// liefert im Hintergrund keine Updates (nur Heartbeat sendet alte Werte).
|
||||
const bgEnabled = await isBackgroundGpsEnabled();
|
||||
if (bgEnabled) {
|
||||
// Ohne ACCESS_BACKGROUND_LOCATION liefert watchPosition im Hintergrund
|
||||
// NICHTS (Android 10+) → der Foreground-Service allein bringt nichts, und
|
||||
// genau der Fall "Ankunft waehrend der Fahrt, Screen aus" faellt durch.
|
||||
// Deshalb erst die Permission sicherstellen (oeffnet ggf. die Android-
|
||||
// Settings fuer "Immer erlauben"), DANN den Location-Foreground-Service
|
||||
// hochziehen — der haelt den Prozess wach, sodass watchPosition + der
|
||||
// 60s-Heartbeat auch unter Doze weiterlaufen.
|
||||
const bgOk = await ensureBackgroundLocationPermission();
|
||||
if (!bgOk) {
|
||||
console.warn('[gps-track] Background-Permission fehlt — Tracking nur im Vordergrund zuverlaessig');
|
||||
}
|
||||
try { await acquireBackgroundAudio('location'); } catch {}
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Platform, DeviceEventEmitter } from 'react-native';
|
||||
import { Platform, DeviceEventEmitter, AppState } from 'react-native';
|
||||
import rvs from './rvs';
|
||||
|
||||
// Lokales Event damit die SettingsScreen Live Logs / Events Tabs
|
||||
@@ -38,6 +38,23 @@ const noop = () => {};
|
||||
let _verbose = true;
|
||||
let _debugLogsToBridge = false;
|
||||
|
||||
// ─── Crash-Kontext ohne adb ─────────────────────────────────────────
|
||||
// Ein RUN_MARKER bleibt gesetzt, solange die App AKTIV laeuft; bei sauberem
|
||||
// Wechsel in den Hintergrund wird er geloescht. Ist er beim naechsten Start
|
||||
// noch da, ist der vorige Lauf unsauber gestorben (nativer Crash/OOM — der
|
||||
// schreibt KEINEN JS-Fehler, taucht also sonst nirgends auf). Wir melden das
|
||||
// dann via RVS mit dem letzten Breadcrumb (was die App zuletzt tat).
|
||||
const RUN_MARKER_KEY = 'aria_run_marker';
|
||||
const BREADCRUMB_KEY = 'aria_last_breadcrumb';
|
||||
let _breadcrumb: { ts: number; scope: string; message: string } = { ts: 0, scope: '', message: '' };
|
||||
let _breadcrumbDirty = false;
|
||||
|
||||
/** Letzte App-Aktivitaet merken — Crash-Kontext fuer den naechsten Boot. */
|
||||
export function noteBreadcrumb(scope: string, message: string): void {
|
||||
_breadcrumb = { ts: Date.now(), scope: scope || '', message: String(message || '').slice(0, 120) };
|
||||
_breadcrumbDirty = true;
|
||||
}
|
||||
|
||||
function applyState(): void {
|
||||
console.log = _verbose ? originalLog : noop;
|
||||
}
|
||||
@@ -53,6 +70,45 @@ export async function initLogger(): Promise<void> {
|
||||
_debugLogsToBridge = d === 'true'; // default: false
|
||||
} catch {}
|
||||
applyState();
|
||||
await _initCrashDetection();
|
||||
}
|
||||
|
||||
// Native-Crash-Erkennung (ohne adb) — siehe RUN_MARKER-Kommentar oben.
|
||||
async function _initCrashDetection(): Promise<void> {
|
||||
try {
|
||||
const marker = await AsyncStorage.getItem(RUN_MARKER_KEY);
|
||||
if (marker) {
|
||||
let bc: any = {};
|
||||
try { bc = JSON.parse((await AsyncStorage.getItem(BREADCRUMB_KEY)) || '{}'); } catch {}
|
||||
const gap = bc && bc.ts ? Math.round((Date.now() - bc.ts) / 1000) : -1;
|
||||
// Verzoegert melden — RVS ist beim Boot oft noch nicht verbunden.
|
||||
setTimeout(() => {
|
||||
reportAppError({
|
||||
scope: 'app.crash-detected',
|
||||
level: 'warn',
|
||||
message: `Voriger Lauf ohne sauberes Shutdown beendet (nativer Crash/OOM?). `
|
||||
+ `Letzte Aktivitaet: [${(bc && bc.scope) || '?'}] ${(bc && bc.message) || '?'}`
|
||||
+ (gap >= 0 ? ` (vor ~${gap}s)` : ''),
|
||||
});
|
||||
}, 6000);
|
||||
}
|
||||
await AsyncStorage.setItem(RUN_MARKER_KEY, String(Date.now()));
|
||||
} catch {}
|
||||
// Breadcrumb throttled persistieren (alle 5s, nur wenn geaendert).
|
||||
setInterval(() => {
|
||||
if (_breadcrumbDirty) {
|
||||
_breadcrumbDirty = false;
|
||||
AsyncStorage.setItem(BREADCRUMB_KEY, JSON.stringify(_breadcrumb)).catch(() => {});
|
||||
}
|
||||
}, 5000);
|
||||
// Sauberer Hintergrund-Wechsel → Marker weg (kein Crash). Rueckkehr → wieder
|
||||
// scharf. So melden nur echte Aktiv-Crashes, kein normales Backgrounden.
|
||||
try {
|
||||
AppState.addEventListener('change', (s) => {
|
||||
if (s === 'background') AsyncStorage.removeItem(RUN_MARKER_KEY).catch(() => {});
|
||||
else if (s === 'active') AsyncStorage.setItem(RUN_MARKER_KEY, String(Date.now())).catch(() => {});
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function isVerboseLogging(): boolean {
|
||||
@@ -94,6 +150,7 @@ let _reportingInstalled = false;
|
||||
/** Schickt einen App-Fehler via RVS an die Bridge. */
|
||||
export function reportAppError(ev: AppErrorEvent): void {
|
||||
const ts = Date.now();
|
||||
noteBreadcrumb(ev.scope, ev.message);
|
||||
try {
|
||||
rvs.send('app_log' as any, {
|
||||
ts,
|
||||
@@ -128,6 +185,9 @@ export function reportAppError(ev: AppErrorEvent): void {
|
||||
* Default aus damit Mama-Modus keine Disk-Schreiblast hat. Error-Reports
|
||||
* (reportAppError) gehen weiterhin IMMER durch. */
|
||||
export function reportAppDebug(scope: string, message: string): void {
|
||||
// Breadcrumb IMMER aktualisieren (auch wenn Debug-Logs-an-Bridge aus ist) —
|
||||
// fuer den Crash-Kontext beim naechsten Boot.
|
||||
noteBreadcrumb(scope, message);
|
||||
if (!_debugLogsToBridge) return;
|
||||
const ts = Date.now();
|
||||
const trimmed = String(message).slice(0, 2000);
|
||||
|
||||
@@ -104,11 +104,7 @@ class RVSConnection {
|
||||
// Verbindungen + doppelte Events.
|
||||
if (this.ws) {
|
||||
this.log('info', 'Bestehende WS-Verbindung wird geschlossen vor Neu-Connect');
|
||||
try {
|
||||
this.ws.onclose = null; // verhindert dass scheduleReconnect doppelt feuert
|
||||
this.ws.onerror = null;
|
||||
this.ws.close();
|
||||
} catch (_) {}
|
||||
this.detachSocket(this.ws);
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
@@ -134,6 +130,15 @@ class RVSConnection {
|
||||
this.setState('disconnected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist die Verbindung WIRKLICH offen? Fragt den Socket selbst, nicht den
|
||||
* gemerkten Zustand — der kann (z.B. nach einem Zombie) falsch sein. Wer
|
||||
* davon abhaengig macht, ob ueberhaupt gesendet wird, muss das Echte wissen.
|
||||
*/
|
||||
isSocketOpen(): boolean {
|
||||
return !!this.ws && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/** Nachricht an den RVS senden */
|
||||
send(type: MessageType, payload: Record<string, unknown>): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
@@ -215,7 +220,12 @@ class RVSConnection {
|
||||
this.startHeartbeat();
|
||||
};
|
||||
|
||||
const socket = this.ws; // genau DIESE Verbindung (nicht 'this.ws' spaeter)
|
||||
this.ws.onmessage = (event: WebSocketMessageEvent) => {
|
||||
// Nur die AKTUELLE Verbindung darf Nachrichten einspeisen. Sonst
|
||||
// koennte eine alte, laengst aufgegebene Verbindung weiter Daten
|
||||
// liefern und vortaeuschen, es sei alles in Ordnung.
|
||||
if (socket !== this.ws) return;
|
||||
try {
|
||||
const message: RVSMessage = JSON.parse(event.data as string);
|
||||
this.notifyMessageListeners(message);
|
||||
@@ -258,8 +268,7 @@ class RVSConnection {
|
||||
this.log('warn', 'TLS fehlgeschlagen — Fallback auf ws:// (ohne TLS)');
|
||||
this.clearTimers();
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null; // onclose-Handler entfernen um Doppel-Reconnect zu verhindern
|
||||
try { this.ws.close(); } catch (_) {}
|
||||
this.detachSocket(this.ws); // inkl. onmessage — sonst bleibt ein Zombie, der weiter empfaengt
|
||||
}
|
||||
this.ws = null;
|
||||
this.shouldReconnect = true;
|
||||
@@ -302,6 +311,25 @@ class RVSConnection {
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Klemmt eine Verbindung VOLLSTAENDIG ab, bevor sie verworfen wird.
|
||||
*
|
||||
* Wichtig: frueher wurden nur onclose/onerror entfernt — onmessage blieb
|
||||
* haengen. Eine aufgegebene (Zombie-)Verbindung lieferte dadurch weiter
|
||||
* Nachrichten an die App, waehrend gesendet wurde ueber die neue/tote.
|
||||
* Symptom: die App zeigt "getrennt", ARIAs Sprachausgabe kommt aber trotzdem
|
||||
* an — und nichts, was man schreibt oder spricht, erreicht den Server.
|
||||
*/
|
||||
private detachSocket(ws: WebSocket): void {
|
||||
try {
|
||||
ws.onopen = null;
|
||||
ws.onmessage = null; // <- das fehlte
|
||||
ws.onclose = null; // verhindert doppelten Reconnect
|
||||
ws.onerror = null;
|
||||
ws.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Teilen-Ziel — Bruecke zum nativen ShareIntent-Modul.
|
||||
*
|
||||
* Zwei Faelle:
|
||||
* App war ZU → der Inhalt liegt beim Start am Intent → getShared()
|
||||
* App LAEUFT → onNewIntent feuert das Event 'ShareReceived' → onShare()
|
||||
*
|
||||
* Die Dateien hat die native Seite schon in den App-Cache kopiert und liefert
|
||||
* echte Pfade; content://-URIs waeren nach dem Dialog womoeglich nicht mehr
|
||||
* lesbar (die Leseerlaubnis gilt nur fuer den Intent).
|
||||
*/
|
||||
import { NativeEventEmitter, NativeModules } from 'react-native';
|
||||
import RNFS from 'react-native-fs';
|
||||
import type { FileData } from '../components/FileUpload';
|
||||
import type { SharedItem } from '../components/ShareModal';
|
||||
|
||||
const { ShareIntent } = NativeModules;
|
||||
|
||||
export interface SharedPayload {
|
||||
text: string;
|
||||
items: SharedItem[];
|
||||
}
|
||||
|
||||
const EMPTY: SharedPayload = { text: '', items: [] };
|
||||
|
||||
/** Inhalt, mit dem die App gestartet wurde (leer, wenn normal geoeffnet). */
|
||||
export async function getShared(): Promise<SharedPayload> {
|
||||
if (!ShareIntent?.getSharedData) return EMPTY;
|
||||
try {
|
||||
const r = await ShareIntent.getSharedData();
|
||||
return { text: r?.text || '', items: Array.isArray(r?.items) ? r.items : [] };
|
||||
} catch (e) {
|
||||
console.warn('[Share] getSharedData fehlgeschlagen:', e);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
/** Abonniert Shares in die bereits laufende App. Gibt Unsubscribe zurueck. */
|
||||
export function onShare(cb: (p: SharedPayload) => void): () => void {
|
||||
if (!ShareIntent) return () => {};
|
||||
const emitter = new NativeEventEmitter(ShareIntent);
|
||||
const sub = emitter.addListener('ShareReceived', (r: any) => {
|
||||
cb({ text: r?.text || '', items: Array.isArray(r?.items) ? r.items : [] });
|
||||
});
|
||||
return () => { try { sub.remove(); } catch (_) {} };
|
||||
}
|
||||
|
||||
/** Zwischenkopien loeschen (nach erfolgreichem Senden). */
|
||||
export async function clearShareCache(): Promise<void> {
|
||||
try { await ShareIntent?.clearCache?.(); } catch (_) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Macht aus geteilten Dateien das Format, das der bestehende Upload-Weg
|
||||
* erwartet (Base64) — so laeuft Teilen durch exakt dieselbe Strecke wie die
|
||||
* Bueroklammer, inklusive Anhang-Darstellung im Chat.
|
||||
*/
|
||||
export async function toFileData(items: SharedItem[]): Promise<FileData[]> {
|
||||
const out: FileData[] = [];
|
||||
for (const it of items) {
|
||||
try {
|
||||
const base64 = await RNFS.readFile(it.path, 'base64');
|
||||
out.push({
|
||||
name: it.name || 'geteilt',
|
||||
type: it.type || 'application/octet-stream',
|
||||
size: it.size || 0,
|
||||
base64,
|
||||
uri: 'file://' + it.path,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[Share] Datei nicht lesbar (%s): %s', it.name, e);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default { getShared, onShare, clearShareCache, toFileData };
|
||||
@@ -30,34 +30,22 @@ type PassiveListenCallback = () => void;
|
||||
|
||||
export type WakeWordState = 'off' | 'armed' | 'conversing' | 'listening';
|
||||
|
||||
/** Default-Dauer fuer den Passive-Listen-Modus nach einer Konversation —
|
||||
* in dem Fenster braucht's kein Wake-Word, Speaker-ID-Filter haelt
|
||||
* fremde Stimmen raus (TV, Familie). 30s default; konfigurierbar. */
|
||||
export const PASSIVE_LISTEN_DEFAULT_MS = 30_000;
|
||||
export const PASSIVE_LISTEN_STORAGE_KEY = 'aria_passive_listen_ms';
|
||||
|
||||
export async function loadPassiveListenMs(): Promise<number> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(PASSIVE_LISTEN_STORAGE_KEY);
|
||||
if (raw) {
|
||||
const n = parseInt(raw, 10);
|
||||
if (isFinite(n) && n >= 0 && n <= 120_000) return n;
|
||||
}
|
||||
} catch {}
|
||||
return PASSIVE_LISTEN_DEFAULT_MS;
|
||||
}
|
||||
|
||||
export async function savePassiveListenMs(ms: number): Promise<void> {
|
||||
await AsyncStorage.setItem(PASSIVE_LISTEN_STORAGE_KEY, String(ms));
|
||||
}
|
||||
/** Reine HANG-Notbremse fuer den Passive-Listen-Modus. Das echte Ende regelt IMMER
|
||||
* die passive Aufnahme selbst: Stille-Toleranz (User pausiert), No-Speech (User
|
||||
* sagt gar nichts) oder Hard-Cap (max. Aufnahmedauer, ~5min) → ChatScreen ruft
|
||||
* dann exitPassiveListening. Dieser Timer darf aktives Reden NIE abschneiden —
|
||||
* deshalb LÄNGER als der Hard-Cap (nur falls ein Endpoint-Event mal verloren geht
|
||||
* und der State sonst ewig 'listening' bliebe). Das alte 30s-Fenster, das lange
|
||||
* Antworten mitten im Satz kappte, ist damit raus. */
|
||||
const PASSIVE_BACKSTOP_MS = 10 * 60_000;
|
||||
|
||||
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
|
||||
|
||||
// Wake-Word-Empfindlichkeit (openWakeWord-Threshold). Hoeher = strenger =
|
||||
// weniger Fehlauslösung (z.B. durch Musik/Radio ueber die Auto-Lautsprecher,
|
||||
// die das Mikro mithoert — der App-Echo-Canceler kann nur ARIAs eigenes TTS
|
||||
// rausrechnen, NICHT Spotify). Default 0.6 (war 0.5). 0..1.
|
||||
export const WAKE_THRESHOLD_DEFAULT = 0.6;
|
||||
// weniger Fehlauslösung, aber man muss deutlicher/lauter sprechen (fuehlt sich
|
||||
// "traege" an). Fehlausloeser werden ueber Speaker-ID (E3) ohnehin verworfen,
|
||||
// deshalb darf der Default empfindlicher sein. 0.45 (war 0.6/0.5). 0..1.
|
||||
export const WAKE_THRESHOLD_DEFAULT = 0.45;
|
||||
export const WAKE_THRESHOLD_MIN = 0.3;
|
||||
export const WAKE_THRESHOLD_MAX = 0.9;
|
||||
export const WAKE_THRESHOLD_STORAGE_KEY = 'aria_wake_threshold';
|
||||
@@ -77,6 +65,49 @@ export async function saveWakeThreshold(v: number): Promise<void> {
|
||||
await AsyncStorage.setItem(WAKE_THRESHOLD_STORAGE_KEY, String(v));
|
||||
}
|
||||
|
||||
// Hintergrund-Wake: darf das Wake-Wort auch triggern, wenn die App im
|
||||
// Hintergrund / der Bildschirm gesperrt ist? Default AUS — im Hintergrund
|
||||
// sind die meisten „Trigger" Fehlalarme (TV, Husten, AudioFocus-Spikes).
|
||||
// AN = auch bei gesperrtem Bildschirm zuhoeren. Die native Erkennung laeuft
|
||||
// ohnehin durch (Foreground-Service + Wake-Locks) — dieser Schalter oeffnet
|
||||
// nur das JS-Gate in onWakeDetected.
|
||||
export const BG_WAKE_STORAGE_KEY = 'aria_bg_wake_enabled';
|
||||
|
||||
export async function loadBgWakeEnabled(): Promise<boolean> {
|
||||
try {
|
||||
return (await AsyncStorage.getItem(BG_WAKE_STORAGE_KEY)) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBgWakeEnabled(enabled: boolean): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.setItem(BG_WAKE_STORAGE_KEY, String(enabled));
|
||||
} 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
|
||||
@@ -103,7 +134,9 @@ export const KEYWORD_LABELS: Record<WakeKeyword, string> = {
|
||||
// Detection-Tuning. Threshold ist ueber die Settings konfigurierbar
|
||||
// (loadWakeThreshold) — der Wert hier ist nur der Fallback.
|
||||
const DEFAULT_THRESHOLD = WAKE_THRESHOLD_DEFAULT;
|
||||
const DEFAULT_PATIENCE = 2;
|
||||
// patience=1 statt 2: nur EIN Frame ueber Threshold noetig → deutlich schneller.
|
||||
// Speaker-ID filtert Fehlausloeser, also ist das vertretbar.
|
||||
const DEFAULT_PATIENCE = 1;
|
||||
const DEFAULT_DEBOUNCE_MS = 1500;
|
||||
|
||||
interface OpenWakeWordModule {
|
||||
@@ -143,6 +176,13 @@ class WakeWordService {
|
||||
* Hintergrund-Detections sind quasi immer false-positives (TV, Husten,
|
||||
* AudioFocus-Switch beim Wechsel zu Musik etc.). */
|
||||
private inBackground: boolean = false;
|
||||
/** Wenn true: Wake-Wort triggert auch im Hintergrund / bei gesperrtem
|
||||
* 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
|
||||
@@ -150,8 +190,9 @@ class WakeWordService {
|
||||
* Ausnahme: bargeListening → Barge-In ist ein legitimer neuer Trigger
|
||||
* waehrend ARIA noch redet, NICHT vom Guard blockieren. */
|
||||
private detectionInProgress: boolean = false;
|
||||
/** Passive-Listen-Timer: feuert nach PASSIVE_LISTEN_MS ohne Stefan-Speech,
|
||||
* beendet den listening-State und geht zurueck zu armed. */
|
||||
/** Passive-Listen-Backstop-Timer: Notbremse (PASSIVE_BACKSTOP_MS). Normal endet
|
||||
* das Fenster ueber die Stille-Toleranz der Aufnahme; feuert dieser Timer
|
||||
* trotzdem, zurueck zu armed. */
|
||||
private passiveListenTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Callbacks fuer den Eintritt in Passive-Listen — ChatScreen startet
|
||||
* hier eine streaming-Aufnahme OHNE User-Bubble (passiv lauschen). */
|
||||
@@ -223,15 +264,20 @@ class WakeWordService {
|
||||
this.initInProgress = (async () => {
|
||||
try {
|
||||
const threshold = await loadWakeThreshold();
|
||||
console.log('[WakeWord] init mit threshold=%s', threshold);
|
||||
this.bgWakeEnabled = await loadBgWakeEnabled();
|
||||
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;
|
||||
@@ -310,7 +356,7 @@ class WakeWordService {
|
||||
/** Cooldown setzen — alle Wake-Word-Detections in den naechsten ms ignorieren.
|
||||
* Wird beim App-Resume gerufen weil AppState-Wechsel Audio-Spikes erzeugen
|
||||
* die openWakeWord faelschlich als Trigger interpretiert. */
|
||||
setResumeCooldown(ms: number = 1500): void {
|
||||
setResumeCooldown(ms: number = 500): void {
|
||||
this.cooldownUntilMs = Date.now() + ms;
|
||||
console.log('[WakeWord] Cooldown aktiv fuer %dms', ms);
|
||||
}
|
||||
@@ -320,23 +366,45 @@ class WakeWordService {
|
||||
* was als „Wake-Word" reinkommt ist Husten/TV/AudioFocus-Switch. */
|
||||
setBackground(): void {
|
||||
this.inBackground = true;
|
||||
console.log('[WakeWord] App im Hintergrund — Detections gesperrt');
|
||||
console.log('[WakeWord] App im Hintergrund — Detections %s',
|
||||
this.bgWakeEnabled ? 'AKTIV (Hintergrund-Wake an)' : 'gesperrt');
|
||||
}
|
||||
|
||||
/** App im Vordergrund: Detections wieder freigeben, plus 3s Cooldown
|
||||
* als Schutz gegen den AudioFocus-/AudioTrack-Spike der direkt nach
|
||||
* dem Resume kommt. Ersetzt das alte setResumeCooldown(3000)-Pattern. */
|
||||
/** Hintergrund-Wake ein/aus schalten (aus den Einstellungen). */
|
||||
setBgWakeEnabled(enabled: boolean): void {
|
||||
this.bgWakeEnabled = enabled;
|
||||
console.log('[WakeWord] Hintergrund-Wake = %s', enabled);
|
||||
}
|
||||
|
||||
/** Wake-Wort-Bestaetigung (Voxtral) ein/aus (aus den Einstellungen). */
|
||||
setWakeConfirmEnabled(enabled: boolean): void {
|
||||
this.wakeConfirmEnabled = enabled;
|
||||
console.log('[WakeWord] Wake-Bestaetigung = %s', enabled);
|
||||
}
|
||||
|
||||
/** Ist Hintergrund-Wake an? Steuert u.a. ob der Konversationsmodus auch im
|
||||
* Hintergrund weiterlaeuft (sonst: im Hintergrund direkt zurueck aufs Wake-Word). */
|
||||
isBgWakeEnabled(): boolean {
|
||||
return this.bgWakeEnabled;
|
||||
}
|
||||
|
||||
/** App im Vordergrund: Detections wieder freigeben, plus kurzer Cooldown
|
||||
* als Schutz gegen den AudioFocus-/AudioTrack-Spike direkt nach dem Resume.
|
||||
* 1s statt 3s — 3s hat sich "traege" angefuehlt (Trigger direkt nach dem
|
||||
* App-Oeffnen wurden verschluckt). */
|
||||
setForeground(): void {
|
||||
this.inBackground = false;
|
||||
this.cooldownUntilMs = Date.now() + 3000;
|
||||
console.log('[WakeWord] App im Vordergrund — Cooldown 3s aktiv');
|
||||
this.cooldownUntilMs = Date.now() + 1000;
|
||||
console.log('[WakeWord] App im Vordergrund — Cooldown 1s aktiv');
|
||||
}
|
||||
|
||||
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
|
||||
private async onWakeDetected(): Promise<void> {
|
||||
if (this.inBackground) {
|
||||
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund)');
|
||||
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background')).catch(()=>{});
|
||||
/** 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(()=>{});
|
||||
return;
|
||||
}
|
||||
// Re-Entry-Guard: blocken wenn ein Detection-Zyklus schon laeuft.
|
||||
@@ -379,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
|
||||
@@ -392,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.
|
||||
@@ -486,13 +598,12 @@ class WakeWordService {
|
||||
import('./logger').then(m => m.reportAppDebug('wake.end',
|
||||
`endConversation called, wasBarge=${wasBarge}, nativeReady=${this.nativeReady}`)).catch(()=>{});
|
||||
|
||||
// Passive-Listen aktiv? Dann nicht direkt zu armed — passive lauschen
|
||||
// fuer N Sekunden, dann erst Wake-Word wieder aktivieren. Speaker-ID
|
||||
// (Phase 3) filtert fremde Stimmen weg, der User kann ohne erneute
|
||||
// Anrede weitersprechen.
|
||||
const passiveMs = await loadPassiveListenMs();
|
||||
if (!skipPassive && passiveMs > 0 && this.nativeReady) {
|
||||
this.enterPassiveListening(passiveMs);
|
||||
// Kein skipPassive? Dann EIN Stille-Fenster zum Weiterreden (kein Wake-Word
|
||||
// noetig). Das echte Ende regelt die Stille-Toleranz der passiven Aufnahme;
|
||||
// der Backstop-Timer ist nur die Notbremse. Der User kann ohne erneute
|
||||
// Anrede weitersprechen; sagt er nichts → zurueck aufs Wake-Word.
|
||||
if (!skipPassive && this.nativeReady) {
|
||||
this.enterPassiveListening(PASSIVE_BACKSTOP_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -534,10 +645,10 @@ class WakeWordService {
|
||||
this.cancelPassiveListenTimer();
|
||||
this.setState('listening');
|
||||
const seconds = Math.round(durationMs / 1000);
|
||||
console.log('[WakeWord] Passive-Listen aktiv (%ds) — Speaker-ID gefiltert', seconds);
|
||||
console.log('[WakeWord] Passive-Listen aktiv (Backstop %ds) — Speaker-ID gefiltert', seconds);
|
||||
import('./logger').then(m => m.reportAppDebug('wake.passive',
|
||||
`entered listening for ${seconds}s, cb-count=${this.passiveListenCallbacks.length}`)).catch(()=>{});
|
||||
ToastAndroid.show(`🎧 ${seconds}s lauscht — sprich einfach weiter`, ToastAndroid.SHORT);
|
||||
`entered listening (backstop ${seconds}s), cb-count=${this.passiveListenCallbacks.length}`)).catch(()=>{});
|
||||
ToastAndroid.show('🎧 sprich einfach weiter', ToastAndroid.SHORT);
|
||||
this.passiveListenTimer = setTimeout(() => {
|
||||
this.passiveListenTimer = null;
|
||||
this.exitPassiveListening('timeout').catch(() => {});
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* AriaViewCanvas — die pannbare Flaeche, auf der ARIAs komponierte Ansicht
|
||||
* (aria_view) MATERIALISIERT: Orb oben, darunter die Karten. Erscheint als
|
||||
* Overlay ueber dem Chat, sobald ARIA present_view aufruft ("sag was → Orb denkt
|
||||
* → Karte fliegt rein"). Der erste, greifbare Vorgeschmack aufs generative
|
||||
* Cockpit (M1).
|
||||
*
|
||||
* Bedienung (NoMachine-Prinzip): 2-Finger halten + schieben bewegt die Welt,
|
||||
* Pinch zoomt. Ein-Finger-Touch geht an die Karten durch (Scrollen). Die Welt
|
||||
* traegt gerenderte/gestreamte Inhalte — interaktive native Panels rasten
|
||||
* spaeter bei Scale 1 ein (Chat bleibt separat darunter).
|
||||
*
|
||||
* Geraete-agnostisch gehalten: liest nur die ViewSpec, damit ein spaeterer Web-/
|
||||
* AR-Renderer dieselbe Spec konsumieren kann.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Animated, {
|
||||
FadeInDown,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import { ViewSpec } from '../services/ariaView';
|
||||
import Orb from './Orb';
|
||||
import CardView from './CardView';
|
||||
|
||||
const MIN_SCALE = 0.5;
|
||||
const MAX_SCALE = 3;
|
||||
|
||||
interface Props {
|
||||
view: ViewSpec;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AriaViewCanvas: React.FC<Props> = ({ view, onClose }) => {
|
||||
const tx = useSharedValue(0);
|
||||
const ty = useSharedValue(0);
|
||||
const scale = useSharedValue(1);
|
||||
const savedTx = useSharedValue(0);
|
||||
const savedTy = useSharedValue(0);
|
||||
const savedScale = useSharedValue(1);
|
||||
|
||||
const pan = Gesture.Pan()
|
||||
.minPointers(2)
|
||||
.maxPointers(2)
|
||||
.onUpdate((e) => {
|
||||
tx.value = savedTx.value + e.translationX;
|
||||
ty.value = savedTy.value + e.translationY;
|
||||
})
|
||||
.onEnd(() => {
|
||||
savedTx.value = tx.value;
|
||||
savedTy.value = ty.value;
|
||||
});
|
||||
|
||||
const pinch = Gesture.Pinch()
|
||||
.onUpdate((e) => {
|
||||
const next = savedScale.value * e.scale;
|
||||
scale.value = Math.max(MIN_SCALE, Math.min(MAX_SCALE, next));
|
||||
})
|
||||
.onEnd(() => {
|
||||
savedScale.value = scale.value;
|
||||
});
|
||||
|
||||
const composed = Gesture.Simultaneous(pan, pinch);
|
||||
|
||||
const worldStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateX: tx.value },
|
||||
{ translateY: ty.value },
|
||||
{ scale: scale.value },
|
||||
],
|
||||
}));
|
||||
|
||||
const resetCamera = () => {
|
||||
tx.value = withTiming(0);
|
||||
ty.value = withTiming(0);
|
||||
scale.value = withTiming(1);
|
||||
savedTx.value = 0;
|
||||
savedTy.value = 0;
|
||||
savedScale.value = 1;
|
||||
};
|
||||
|
||||
const cards = Array.isArray(view.cards) ? view.cards : [];
|
||||
|
||||
return (
|
||||
<View style={styles.overlay}>
|
||||
<GestureDetector gesture={composed}>
|
||||
<Animated.View style={[styles.world, worldStyle]}>
|
||||
<View style={styles.orbWrap}>
|
||||
<Orb state={view.orb} size={110} />
|
||||
</View>
|
||||
{!!view.title && <Text style={styles.worldTitle}>{view.title}</Text>}
|
||||
<View style={styles.cards}>
|
||||
{cards.map((c, i) => (
|
||||
<Animated.View
|
||||
key={i}
|
||||
entering={FadeInDown.duration(420).delay(120 + i * 90)}
|
||||
>
|
||||
<CardView card={c} />
|
||||
</Animated.View>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
|
||||
{/* Steuerung — ausserhalb des Transforms, immer bei Scale 1 bedienbar */}
|
||||
<View style={styles.topBar} pointerEvents="box-none">
|
||||
<TouchableOpacity style={styles.iconBtn} onPress={resetCamera}>
|
||||
<Text style={styles.icon}>⤢</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.iconBtn} onPress={onClose}>
|
||||
<Text style={styles.icon}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.hintWrap} pointerEvents="none">
|
||||
<Text style={styles.hint}>2 Finger: schieben · Pinch: zoomen</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(6,6,16,0.94)',
|
||||
zIndex: 50,
|
||||
},
|
||||
world: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
paddingTop: 48,
|
||||
paddingHorizontal: 18,
|
||||
},
|
||||
orbWrap: { marginTop: 8, marginBottom: 6 },
|
||||
worldTitle: {
|
||||
color: '#C9C9FF',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
marginBottom: 4,
|
||||
textAlign: 'center',
|
||||
},
|
||||
cards: { width: '100%', maxWidth: 560 },
|
||||
topBar: {
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 12,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
iconBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
marginLeft: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(30,30,60,0.9)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(123,92,255,0.4)',
|
||||
},
|
||||
icon: { color: '#C9C9FF', fontSize: 18 },
|
||||
hintWrap: {
|
||||
position: 'absolute',
|
||||
bottom: 14,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
hint: {
|
||||
color: '#6A6A90',
|
||||
fontSize: 12,
|
||||
},
|
||||
});
|
||||
|
||||
export default AriaViewCanvas;
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* CardView — rendert EINE Karte einer aria_view-Spec (M1). Schaltet nach
|
||||
* card.type auf den passenden Renderer. Unbekannte Typen werden als Text-
|
||||
* Fallback gezeigt (nie crashen).
|
||||
*
|
||||
* Bewusst dependency-leicht (v1): Markdown wird als Klartext dargestellt, Map
|
||||
* als Marker-Liste (kein Karten-Lib), Code als Monospace-Block. Spaeter koennen
|
||||
* einzelne Renderer aufgebohrt werden, ohne die Spec/den Fluss zu aendern.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Image, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { ViewCard, ViewMarker } from '../services/ariaView';
|
||||
|
||||
const ImageBody: React.FC<{ src?: string }> = ({ src }) => {
|
||||
const isUrl = !!src && /^https?:\/\//i.test(src);
|
||||
if (isUrl) {
|
||||
return <Image source={{ uri: src }} style={styles.image} resizeMode="contain" />;
|
||||
}
|
||||
return <Text style={styles.muted}>🖼️ {src || '(kein Bild)'}</Text>;
|
||||
};
|
||||
|
||||
const ListBody: React.FC<{ md?: string }> = ({ md }) => {
|
||||
const lines = (md || '')
|
||||
.split('\n')
|
||||
.map((l) => l.replace(/^\s*[-*•]\s?/, '').trim())
|
||||
.filter(Boolean);
|
||||
if (lines.length === 0) return <Text style={styles.muted}>(leer)</Text>;
|
||||
return (
|
||||
<View>
|
||||
{lines.map((l, i) => (
|
||||
<View key={i} style={styles.listRow}>
|
||||
<Text style={styles.bullet}>•</Text>
|
||||
<Text style={styles.text}>{l}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const MapBody: React.FC<{ markers?: ViewMarker[] }> = ({ markers }) => {
|
||||
const ms = Array.isArray(markers) ? markers : [];
|
||||
return (
|
||||
<View style={styles.map}>
|
||||
<Text style={styles.mapHint}>🗺️ Karte ({ms.length} Orte)</Text>
|
||||
{ms.map((m, i) => (
|
||||
<Text key={i} style={styles.text}>
|
||||
📍 {m.label || `${m.lat?.toFixed?.(4)}, ${m.lon?.toFixed?.(4)}`}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const CodeBody: React.FC<{ md?: string; path?: string; lang?: string }> = ({ md, path, lang }) => (
|
||||
<View>
|
||||
{(path || lang) && (
|
||||
<Text style={styles.codeCaption}>
|
||||
{path || ''}{lang ? ` · ${lang}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
<ScrollView horizontal style={styles.codeScroll}>
|
||||
<Text style={styles.code}>{md || ''}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
|
||||
const CardView: React.FC<{ card: ViewCard }> = ({ card }) => {
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
{!!card.title && <Text style={styles.cardTitle}>{card.title}</Text>}
|
||||
{card.type === 'image' ? (
|
||||
<ImageBody src={card.src} />
|
||||
) : card.type === 'list' ? (
|
||||
<ListBody md={card.md} />
|
||||
) : card.type === 'map' ? (
|
||||
<MapBody markers={card.markers} />
|
||||
) : card.type === 'code' ? (
|
||||
<CodeBody md={card.md} path={card.path} lang={card.lang} />
|
||||
) : (
|
||||
<Text style={styles.text}>{card.md || ''}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: 'rgba(18,18,42,0.92)',
|
||||
borderColor: 'rgba(123,92,255,0.35)',
|
||||
borderWidth: 1,
|
||||
borderRadius: 14,
|
||||
padding: 14,
|
||||
marginVertical: 8,
|
||||
shadowColor: '#7B5CFF',
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
elevation: 6,
|
||||
},
|
||||
cardTitle: { color: '#C9C9FF', fontSize: 15, fontWeight: '700', marginBottom: 8 },
|
||||
text: { color: '#E6E6F0', fontSize: 14, lineHeight: 20, flexShrink: 1 },
|
||||
muted: { color: '#8A8AB0', fontSize: 13, fontStyle: 'italic' },
|
||||
image: { width: '100%', height: 200, borderRadius: 8, backgroundColor: '#0D0D1A' },
|
||||
listRow: { flexDirection: 'row', alignItems: 'flex-start', marginVertical: 2 },
|
||||
bullet: { color: '#7B5CFF', marginRight: 8, fontSize: 14, lineHeight: 20 },
|
||||
map: { backgroundColor: '#0D0D1A', borderRadius: 8, padding: 10 },
|
||||
mapHint: { color: '#00B4D8', fontSize: 13, fontWeight: '600', marginBottom: 6 },
|
||||
codeCaption: { color: '#8A8AB0', fontSize: 12, marginBottom: 6 },
|
||||
codeScroll: { backgroundColor: '#0A0A14', borderRadius: 8, padding: 10 },
|
||||
code: { color: '#B9F5C9', fontFamily: 'monospace', fontSize: 12.5, lineHeight: 18 },
|
||||
});
|
||||
|
||||
export default React.memo(CardView);
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Orb — ARIAs Praesenz-Avatar (M1). Zeigt ihren Zustand (idle/listening/
|
||||
* thinking/speaking/working) als pulsierender Leucht-Kern und ist das
|
||||
* verbindende Element ueber alle Oberflaechen (App/Web/spaeter Brille).
|
||||
*
|
||||
* Reine Optik, keine Logik — der Zustand kommt von aussen (aria_view.orb bzw.
|
||||
* spaeter direkt von Audio/Wake-Word-Signalen). Dependency-leicht: nur
|
||||
* reanimated (schon installiert), kein SVG/Gradient noetig.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import Animated, {
|
||||
Easing,
|
||||
cancelAnimation,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { OrbState } from '../services/ariaView';
|
||||
|
||||
const COLORS: Record<OrbState, string> = {
|
||||
idle: '#3A6EA5',
|
||||
listening: '#00B4D8',
|
||||
thinking: '#7B5CFF',
|
||||
speaking: '#34C759',
|
||||
working: '#FF9500',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
state?: OrbState;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const Orb: React.FC<Props> = ({ state = 'idle', size = 120 }) => {
|
||||
const pulse = useSharedValue(1);
|
||||
|
||||
useEffect(() => {
|
||||
const fast = state === 'thinking' || state === 'working';
|
||||
cancelAnimation(pulse);
|
||||
pulse.value = 1;
|
||||
pulse.value = withRepeat(
|
||||
withTiming(fast ? 1.14 : 1.07, {
|
||||
duration: fast ? 620 : 1500,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
}),
|
||||
-1,
|
||||
true,
|
||||
);
|
||||
return () => cancelAnimation(pulse);
|
||||
}, [state, pulse]);
|
||||
|
||||
const animStyle = useAnimatedStyle(() => ({ transform: [{ scale: pulse.value }] }));
|
||||
const color = COLORS[state] || COLORS.idle;
|
||||
|
||||
return (
|
||||
<View style={[styles.wrap, { width: size, height: size }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.glow,
|
||||
{ width: size, height: size, borderRadius: size / 2, backgroundColor: color },
|
||||
animStyle,
|
||||
]}
|
||||
/>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.ring,
|
||||
{
|
||||
width: size * 0.72,
|
||||
height: size * 0.72,
|
||||
borderRadius: size * 0.36,
|
||||
borderColor: color,
|
||||
},
|
||||
animStyle,
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
styles.core,
|
||||
{
|
||||
width: size * 0.44,
|
||||
height: size * 0.44,
|
||||
borderRadius: size * 0.22,
|
||||
backgroundColor: color,
|
||||
shadowColor: color,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: { alignItems: 'center', justifyContent: 'center' },
|
||||
glow: { position: 'absolute', opacity: 0.22 },
|
||||
ring: { position: 'absolute', borderWidth: 2, opacity: 0.55 },
|
||||
core: {
|
||||
shadowOpacity: 0.9,
|
||||
shadowRadius: 16,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
elevation: 12,
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(Orb);
|
||||
@@ -5,17 +5,20 @@
|
||||
* Cockpit-Modus → Workbench mit Taskleisten-Dock: Chat · Code · Desktop.
|
||||
*
|
||||
* Aktivitaets-Badges am Dock: Editor blau, wenn schon Code-Dateien da sind;
|
||||
* Desktop gruen, wenn eine VM verbunden ist.
|
||||
* Desktop gruen NUR, wenn im aktiven Projekt eine VM laeuft.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import projectFocus, { FocusSnapshot } from '../services/projectFocus';
|
||||
import codeFile from '../services/codeFile';
|
||||
import desktop from '../services/desktop';
|
||||
import brainApi from '../services/brainApi';
|
||||
import viewMode, { ViewModeValue } from '../services/viewMode';
|
||||
import ChatScreen from '../screens/ChatScreen';
|
||||
import { TileId } from './layout';
|
||||
import WorkspaceDeck from './WorkspaceDeck';
|
||||
import ariaView, { AriaView } from '../services/ariaView';
|
||||
import AriaViewCanvas from './AriaViewCanvas';
|
||||
|
||||
const COCKPIT_PANELS: TileId[] = ['chat', 'files', 'editor', 'vnc'];
|
||||
|
||||
@@ -24,12 +27,20 @@ const WorkspaceScreen: React.FC = () => {
|
||||
const [focus, setFocus] = useState<FocusSnapshot>(projectFocus.get());
|
||||
const [hasCode, setHasCode] = useState(false);
|
||||
const [hasDesktop, setHasDesktop] = useState(false);
|
||||
const [view, setView] = useState<AriaView | undefined>(undefined);
|
||||
|
||||
useEffect(() => viewMode.subscribe(setMode), []);
|
||||
useEffect(() => projectFocus.subscribe(setFocus), []);
|
||||
|
||||
const pid = focus.focusedProjectId;
|
||||
const kind = projectFocus.getProjectKind(pid);
|
||||
|
||||
// aria_view: ARIAs komponierte Ansicht fuers fokussierte Projekt spiegeln.
|
||||
useEffect(() => {
|
||||
setView(ariaView.getView(pid));
|
||||
return ariaView.subscribe((v) => {
|
||||
if ((v.projectId || '') === (pid || '')) setView(v);
|
||||
});
|
||||
}, [pid]);
|
||||
|
||||
// Code-Signal: hat der Spiegel schon Dateien fuer dieses Projekt?
|
||||
useEffect(() => {
|
||||
@@ -39,26 +50,53 @@ const WorkspaceScreen: React.FC = () => {
|
||||
});
|
||||
}, [pid]);
|
||||
|
||||
// Desktop-Signal + einmaliger Check beim Betreten eines Code-Projekts.
|
||||
// Desktop-Signal: gruener Punkt NUR, wenn im AKTIVEN Projekt wirklich eine VM
|
||||
// laeuft (nicht generell irgendwo). Quelle ist die projektbezogene VM-Liste;
|
||||
// leichtes Nachfassen, damit Start/Stop sich zeitnah zeigt.
|
||||
useEffect(() => {
|
||||
setHasDesktop(desktop.getStatus().available);
|
||||
const unsub = desktop.subscribeStatus((s) => setHasDesktop(s.available));
|
||||
if (kind === 'code') desktop.requestCheck(pid);
|
||||
return unsub;
|
||||
}, [pid, kind]);
|
||||
if (!pid) { setHasDesktop(false); return; }
|
||||
let alive = true;
|
||||
const check = () => {
|
||||
brainApi.listProjectVms(pid)
|
||||
.then(r => { if (alive) setHasDesktop((r.vms || []).some(v => v.running)); })
|
||||
.catch(() => { if (alive) setHasDesktop(false); });
|
||||
};
|
||||
check();
|
||||
const t = setInterval(check, 6000);
|
||||
return () => { alive = false; clearInterval(t); };
|
||||
}, [pid]);
|
||||
|
||||
const badges = useMemo(() => ({
|
||||
editor: hasCode ? '#0096FF' : undefined,
|
||||
vnc: hasDesktop ? '#34C759' : undefined,
|
||||
} as Partial<Record<TileId, string>>), [hasCode, hasDesktop]);
|
||||
|
||||
// Kompakt-Ansicht: klassischer Vollbild-Chat, exakt wie vor dem Umbau.
|
||||
if (mode === 'compact') {
|
||||
return <ChatScreen />;
|
||||
}
|
||||
// Kompakt-Ansicht: klassischer Vollbild-Chat; Cockpit: Workbench mit Dock.
|
||||
const content =
|
||||
mode === 'compact' ? (
|
||||
<ChatScreen />
|
||||
) : (
|
||||
<WorkspaceDeck projectId={pid} panels={COCKPIT_PANELS} badges={badges} />
|
||||
);
|
||||
|
||||
// Cockpit: Workbench mit Dock.
|
||||
return <WorkspaceDeck projectId={pid} panels={COCKPIT_PANELS} badges={badges} />;
|
||||
// Generative Flaeche als Overlay, sobald ARIA fuer dieses Projekt eine Ansicht
|
||||
// komponiert hat (present_view → aria_view). Chat/Cockpit bleiben darunter.
|
||||
const showView = !!view && (view.projectId || '') === (pid || '');
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
{content}
|
||||
{showView && view && (
|
||||
<AriaViewCanvas
|
||||
view={view.view}
|
||||
onClose={() => {
|
||||
ariaView.clear(pid);
|
||||
setView(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceScreen;
|
||||
|
||||
@@ -77,20 +77,13 @@ export const NOVNC_HTML = `<!doctype html><html><head><meta charset="utf-8">
|
||||
|
||||
var msg=document.getElementById('msg');
|
||||
|
||||
// (Fast) unsichtbares Eingabefeld → Software-Tastatur des Handys tippt in die
|
||||
// VM. WICHTIG: on-screen (nicht off-screen), sonst oeffnet Android die Tastatur
|
||||
// oft nicht bzw. liefert keine Events. opacity:0 + pointer-events:none = sichtbar
|
||||
// fuer den Fokus, aber unsichtbar und klaut keine VM-Touches. font-size:16px
|
||||
// verhindert Auto-Zoom.
|
||||
var kbd=document.createElement('input');
|
||||
kbd.setAttribute('autocomplete','off'); kbd.setAttribute('autocorrect','off');
|
||||
kbd.setAttribute('autocapitalize','off'); kbd.spellcheck=false;
|
||||
kbd.style.cssText='position:fixed;bottom:0;left:0;width:100%;height:1px;opacity:0;'
|
||||
+'border:0;padding:0;margin:0;background:transparent;color:transparent;'
|
||||
+'caret-color:transparent;font-size:16px;pointer-events:none;';
|
||||
document.body.appendChild(kbd);
|
||||
var SPECIAL={Enter:0xff0d,Backspace:0xff08,Tab:0xff09,Escape:0xff1b,Delete:0xffff,
|
||||
ArrowLeft:0xff51,ArrowUp:0xff52,ArrowRight:0xff53,ArrowDown:0xff54,Home:0xff50,End:0xff57};
|
||||
// Tastatur laeuft NICHT mehr ueber ein verstecktes WebView-Feld (Android
|
||||
// oeffnet die Software-Tastatur dafuer unzuverlaessig). Stattdessen haelt die
|
||||
// App ein echtes RN-<TextInput> und ruft window.ariaVncKey.* per
|
||||
// injectJavaScript auf → wird unten (nach RFB-Init) definiert.
|
||||
// cp<0x100 → Keysym == Codepoint (Latin-1)
|
||||
// sonst → X11-Unicode-Keysym 0x01000000+cp
|
||||
function cpToKeysym(cp){ return cp < 0x100 ? cp : 0x01000000 + cp; }
|
||||
|
||||
import('https://cdn.jsdelivr.net/npm/@novnc/novnc@1.4.0/core/rfb.js').then(function(mod){
|
||||
var RFB = mod.default;
|
||||
@@ -105,30 +98,29 @@ export const NOVNC_HTML = `<!doctype html><html><head><meta charset="utf-8">
|
||||
});
|
||||
window.__rfb = rfb;
|
||||
|
||||
// Tasten aus dem versteckten Feld an die VM schicken.
|
||||
// Down+Up einer Taste an die VM schicken.
|
||||
function tap(keysym, code){ try{ rfb.sendKey(keysym, code||null, true); rfb.sendKey(keysym, code||null, false); }catch(_){} }
|
||||
// Sondertasten (feuern zuverlaessig als keydown).
|
||||
kbd.addEventListener('keydown', function(e){
|
||||
if(SPECIAL[e.key]!==undefined){ tap(SPECIAL[e.key], e.code); e.preventDefault(); }
|
||||
});
|
||||
// Druckbare Zeichen + Editieren: beforeinput ist auf Android robuster als
|
||||
// ein input-Diff. preventDefault haelt das Feld leer → kein Doppel-Senden.
|
||||
kbd.addEventListener('beforeinput', function(e){
|
||||
var t=e.inputType||'';
|
||||
if(t==='insertText' && e.data){ for(var i=0;i<e.data.length;i++) tap(e.data.charCodeAt(i)); e.preventDefault(); }
|
||||
else if(t.indexOf('insertLineBreak')>=0 || t.indexOf('insertParagraph')>=0){ tap(0xff0d); e.preventDefault(); }
|
||||
else if(t.indexOf('deleteContentBackward')>=0){ tap(0xff08); e.preventDefault(); }
|
||||
kbd.value='';
|
||||
});
|
||||
// Fallback, falls beforeinput nicht unterstuetzt wird.
|
||||
kbd.addEventListener('input', function(){
|
||||
if(kbd.value){ for(var i=0;i<kbd.value.length;i++){ tap(kbd.value.charCodeAt(i)); } kbd.value=''; }
|
||||
});
|
||||
|
||||
// Empfaenger-API: die App (RN-<TextInput> + Sondertasten-Leiste) ruft das
|
||||
// per injectJavaScript.
|
||||
// char(cp) druckbares Zeichen (Codepoint)
|
||||
// keysym(ks) Sondertaste als fertiges X11-Keysym (Enter/Esc/F1/…)
|
||||
// combo(mods,ks) Modifier(-Keysyms) halten → Taste → wieder loslassen
|
||||
// (Strg+C, Strg+Alt+Entf, …). mods = Array von Keysyms.
|
||||
window.ariaVncKey = {
|
||||
char: function(cp){ tap(cpToKeysym(cp)); },
|
||||
keysym: function(ks){ tap(ks); },
|
||||
combo: function(mods, ks){
|
||||
try{
|
||||
for(var i=0;i<mods.length;i++) rfb.sendKey(mods[i], null, true);
|
||||
rfb.sendKey(ks, null, true); rfb.sendKey(ks, null, false);
|
||||
for(var j=mods.length-1;j>=0;j--) rfb.sendKey(mods[j], null, false);
|
||||
}catch(_){}
|
||||
}
|
||||
};
|
||||
|
||||
// Steuerungs-API fuer die App (per injectJavaScript).
|
||||
window.ariaVncCtl = {
|
||||
focusKeyboard: function(){ try{ kbd.value=''; kbd.focus(); }catch(_){} },
|
||||
blurKeyboard: function(){ try{ kbd.blur(); }catch(_){} },
|
||||
cad: function(){ try{ rfb.sendCtrlAltDel(); }catch(_){} },
|
||||
toggleFit: function(){ fit=!fit; rfb.scaleViewport=fit; rfb.clipViewport=!fit; post({event:'vnc_fit', fit:fit}); }
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
const [shot, setShot] = useState<{ name: string; b64: string } | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!projectId) { setVms([]); setErr(''); setLoading(false); return; }
|
||||
setLoading(true); setErr('');
|
||||
brainApi.listProjectVms(projectId)
|
||||
.then(r => setVms(r.vms || []))
|
||||
@@ -82,6 +83,8 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
<ActivityIndicator color="#0096FF" style={{ marginTop: 20 }} />
|
||||
) : err ? (
|
||||
<Text style={styles.err}>{err}</Text>
|
||||
) : !projectId ? (
|
||||
<Text style={styles.empty}>Kein aktives Projekt — wechsle in ein Projekt für dessen VMs.</Text>
|
||||
) : vms.length === 0 ? (
|
||||
<Text style={styles.empty}>
|
||||
Noch keine VM in diesem Projekt.{'\n'}
|
||||
|
||||
@@ -43,6 +43,7 @@ const FilesTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
const [previewBusy, setPreviewBusy] = useState('');
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!projectId) { setFiles([]); setErr(''); setLoading(false); return; }
|
||||
setLoading(true); setErr('');
|
||||
brainApi.listProjectFiles(projectId)
|
||||
.then(r => setFiles((r.files || []).slice().sort((a, b) => a.path.localeCompare(b.path))))
|
||||
@@ -88,7 +89,7 @@ const FilesTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
) : err ? (
|
||||
<Text style={styles.err}>{err}</Text>
|
||||
) : files.length === 0 ? (
|
||||
<Text style={styles.empty}>Noch keine Dateien in diesem Projekt.</Text>
|
||||
<Text style={styles.empty}>{!projectId ? 'Kein aktives Projekt — wechsle in ein Projekt für dessen Dateien.' : 'Noch keine Dateien in diesem Projekt.'}</Text>
|
||||
) : (
|
||||
files.map(f => (
|
||||
<TouchableOpacity key={f.path} onPress={() => open(f)} style={styles.row} disabled={previewBusy === f.path}>
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
* VncTile — Live-Desktop der QEMU-VM (noVNC in einer WebView, RFB durch RVS).
|
||||
*
|
||||
* Nur aktiv, wenn das Desktop-Panel offen ist (focused): dann WebView mounten,
|
||||
* bei 'ready' den RVS-VNC-Tunnel oeffnen. Eine kleine Steuerungs-Leiste macht
|
||||
* die VM auf dem Handy bedienbar: Tastatur einblenden (tippt in die VM),
|
||||
* Strg-Alt-Entf, und Fit ↔ 1:1 umschalten.
|
||||
* bei 'ready' den RVS-VNC-Tunnel oeffnen. Zwei Bedien-Leisten machen die VM auf
|
||||
* dem Handy voll bedienbar:
|
||||
* - ctlBar (oben rechts): Fn-Leiste ein/aus, Software-Tastatur, Fit ↔ 1:1.
|
||||
* - keyBar (oben, Fn): echte Steuertasten, die keine Software-Tastatur
|
||||
* liefert — Esc, Tab, Pfeile, Pos1/Ende/Bild, Einfg/Entf, Enter, F1–F12 und
|
||||
* Sticky-Modifier Strg/Alt/Shift (fuer Strg+C, Strg+Alt+Entf, …).
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Keyboard, NativeSyntheticEvent, ScrollView, StyleSheet, Text, TextInput, TextInputChangeEventData, TextInputKeyPressEventData, TouchableOpacity, View } from 'react-native';
|
||||
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
||||
import desktop from '../../services/desktop';
|
||||
import { NOVNC_HTML } from '../assets/novncHtml';
|
||||
@@ -19,9 +22,30 @@ interface Props {
|
||||
port?: number; // VNC-Port der zu verbindenden VM (Default 5901 = Display :1)
|
||||
}
|
||||
|
||||
// X11-Keysyms fuer Sondertasten, die kein druckbares Zeichen liefern.
|
||||
const KEYSYM = { Backspace: 0xff08, Enter: 0xff0d, Tab: 0xff09 };
|
||||
const MOD = { ctrl: 0xffe3, alt: 0xffe9, shift: 0xffe1 };
|
||||
const cpToKeysym = (cp: number) => (cp < 0x100 ? cp : 0x01000000 + cp);
|
||||
|
||||
// Sondertasten fuer die Fn-Leiste (Label → Keysym).
|
||||
const NAV_KEYS: { label: string; ks: number }[] = [
|
||||
{ label: 'Esc', ks: 0xff1b }, { label: 'Tab', ks: 0xff09 },
|
||||
{ label: '←', ks: 0xff51 }, { label: '↑', ks: 0xff52 }, { label: '↓', ks: 0xff54 }, { label: '→', ks: 0xff53 },
|
||||
{ label: 'Pos1', ks: 0xff50 }, { label: 'Ende', ks: 0xff57 },
|
||||
{ label: 'Bild↑', ks: 0xff55 }, { label: 'Bild↓', ks: 0xff56 },
|
||||
{ label: 'Einfg', ks: 0xff63 }, { label: 'Entf', ks: 0xffff }, { label: '⏎', ks: 0xff0d },
|
||||
];
|
||||
const F_KEYS: { label: string; ks: number }[] = Array.from({ length: 12 }, (_, i) => ({ label: 'F' + (i + 1), ks: 0xffbe + i }));
|
||||
|
||||
const VncTile: React.FC<Props> = ({ projectId, focused, port = 5901 }) => {
|
||||
const webRef = useRef<WebView>(null);
|
||||
const kbdRef = useRef<TextInput>(null);
|
||||
const bufRef = useRef(''); // Spiegel des TextInput-Textes
|
||||
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle');
|
||||
const [kbdOn, setKbdOn] = useState(false);
|
||||
const [keyBar, setKeyBar] = useState(false); // Fn-Leiste sichtbar?
|
||||
const [mods, setMods] = useState({ ctrl: false, alt: false, shift: false });
|
||||
const modRef = useRef({ ctrl: false, alt: false, shift: false }); // Spiegel fuer Closures
|
||||
const unsubDataRef = useRef<null | (() => void)>(null);
|
||||
|
||||
const teardown = useCallback(() => {
|
||||
@@ -34,10 +58,87 @@ const VncTile: React.FC<Props> = ({ projectId, focused, port = 5901 }) => {
|
||||
return () => teardown();
|
||||
}, [focused, teardown]);
|
||||
|
||||
// Button-Zustand an die ECHTE Tastatur-Sichtbarkeit koppeln: Androids
|
||||
// Zurueck-Taste blendet die Tastatur aus, ohne den TextInput zu blurren —
|
||||
// ueber keyboardDidHide setzen wir das ⌨-Symbol trotzdem zurueck.
|
||||
useEffect(() => {
|
||||
if (!focused) return;
|
||||
const show = Keyboard.addListener('keyboardDidShow', () => setKbdOn(true));
|
||||
const hide = Keyboard.addListener('keyboardDidHide', () => setKbdOn(false));
|
||||
return () => { show.remove(); hide.remove(); };
|
||||
}, [focused]);
|
||||
|
||||
const ctl = useCallback((fn: string) => {
|
||||
webRef.current?.injectJavaScript(`window.ariaVncCtl && window.ariaVncCtl.${fn}(); true;`);
|
||||
}, []);
|
||||
|
||||
const sendKeysym = useCallback((ks: number) => {
|
||||
webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.keysym(${ks}); true;`);
|
||||
}, []);
|
||||
const sendCombo = useCallback((modKeysyms: number[], ks: number) => {
|
||||
webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.combo(${JSON.stringify(modKeysyms)}, ${ks}); true;`);
|
||||
}, []);
|
||||
|
||||
// Aktive Sticky-Modifier als Keysym-Liste; nach dem Anwenden one-shot zuruecksetzen.
|
||||
const activeMods = useCallback(() => {
|
||||
const m = modRef.current; const a: number[] = [];
|
||||
if (m.ctrl) a.push(MOD.ctrl); if (m.alt) a.push(MOD.alt); if (m.shift) a.push(MOD.shift);
|
||||
return a;
|
||||
}, []);
|
||||
const clearMods = useCallback(() => {
|
||||
if (modRef.current.ctrl || modRef.current.alt || modRef.current.shift) {
|
||||
modRef.current = { ctrl: false, alt: false, shift: false };
|
||||
setMods(modRef.current);
|
||||
}
|
||||
}, []);
|
||||
const toggleMod = useCallback((k: 'ctrl' | 'alt' | 'shift') => {
|
||||
modRef.current = { ...modRef.current, [k]: !modRef.current[k] };
|
||||
setMods(modRef.current);
|
||||
}, []);
|
||||
|
||||
// Eine Taste (fertiges Keysym) senden — mit ggf. aktiven Modifiern.
|
||||
const pressKey = useCallback((ks: number) => {
|
||||
const m = activeMods();
|
||||
if (m.length) { sendCombo(m, ks); clearMods(); } else sendKeysym(ks);
|
||||
}, [activeMods, sendCombo, clearMods, sendKeysym]);
|
||||
|
||||
// Ein druckbares Zeichen senden — mit ggf. aktiven Modifiern (Strg+C etc.).
|
||||
const pressChar = useCallback((cp: number) => {
|
||||
const m = activeMods();
|
||||
if (m.length) { sendCombo(m, cpToKeysym(cp)); clearMods(); }
|
||||
else webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.char(${cp}); true;`);
|
||||
}, [activeMods, sendCombo, clearMods]);
|
||||
|
||||
// Tastatur ein-/ausblenden. Oeffnen: blur→focus erzwingt das Aufklappen auch
|
||||
// dann, wenn der TextInput noch fokussiert ist (Tastatur per Zurueck-Taste
|
||||
// versteckt). Schliessen: Keyboard.dismiss(); den Button-Zustand setzt der
|
||||
// keyboardDidShow/Hide-Listener — nicht hier —, damit er nie „haengen" bleibt.
|
||||
const toggleKbd = useCallback(() => {
|
||||
if (kbdOn) { Keyboard.dismiss(); }
|
||||
else { kbdRef.current?.blur(); setTimeout(() => kbdRef.current?.focus(), 30); }
|
||||
}, [kbdOn]);
|
||||
|
||||
// Druckbare Zeichen: Prefix-Diff des (wachsenden) Feldes → nur neu Getipptes an
|
||||
// die VM. Loeschungen kommen ueber onKeyPress(Backspace), daher hier nur Inserts.
|
||||
const onKbdChange = useCallback((e: NativeSyntheticEvent<TextInputChangeEventData>) => {
|
||||
const text = e.nativeEvent.text || '';
|
||||
const prev = bufRef.current;
|
||||
let i = 0;
|
||||
const min = Math.min(prev.length, text.length);
|
||||
while (i < min && prev.charCodeAt(i) === text.charCodeAt(i)) i++;
|
||||
for (const ch of text.slice(i)) { const cp = ch.codePointAt(0); if (cp) pressChar(cp); }
|
||||
bufRef.current = text;
|
||||
if (text.length > 200) { bufRef.current = ''; kbdRef.current?.setNativeProps({ text: '' }); }
|
||||
}, [pressChar]);
|
||||
|
||||
// Sondertasten der Software-Tastatur: Backspace feuert auf Android zuverlaessig
|
||||
// als keyPress; die Return-Taste (Haken) kommt als onSubmitEditing (s.u.).
|
||||
const onKbdKeyPress = useCallback((e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
||||
const k = e.nativeEvent.key;
|
||||
if (k === 'Backspace') pressKey(KEYSYM.Backspace);
|
||||
else if (k === 'Enter') pressKey(KEYSYM.Enter);
|
||||
}, [pressKey]);
|
||||
|
||||
const onMessage = useCallback((e: WebViewMessageEvent) => {
|
||||
let m: any;
|
||||
try { m = JSON.parse(e.nativeEvent.data); } catch { return; }
|
||||
@@ -84,14 +185,34 @@ const VncTile: React.FC<Props> = ({ projectId, focused, port = 5901 }) => {
|
||||
keyboardDisplayRequiresUserAction={false}
|
||||
/>
|
||||
|
||||
{/* Verstecktes Eingabefeld: fokussiert → Android-Tastatur tippt in die VM.
|
||||
keyboardType=visible-password schaltet Autokorrektur/Vorschlaege ab und
|
||||
liefert saubere Einzelzeichen. Offscreen, aber fokussierbar. */}
|
||||
<TextInput
|
||||
ref={kbdRef}
|
||||
style={styles.hiddenInput}
|
||||
onChange={onKbdChange}
|
||||
onKeyPress={onKbdKeyPress}
|
||||
onSubmitEditing={() => pressKey(KEYSYM.Enter)}
|
||||
keyboardType="visible-password"
|
||||
returnKeyType="send"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
blurOnSubmit={false}
|
||||
caretHidden
|
||||
contextMenuHidden
|
||||
multiline={false}
|
||||
/>
|
||||
|
||||
{/* Steuerungs-Leiste — nur wenn verbunden */}
|
||||
{connected && (
|
||||
<View style={styles.ctlBar}>
|
||||
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('focusKeyboard')} activeOpacity={0.7}>
|
||||
<Text style={styles.ctlText}>⌨</Text>
|
||||
<TouchableOpacity style={[styles.ctlBtn, keyBar && styles.ctlBtnOn]} onPress={() => setKeyBar(v => !v)} activeOpacity={0.7}>
|
||||
<Text style={styles.ctlText}>Fn</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('cad')} activeOpacity={0.7}>
|
||||
<Text style={styles.ctlTextSmall}>Strg+Alt+Entf</Text>
|
||||
<TouchableOpacity style={[styles.ctlBtn, kbdOn && styles.ctlBtnOn]} onPress={toggleKbd} activeOpacity={0.7}>
|
||||
<Text style={styles.ctlText}>⌨</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('toggleFit')} activeOpacity={0.7}>
|
||||
<Text style={styles.ctlText}>⤢</Text>
|
||||
@@ -99,6 +220,26 @@ const VncTile: React.FC<Props> = ({ projectId, focused, port = 5901 }) => {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Fn-Leiste — echte Steuertasten (oben, ueber der Software-Tastatur). */}
|
||||
{connected && keyBar && (
|
||||
<View style={styles.keyBar} pointerEvents="box-none">
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="always" contentContainerStyle={styles.keyRow}>
|
||||
<TouchableOpacity style={[styles.key, mods.ctrl && styles.keyOn]} onPress={() => toggleMod('ctrl')} activeOpacity={0.7}><Text style={styles.keyText}>Strg</Text></TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.key, mods.alt && styles.keyOn]} onPress={() => toggleMod('alt')} activeOpacity={0.7}><Text style={styles.keyText}>Alt</Text></TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.key, mods.shift && styles.keyOn]} onPress={() => toggleMod('shift')} activeOpacity={0.7}><Text style={styles.keyText}>Shift</Text></TouchableOpacity>
|
||||
{NAV_KEYS.map(k => (
|
||||
<TouchableOpacity key={k.label} style={styles.key} onPress={() => pressKey(k.ks)} activeOpacity={0.7}><Text style={styles.keyText}>{k.label}</Text></TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="always" contentContainerStyle={styles.keyRow}>
|
||||
{F_KEYS.map(k => (
|
||||
<TouchableOpacity key={k.label} style={styles.key} onPress={() => pressKey(k.ks)} activeOpacity={0.7}><Text style={styles.keyText}>{k.label}</Text></TouchableOpacity>
|
||||
))}
|
||||
<TouchableOpacity style={styles.key} onPress={() => ctl('cad')} activeOpacity={0.7}><Text style={styles.keyTextSm}>Strg+Alt+Entf</Text></TouchableOpacity>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!connected && (
|
||||
<View style={styles.overlay} pointerEvents="none">
|
||||
<Text style={styles.overlayText}>
|
||||
@@ -135,8 +276,21 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
ctlBtnOn: { backgroundColor: 'rgba(0,150,255,0.85)', borderColor: '#0096FF' },
|
||||
ctlText: { color: '#E0E0F0', fontSize: 16, fontWeight: '700' },
|
||||
ctlTextSmall: { color: '#E0E0F0', fontSize: 11, fontWeight: '700' },
|
||||
// Fokussierbar (nicht display:none), aber aus dem Sichtfeld geschoben.
|
||||
hiddenInput: { position: 'absolute', width: 1, height: 1, top: -100, left: -100, opacity: 0, padding: 0 },
|
||||
keyBar: { position: 'absolute', top: 74, left: 0, right: 0, gap: 5 },
|
||||
keyRow: { paddingHorizontal: 6, gap: 5, alignItems: 'center' },
|
||||
key: {
|
||||
backgroundColor: 'rgba(18,18,42,0.92)', borderColor: '#2A2A3E', borderWidth: 1,
|
||||
borderRadius: 8, paddingHorizontal: 9, paddingVertical: 7, minWidth: 34,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
keyOn: { backgroundColor: 'rgba(0,150,255,0.85)', borderColor: '#0096FF' },
|
||||
keyText: { color: '#E0E0F0', fontSize: 13, fontWeight: '700' },
|
||||
keyTextSm: { color: '#E0E0F0', fontSize: 10, fontWeight: '700' },
|
||||
overlay: { position: 'absolute', top: 12, left: 0, right: 0, alignItems: 'center' },
|
||||
overlayText: { color: '#9090B0', fontSize: 12, backgroundColor: 'rgba(0,0,0,0.6)', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 10, overflow: 'hidden' },
|
||||
});
|
||||
|
||||
+1250
-26
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
"""Einmaliger Backfill: weist bestehenden Memory-Punkten ein `scope`
|
||||
(system | personal) zu. Sicher & reversibel — Stefan kann pro Eintrag in der
|
||||
Diagnostic-UI umschalten. Idempotent: laeuft mehrfach ohne Schaden.
|
||||
|
||||
Heuristik (datengetrieben aus dem realen Bestand):
|
||||
- type=preference / fact / conversation / reminder -> personal
|
||||
- source in (seed, auto-feedback) -> system
|
||||
- type=identity -> system
|
||||
- type in (rule, tool, skill) und category in SYSTEM_CATS -> system
|
||||
- sonst -> personal (sicher: nichts leakt)
|
||||
|
||||
Aufruf im Brain-Container:
|
||||
docker exec aria-brain python3 /app/backfill_scope.py # dry-run
|
||||
docker exec aria-brain python3 /app/backfill_scope.py --apply # schreibt
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter
|
||||
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.http import models as qm
|
||||
|
||||
COLLECTION = "aria_memory"
|
||||
SYSTEM_CATS = {
|
||||
"sicherheit", "arbeitsweise", "architektur", "ehrlichkeit", "verhalten",
|
||||
"voice", "skills", "freigaben", "infrastruktur", "persoenlichkeit",
|
||||
"pentest", "ausgabe",
|
||||
}
|
||||
|
||||
|
||||
def compute_scope(pl: dict) -> str:
|
||||
typ = pl.get("type")
|
||||
src = pl.get("source")
|
||||
cat = (pl.get("category") or "").lower()
|
||||
if typ == "preference":
|
||||
return "personal"
|
||||
if typ in ("fact", "conversation", "reminder"):
|
||||
return "personal"
|
||||
if src in ("seed", "auto-feedback"):
|
||||
return "system"
|
||||
if typ == "identity":
|
||||
return "system"
|
||||
if typ in ("rule", "tool", "skill") and cat in SYSTEM_CATS:
|
||||
return "system"
|
||||
return "personal"
|
||||
|
||||
|
||||
def main():
|
||||
apply = "--apply" in sys.argv
|
||||
force = "--force" in sys.argv # auch schon gesetzte scopes ueberschreiben
|
||||
c = QdrantClient(
|
||||
host=os.environ.get("QDRANT_HOST", "aria-qdrant"),
|
||||
port=int(os.environ.get("QDRANT_PORT", "6333")),
|
||||
)
|
||||
pts, _ = c.scroll(collection_name=COLLECTION, limit=5000,
|
||||
with_payload=True, with_vectors=False)
|
||||
|
||||
per_scope: dict[str, list] = {"system": [], "personal": []}
|
||||
pinned_examples = Counter()
|
||||
skipped = 0
|
||||
for p in pts:
|
||||
pl = p.payload or {}
|
||||
if pl.get("scope") in ("system", "personal") and not force:
|
||||
skipped += 1
|
||||
continue
|
||||
scope = compute_scope(pl)
|
||||
per_scope[scope].append(p.id)
|
||||
if pl.get("pinned"):
|
||||
pinned_examples[(scope, pl.get("source"), pl.get("type"),
|
||||
pl.get("category"))] += 1
|
||||
|
||||
print(f"total={len(pts)} skipped(already set)={skipped}")
|
||||
print(f"-> system={len(per_scope['system'])} personal={len(per_scope['personal'])}")
|
||||
print("pinned split (scope, source, type, category):")
|
||||
for k, v in sorted(pinned_examples.items()):
|
||||
print(" ", k, v)
|
||||
|
||||
if not apply:
|
||||
print("\nDRY-RUN — nichts geschrieben. Mit --apply ausfuehren.")
|
||||
return
|
||||
|
||||
for scope, ids in per_scope.items():
|
||||
if not ids:
|
||||
continue
|
||||
c.set_payload(collection_name=COLLECTION, payload={"scope": scope}, points=ids)
|
||||
print(f"\nAPPLIED: system={len(per_scope['system'])} personal={len(per_scope['personal'])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -149,14 +149,23 @@ async def _fire(trigger: dict, agent_factory) -> None:
|
||||
)
|
||||
|
||||
try:
|
||||
agent = agent_factory()
|
||||
reply, _, _, _, _ = agent.chat(prompt, source="trigger")
|
||||
events = agent.pop_events()
|
||||
# WICHTIG: agent.chat() ist ein SYNCHRONER, blockierender Aufruf (Proxy-
|
||||
# HTTP mit bis zu 24h Read-Timeout). NIEMALS direkt im async-Loop —
|
||||
# sonst friert ein einziger getriggerter Turn den GESAMTEN Brain ein
|
||||
# (kein /health, kein weiterer Request). Wie der /chat-Pfad in den
|
||||
# Executor auslagern, damit der Event-Loop frei bleibt.
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _run_turn():
|
||||
a = agent_factory()
|
||||
rep, *_rest = a.chat(prompt, source="trigger")
|
||||
return rep, a.pop_events()
|
||||
|
||||
reply, events = await loop.run_in_executor(None, _run_turn)
|
||||
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
|
||||
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
|
||||
# Reply an die Bridge pushen, damit App + Diagnostic + TTS sie kriegen.
|
||||
# Ohne diesen Push wuerde die Antwort nur im Brain-Log landen.
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, _push_to_bridge, reply, name, ttype, events)
|
||||
except Exception as e:
|
||||
logger.exception("Trigger %s feuern fehlgeschlagen: %s", name, e)
|
||||
|
||||
@@ -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;
|
||||
|
||||
+52
-13
@@ -190,6 +190,7 @@ class MemoryIn(BaseModel):
|
||||
pinned: bool = False
|
||||
category: str = ""
|
||||
source: str = "manual"
|
||||
scope: str = "personal" # system | personal — steuert Bootstrap-Export
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
conversation_id: Optional[str] = None
|
||||
# Vorhandene Anhang-Metadaten beim Save mitgeben (i.d.R. werden Anhaenge
|
||||
@@ -203,6 +204,7 @@ class MemoryUpdate(BaseModel):
|
||||
content: Optional[str] = None
|
||||
pinned: Optional[bool] = None
|
||||
category: Optional[str] = None
|
||||
scope: Optional[str] = None # system | personal
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
@@ -214,6 +216,7 @@ class MemoryOut(BaseModel):
|
||||
pinned: bool
|
||||
category: str
|
||||
source: str
|
||||
scope: str = "personal"
|
||||
tags: List[str]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
@@ -328,6 +331,7 @@ def memory_save(body: MemoryIn):
|
||||
pinned=body.pinned,
|
||||
category=body.category,
|
||||
source=body.source,
|
||||
scope=body.scope,
|
||||
tags=body.tags,
|
||||
conversation_id=body.conversation_id,
|
||||
attachments=body.attachments or [],
|
||||
@@ -353,6 +357,8 @@ def memory_update(point_id: str, body: MemoryUpdate):
|
||||
existing.pinned = body.pinned
|
||||
if body.category is not None:
|
||||
existing.category = body.category
|
||||
if body.scope is not None:
|
||||
existing.scope = body.scope
|
||||
if body.tags is not None:
|
||||
existing.tags = body.tags
|
||||
|
||||
@@ -537,12 +543,23 @@ def memory_import_files():
|
||||
# Wiederherstellen einer schlanken ARIA nach Wipe.
|
||||
|
||||
@app.get("/memory/export-bootstrap")
|
||||
def memory_export_bootstrap():
|
||||
"""Gibt alle pinned Memories als JSON zurueck — fuer Browser-Download."""
|
||||
def memory_export_bootstrap(scope: str = "system"):
|
||||
"""Gibt pinned Memories als JSON zurueck — fuer Browser-Download.
|
||||
|
||||
scope='system' → nur generische Regeln (fuer ein frisches System),
|
||||
scope='personal' → nur Stefan-spezifisches (Name, Zugangsdaten, Projekte),
|
||||
scope='all' → alles pinned (Vollbackup).
|
||||
Default 'system', damit man nicht versehentlich Persoenliches teilt."""
|
||||
s = store()
|
||||
pinned = s.list_pinned()
|
||||
if scope == "all":
|
||||
pinned = s.list_pinned()
|
||||
elif scope in ("system", "personal"):
|
||||
pinned = s.list_pinned_by_scope(scope)
|
||||
else:
|
||||
raise HTTPException(400, f"Ungueltiger scope: {scope}")
|
||||
return {
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"scope": scope,
|
||||
"exported_at": __import__("datetime").datetime.now(
|
||||
__import__("datetime").timezone.utc
|
||||
).isoformat(),
|
||||
@@ -555,6 +572,7 @@ def memory_export_bootstrap():
|
||||
"pinned": True,
|
||||
"category": p.category,
|
||||
"source": p.source,
|
||||
"scope": p.scope,
|
||||
"tags": p.tags,
|
||||
}
|
||||
for p in pinned
|
||||
@@ -564,13 +582,18 @@ def memory_export_bootstrap():
|
||||
|
||||
class BootstrapBundle(BaseModel):
|
||||
version: int = 1
|
||||
scope: Optional[str] = None # system | personal | all (aus dem Export)
|
||||
memories: List[dict]
|
||||
|
||||
|
||||
@app.post("/memory/import-bootstrap")
|
||||
def memory_import_bootstrap(body: BootstrapBundle):
|
||||
"""Loescht alle pinned Memories und importiert die im Bundle.
|
||||
Cold Memory (unpinned) bleibt unangetastet.
|
||||
"""Importiert ein Bootstrap-Bundle scope-sicher.
|
||||
|
||||
Es werden NUR die aktuell pinned Punkte geloescht, deren scope zum Import
|
||||
gehoert — ein System-Import laesst also die persoenlichen pinned Memories
|
||||
(Name, Zugangsdaten) unangetastet und umgekehrt. Bei einem 'all'-Bundle
|
||||
(Vollbackup) werden alle pinned ersetzt.
|
||||
|
||||
Wenn keine Memories im Bundle: nur loeschen ist NICHT erlaubt — der
|
||||
Caller soll erst exportieren und dann importieren.
|
||||
@@ -580,23 +603,31 @@ def memory_import_bootstrap(body: BootstrapBundle):
|
||||
|
||||
s = store()
|
||||
e = embedder()
|
||||
|
||||
# Alle aktuell pinned Punkte loeschen
|
||||
from qdrant_client.http import models as qm
|
||||
from memory.vector_store import COLLECTION
|
||||
|
||||
# Scope bestimmen: explizit aus dem Bundle, sonst aus den memories ableiten.
|
||||
bundle_scope = body.scope
|
||||
if bundle_scope not in ("system", "personal", "all"):
|
||||
scopes_in_mems = {m.get("scope", "personal") for m in body.memories}
|
||||
bundle_scope = scopes_in_mems.pop() if len(scopes_in_mems) == 1 else "all"
|
||||
|
||||
# Nur die pinned Punkte des betroffenen scope loeschen.
|
||||
del_must = [qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))]
|
||||
if bundle_scope in ("system", "personal"):
|
||||
del_must.append(qm.FieldCondition(key="scope", match=qm.MatchValue(value=bundle_scope)))
|
||||
s.client.delete(
|
||||
collection_name=COLLECTION,
|
||||
points_selector=qm.FilterSelector(filter=qm.Filter(must=[
|
||||
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))
|
||||
])),
|
||||
points_selector=qm.FilterSelector(filter=qm.Filter(must=del_must)),
|
||||
)
|
||||
|
||||
# Neue Punkte einspeisen
|
||||
# Neue Punkte einspeisen — scope pro memory (Fallback: bundle_scope bzw. personal).
|
||||
created = 0
|
||||
for m in body.memories:
|
||||
content = (m.get("content") or "").strip()
|
||||
if not content:
|
||||
continue
|
||||
mscope = m.get("scope") or (bundle_scope if bundle_scope != "all" else "personal")
|
||||
point = MemoryPoint(
|
||||
id="",
|
||||
type=m.get("type", "fact"),
|
||||
@@ -605,13 +636,14 @@ def memory_import_bootstrap(body: BootstrapBundle):
|
||||
pinned=True,
|
||||
category=m.get("category", ""),
|
||||
source=m.get("source", "bootstrap-import"),
|
||||
scope=mscope,
|
||||
tags=list(m.get("tags", [])),
|
||||
)
|
||||
vec = e.embed(content)
|
||||
s.upsert(point, vec)
|
||||
created += 1
|
||||
|
||||
return {"created": created, "deleted_previous_pinned": True}
|
||||
return {"created": created, "scope": bundle_scope, "deleted_previous_pinned": True}
|
||||
|
||||
|
||||
# ─── Conversation-Loop ──────────────────────────────────────────────
|
||||
@@ -644,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.
|
||||
@@ -753,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] = [
|
||||
|
||||
@@ -11,6 +11,10 @@ Punkt-Schema (Payload):
|
||||
content — eigentlicher Text (wird embedded)
|
||||
pinned — bool, True = Hot Memory (immer in Prompt)
|
||||
source — import | conversation | manual
|
||||
scope — system | personal. system = generische Regeln, die JEDER
|
||||
braucht, der das System aufsetzt (Sicherheit, Ehrlichkeit,
|
||||
Skill-Regeln). personal = Stefan-spezifisch (Name, Zugangs-
|
||||
daten, Projekte). Steuert den getrennten Bootstrap-Export.
|
||||
tags — Liste von Strings
|
||||
created_at, updated_at — ISO-Strings
|
||||
conversation_id — optional, nur fuer type=conversation
|
||||
@@ -55,6 +59,7 @@ class MemoryPoint:
|
||||
pinned: bool = False
|
||||
category: str = ""
|
||||
source: str = "manual"
|
||||
scope: str = "personal" # system | personal — steuert Bootstrap-Export
|
||||
tags: List[str] = field(default_factory=list)
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
@@ -74,6 +79,7 @@ class MemoryPoint:
|
||||
"pinned": self.pinned,
|
||||
"category": self.category,
|
||||
"source": self.source,
|
||||
"scope": self.scope,
|
||||
"tags": self.tags,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
@@ -94,6 +100,7 @@ class MemoryPoint:
|
||||
pinned=payload.get("pinned", False),
|
||||
category=payload.get("category", ""),
|
||||
source=payload.get("source", "manual"),
|
||||
scope=payload.get("scope", "personal"),
|
||||
tags=payload.get("tags", []),
|
||||
created_at=payload.get("created_at", ""),
|
||||
updated_at=payload.get("updated_at", ""),
|
||||
@@ -120,14 +127,23 @@ class VectorStore:
|
||||
collection_name=COLLECTION,
|
||||
vectors_config=qm.VectorParams(size=VECTOR_DIM, distance=qm.Distance.COSINE),
|
||||
)
|
||||
# Indexe fuer typische Filter-Felder
|
||||
for field_name in ("type", "pinned", "category", "source", "migration_key"):
|
||||
# Indexe fuer typische Filter-Felder — idempotent, laeuft auch auf
|
||||
# einer bestehenden Collection (fuer neu hinzugekommene Felder wie scope).
|
||||
self._ensure_indexes()
|
||||
|
||||
def _ensure_indexes(self):
|
||||
for field_name in ("type", "pinned", "category", "source", "scope", "migration_key"):
|
||||
schema = (qm.PayloadSchemaType.BOOL if field_name == "pinned"
|
||||
else qm.PayloadSchemaType.KEYWORD)
|
||||
try:
|
||||
self.client.create_payload_index(
|
||||
collection_name=COLLECTION,
|
||||
field_name=field_name,
|
||||
field_schema=qm.PayloadSchemaType.KEYWORD if field_name != "pinned"
|
||||
else qm.PayloadSchemaType.BOOL,
|
||||
field_schema=schema,
|
||||
)
|
||||
except Exception:
|
||||
# Index existiert bereits — kein Problem.
|
||||
pass
|
||||
|
||||
# ─── Schreib-Operationen ─────────────────────────────────────────
|
||||
|
||||
@@ -164,6 +180,38 @@ class VectorStore:
|
||||
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))
|
||||
]))
|
||||
|
||||
def list_pinned_by_scope(self, scope: str) -> List[MemoryPoint]:
|
||||
"""Alle pinned Punkte eines scope (system | personal). Fuer den
|
||||
getrennten Bootstrap-Export."""
|
||||
return self._scroll(filter=qm.Filter(must=[
|
||||
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)),
|
||||
qm.FieldCondition(key="scope", match=qm.MatchValue(value=scope)),
|
||||
]))
|
||||
|
||||
def list_index_titles(self, limit: int = 500) -> List[MemoryPoint]:
|
||||
"""Leichtgewichtiger Titel-Index des kalten Gedaechtnisses fuer den
|
||||
System-Prompt: ARIA sieht WAS sie an Nachschlage-Wissen hat (Zugangs-
|
||||
daten, Infrastruktur, Projekte) und holt den Inhalt bei Bedarf via
|
||||
memory_search — statt Stefan nach etwas zu fragen, das schon da ist.
|
||||
|
||||
Bewusst NUR die deliberat gespeicherten Punkte:
|
||||
- nicht pinned (die sind eh schon voll im Prompt),
|
||||
- kein type=conversation (Chat-Mitschnitte),
|
||||
- kein source=distilled (die 100e auto-destillierten Gespraechs-
|
||||
Fakten — die traegt das semantische Auto-Retrieval, sie hier
|
||||
als Titel zu listen wuerde nur Kontext fressen).
|
||||
So bleibt der Index klein (Dutzende statt Hunderte Zeilen)."""
|
||||
return self._scroll(
|
||||
filter=qm.Filter(
|
||||
must_not=[
|
||||
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)),
|
||||
qm.FieldCondition(key="type", match=qm.MatchValue(value="conversation")),
|
||||
qm.FieldCondition(key="source", match=qm.MatchValue(value="distilled")),
|
||||
]
|
||||
),
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
def list_by_type(self, type_: str, limit: int = 100) -> List[MemoryPoint]:
|
||||
return self._scroll(
|
||||
filter=qm.Filter(must=[
|
||||
|
||||
@@ -252,6 +252,7 @@ def _parse_user_md(md: str, source_file: str) -> List[MemoryPoint]:
|
||||
type_="preference", title=f"User: {btitle}",
|
||||
content=btext, category="allgemein",
|
||||
migration_key=f"{source_file}/general-{idx}",
|
||||
scope="personal",
|
||||
))
|
||||
else:
|
||||
cat_key = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "allgemein"
|
||||
@@ -259,6 +260,7 @@ def _parse_user_md(md: str, source_file: str) -> List[MemoryPoint]:
|
||||
type_="preference", title=title,
|
||||
content=content, category=cat_key,
|
||||
migration_key=f"{source_file}/{cat_key}",
|
||||
scope="personal",
|
||||
))
|
||||
return points
|
||||
|
||||
@@ -283,7 +285,11 @@ def _mk(
|
||||
migration_key: str,
|
||||
pinned: bool = True,
|
||||
category: str = "",
|
||||
scope: str = "system",
|
||||
) -> MemoryPoint:
|
||||
# scope-Default 'system': AGENT.md + TOOLING.md beschreiben ARIA selbst
|
||||
# (Identitaet, Sicherheit, Architektur) — das braucht jedes System.
|
||||
# USER.md-Praeferenzen sind personal und uebergeben scope='personal'.
|
||||
p = MemoryPoint(
|
||||
id="",
|
||||
type=type_,
|
||||
@@ -292,6 +298,7 @@ def _mk(
|
||||
pinned=pinned,
|
||||
category=category,
|
||||
source="import",
|
||||
scope=scope,
|
||||
tags=[],
|
||||
)
|
||||
# migration_key wird ueber Payload-Index angesprochen — in to_payload manuell anhaengen
|
||||
|
||||
+83
-1
@@ -162,6 +162,53 @@ def build_time_section() -> str:
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
def build_voice_flow_section() -> str:
|
||||
"""Sprach-/Gespraechssteuerung: ARIA erkennt AUS DEM TEXT die Phase (Befehl vs.
|
||||
Frage, Kette vs. Ende) und deklariert sie per Marker — wie [[AWAIT]]. Die
|
||||
Marker werden im Brain entfernt (nie angezeigt/vorgelesen)."""
|
||||
return "\n".join([
|
||||
"## Sprach- & Gespraechssteuerung (Voice-First — du entscheidest die Phase)",
|
||||
"Stefan spricht meist mit dir. DU erkennst aus dem Text, was gerade Phase "
|
||||
"ist — niemand raet das fuer dich. Dazu haengst du EINEN Marker (bei Bedarf "
|
||||
"zwei) ganz ans ENDE deiner Antwort. Sie werden entfernt: nicht angezeigt, "
|
||||
"nicht vorgelesen, nicht gespeichert — genau wie `[[AWAIT]]`.",
|
||||
"",
|
||||
"- `[[STUMM]]` → Deine Antwort ist ein reiner **Steuerbefehl** (du hast etwas "
|
||||
"GETAN: Musik, VNC oeffnen, einen Menuepunkt klicken, Licht …). Sie wird "
|
||||
"NICHT vorgelesen; der kurze Bestaetigungstext steht nur in der Bubble. "
|
||||
"Setz das IMMER, wenn Stefan dir einen Befehl gibt statt eine Frage stellt — "
|
||||
"AUCH wenn du den Befehl ueber ein Skill/Tool ausfuehrst (nicht nur beim "
|
||||
"Fast-Path). `[[STUMM]]` ALLEIN = Einzelbefehl → danach direkt zurueck aufs "
|
||||
"Wake-Word.",
|
||||
"- `[[WEITER]]` → Das Gespraech bzw. eine **Befehlskette** laeuft weiter: das "
|
||||
"Mikro bleibt offen, du wartest auf die naechste Eingabe (kein erneutes "
|
||||
"\"Computer\" noetig). Setz das, wenn Stefan eine Kette ankuendigt ('ich geb "
|
||||
"dir gleich mehrere Befehle', 'wir machen das jetzt Schritt fuer Schritt') "
|
||||
"oder das Gespraech klar weitergeht.",
|
||||
"- `[[ENDE]]` → Konversation/Kette ist zu Ende: zurueck aufs Wake-Word. Setz "
|
||||
"das, wenn Stefan schliesst ('das war's', 'Konversation Ende', 'Befehlskette "
|
||||
"Ende', 'danke, fertig'). Stellt er in DERSELBEN Nachricht noch eine Frage, "
|
||||
"beantworte sie normal (OHNE `[[STUMM]]`, wird also vorgelesen) UND haeng "
|
||||
"`[[ENDE]]` an.",
|
||||
"",
|
||||
"Regeln:",
|
||||
"- Befehl (etwas TUN) → `[[STUMM]]`. Frage (etwas WISSEN / plaudern) → normal, "
|
||||
"ohne Marker (wird vorgelesen).",
|
||||
"- Befehlskette: JEDER Schritt `[[STUMM]] [[WEITER]]` (stumm arbeiten, Mikro "
|
||||
"offen), bis Stefan die Kette beendet → letzter Turn `[[ENDE]]`.",
|
||||
"- 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.",
|
||||
])
|
||||
|
||||
|
||||
TYPE_HEADINGS = {
|
||||
"identity": "## Wer du bist",
|
||||
"rule": "## Sicherheitsregeln & Prinzipien",
|
||||
@@ -260,6 +307,36 @@ def build_cold_memory_section(matches: List[MemoryPoint]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_memory_index_section(index_titles: List[MemoryPoint]) -> str:
|
||||
"""Titel-Index des kalten Gedaechtnisses: ARIA sieht WELCHES Nachschlage-
|
||||
Wissen sie hat (nur Titel, kein Inhalt = billig), damit sie den Inhalt via
|
||||
memory_search holt statt Stefan nach etwas zu fragen, das schon da ist.
|
||||
Nach Kategorie gruppiert; Conversation-Logs + auto-destillierte Fakten sind
|
||||
bereits ausgefiltert (siehe list_index_titles)."""
|
||||
if not index_titles:
|
||||
return ""
|
||||
grouped: dict[str, List[MemoryPoint]] = {}
|
||||
for p in index_titles:
|
||||
key = (p.category or p.type or "sonstiges").strip() or "sonstiges"
|
||||
grouped.setdefault(key, []).append(p)
|
||||
|
||||
lines = [
|
||||
"## Was in deinem Gedaechtnis liegt (per memory_search abrufbar)",
|
||||
"Diese Eintraege hast DU gespeichert — hier nur die Titel, nicht der "
|
||||
"Inhalt. Wenn einer zur Aufgabe passt, hol den Inhalt mit `memory_search` "
|
||||
"(Titel oder Stichwort). **Frag Stefan NICHT nach etwas, das hier steht** "
|
||||
"(Zugangsdaten, Server/Hosts, Projekt-Stand, Konfig) — erst nachsehen.",
|
||||
"",
|
||||
]
|
||||
for cat in sorted(grouped.keys()):
|
||||
items = grouped[cat]
|
||||
lines.append(f"### {cat}")
|
||||
for p in items:
|
||||
lines.append(f"- {p.title}")
|
||||
lines.append("")
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def build_skills_section(skills: List[dict]) -> str:
|
||||
"""Listet alle Skills (aktiv + deaktiviert) damit ARIA weiss was es gibt
|
||||
und keine doppelt baut. Plus klare Schwelle wann ein Skill sich lohnt."""
|
||||
@@ -450,6 +527,7 @@ def build_flux_section(flux_config: dict) -> str:
|
||||
def build_system_prompt(
|
||||
pinned: List[MemoryPoint],
|
||||
cold: List[MemoryPoint] | None = None,
|
||||
memory_index: List[MemoryPoint] | None = None,
|
||||
skills: List[dict] | None = None,
|
||||
triggers: List[dict] | None = None,
|
||||
condition_vars: List[dict] | None = None,
|
||||
@@ -463,7 +541,8 @@ def build_system_prompt(
|
||||
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth."""
|
||||
# Identitaets-Anker IMMER zuerst — vor allen Memories/Sektionen, damit die
|
||||
# ARIA-Rolle auch in Projekten mit injection-artigem Inhalt (Pentest) haelt.
|
||||
parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_time_section()]
|
||||
parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_time_section(),
|
||||
"", build_voice_flow_section()]
|
||||
if skills:
|
||||
parts.append("")
|
||||
parts.append(build_skills_section(skills))
|
||||
@@ -482,6 +561,9 @@ def build_system_prompt(
|
||||
callback_host=oauth_callback_host,
|
||||
callback_port=oauth_callback_port,
|
||||
callback_tls=oauth_callback_tls))
|
||||
if memory_index:
|
||||
parts.append("")
|
||||
parts.append(build_memory_index_section(memory_index))
|
||||
if cold:
|
||||
parts.append("")
|
||||
parts.append(build_cold_memory_section(cold))
|
||||
|
||||
@@ -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"
|
||||
@@ -915,6 +933,7 @@ def apply(store: VectorStore, embedder: Embedder) -> dict:
|
||||
"pinned": True,
|
||||
"category": rule.get("category", ""),
|
||||
"source": "seed",
|
||||
"scope": "system",
|
||||
"tags": [],
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
|
||||
+482
-65
@@ -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
|
||||
@@ -790,7 +800,8 @@ class ARIABridge:
|
||||
# Anfrage an aria-core. Sonst antwortet ARIA zweimal (einmal "warte auf
|
||||
# Anweisung" beim file, einmal auf den Chat-Text).
|
||||
# Liste von Tuples: (file_path, name, file_type, size_kb, width, height)
|
||||
self._pending_files: list[tuple[str, str, str, int, int, int]] = []
|
||||
# (file_path, name, type, kb, width, height, clientMsgId)
|
||||
self._pending_files: list[tuple[str, str, str, int, int, int, str]] = []
|
||||
self._pending_files_flush_task: Optional[asyncio.Task] = None
|
||||
# Projekt-Kontext der gerade gepufferten Anhaenge (aus dem file-Upload).
|
||||
# Wird beim Flush an send_to_core gegeben, damit Anhaenge im richtigen
|
||||
@@ -809,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.
|
||||
|
||||
@@ -1621,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),
|
||||
})
|
||||
@@ -1673,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)
|
||||
|
||||
@@ -1741,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:
|
||||
@@ -1839,16 +1862,16 @@ class ARIABridge:
|
||||
return " ".join(parts) + " " + text
|
||||
return text
|
||||
|
||||
def _build_pending_files_message(self, user_text: str) -> str:
|
||||
"""Baut eine Anweisung an aria-core aus den gepufferten Files + optionalem
|
||||
def _build_pending_files_message(self, user_text: str, files: list) -> str:
|
||||
"""Baut eine Anweisung an aria-core aus den uebergebenen Files + optionalem
|
||||
User-Text. user_text leer → 'warte auf Anweisung'-Variante."""
|
||||
parts: list[str] = []
|
||||
for fp, name, ftype, kb, w, h in self._pending_files:
|
||||
for fp, name, ftype, kb, w, h, _cmid in files:
|
||||
dim = f" {w}x{h}px" if (w and h) else ""
|
||||
kind = "Bild" if ftype.startswith("image/") else "Datei"
|
||||
parts.append(f"- {kind}: {name}{dim} ({ftype}, {kb}KB) liegt unter {fp}")
|
||||
files_summary = "\n".join(parts)
|
||||
n = len(self._pending_files)
|
||||
n = len(files)
|
||||
anhang = "Anhang" if n == 1 else "Anhaenge"
|
||||
if user_text:
|
||||
return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n"
|
||||
@@ -1857,15 +1880,16 @@ class ARIABridge:
|
||||
f"Warte auf seine Anweisung was du damit tun sollst.")
|
||||
|
||||
async def _flush_pending_files_after(self, delay: float) -> None:
|
||||
"""Wenn nach `delay`s kein chat-Text gekommen ist: Files alleine an
|
||||
aria-core senden ('warte auf Anweisung'-Variante)."""
|
||||
"""Wenn nach `delay`s kein chat-Text gekommen ist: alle noch gepufferten
|
||||
Files alleine an aria-core senden ('warte auf Anweisung'-Variante)."""
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
if not self._pending_files:
|
||||
return
|
||||
text = self._build_pending_files_message("")
|
||||
files = self._pending_files
|
||||
text = self._build_pending_files_message("", files)
|
||||
self._pending_files = []
|
||||
self._pending_files_flush_task = None
|
||||
pid = self._pending_files_project_id
|
||||
@@ -1873,23 +1897,48 @@ class ARIABridge:
|
||||
await self.send_to_core(text, source="app-file", project_id=pid)
|
||||
|
||||
async def _flush_pending_files_with_text(self, user_text: str,
|
||||
project_id: str = "") -> bool:
|
||||
project_id: str = "",
|
||||
client_msg_id: str = "") -> bool:
|
||||
"""Wenn ein chat-Text reinkommt waehrend Files gepuffert sind:
|
||||
Files + Text zu einer einzigen aria-core-Nachricht mergen.
|
||||
Returns True wenn gemerged wurde (Caller soll dann nicht nochmal senden).
|
||||
|
||||
KORRELATION (Fix Queue-Bug): Files tragen dieselbe clientMsgId wie ihr
|
||||
Text. Bei einer Queue gehen Files (fire-and-forget) und Text (ACK-
|
||||
getrackt, ggf. verzoegert) auseinander — ohne Korrelation landeten die
|
||||
Bilder beim falschen Text. Wir mergen darum NUR die Files mit passender
|
||||
cmid; der Rest bleibt gepuffert fuer seine eigene Nachricht. Fallback
|
||||
(Legacy-App ohne cmid an Files, oder cmid ohne Treffer): altes Verhalten
|
||||
(alle Files mit diesem Text), damit nie ein Bild verloren geht.
|
||||
|
||||
project_id: Projekt-Kontext aus dem chat-Payload (der sichtbare Focus
|
||||
beim Absenden). Faellt auf den beim File-Upload gemerkten Kontext
|
||||
zurueck, damit Anhaenge im richtigen Projekt landen statt im Hauptchat."""
|
||||
beim Absenden). Faellt auf den beim File-Upload gemerkten Kontext zurueck.
|
||||
"""
|
||||
if not self._pending_files:
|
||||
return False
|
||||
cmid = (client_msg_id or "").strip()
|
||||
matching = [f for f in self._pending_files if cmid and f[6] == cmid]
|
||||
if not matching:
|
||||
# Kein cmid-Treffer → altes Verhalten: alle gepufferten Files mergen.
|
||||
matching = list(self._pending_files)
|
||||
remaining: list = []
|
||||
else:
|
||||
remaining = [f for f in self._pending_files if f not in matching]
|
||||
|
||||
text = self._build_pending_files_message(user_text, matching)
|
||||
self._pending_files = remaining
|
||||
pid = (project_id or "").strip() or self._pending_files_project_id
|
||||
# Flush-Timer neu setzen wenn noch Files anderer Nachrichten warten,
|
||||
# sonst zuruecksetzen.
|
||||
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
|
||||
self._pending_files_flush_task.cancel()
|
||||
self._pending_files_flush_task = None
|
||||
text = self._build_pending_files_message(user_text)
|
||||
self._pending_files = []
|
||||
pid = (project_id or "").strip() or self._pending_files_project_id
|
||||
self._pending_files_project_id = ""
|
||||
if remaining:
|
||||
self._pending_files_flush_task = asyncio.create_task(
|
||||
self._flush_pending_files_after(self._PENDING_FILES_WINDOW_SEC)
|
||||
)
|
||||
else:
|
||||
self._pending_files_project_id = ""
|
||||
# create_task statt await — sonst blockt der RVS-recv-Loop bis Brain
|
||||
# fertig ist (siehe chat-handler oben).
|
||||
asyncio.create_task(self.send_to_core(text, source="app-file+chat", project_id=pid))
|
||||
@@ -1936,10 +1985,13 @@ class ARIABridge:
|
||||
url, data=payload, method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
# 20 Min Timeout — lange Multi-Tool-Workflows (Karten,
|
||||
# PDFs, viele curl-Calls) brauchen das. 5 Min waren chronisch
|
||||
# zu knapp und haben ARIA mitten in der Arbeit gekappt.
|
||||
with urllib.request.urlopen(req, timeout=1200) as resp:
|
||||
# Timeout MUSS zum Proxy passen (der laesst ARIA bis 24h
|
||||
# rechnen). 1200s (20 Min) war zu knapp: lange Software-Dev-
|
||||
# Turns dauern laenger → die Bridge gab auf, die Antwort ging
|
||||
# verloren (kein Bubble), der Kontext blieb auf 'running'
|
||||
# haengen. Jetzt 24h (env BRAIN_CHAT_TIMEOUT_SEC).
|
||||
_chat_timeout = float(os.environ.get("BRAIN_CHAT_TIMEOUT_SEC", "86400"))
|
||||
with urllib.request.urlopen(req, timeout=_chat_timeout) as resp:
|
||||
return resp.status, resp.read().decode("utf-8", errors="ignore")
|
||||
except Exception as exc:
|
||||
return None, str(exc)
|
||||
@@ -1987,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
|
||||
@@ -2050,6 +2106,22 @@ class ARIABridge:
|
||||
proj = event.get("project") or {}
|
||||
logger.info("[brain] Projekt %s: %s (id=%s)",
|
||||
event.get("action") or "?", proj.get("name"), proj.get("id"))
|
||||
elif etype == "aria_view":
|
||||
# M1: ARIA hat via present_view eine raeumliche Ansicht komponiert.
|
||||
# View-Spec (Orb + Karten) + Projekt-Kontext an App/Web/Diagnostic;
|
||||
# deren Renderer materialisieren die Karten auf der Flaeche.
|
||||
view = event.get("view") or {}
|
||||
await self._send_to_rvs({
|
||||
"type": "aria_view",
|
||||
"payload": {
|
||||
"view": view,
|
||||
"projectId": event.get("project_id") or "",
|
||||
"clientMsgId": client_msg_id or "",
|
||||
},
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
logger.info("[brain] ARIA hat eine Ansicht geschickt: %d Karte(n), orb=%s",
|
||||
len(view.get("cards") or []), view.get("orb"))
|
||||
|
||||
# _process_core_response uebernimmt alles weitere:
|
||||
# File-Marker extrahieren + broadcasten, NO_REPLY-Check, Chat-
|
||||
@@ -2062,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)
|
||||
@@ -2133,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())
|
||||
@@ -2364,7 +2438,8 @@ class ARIABridge:
|
||||
# gesendet), mergen wir sie zu einer einzigen Anfrage statt
|
||||
# zwei separater send_to_core-Calls.
|
||||
merged = await self._flush_pending_files_with_text(
|
||||
text, project_id=str(payload.get("projectId") or ""))
|
||||
text, project_id=str(payload.get("projectId") or ""),
|
||||
client_msg_id=client_msg_id or "")
|
||||
if merged:
|
||||
logger.info("[rvs] App-Chat (mit Anhaengen) project=%s: '%s'",
|
||||
str(payload.get("projectId") or "") or "(main)", text[:80])
|
||||
@@ -2406,6 +2481,20 @@ class ARIABridge:
|
||||
await self._emit_activity("idle", "", project_id=cancel_pid)
|
||||
return
|
||||
|
||||
if msg_type == "interject":
|
||||
# Zwischenruf: waehrend eines laufenden Turns eine Korrektur
|
||||
# reinschieben — KEIN Abbruch, keine Queue. Geht an den Proxy-
|
||||
# internen /interject, der die Message in den laufenden Subprozess
|
||||
# des Kontexts schreibt (claude greift sie an der naechsten Tool-
|
||||
# Grenze auf).
|
||||
interject_pid = str(payload.get("projectId") or "")
|
||||
interject_text = str(payload.get("text") or "")
|
||||
logger.info("[rvs] Zwischenruf project=%s: '%s'",
|
||||
interject_pid or "(main)", interject_text[:80])
|
||||
if interject_text.strip():
|
||||
await self._interject_proxy_for_project(interject_pid, interject_text)
|
||||
return
|
||||
|
||||
elif msg_type == "audio_pcm":
|
||||
# Audio-PCM geht direkt von XTTS-Bridge an die App.
|
||||
# Die aria-bridge darf es NICHT rebroadcasten — sonst bekommt die App
|
||||
@@ -2479,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:
|
||||
@@ -2503,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
|
||||
@@ -2658,8 +2747,11 @@ class ARIABridge:
|
||||
logger.warning("[rvs] Bild-Resize fehlgeschlagen (%s) — Original wird genutzt: %s",
|
||||
file_name, e)
|
||||
|
||||
# In Pending-Queue + Flush-Timer (anti-spam Buffering)
|
||||
self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0)))
|
||||
# In Pending-Queue + Flush-Timer (anti-spam Buffering).
|
||||
# clientMsgId mitpuffern → spaeterer Text-Flush ordnet die Datei
|
||||
# genau SEINER Nachricht zu (Queue-Korrelation, s. _flush_*).
|
||||
file_cmid = str(payload.get("clientMsgId") or "")
|
||||
self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0), file_cmid))
|
||||
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
|
||||
self._pending_files_flush_task.cancel()
|
||||
self._pending_files_flush_task = asyncio.create_task(
|
||||
@@ -2719,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:
|
||||
@@ -3305,11 +3407,18 @@ class ARIABridge:
|
||||
# weiter auseinander (man hoert den Wechsel erst).
|
||||
_norm_txt = " ".join(text.lower().split())
|
||||
_now_t = asyncio.get_event_loop().time()
|
||||
# Fenster bewusst KURZ (1.5s): es soll nur den Zwilling abfangen, den
|
||||
# zwei STT-Worker fuer DIESELBE Aufnahme liefern. Mit den frueheren 5s
|
||||
# fielen echte Wiederholungen weg — also genau der Fall "ARIA hat mich
|
||||
# nicht gehoert, ich sag's nochmal" und kurze Kommandos wie "lauter",
|
||||
# "nächstes", "stopp". Die eigentliche Absicherung ist ohnehin die
|
||||
# Idempotenz ueber audioRequestId weiter oben.
|
||||
_DUPE_WINDOW_SEC = 1.5
|
||||
if (_norm_txt
|
||||
and _norm_txt == getattr(self, "_last_endpoint_norm", "")
|
||||
and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < 5.0):
|
||||
logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <5s): %r",
|
||||
text[:60])
|
||||
and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < _DUPE_WINDOW_SEC):
|
||||
logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <%.1fs): %r",
|
||||
_DUPE_WINDOW_SEC, text[:60])
|
||||
return
|
||||
self._last_endpoint_norm = _norm_txt
|
||||
self._last_endpoint_at = _now_t
|
||||
@@ -3361,7 +3470,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():
|
||||
@@ -3370,7 +3479,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.
|
||||
@@ -3453,6 +3562,107 @@ 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 == "agent_history_query":
|
||||
# Diagnostic fragt die gespeicherte Historie eines Agenten ab.
|
||||
hid = (payload.get("host") or "").strip()
|
||||
events = self._agent_history_read(hid) if hid else []
|
||||
await self._send_to_rvs({
|
||||
"type": "agent_history_list",
|
||||
"payload": {"host": hid, "events": events},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
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)
|
||||
@@ -3478,11 +3688,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
|
||||
|
||||
@@ -3574,8 +3784,12 @@ class ARIABridge:
|
||||
r")\s*[.!?]?\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Trenner bewusst nur ':' und '-' — NICHT das Komma. Whisper setzt gern
|
||||
# Kommata, und damit matchten normale Saetze wie "In Ordnung, mach mal ..."
|
||||
# als Projekt-Prefix: die Nachricht wanderte still in ein Projekt und war im
|
||||
# Hauptchat unsichtbar ("wird gar nicht verarbeitet").
|
||||
_VOICE_META_PROJECT_PREFIX = re.compile(
|
||||
r"^\s*(?:aria[,.]?\s+)?(?:f(?:ü|ue)r|ins?)\s+([\w\-äöüßÄÖÜ]{2,40})[:\-,]\s*(.+?)\s*$",
|
||||
r"^\s*(?:aria[,.]?\s+)?(?:f(?:ü|ue)r|ins?)\s+([\w\-äöüßÄÖÜ]{2,40})\s*[:\-]\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
@@ -3630,7 +3844,10 @@ class ARIABridge:
|
||||
s = SequenceMatcher(None, q, pname).ratio()
|
||||
if s > best_score:
|
||||
best, best_score = p, s
|
||||
if best and best_score >= 0.6:
|
||||
# Schwelle bewusst hoch (0.82): bei 0.6 reichte schon eine vage
|
||||
# Aehnlichkeit, und die Nachricht verschwand still in einem
|
||||
# fremden Projekt. Lieber im Hauptchat lassen als falsch routen.
|
||||
if best and best_score >= 0.82:
|
||||
pid = best["id"]
|
||||
self._voice_sticky_project_id = pid
|
||||
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
|
||||
@@ -3642,8 +3859,10 @@ class ARIABridge:
|
||||
# Kein Match → als normale Nachricht weiter (Sticky wenn aktiv)
|
||||
# 3) Kein Meta / Prefix → Sticky oder Default
|
||||
if self._voice_sticky_project_id and now < self._voice_sticky_expires_at:
|
||||
# Sticky refreshen
|
||||
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
|
||||
# Sticky NICHT verlaengern: frueher hat jede Folgenachricht die Frist
|
||||
# erneuert — ein einziger Fehl-Match und danach landete dauerhaft
|
||||
# alles in diesem Projekt. Jetzt laeuft er nach der urspruenglichen
|
||||
# Zeit aus und der Hauptchat uebernimmt wieder.
|
||||
return (True, stripped, self._voice_sticky_project_id, "sticky")
|
||||
# Sticky abgelaufen — zurücksetzen
|
||||
self._voice_sticky_project_id = ""
|
||||
@@ -3835,15 +4054,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:
|
||||
@@ -3868,8 +4087,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,
|
||||
@@ -3880,7 +4106,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"}
|
||||
@@ -4101,6 +4327,28 @@ class ARIABridge:
|
||||
logger.info("[cancel] proxy /cancel project=%s: %s %s",
|
||||
project_id or "(main)", status, body)
|
||||
|
||||
async def _interject_proxy_for_project(self, project_id: str, text: str) -> None:
|
||||
"""Zwischenruf: schiebt eine User-Message in den laufenden Turn dieses
|
||||
Kontexts (proxy-internes /interject) — ohne Abbruch. claude greift sie
|
||||
an der naechsten Tool-Grenze auf."""
|
||||
url = os.environ.get("PROXY_INTERNAL_URL", "http://aria-proxy:3457") + "/interject"
|
||||
data = json.dumps({"projectId": project_id or "", "text": text}).encode("utf-8")
|
||||
|
||||
def _do_request():
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
url, method="POST", data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=3) as resp:
|
||||
return resp.status, resp.read().decode("utf-8", "ignore")[:200]
|
||||
except Exception as e:
|
||||
return f"error: {e}", ""
|
||||
|
||||
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_request)
|
||||
logger.info("[interject] proxy /interject project=%s: %s %s",
|
||||
project_id or "(main)", status, body)
|
||||
|
||||
async def _emit_activity(self, activity: str, tool: str = "", force: bool = False,
|
||||
project_id: str = "") -> None:
|
||||
"""Sendet agent_activity an die App — nur wenn sich der State geaendert hat.
|
||||
@@ -4338,6 +4586,38 @@ 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/agent-history":
|
||||
# Brain: ein Historie-Eintrag pro Host-Aktion (fuer die Diagnostic).
|
||||
try:
|
||||
data = json.loads(body.decode("utf-8", "ignore"))
|
||||
except Exception as exc:
|
||||
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
|
||||
return
|
||||
self._agent_history_append(data)
|
||||
await self._send_to_rvs({"type": "agent_history", "payload": data,
|
||||
"timestamp": int(time.time() * 1000)})
|
||||
await _send_response(writer, 200, {"ok": True})
|
||||
elif method == "POST" and path == "/internal/satellite":
|
||||
# Brain-Tool: Discovery oder Command an einen Satelliten.
|
||||
# body: {op:'discover'|'command', satellite, device?, action?, params?}
|
||||
@@ -4358,7 +4638,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
|
||||
@@ -4395,7 +4675,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"))
|
||||
@@ -4587,6 +4867,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,
|
||||
@@ -4623,6 +4957,89 @@ 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()]
|
||||
|
||||
_AGENT_HISTORY_DIR = "/shared/config/agent_history"
|
||||
_AGENT_HISTORY_MAX = 200
|
||||
|
||||
def _agent_history_path(self, host: str) -> str:
|
||||
safe = re.sub(r"[^a-zA-Z0-9_-]+", "-", host).strip("-") or "host"
|
||||
return os.path.join(self._AGENT_HISTORY_DIR, f"{safe}.jsonl")
|
||||
|
||||
def _agent_history_append(self, entry: dict) -> None:
|
||||
"""Haengt einen Historie-Eintrag an <hostId>.jsonl (Ringpuffer)."""
|
||||
host = (entry.get("host") or "").strip()
|
||||
if not host:
|
||||
return
|
||||
try:
|
||||
os.makedirs(self._AGENT_HISTORY_DIR, exist_ok=True)
|
||||
path = self._agent_history_path(host)
|
||||
lines = []
|
||||
if os.path.exists(path):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
lines = f.read().splitlines()
|
||||
lines.append(json.dumps(entry, ensure_ascii=False))
|
||||
lines = lines[-self._AGENT_HISTORY_MAX:]
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
except Exception as exc:
|
||||
logger.warning("[history] append fehlgeschlagen: %s", exc)
|
||||
|
||||
def _agent_history_read(self, host: str, limit: int = 200) -> list:
|
||||
try:
|
||||
path = self._agent_history_path(host)
|
||||
if not os.path.exists(path):
|
||||
return []
|
||||
out = []
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
out.append(json.loads(line))
|
||||
except Exception:
|
||||
pass
|
||||
return out[-limit:]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
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.
|
||||
|
||||
+1535
-265
File diff suppressed because it is too large
Load Diff
+590
-9
@@ -328,6 +328,87 @@ function writeLocalLlmConfig(patch) {
|
||||
return cur;
|
||||
}
|
||||
|
||||
// ── Jellyfin-Server (Medien-Integration) ─────────────────────
|
||||
// /shared/config/jellyfin.json — mehrere Server moeglich (eigener + z.B. vom
|
||||
// Kumpel). ARIAs Brain liest dieselbe Datei und spricht die HTTP-API direkt an:
|
||||
// Film suchen -> Sitzung des Abspielgeraets finden -> abspielen. Das ist
|
||||
// deutlich zuverlaessiger, als sich durch die Fire-TV-Oberflaeche zu klicken.
|
||||
const JELLYFIN_CONFIG_FILE = "/shared/config/jellyfin.json";
|
||||
function readJellyfinConfig() {
|
||||
try {
|
||||
const p = JSON.parse(fs.readFileSync(JELLYFIN_CONFIG_FILE, "utf-8"));
|
||||
const servers = Array.isArray(p.servers) ? p.servers : [];
|
||||
return {
|
||||
servers: servers.map(s => ({
|
||||
id: String(s.id || ""),
|
||||
name: String(s.name || ""),
|
||||
url: String(s.url || ""),
|
||||
apiKey: String(s.apiKey || ""),
|
||||
isDefault: !!s.isDefault,
|
||||
})).filter(s => s.id && s.url),
|
||||
};
|
||||
} catch {
|
||||
return { servers: [] };
|
||||
}
|
||||
}
|
||||
function writeJellyfinConfig(cfg) {
|
||||
const servers = (Array.isArray(cfg.servers) ? cfg.servers : [])
|
||||
.map(s => ({
|
||||
id: String(s.id || "").trim() || Math.random().toString(36).slice(2, 10),
|
||||
name: String(s.name || "").trim(),
|
||||
url: String(s.url || "").trim().replace(/\/+$/, ""), // ohne Slash am Ende
|
||||
apiKey: String(s.apiKey || "").trim(),
|
||||
isDefault: !!s.isDefault,
|
||||
}))
|
||||
.filter(s => s.url);
|
||||
// Genau einen Standard-Server markieren (der erste, falls keiner gesetzt ist).
|
||||
if (servers.length && !servers.some(s => s.isDefault)) servers[0].isDefault = true;
|
||||
let seen = false;
|
||||
for (const s of servers) {
|
||||
if (s.isDefault && seen) s.isDefault = false;
|
||||
if (s.isDefault) seen = true;
|
||||
}
|
||||
fs.mkdirSync("/shared/config", { recursive: true });
|
||||
const tmp = JELLYFIN_CONFIG_FILE + ".tmp";
|
||||
fs.writeFileSync(tmp, JSON.stringify({ servers }, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(tmp, JELLYFIN_CONFIG_FILE);
|
||||
try { fs.chmodSync(JELLYFIN_CONFIG_FILE, 0o600); } catch (_) {} // API-Keys!
|
||||
return { servers };
|
||||
}
|
||||
|
||||
/** Erreichbarkeit + Version eines Jellyfin-Servers pruefen. */
|
||||
function testJellyfin(url, apiKey) {
|
||||
return new Promise((resolve) => {
|
||||
let u;
|
||||
try { u = new URL(url.replace(/\/+$/, "") + "/System/Info"); }
|
||||
catch (e) { return resolve({ ok: false, error: "Ungültige URL" }); }
|
||||
const mod = u.protocol === "https:" ? require("https") : require("http");
|
||||
const req = mod.request(u, {
|
||||
method: "GET",
|
||||
timeout: 8000,
|
||||
headers: { "Authorization": `MediaBrowser Token="${apiKey}"` },
|
||||
}, (res) => {
|
||||
let body = "";
|
||||
res.on("data", c => { body += c; if (body.length > 65536) req.destroy(); });
|
||||
res.on("end", () => {
|
||||
if (res.statusCode === 401 || res.statusCode === 403) {
|
||||
return resolve({ ok: false, error: "API-Key abgelehnt (401/403)" });
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
return resolve({ ok: false, error: `HTTP ${res.statusCode}` });
|
||||
}
|
||||
try {
|
||||
const j = JSON.parse(body);
|
||||
resolve({ ok: true, name: j.ServerName || "", version: j.Version || "" });
|
||||
} catch (e) { resolve({ ok: false, error: "Antwort nicht lesbar" }); }
|
||||
});
|
||||
});
|
||||
req.on("timeout", () => { req.destroy(); resolve({ ok: false, error: "Zeitüberschreitung" }); });
|
||||
req.on("error", (e) => resolve({ ok: false, error: e.message }));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Lokale Modell-Liste (Diagnostic-Dropdown) ────────────────
|
||||
// /shared/config/local_models.json — kuratierte Liste; muss zu den KEYS in
|
||||
// xtts/llama-swap/config.yaml passen. Wird bei Bedarf mit Defaults seeded.
|
||||
@@ -349,6 +430,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 +631,99 @@ 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,
|
||||
ip: h.ip || "", 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,
|
||||
openaiPort: w.openaiPort || "", openaiBind: w.openaiBind || "",
|
||||
openaiKeySet: !!w.openaiKeySet, openaiEnvLocked: !!w.openaiEnvLocked,
|
||||
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 +1163,105 @@ 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, ip: p.ip || "",
|
||||
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 === "llm_openai_config_result") {
|
||||
broadcast({ type: "llm_openai_config_result", payload: msg.payload || {} });
|
||||
} else if (msg.type === "sat_result") {
|
||||
// Ergebnis einer Satelliten-Aktion → an den Browser (z.B. APK-Rollout).
|
||||
broadcast({ type: "sat_result", payload: msg.payload || {} });
|
||||
} else if (msg.type === "agent_history" || msg.type === "agent_history_list") {
|
||||
// Live-Event (agent_history) bzw. Abfrage-Antwort (agent_history_list)
|
||||
// der Bridge → an den Browser (Agent-Historie).
|
||||
broadcast({ type: msg.type, 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] : []),
|
||||
// OpenAI-Endpunkt (nur llm-Worker): Port/Bind kommen aus der .env der
|
||||
// Box, der Key wird NIE uebertragen — nur ob einer gesetzt ist und ob
|
||||
// die .env ihn vorgibt (dann ist das Feld hier schreibgeschuetzt).
|
||||
openaiPort: p.openaiPort || "", openaiBind: p.openaiBind || "",
|
||||
openaiKeySet: !!p.openaiKeySet, openaiEnvLocked: !!p.openaiEnvLocked,
|
||||
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 +1308,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 +1323,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 +1373,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 +1381,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() }));
|
||||
});
|
||||
@@ -1559,6 +1892,70 @@ function dockerExec(containerName, cmd) {
|
||||
});
|
||||
}
|
||||
|
||||
// POST gegen die Docker-Daemon-API (via gemountetem Socket). Fuer prune-
|
||||
// Endpoints — die geben SpaceReclaimed (Bytes) zurueck.
|
||||
function dockerApiPost(apiPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({
|
||||
socketPath: "/var/run/docker.sock",
|
||||
path: apiPath,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Content-Length": 0 },
|
||||
}, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (c) => data += c);
|
||||
res.on("end", () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try { resolve(JSON.parse(data || "{}")); } catch { resolve({}); }
|
||||
} else {
|
||||
reject(new Error(`Docker API ${apiPath}: HTTP ${res.statusCode} — ${String(data).slice(0, 200)}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// "Sicher aufraeumen": Build-Cache + ungenutzte Images prunen — OHNE Volumes
|
||||
// (keine Daten weg). "aggressive": zusaetzlich gestoppte Container + ungenutzte
|
||||
// Volumes (kann Daten kosten → nur auf ausdrueckliche Wahl). Fuehrt es WIRKLICH
|
||||
// aus (frueher kopierte der Button nur den Befehl in die Zwischenablage).
|
||||
async function handleDiskCleanup(clientWs, variant) {
|
||||
const aggressive = variant === "aggressive";
|
||||
const send = (o) => { try { clientWs.send(JSON.stringify(o)); } catch (_) {} };
|
||||
send({ type: "disk_cleanup", status: "running", variant });
|
||||
log("warn", "server", `Disk-Cleanup gestartet (${aggressive ? "aggressive" : "safe"})`);
|
||||
try {
|
||||
let reclaimed = 0;
|
||||
const steps = [];
|
||||
const bp = await dockerApiPost("/build/prune?all=true");
|
||||
reclaimed += (bp.SpaceReclaimed || 0);
|
||||
steps.push("Build-Cache");
|
||||
// dangling=false → ALLE ungenutzten Images (nicht nur dangling).
|
||||
// Docker-API-Filterformat: map[string][]string.
|
||||
const imgFilter = encodeURIComponent(JSON.stringify({ dangling: ["false"] }));
|
||||
const ip = await dockerApiPost("/images/prune?filters=" + imgFilter);
|
||||
reclaimed += (ip.SpaceReclaimed || 0);
|
||||
steps.push("ungenutzte Images");
|
||||
if (aggressive) {
|
||||
const cp = await dockerApiPost("/containers/prune");
|
||||
reclaimed += (cp.SpaceReclaimed || 0);
|
||||
steps.push("gestoppte Container");
|
||||
const vp = await dockerApiPost("/volumes/prune");
|
||||
reclaimed += (vp.SpaceReclaimed || 0);
|
||||
steps.push("ungenutzte Volumes");
|
||||
}
|
||||
const mb = (reclaimed / (1024 * 1024));
|
||||
const freed = mb >= 1024 ? (mb / 1024).toFixed(2) + " GB" : mb.toFixed(0) + " MB";
|
||||
log("info", "server", `Disk-Cleanup fertig: ${freed} frei (${steps.join(", ")})`);
|
||||
send({ type: "disk_cleanup", status: "done", variant, reclaimedBytes: reclaimed, freed, steps });
|
||||
} catch (err) {
|
||||
log("error", "server", `Disk-Cleanup fehlgeschlagen: ${err.message}`);
|
||||
send({ type: "disk_cleanup", status: "error", variant, error: String(err && err.message || err) });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hilfsfunktionen ─────────────────────────────────────
|
||||
|
||||
function waitForMessage(ws, timeoutMs) {
|
||||
@@ -1645,6 +2042,19 @@ const server = http.createServer((req, res) => {
|
||||
"Expires": "0",
|
||||
});
|
||||
res.end(fs.readFileSync(htmlPath, "utf-8"));
|
||||
} else if (req.url.startsWith("/uploads/")) {
|
||||
// Screenshots aus der Agent-Historie (nur /shared/uploads, kein Traversal).
|
||||
const base = path.basename(decodeURIComponent(req.url.slice("/uploads/".length)));
|
||||
const file = path.join("/shared/uploads", base);
|
||||
if (!file.startsWith("/shared/uploads/") || !fs.existsSync(file)) {
|
||||
res.writeHead(404); res.end("not found"); return;
|
||||
}
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
const ct = ext === ".png" ? "image/png"
|
||||
: (ext === ".jpg" || ext === ".jpeg") ? "image/jpeg"
|
||||
: ext === ".webp" ? "image/webp" : "application/octet-stream";
|
||||
res.writeHead(200, { "Content-Type": ct, "Cache-Control": "max-age=86400" });
|
||||
res.end(fs.readFileSync(file));
|
||||
} else if (req.url === "/api/state") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ state, logs: logs.slice(-100) }));
|
||||
@@ -1674,6 +2084,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()));
|
||||
@@ -1692,6 +2116,39 @@ const server = http.createServer((req, res) => {
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else if (req.url === "/api/jellyfin-config" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(readJellyfinConfig()));
|
||||
} else if (req.url === "/api/jellyfin-config" && req.method === "POST") {
|
||||
let body = "";
|
||||
req.on("data", chunk => { body += chunk; if (body.length > 65536) req.destroy(); });
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const cfg = writeJellyfinConfig(JSON.parse(body));
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, config: cfg }));
|
||||
log("info", "server", `Jellyfin-Config: ${cfg.servers.length} Server gespeichert`);
|
||||
} catch (err) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: err.message }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else if (req.url === "/api/jellyfin-test" && req.method === "POST") {
|
||||
let body = "";
|
||||
req.on("data", chunk => { body += chunk; if (body.length > 8192) req.destroy(); });
|
||||
req.on("end", async () => {
|
||||
try {
|
||||
const { url, apiKey } = JSON.parse(body);
|
||||
const r = await testJellyfin(String(url || ""), String(apiKey || ""));
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(r));
|
||||
} catch (err) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: err.message }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else if (req.url === "/api/onboarding") {
|
||||
// RVS-Credentials fuer QR-Code App-Onboarding
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
@@ -2441,6 +2898,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 {
|
||||
@@ -2455,6 +2916,16 @@ wss.on("connection", (ws) => {
|
||||
} else if (msg.action === "test_rvs") {
|
||||
traceStart("RVS", msg.text || "aria lebst du noch?");
|
||||
sendToRVS(msg.text || "aria lebst du noch?", true, msg.projectId || "");
|
||||
} else if (msg.action === "interject") {
|
||||
// Zwischenruf: in den laufenden Turn schieben (kein Abbruch, keine
|
||||
// Queue) → RVS interject → Bridge → Proxy /interject.
|
||||
const t = String(msg.text || "");
|
||||
if (t.trim()) {
|
||||
sendToRVS_raw({ type: "interject", payload: { projectId: msg.projectId || "", text: t }, timestamp: Date.now() });
|
||||
log("info", "server", "Zwischenruf an RVS (project=" + (msg.projectId || "(main)") + "): " + t.slice(0, 60));
|
||||
}
|
||||
} else if (msg.action === "disk_cleanup") {
|
||||
handleDiskCleanup(ws, msg.variant === "aggressive" ? "aggressive" : "safe");
|
||||
} else if (msg.action === "reconnect_gateway") {
|
||||
connectGateway();
|
||||
} else if (msg.action === "reconnect_rvs") {
|
||||
@@ -2462,11 +2933,84 @@ 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 === "host_forget") {
|
||||
// Alten/umbenannten Agenten aus der Liste werfen. Meldet er sich wieder
|
||||
// (host_hello), taucht er automatisch neu auf — das ist also gefahrlos.
|
||||
const id = String(msg.hostId || "");
|
||||
if (id && hosts.delete(id)) {
|
||||
log("info", "host", `Agent '${id}' aus der Liste entfernt`);
|
||||
broadcastHosts();
|
||||
}
|
||||
} 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 === "sat_command") {
|
||||
// Browser loest eine Satelliten-Aktion aus (z.B. APK auf Fire TV ausrollen).
|
||||
// Die Antwort (sat_result) kommt ueber die rvsWs zurueck.
|
||||
const params = msg.params && typeof msg.params === "object" ? msg.params : {};
|
||||
// Fern-Einrichtung: der RVS-Zugang wird SERVERSEITIG ergaenzt — so muss
|
||||
// niemand Host/Token im Browser eintippen (und das Token steht nicht im
|
||||
// Browser-Formular). Gilt fuer adb.provision und adb.install mit Setup.
|
||||
if (params.provision === true) {
|
||||
delete params.provision;
|
||||
params.rvs_host = RVS_HOST;
|
||||
params.rvs_port = Number(RVS_PORT) || 443;
|
||||
params.rvs_tls = RVS_TLS === "true";
|
||||
params.rvs_token = RVS_TOKEN;
|
||||
}
|
||||
sendToRVS_raw({
|
||||
type: "sat_command",
|
||||
payload: {
|
||||
satellite: String(msg.satellite || ""),
|
||||
device: String(msg.device || ""),
|
||||
action: String(msg.cmd || ""),
|
||||
params,
|
||||
requestId: String(msg.requestId || ""),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else if (msg.action === "llm_openai_config") {
|
||||
// Browser setzt den API-Key des OpenAI-Endpunkts einer Box.
|
||||
sendToRVS_raw({
|
||||
type: "llm_openai_config",
|
||||
payload: {
|
||||
instanceId: String(msg.instanceId || ""),
|
||||
apiKey: String(msg.apiKey || ""),
|
||||
requestId: String(msg.requestId || ""),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else if (msg.action === "agent_history_query") {
|
||||
// Browser will die gespeicherte Historie eines Agenten — die Bridge
|
||||
// antwortet mit agent_history_list (auf derselben rvsWs).
|
||||
sendToRVS_raw({ type: "agent_history_query",
|
||||
payload: { host: String(msg.host || "") }, 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") {
|
||||
@@ -2494,19 +3038,23 @@ 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})`);
|
||||
} else if (msg.action === "cancel_request") {
|
||||
// Laufende Anfrage abbrechen — doctor --fix beendet stuck runs
|
||||
log("warn", "server", "Anfrage abgebrochen — fuehre doctor --fix aus");
|
||||
// Laufende Anfrage abbrechen — ECHTER Cancel: RVS cancel_request (hard)
|
||||
// an die Bridge, die den Proxy-/cancel-all Side-Channel anruft und den
|
||||
// laufenden claude-Subprozess killt. Das alte `openclaw doctor --fix`
|
||||
// zielte auf den Container aria-core, den es nicht mehr gibt — es
|
||||
// beendete den Run nie (ARIA lief munter weiter).
|
||||
log("warn", "server", "Anfrage abgebrochen — cancel_request (hard) an Bridge/Proxy");
|
||||
pendingMessageTime = 0;
|
||||
watchdogWarned = false;
|
||||
watchdogFixAttempted = false;
|
||||
if (traceActive) traceEnd(false, "Vom Benutzer abgebrochen");
|
||||
broadcast({ type: "agent_activity", activity: "idle" });
|
||||
dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {});
|
||||
sendToRVS_raw({ type: "cancel_request", payload: { hard: true, source: "diagnostic-cancel" }, timestamp: Date.now() });
|
||||
} else if (msg.action === "aria_panic_stop") {
|
||||
// NOT-AUS aus ARIA-Live-View: lokales /api/cancel UND Hard-Kill via
|
||||
// Bridge (die wiederum den Proxy-Side-Channel /cancel-all anruft).
|
||||
@@ -2533,9 +3081,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.
|
||||
@@ -2603,6 +3153,12 @@ wss.on("connection", (ws) => {
|
||||
const t = parseFloat(msg.voiceIdThreshold);
|
||||
if (t >= 0.0 && t <= 1.0) voiceConfig.voiceIdThreshold = t;
|
||||
}
|
||||
// Speaker-ID Gating an/aus ("nur meine Stimme"). Default aus (fail-open) —
|
||||
// bewusster Schalter. voxtral/whisper-bridge lesen voiceIdEnabled aus dem
|
||||
// config-Broadcast; aus = gar keine Pruefung.
|
||||
if (msg.voiceIdEnabled !== undefined) {
|
||||
voiceConfig.voiceIdEnabled = !!msg.voiceIdEnabled;
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync("/shared/config", { recursive: true });
|
||||
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
|
||||
@@ -2618,6 +3174,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
-5
@@ -11,11 +11,7 @@ services:
|
||||
npm install -g @anthropic-ai/claude-code claude-max-api-proxy &&
|
||||
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
|
||||
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
|
||||
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
|
||||
sed -i 's/\"--dangerously-skip-permissions\",/\"--dangerously-skip-permissions\",\"--system-prompt\",options.systemPrompt,/' $$DIST/subprocess/manager.js &&
|
||||
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 86400000;/' $$DIST/subprocess/manager.js &&
|
||||
sed -i '/prompt, \\/\\/ Pass prompt as argument/d' $$DIST/subprocess/manager.js &&
|
||||
sed -i 's|this\\.process\\.stdin?\\.end();|this.process.stdin?.end(prompt);|' $$DIST/subprocess/manager.js &&
|
||||
cp /proxy-patches/manager.js $$DIST/subprocess/manager.js &&
|
||||
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
|
||||
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
|
||||
cp /proxy-patches/routes.js $$DIST/server/routes.js &&
|
||||
|
||||
@@ -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,197 @@
|
||||
# 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, `gui`-Methode |
|
||||
| `screenshot` | Bildschirmfoto (X11: scrot/maim · Wayland: grim) |
|
||||
| `ui_tap` | Mausklick auf x,y (button/double optional) *(nur mit GUI)* |
|
||||
| `ui_text` | Text ins fokussierte Feld tippen *(nur mit GUI)* |
|
||||
| `ui_key` | Taste/Kombi (Return, Escape, ctrl+c …) *(nur mit GUI)* |
|
||||
| `ui_swipe` | Maus ziehen x1,y1 → x2,y2 *(nur mit GUI)* |
|
||||
| `app_launch` | App/Programm starten (`app`/`query` oder `command`) *(nur mit GUI)* |
|
||||
|
||||
ARIA nutzt diese über die Brain-Tools `host_list`/`host_exec`/`host_read`/
|
||||
`host_write`/`host_info`/`host_screenshot` sowie `host_ui_tap`/`host_ui_text`/
|
||||
`host_ui_key`/`host_ui_swipe`/`host_app_launch` (dieselben Tools wie beim
|
||||
Android-Agenten — ein Werkzeugset für Handy **und** Rechner).
|
||||
|
||||
> **GUI-Steuerung ist capability-gated.** Der Agent meldet die `ui_*`/`app_launch`-
|
||||
> Fähigkeiten in seinen `caps` nur, wenn eine grafische Session da ist — ein reiner
|
||||
> Terminal-/Server-Linux (kein `DISPLAY`/`WAYLAND_DISPLAY`) bekommt sie **nicht**
|
||||
> (dort bleibt es bei `exec` & Co.). ARIA arbeitet mit dem **Screenshot** als Augen
|
||||
> (voller Auflösung = echte Pixel; auf dem Desktop gibt es kein `ui_dump`).
|
||||
>
|
||||
> Nötige Helfer je System: **Linux/X11** → `xdotool`; **Linux/Wayland** → `ydotool`
|
||||
> (Daemon, meist root); **macOS** → Text/Tasten via Bordmittel (`osascript`), Maus
|
||||
> via `cliclick` (`brew install cliclick`); **Windows** → Bordmittel (PowerShell).
|
||||
> HiDPI/Retina-Hinweis: Screenshot-Pixel können 2× der Klick-Koordinaten sein — bei
|
||||
> Fehlklicks Koordinaten halbieren.
|
||||
|
||||
## 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 | GUI-Steuerung (`ui_*`) |
|
||||
|---|---|---|---|---|
|
||||
| **Linux** | `bash -lc` | sudo (`SUDO_PASSWORD`/`SUDO_NOPASSWD`) / root | grim (Wayland) · scrot/maim (X11) | `xdotool` (X11) · `ydotool` (Wayland) |
|
||||
| **macOS** | `bash -lc` | sudo (wie Linux) | `screencapture` (Bordmittel) | `osascript` (Text/Tasten) · `cliclick` (Maus) |
|
||||
| **Windows** | PowerShell | Agent **als Administrator** starten (kein sudo) | PowerShell/System.Drawing (Bordmittel) | PowerShell (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,270 @@
|
||||
# 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.
|
||||
|
||||
### Fire TV / Android TV
|
||||
|
||||
**Dieselbe APK** läuft auf Handy und Stick — die TV-Fähigkeiten schalten sich
|
||||
automatisch frei (`caps` enthalten dann `tv`). Zusätzlich auf dem TV:
|
||||
|
||||
| Action | Was |
|
||||
|---|---|
|
||||
| `notify` | **Einblendung über dem laufenden Bild** (Text/Bild) — ohne Netflix & Co. zu stoppen; oder als Sprache, je nach Einstellung (`mode` überschreibt) |
|
||||
| `speak` | Sprachausgabe über die TV-Lautsprecher (ARIAs echte Stimme, sonst Geräte-TTS) |
|
||||
| `volume` | Lautstärke setzen (`percent`) |
|
||||
| `listen` / `listen_stop` | Mikrofon öffnen/schließen — ARIA-Sprachdialog auf dem Gerät |
|
||||
| `ui_key` | zusätzlich D-Pad: `up/down/left/right/ok`, `play_pause/next/prev`, `lauter/leiser/stumm` |
|
||||
|
||||
**Sprachdialog:** Die App spricht das gleiche Protokoll wie die ARIA-App
|
||||
(`stt_stream_*` rein, `chat` + `audio_pcm` raus) — **serverseitig ist nichts
|
||||
anzupassen**. In den App-Einstellungen wählst Du, wie ARIA hier antwortet:
|
||||
**Einblendung** (Standard) oder **Sprache**; sie kann es pro Anfrage überschreiben.
|
||||
|
||||
> **Turn-Ownership:** Der RVS verteilt jede Antwort an *alle* Clients im Raum.
|
||||
> Damit nicht Handy **und** Stick gleichzeitig losreden, gibt dieses Gerät nur
|
||||
> aus, was aus **seiner eigenen** Frage entstanden ist.
|
||||
|
||||
> **Mikrofon der Fire-TV-Fernbedienung:** Die Mikro-Taste ist systemseitig mit
|
||||
> Alexa belegt; ob Fremd-Apps an das Remote-Mikro kommen, ist je nach Stick-
|
||||
> Generation unterschiedlich und **muss am Gerät ausprobiert werden**. Sicher
|
||||
> funktioniert der **Sprech-Knopf in der App** (OK-Taste) — und falls das Remote-
|
||||
> Mikro nicht hergibt, tut es ein USB-Mikro am Stick.
|
||||
|
||||
Berechtigungen einmalig in der App: **„Einblendung erlauben"** (Über anderen Apps
|
||||
anzeigen) und beim ersten Sprechen das **Mikrofon**.
|
||||
|
||||
### Ausrollen per Satellit (ohne Datei-Kopieren)
|
||||
|
||||
Statt die APK von Hand aufs Gerät zu schaufeln, kann ein **Satellit** sie per ADB
|
||||
über das Netz installieren:
|
||||
|
||||
1. Am Fire TV: **Einstellungen › Mein Fire TV › Entwickleroptionen › ADB-Debugging** an.
|
||||
2. Diagnostic → Satelliten → scannen. ADB-fähige Geräte zeigen ein **ADB**-Tag
|
||||
und einen **📦**-Button.
|
||||
3. 📦 → APK-URL (aus dem Gitea-Release) → **Jetzt ausrollen**. Beim ersten Mal
|
||||
erscheint auf dem TV „USB-Debugging zulassen?" → bestätigen (Haken „Immer erlauben").
|
||||
Der Rollout **wartet darauf** und macht dann automatisch weiter.
|
||||
|
||||
**Einrichtung ohne Tippen:** Am Fire TV gibt es keine Kamera (kein QR-Scan) und
|
||||
Host/Token mit der Fernbedienung einzugeben ist eine Qual. Deshalb ist im Rollout-
|
||||
Dialog **„Gleich einrichten"** vorausgewählt: die Diagnostic kennt den RVS-Zugang
|
||||
selbst und gibt ihn dem Agenten über dieselbe ADB-Verbindung mit
|
||||
(`adb.provision` → `am start` mit Intent-Extras). Der Agent speichert die Daten,
|
||||
startet den Dienst und ist **sofort online** — null Eingaben am Fernseher.
|
||||
Das Token geht dabei **serverseitig** raus, es steht nie im Browser-Formular.
|
||||
|
||||
Mit **„Status prüfen"** (`adb.status`) siehst Du jederzeit, ob der Agent auf dem
|
||||
Gerät installiert ist, welche Version läuft und ob der Dienst aktiv ist.
|
||||
|
||||
> **Sicherheitshinweis:** Die Einrichtung per Intent geht an die (exportierte)
|
||||
> Haupt-Activity. Wer ADB-Zugriff auf das Gerät hat, kann den Agenten damit
|
||||
> umkonfigurieren — wer ADB hat, kontrolliert das Gerät ohnehin. Die App zeigt
|
||||
> beim Empfang einen Hinweis („Von ARIA eingerichtet"), damit es nicht unbemerkt
|
||||
> passiert. Auf einem dedizierten Stick ist das unkritisch; schalte ADB-Debugging
|
||||
> nach dem Ausrollen ab, wenn Du ganz sicher gehen willst.
|
||||
|
||||
ARIA kann dasselbe: `satellite_command` mit `action='adb.install'`,
|
||||
`params={'ip':'…','url':'…'}`. Der Satellit lädt die APK **einmal** und kann sie
|
||||
auf beliebig viele Sticks installieren.
|
||||
|
||||
> **⚠️ Gerät muss ENTSPERRT sein, damit ARIA es steuern kann.** Gesten und
|
||||
> Systemtasten (`ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch`) greifen bei
|
||||
> gesperrtem Bildschirm nicht — Android-Sicherheit. Der Agent erkennt das und
|
||||
> meldet „Gerät ist gesperrt — bitte erst entsperren", statt still „ok" zu sagen.
|
||||
> **Sehen** (`screenshot`, `ui_dump`, `info`) funktioniert dagegen auch bei
|
||||
> gesperrtem Gerät.
|
||||
|
||||
## 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 (`canPerformGestures`, `dispatchGesture`, `ACTION_SET_TEXT`,
|
||||
`performGlobalAction`) → Brain-Tools `host_ui_tap`/`_text`/`_swipe`/`_key`/
|
||||
`host_app_launch`. Tap-Koordinaten = die x/y aus `ui_dump` (echte Pixel), NICHT
|
||||
aus dem (skalierten) Screenshot. → ARIA bedient Apps (E-Mail-Setup).
|
||||
4. **Feinschliff** — `app_list`/`notify`, Build-Härtung, `release_agent.sh`.
|
||||
|
||||
## 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–M3 fertig** — verbinden, `info`, `screenshot`, `ui_dump` (sehen)
|
||||
> und `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` (steuern);
|
||||
> `release_agent.sh` vorhanden. Damit läuft der volle Ablauf sehen→steuern
|
||||
> (z.B. E-Mail-Konto einrichten). Nächstes: Feinschliff (M4).
|
||||
@@ -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 102
|
||||
versionName '0.0.1.2'
|
||||
}
|
||||
|
||||
// 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,89 @@
|
||||
<?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.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<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" />
|
||||
<!-- app_launch: Start-Intents/App-Labels sind ab Android 11 sonst unsichtbar. -->
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
<!-- Overlay: ARIA blendet Text/Bild UEBER das laufende Programm ein (Fire TV).
|
||||
Muss vom Nutzer einmalig erlaubt werden (Spezialberechtigung). -->
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<!-- Sprache: Mikrofon (Fire-TV-Fernbedienung / USB-Mikro) fuer ARIA. -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-feature android:name="android.hardware.microphone" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<!-- Fire TV / Android TV: dieselbe APK laeuft auf Stick UND Handy.
|
||||
touchscreen/faketouch NICHT erforderlich (Fire TV hat keinen), und
|
||||
leanback ebenfalls optional (sonst laeuft sie nicht mehr aufs Handy). -->
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.faketouch" android:required="false" />
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:banner="@drawable/banner_tv"
|
||||
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:launchMode="singleTop"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<!-- Ohne LEANBACK_LAUNCHER taucht die App im Fire-TV-Menue NICHT
|
||||
auf (sie waere installiert, aber nicht startbar). -->
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Unsichtbarer Helfer: schaltet die Bildschirm-Aufnahme nach einem
|
||||
Neustart automatisch wieder scharf (die Sitzung ist nicht speicherbar,
|
||||
nur die Berechtigung). Durchsichtig, ohne Animation -> man sieht nichts. -->
|
||||
<activity
|
||||
android:name=".CaptureActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:taskAffinity=""
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
|
||||
<service
|
||||
android:name=".AgentService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync|mediaProjection" />
|
||||
|
||||
<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,64 @@
|
||||
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,
|
||||
/** Wie antwortet ARIA auf diesem Geraet standardmaessig: "overlay" oder "speech".
|
||||
* Pro Anfrage ueberschreibbar ("...bitte als Sprache"). */
|
||||
var replyMode: String = "overlay",
|
||||
/** Soll die Bildschirm-Aufnahme nach einem Neustart automatisch wieder scharf
|
||||
* geschaltet werden? Wird gesetzt, sobald sie einmal erlaubt wurde — die
|
||||
* SITZUNG ist naemlich nicht speicherbar, nur die Berechtigung. */
|
||||
var captureEnabled: 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),
|
||||
replyMode = p.getString("replyMode", "overlay") ?: "overlay",
|
||||
captureEnabled = p.getBoolean("capture", 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)
|
||||
putString("replyMode", replyMode)
|
||||
putBoolean("capture", captureEnabled)
|
||||
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,186 @@
|
||||
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.content.pm.ServiceInfo
|
||||
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 ACTION_TALK = "de.hackersoft.ariaagent.TALK"
|
||||
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()
|
||||
startForegroundDataSync("startet …")
|
||||
}
|
||||
|
||||
/** Normaler Vordergrund-Start (Typ dataSync). mediaProjection wird NUR beim
|
||||
* Projizieren gesetzt — auf Android 14 darf man diesen Typ sonst nicht ohne
|
||||
* Projection-Token verwenden. */
|
||||
private fun startForegroundDataSync(text: String) {
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
startForeground(NOTIF_ID, buildNotification(text),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(NOTIF_ID, buildNotification(text))
|
||||
}
|
||||
}
|
||||
|
||||
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).
|
||||
startForegroundDataSync(status)
|
||||
|
||||
if (intent?.action == ACTION_TALK) {
|
||||
// Sprech-Taste: laeuft gerade eine Aufnahme -> beenden, sonst starten.
|
||||
val v = rvs?.voice
|
||||
if (v == null) startRvs()
|
||||
else if (v.isRecording) v.stopListening("user") else v.startListening()
|
||||
return START_STICKY
|
||||
}
|
||||
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 {
|
||||
// Android verlangt beim Projizieren einen Vordergrund-Dienst vom
|
||||
// Typ mediaProjection — VOR getMediaProjection() setzen, sonst
|
||||
// SecurityException (FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION).
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
startForeground(
|
||||
NOTIF_ID, buildNotification("Bildschirm-Zugriff aktiv"),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION or
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
)
|
||||
}
|
||||
ScreenCapturer.start(applicationContext, code, data)
|
||||
if (ScreenCapturer.active) {
|
||||
// Merken: nach einem Neustart automatisch wieder scharf
|
||||
// schalten (die Sitzung selbst ist nicht speicherbar).
|
||||
AgentConfig.load(this).takeIf { !it.captureEnabled }?.let {
|
||||
it.captureEnabled = true; it.save(this)
|
||||
}
|
||||
updateNotification("$status · Bildschirm-Zugriff aktiv")
|
||||
} else {
|
||||
updateNotification("Bildschirm-Fehler: ${ScreenCapturer.lastError}")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
ScreenCapturer.lastError = "${e.javaClass.simpleName}: ${e.message}"
|
||||
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()
|
||||
|
||||
// Bildschirm-Aufnahme nach Geraete-/App-Neustart automatisch wieder scharf
|
||||
// schalten. Die Berechtigung (AppOp PROJECT_MEDIA) bleibt erhalten, die
|
||||
// SITZUNG nicht — ohne das muesste man am Fernseher jedes Mal von Hand
|
||||
// "Bildschirm-Zugriff erlauben" druecken. Ist der AppOp gesetzt, laeuft
|
||||
// die Anfrage ohne Dialog durch; sonst fragt Android einmal nach.
|
||||
if (cfg.captureEnabled && !ScreenCapturer.active) {
|
||||
android.os.Handler(mainLooper).postDelayed({
|
||||
if (!ScreenCapturer.active) {
|
||||
try { CaptureActivity.request(applicationContext) } catch (_: Throwable) {}
|
||||
}
|
||||
}, 2500)
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
try { rvs?.voice?.shutdown() } catch (_: Exception) {}
|
||||
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))
|
||||
}
|
||||
}
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
package de.hackersoft.ariaagent
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.accessibilityservice.GestureDescription
|
||||
import android.graphics.Path
|
||||
import android.graphics.Rect
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Steuern (Meilenstein 3) ────────────────────────────────────
|
||||
|
||||
/** Tippt auf Bildschirm-Koordinaten (Pixel wie in ui_dump x/y). */
|
||||
fun tap(x: Int, y: Int): JSONObject {
|
||||
val path = Path().apply { moveTo(x.toFloat(), y.toFloat()) }
|
||||
return gesture(path, 0, 60, "Tippen ($x,$y)")
|
||||
}
|
||||
|
||||
/** Wischt von (x1,y1) nach (x2,y2) ueber dauerMs (Scrollen/Swipen). */
|
||||
fun swipe(x1: Int, y1: Int, x2: Int, y2: Int, durationMs: Int): JSONObject {
|
||||
val path = Path().apply {
|
||||
moveTo(x1.toFloat(), y1.toFloat())
|
||||
lineTo(x2.toFloat(), y2.toFloat())
|
||||
}
|
||||
return gesture(path, 0, durationMs.coerceIn(50, 5000).toLong(),
|
||||
"Wischen ($x1,$y1 -> $x2,$y2)")
|
||||
}
|
||||
|
||||
private fun gesture(path: Path, startMs: Long, durationMs: Long, desc: String): JSONObject {
|
||||
val g = GestureDescription.Builder()
|
||||
.addStroke(GestureDescription.StrokeDescription(path, startMs, durationMs))
|
||||
.build()
|
||||
val latch = CountDownLatch(1)
|
||||
val ok = AtomicBoolean(false)
|
||||
val dispatched = dispatchGesture(g, object : GestureResultCallback() {
|
||||
override fun onCompleted(d: GestureDescription?) { ok.set(true); latch.countDown() }
|
||||
override fun onCancelled(d: GestureDescription?) { latch.countDown() }
|
||||
}, null)
|
||||
if (!dispatched) return errMsg("Geste konnte nicht ausgeloest werden ($desc)")
|
||||
try { latch.await(6, TimeUnit.SECONDS) } catch (_: InterruptedException) {}
|
||||
return if (ok.get()) okMsg("$desc ausgefuehrt") else errMsg("$desc abgebrochen/timeout")
|
||||
}
|
||||
|
||||
/** Schreibt Text in ein Eingabefeld an (x,y) — oder in das fokussierte Feld. */
|
||||
fun setText(x: Int, y: Int, text: String): JSONObject {
|
||||
val root = rootInActiveWindow ?: return errMsg("kein aktives Fenster")
|
||||
val node = editableAt(root, x, y)
|
||||
?: root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)?.takeIf { it.isEditable }
|
||||
?: return errMsg("kein Textfeld an ($x,$y) gefunden — vorher ui_tap aufs Feld?")
|
||||
node.performAction(AccessibilityNodeInfo.ACTION_FOCUS)
|
||||
val args = Bundle().apply {
|
||||
putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text)
|
||||
}
|
||||
val done = node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
|
||||
return if (done) okMsg("Text gesetzt (${text.length} Zeichen)")
|
||||
else errMsg("Text setzen fehlgeschlagen (Feld nicht editierbar?)")
|
||||
}
|
||||
|
||||
/** Tiefste editierbare Node, deren Rahmen (x,y) enthaelt. */
|
||||
private fun editableAt(node: AccessibilityNodeInfo?, x: Int, y: Int): AccessibilityNodeInfo? {
|
||||
if (node == null) return null
|
||||
var found: AccessibilityNodeInfo? = null
|
||||
for (i in 0 until node.childCount) {
|
||||
editableAt(node.getChild(i), x, y)?.let { found = it }
|
||||
}
|
||||
if (found != null) return found
|
||||
val r = Rect(); node.getBoundsInScreen(r)
|
||||
return if (node.isEditable && r.contains(x, y)) node else null
|
||||
}
|
||||
|
||||
/** Globale Taste: back/home/recents/notifications — plus D-Pad (Fire TV). */
|
||||
fun globalKey(name: String): JSONObject {
|
||||
val key = name.trim().lowercase()
|
||||
// D-Pad/Medien: auf dem Fernseher wird damit navigiert (kein Touch).
|
||||
DPAD_KEYS[key]?.let { return dpad(it, key) }
|
||||
val action = when (key) {
|
||||
"back", "zurueck", "zurück" -> GLOBAL_ACTION_BACK
|
||||
"home", "start", "startseite" -> GLOBAL_ACTION_HOME
|
||||
"recents", "letzte", "uebersicht", "übersicht" -> GLOBAL_ACTION_RECENTS
|
||||
"notifications", "benachrichtigungen" -> GLOBAL_ACTION_NOTIFICATIONS
|
||||
else -> return errMsg("Taste '$name' unbekannt. Verfuegbar: back, home, " +
|
||||
"recents, notifications, up, down, left, right, ok/center, " +
|
||||
"play_pause, next, prev, volume_up, volume_down, mute")
|
||||
}
|
||||
return if (performGlobalAction(action)) okMsg("Taste '$name' ausgefuehrt")
|
||||
else errMsg("Taste '$name' fehlgeschlagen")
|
||||
}
|
||||
|
||||
/** D-Pad/Medien-Taste — via Fokus-Navigation bzw. Medien-Session. */
|
||||
private fun dpad(keyCode: Int, label: String): JSONObject {
|
||||
// Lautstaerke/Stumm gehen ueber den AudioManager (zuverlaessiger als KeyEvent).
|
||||
when (keyCode) {
|
||||
android.view.KeyEvent.KEYCODE_VOLUME_UP,
|
||||
android.view.KeyEvent.KEYCODE_VOLUME_DOWN,
|
||||
android.view.KeyEvent.KEYCODE_VOLUME_MUTE -> {
|
||||
val am = getSystemService(android.content.Context.AUDIO_SERVICE)
|
||||
as android.media.AudioManager
|
||||
val dir = when (keyCode) {
|
||||
android.view.KeyEvent.KEYCODE_VOLUME_UP -> android.media.AudioManager.ADJUST_RAISE
|
||||
android.view.KeyEvent.KEYCODE_VOLUME_DOWN -> android.media.AudioManager.ADJUST_LOWER
|
||||
else -> android.media.AudioManager.ADJUST_TOGGLE_MUTE
|
||||
}
|
||||
am.adjustStreamVolume(android.media.AudioManager.STREAM_MUSIC, dir,
|
||||
android.media.AudioManager.FLAG_SHOW_UI)
|
||||
return okMsg("Taste '$label' ausgefuehrt")
|
||||
}
|
||||
}
|
||||
// Medientasten an die aktive Wiedergabe-Session.
|
||||
if (keyCode in MEDIA_KEYS) {
|
||||
val am = getSystemService(android.content.Context.AUDIO_SERVICE)
|
||||
as android.media.AudioManager
|
||||
val now = android.os.SystemClock.uptimeMillis()
|
||||
am.dispatchMediaKeyEvent(android.view.KeyEvent(now, now,
|
||||
android.view.KeyEvent.ACTION_DOWN, keyCode, 0))
|
||||
am.dispatchMediaKeyEvent(android.view.KeyEvent(now, now,
|
||||
android.view.KeyEvent.ACTION_UP, keyCode, 0))
|
||||
return okMsg("Taste '$label' ausgefuehrt")
|
||||
}
|
||||
// Richtungen/OK: ueber die Bedienungshilfe-Fokus-Navigation.
|
||||
val root = rootInActiveWindow ?: return errMsg("kein aktives Fenster")
|
||||
val focus = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
|
||||
?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)
|
||||
if (keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER) {
|
||||
val target = focus ?: return errMsg("nichts fokussiert — vorher navigieren")
|
||||
return if (target.performAction(AccessibilityNodeInfo.ACTION_CLICK))
|
||||
okMsg("OK/Auswahl ausgefuehrt") else errMsg("Auswahl fehlgeschlagen")
|
||||
}
|
||||
val dir = when (keyCode) {
|
||||
android.view.KeyEvent.KEYCODE_DPAD_UP -> View.FOCUS_UP
|
||||
android.view.KeyEvent.KEYCODE_DPAD_DOWN -> View.FOCUS_DOWN
|
||||
android.view.KeyEvent.KEYCODE_DPAD_LEFT -> View.FOCUS_LEFT
|
||||
else -> View.FOCUS_RIGHT
|
||||
}
|
||||
val from = focus ?: root
|
||||
val next = from.focusSearch(dir)
|
||||
?: return errMsg("kein Element in Richtung '$label' gefunden")
|
||||
return if (next.performAction(AccessibilityNodeInfo.ACTION_FOCUS))
|
||||
okMsg("Taste '$label' ausgefuehrt") else errMsg("Taste '$label' fehlgeschlagen")
|
||||
}
|
||||
|
||||
private fun okMsg(m: String): JSONObject =
|
||||
JSONObject().put("ok", true).put("result", JSONObject().put("message", m))
|
||||
|
||||
private fun errMsg(m: String): JSONObject =
|
||||
JSONObject().put("ok", false).put("error", m)
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
var instance: AriaAccessibilityService? = null
|
||||
|
||||
val available: Boolean get() = instance != null
|
||||
|
||||
/** Fernseher-Navigation (Fire TV): D-Pad, Medien- und Lautstaerke-Tasten. */
|
||||
private val DPAD_KEYS: Map<String, Int> = mapOf(
|
||||
"up" to android.view.KeyEvent.KEYCODE_DPAD_UP,
|
||||
"hoch" to android.view.KeyEvent.KEYCODE_DPAD_UP,
|
||||
"down" to android.view.KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
"runter" to android.view.KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
"left" to android.view.KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
"links" to android.view.KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
"right" to android.view.KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
"rechts" to android.view.KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
"ok" to android.view.KeyEvent.KEYCODE_DPAD_CENTER,
|
||||
"center" to android.view.KeyEvent.KEYCODE_DPAD_CENTER,
|
||||
"select" to android.view.KeyEvent.KEYCODE_DPAD_CENTER,
|
||||
"enter" to android.view.KeyEvent.KEYCODE_DPAD_CENTER,
|
||||
"play_pause" to android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
|
||||
"play" to android.view.KeyEvent.KEYCODE_MEDIA_PLAY,
|
||||
"pause" to android.view.KeyEvent.KEYCODE_MEDIA_PAUSE,
|
||||
"stop" to android.view.KeyEvent.KEYCODE_MEDIA_STOP,
|
||||
"next" to android.view.KeyEvent.KEYCODE_MEDIA_NEXT,
|
||||
"prev" to android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS,
|
||||
"forward" to android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD,
|
||||
"rewind" to android.view.KeyEvent.KEYCODE_MEDIA_REWIND,
|
||||
"volume_up" to android.view.KeyEvent.KEYCODE_VOLUME_UP,
|
||||
"lauter" to android.view.KeyEvent.KEYCODE_VOLUME_UP,
|
||||
"volume_down" to android.view.KeyEvent.KEYCODE_VOLUME_DOWN,
|
||||
"leiser" to android.view.KeyEvent.KEYCODE_VOLUME_DOWN,
|
||||
"mute" to android.view.KeyEvent.KEYCODE_VOLUME_MUTE,
|
||||
"stumm" to android.view.KeyEvent.KEYCODE_VOLUME_MUTE,
|
||||
)
|
||||
|
||||
private val MEDIA_KEYS: Set<Int> = setOf(
|
||||
android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
|
||||
android.view.KeyEvent.KEYCODE_MEDIA_PLAY,
|
||||
android.view.KeyEvent.KEYCODE_MEDIA_PAUSE,
|
||||
android.view.KeyEvent.KEYCODE_MEDIA_STOP,
|
||||
android.view.KeyEvent.KEYCODE_MEDIA_NEXT,
|
||||
android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS,
|
||||
android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD,
|
||||
android.view.KeyEvent.KEYCODE_MEDIA_REWIND,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.hackersoft.ariaagent
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
|
||||
/**
|
||||
* Audio-Fokus — damit ARIA nicht einfach ueber den laufenden Film redet.
|
||||
*
|
||||
* Ohne Fokus mischt Android beide Tonspuren: Netflix/Jellyfin/Spotify laufen in
|
||||
* voller Lautstaerke weiter und ARIA ist kaum zu verstehen. Mit Fokus verhaelt
|
||||
* sie sich wie ein normaler Sprachassistent:
|
||||
*
|
||||
* "duck" (Standard) laufende Medien werden LEISER, ARIA spricht darueber,
|
||||
* danach geht die Lautstaerke von selbst wieder hoch.
|
||||
* "pause" laufende Medien werden PAUSIERT (Player reagieren auf
|
||||
* den Fokusverlust) — sinnvoll beim Zuhoeren, damit das
|
||||
* Mikrofon nicht den Filmton aufnimmt.
|
||||
* "none" kein Fokus (alte Mischung).
|
||||
*/
|
||||
object AudioFocusHelper {
|
||||
|
||||
private var request: AudioFocusRequest? = null
|
||||
private var holder: String = ""
|
||||
|
||||
/** Fordert den Fokus an. [mode] = duck | pause | none. */
|
||||
@Synchronized
|
||||
fun request(ctx: Context, mode: String, who: String): Boolean {
|
||||
if (mode == "none") return true
|
||||
abandon(ctx)
|
||||
val gain = if (mode == "pause") AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
|
||||
else AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
|
||||
val usage = if (who == "listen") AudioAttributes.USAGE_ASSISTANCE_SONIFICATION
|
||||
else AudioAttributes.USAGE_ASSISTANT
|
||||
return try {
|
||||
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val req = AudioFocusRequest.Builder(gain)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(usage)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
)
|
||||
// Kurze Unterbrechungen nicht als "verloren" behandeln.
|
||||
.setWillPauseWhenDucked(false)
|
||||
.setOnAudioFocusChangeListener { }
|
||||
.build()
|
||||
request = req
|
||||
holder = who
|
||||
am.requestAudioFocus(req) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Gibt den Fokus zurueck — die Medien werden wieder lauter / laufen weiter. */
|
||||
@Synchronized
|
||||
fun abandon(ctx: Context) {
|
||||
val req = request ?: return
|
||||
request = null
|
||||
holder = ""
|
||||
try {
|
||||
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
am.abandonAudioFocusRequest(req)
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,65 @@
|
||||
package de.hackersoft.ariaagent
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Bundle
|
||||
|
||||
/**
|
||||
* Unsichtbarer Helfer, der die Bildschirm-Aufnahme (neu) scharf schaltet.
|
||||
*
|
||||
* Hintergrund: Die Berechtigung (AppOp PROJECT_MEDIA) bleibt dauerhaft erhalten,
|
||||
* die MediaProjection-SITZUNG aber nicht — Android laesst sie grundsaetzlich
|
||||
* nicht speichern, sie stirbt mit dem Prozess. Nach einem Neustart des Geraets
|
||||
* oder der App muesste man sonst jedes Mal von Hand "Bildschirm-Zugriff erlauben"
|
||||
* druecken (auf einem Fire TV mit der Fernbedienung besonders laestig).
|
||||
*
|
||||
* Diese Activity loest die Anfrage automatisch aus und reicht das Ergebnis an den
|
||||
* Dienst weiter. Ist PROJECT_MEDIA per ADB erlaubt, laeuft das OHNE Dialog durch —
|
||||
* der Nutzer sieht nichts (durchsichtiges Theme, kein Fenster-Inhalt).
|
||||
*/
|
||||
class CaptureActivity : Activity() {
|
||||
|
||||
companion object {
|
||||
private const val REQ = 4711
|
||||
|
||||
/** Startet die (Neu-)Scharfschaltung aus dem Dienst heraus. */
|
||||
fun request(ctx: Context) {
|
||||
val i = Intent(ctx, CaptureActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS or
|
||||
Intent.FLAG_ACTIVITY_NO_ANIMATION)
|
||||
ctx.startActivity(i)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
try {
|
||||
val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
startActivityForResult(mpm.createScreenCaptureIntent(), REQ)
|
||||
} catch (e: Throwable) {
|
||||
ScreenCapturer.lastError = "Anfrage fehlgeschlagen: ${e.message}"
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == REQ && resultCode == RESULT_OK && data != null) {
|
||||
val i = Intent(this, AgentService::class.java)
|
||||
.setAction(AgentService.ACTION_PROJECTION)
|
||||
.putExtra(AgentService.EXTRA_RESULT_CODE, resultCode)
|
||||
.putExtra(AgentService.EXTRA_RESULT_DATA, data)
|
||||
androidx.core.content.ContextCompat.startForegroundService(this, i)
|
||||
} else {
|
||||
// Abgelehnt oder (ohne AppOp) Dialog weggeklickt — kein Drama, der
|
||||
// Nutzer kann es in der App jederzeit nachholen.
|
||||
ScreenCapturer.lastError =
|
||||
"Bildschirm-Zugriff nicht erteilt (in der Agent-App nachholen)"
|
||||
}
|
||||
finish()
|
||||
overridePendingTransition(0, 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
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.RadioButton
|
||||
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 micPerm = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (granted) talkToAria() else toast("Ohne Mikrofon-Berechtigung kein Sprachdialog")
|
||||
}
|
||||
|
||||
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.btnTalk).setOnClickListener {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
|
||||
== PackageManager.PERMISSION_GRANTED) talkToAria()
|
||||
else micPerm.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
findViewById<Button>(R.id.btnOverlayPerm).setOnClickListener {
|
||||
if (OverlayView.canDraw(this)) { toast("Einblendung ist bereits erlaubt"); return@setOnClickListener }
|
||||
try {
|
||||
startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
android.net.Uri.parse("package:$packageName")))
|
||||
toast("'ARIA Host-Agent' erlauben")
|
||||
} catch (_: Exception) {
|
||||
try { startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION)) }
|
||||
catch (_: Exception) { toast("Einstellung nicht gefunden") }
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// Fern-Einrichtung (z.B. Fire TV, wo QR-Scan und Tippen wegfallen): der
|
||||
// Satellit startet die App per ADB mit den Zugangsdaten als Extras.
|
||||
// Hat das geklappt, ist die UI schon befuellt — sonst normal laden.
|
||||
if (!applyProvisioning(intent)) loadIntoUi(AgentConfig.load(this))
|
||||
|
||||
// Fire TV: kein Touchscreen — der QR-Scan ist dort sinnlos (keine Kamera),
|
||||
// und der erste Fokus muss gesetzt sein, damit die Fernbedienung greift.
|
||||
if (isTvDevice()) {
|
||||
findViewById<Button>(R.id.btnScan).visibility = android.view.View.GONE
|
||||
host.requestFocus()
|
||||
}
|
||||
|
||||
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
|
||||
findViewById<RadioButton>(
|
||||
if (c.replyMode == "speech") R.id.modeSpeech else R.id.modeOverlay
|
||||
).isChecked = true
|
||||
}
|
||||
|
||||
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,
|
||||
replyMode = if (findViewById<RadioButton>(R.id.modeSpeech).isChecked) "speech" else "overlay",
|
||||
)
|
||||
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"
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
super.onNewIntent(intent)
|
||||
if (intent == null) return
|
||||
setIntent(intent)
|
||||
applyProvisioning(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fern-Einrichtung per Intent-Extras (vom Satelliten via ADB gestartet).
|
||||
*
|
||||
* Auf einem Fire TV gibt es keine Kamera (kein QR) und Tippen mit der
|
||||
* Fernbedienung ist muehsam — deshalb kann der Satellit die Zugangsdaten
|
||||
* direkt mitgeben:
|
||||
* adb shell am start -n de.hackersoft.ariaagent/.MainActivity \
|
||||
* -a de.hackersoft.ariaagent.PROVISION \
|
||||
* -e host rvs.example.com --ei port 443 --ez tls true -e token XYZ
|
||||
*
|
||||
* Gibt true zurueck, wenn eingerichtet wurde (dann startet der Dienst gleich).
|
||||
*/
|
||||
private fun applyProvisioning(intent: Intent): Boolean {
|
||||
val host = intent.getStringExtra("host")?.trim().orEmpty()
|
||||
val token = intent.getStringExtra("token")?.trim().orEmpty()
|
||||
if (host.isEmpty() || token.isEmpty()) return false
|
||||
|
||||
val old = AgentConfig.load(this)
|
||||
val cfg = AgentConfig(
|
||||
host = host,
|
||||
port = intent.getIntExtra("port", old.port.takeIf { it > 0 } ?: 443),
|
||||
tls = intent.getBooleanExtra("tls", old.tls),
|
||||
token = token,
|
||||
name = intent.getStringExtra("name")?.trim().takeUnless { it.isNullOrEmpty() } ?: old.name,
|
||||
controlEnabled = intent.getBooleanExtra("control", true),
|
||||
replyMode = intent.getStringExtra("reply_mode")?.trim()
|
||||
.takeUnless { it.isNullOrEmpty() } ?: old.replyMode,
|
||||
// Der Satellit erteilt beim Einrichten PROJECT_MEDIA — dann darf sich
|
||||
// die Bildschirm-Aufnahme nach jedem Neustart selbst scharf schalten.
|
||||
captureEnabled = intent.getBooleanExtra("capture", old.captureEnabled),
|
||||
)
|
||||
cfg.save(this)
|
||||
AgentService.start(this)
|
||||
// Sichtbar machen, dass die Konfiguration von aussen kam (Transparenz).
|
||||
toast("Von ARIA eingerichtet: ${cfg.host} — Agent gestartet")
|
||||
loadIntoUi(cfg)
|
||||
refreshStatus()
|
||||
return true
|
||||
}
|
||||
|
||||
/** Sprech-Taste: startet/stoppt die Aufnahme im laufenden Dienst. */
|
||||
private fun talkToAria() {
|
||||
if (!AgentConfig.load(this).isValid()) { toast("Erst verbinden"); return }
|
||||
ContextCompat.startForegroundService(this,
|
||||
Intent(this, AgentService::class.java).setAction(AgentService.ACTION_TALK))
|
||||
toast("Sprich jetzt — nochmal drücken beendet die Aufnahme")
|
||||
}
|
||||
|
||||
/** Fire TV / Android TV (kein Touchscreen, Bedienung per Fernbedienung). */
|
||||
private fun isTvDevice(): Boolean = try {
|
||||
packageManager.hasSystemFeature("android.software.leanback") ||
|
||||
packageManager.hasSystemFeature("android.hardware.type.television")
|
||||
} catch (_: Exception) { false }
|
||||
|
||||
private fun toast(m: String) = Toast.makeText(this, m, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package de.hackersoft.ariaagent
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.util.Base64
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
|
||||
/**
|
||||
* Einblendung ueber dem laufenden Bild (Fire TV / Android TV).
|
||||
*
|
||||
* ARIA kann Text (und optional ein Bild) UEBER Netflix & Co. anzeigen, ohne die
|
||||
* Wiedergabe zu stoppen — fuer Antworten, Hinweise, Tuerklingel-Schnappschuesse.
|
||||
* Technisch ein TYPE_APPLICATION_OVERLAY-Fenster; der Nutzer muss die
|
||||
* Spezialberechtigung "Ueber anderen Apps anzeigen" einmalig erteilen.
|
||||
*
|
||||
* Nicht fokussierbar (FLAG_NOT_FOCUSABLE) — die Fernbedienung steuert weiter die
|
||||
* App darunter, die Einblendung schluckt keine Tastendruecke.
|
||||
*/
|
||||
object OverlayView {
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private var root: LinearLayout? = null
|
||||
private var hideRunnable: Runnable? = null
|
||||
|
||||
/** Hat der Nutzer "Ueber anderen Apps anzeigen" erlaubt? */
|
||||
fun canDraw(ctx: Context): Boolean =
|
||||
if (Build.VERSION.SDK_INT >= 23) Settings.canDrawOverlays(ctx) else true
|
||||
|
||||
/**
|
||||
* Zeigt eine Einblendung. [seconds] = Anzeigedauer (0 = bis zum naechsten
|
||||
* hide()). [imageB64] optional ein PNG/JPEG als Base64.
|
||||
* Muss NICHT vom Main-Thread gerufen werden.
|
||||
*/
|
||||
fun show(ctx: Context, title: String, text: String, seconds: Int, imageB64: String?) {
|
||||
main.post {
|
||||
try {
|
||||
hideNow(ctx)
|
||||
val wm = ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
|
||||
val box = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(36, 28, 36, 28)
|
||||
background = GradientDrawable().apply {
|
||||
cornerRadius = 22f
|
||||
setColor(Color.parseColor("#E60D0D1A")) // dunkel, leicht transparent
|
||||
setStroke(3, Color.parseColor("#3FFF9F"))
|
||||
}
|
||||
}
|
||||
if (title.isNotBlank()) {
|
||||
box.addView(TextView(ctx).apply {
|
||||
setText(title)
|
||||
setTextColor(Color.parseColor("#3FFF9F"))
|
||||
textSize = 22f
|
||||
setPadding(0, 0, 0, 10)
|
||||
})
|
||||
}
|
||||
if (text.isNotBlank()) {
|
||||
box.addView(TextView(ctx).apply {
|
||||
setText(text)
|
||||
setTextColor(Color.parseColor("#E0E0F0"))
|
||||
textSize = 19f
|
||||
maxLines = 8
|
||||
})
|
||||
}
|
||||
imageB64?.let { b64 ->
|
||||
try {
|
||||
val bytes = Base64.decode(b64, Base64.DEFAULT)
|
||||
val bmp = android.graphics.BitmapFactory
|
||||
.decodeByteArray(bytes, 0, bytes.size)
|
||||
if (bmp != null) {
|
||||
box.addView(ImageView(ctx).apply {
|
||||
setImageBitmap(bmp)
|
||||
adjustViewBounds = true
|
||||
maxHeight = 520
|
||||
setPadding(0, 14, 0, 0)
|
||||
})
|
||||
}
|
||||
} catch (_: Exception) { /* Bild optional */ }
|
||||
}
|
||||
|
||||
val type = if (Build.VERSION.SDK_INT >= 26)
|
||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
||||
else
|
||||
@Suppress("DEPRECATION") WindowManager.LayoutParams.TYPE_SYSTEM_ALERT
|
||||
val lp = WindowManager.LayoutParams(
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
type,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
|
||||
android.graphics.PixelFormat.TRANSLUCENT,
|
||||
).apply {
|
||||
gravity = Gravity.TOP or Gravity.END
|
||||
x = 48
|
||||
y = 48
|
||||
width = (ctx.resources.displayMetrics.widthPixels * 0.42f).toInt()
|
||||
}
|
||||
|
||||
wm.addView(box, lp)
|
||||
root = box
|
||||
|
||||
if (seconds > 0) {
|
||||
hideRunnable = Runnable { hideNow(ctx) }
|
||||
main.postDelayed(hideRunnable!!, seconds * 1000L)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
root = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Blendet eine laufende Einblendung aus. */
|
||||
fun hide(ctx: Context) {
|
||||
main.post { hideNow(ctx) }
|
||||
}
|
||||
|
||||
private fun hideNow(ctx: Context) {
|
||||
hideRunnable?.let { main.removeCallbacks(it) }
|
||||
hideRunnable = null
|
||||
val v: View? = root
|
||||
root = null
|
||||
if (v != null) {
|
||||
try {
|
||||
(ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager).removeView(v)
|
||||
} catch (_: Exception) { /* schon weg */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
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
|
||||
|
||||
/** Sprachdialog mit ARIA (Mikro rein, Sprache/Einblendung raus). */
|
||||
val voice: VoiceSession by lazy {
|
||||
VoiceSession(appCtx, config,
|
||||
send = { t, p -> ws?.let { send(it, t, p) } },
|
||||
onEvent = { msg -> onStatus(true, msg) })
|
||||
}
|
||||
|
||||
/** Fire TV / Android TV? Dann gibt es keinen Touchscreen, dafuer D-Pad. */
|
||||
private val isTv: Boolean by lazy {
|
||||
try {
|
||||
val pm = appCtx.packageManager
|
||||
pm.hasSystemFeature("android.software.leanback") ||
|
||||
pm.hasSystemFeature("android.hardware.type.television") ||
|
||||
(appCtx.getSystemService(Context.UI_MODE_SERVICE) as android.app.UiModeManager)
|
||||
.currentModeType == android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
private val caps: List<String> by lazy {
|
||||
val base = mutableListOf("info", "screenshot", "ui_dump",
|
||||
"ui_tap", "ui_text", "ui_swipe", "ui_key", "app_launch",
|
||||
// Ausgabe auf dem Geraet: Einblendung ueber dem Bild + Sprachausgabe.
|
||||
"notify", "speak", "volume",
|
||||
// Sprachdialog: Mikro oeffnen/schliessen (ARIA kann es auch anstossen).
|
||||
"listen", "listen_stop")
|
||||
if (isTv) base += "tv" // Kennzeichen: dieses Geraet ist ein Fernseher/Stick
|
||||
base
|
||||
}
|
||||
|
||||
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())
|
||||
val kind = if (isTv) "Fire TV / Android TV" else "Android"
|
||||
p.put("os", "$kind ${Build.VERSION.RELEASE} (${Build.MODEL})")
|
||||
// Eigene LAN-IP mitmelden: so kann die Diagnostic diesen Agenten dem im
|
||||
// Netz entdeckten Geraet zuordnen (z.B. um beim Ausrollen den bereits
|
||||
// vergebenen Namen vorzuschlagen statt der technischen Kennung).
|
||||
localIp()?.let { p.put("ip", it) }
|
||||
p.put("caps", JSONArray(caps))
|
||||
p.put("control", config.controlEnabled)
|
||||
send(webSocket, "host_hello", p)
|
||||
}
|
||||
|
||||
/** Erste echte IPv4 dieses Geraets im LAN (ohne Loopback). */
|
||||
private fun localIp(): String? = try {
|
||||
java.net.NetworkInterface.getNetworkInterfaces().toList()
|
||||
.filter { it.isUp && !it.isLoopback }
|
||||
.flatMap { it.inetAddresses.toList() }
|
||||
.firstOrNull { !it.isLoopbackAddress && it is java.net.Inet4Address }
|
||||
?.hostAddress
|
||||
} catch (_: Throwable) { null }
|
||||
|
||||
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 }
|
||||
val type = msg.optString("type")
|
||||
// Sprachdialog: chat/audio_pcm/stt_* laufen ueber dieselbe Verbindung.
|
||||
// (Der RVS broadcastet alles im Raum — VoiceSession filtert auf den
|
||||
// eigenen Turn, damit nicht auch Handy und Stick gleichzeitig antworten.)
|
||||
if (type != "host_command") {
|
||||
if (type in VOICE_TYPES) {
|
||||
try { voice.handle(type, msg.optJSONObject("payload") ?: JSONObject()) }
|
||||
catch (_: Throwable) {}
|
||||
}
|
||||
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 params = payload.optJSONObject("params") ?: JSONObject()
|
||||
// WICHTIG: JEDE Aktion muss ein host_result liefern — auch bei Exception
|
||||
// ODER OutOfMemoryError (Throwable!). Sonst bekommt ARIA statt einer
|
||||
// Fehlermeldung nur einen Timeout (kein Result kommt zurueck).
|
||||
val result: JSONObject = try {
|
||||
when {
|
||||
!config.controlEnabled ->
|
||||
err("Steuerung ist in der Agent-App deaktiviert (Schalter 'Steuerung erlauben').")
|
||||
action == "info" -> doInfo()
|
||||
action == "screenshot" -> doScreenshot()
|
||||
action == "ui_dump" -> doUiDump()
|
||||
// Steuern geht nur bei ENTSPERRTEM Gerät (Gesten/Tasten greifen sonst
|
||||
// nicht — Android-Sicherheit). Sehen (screenshot/ui_dump) geht gesperrt.
|
||||
action in CONTROL_ACTIONS && isLocked() ->
|
||||
err("Gerät ist gesperrt — zum Steuern bitte erst entsperren. " +
|
||||
"(Screenshot/Bildschirm lesen geht auch gesperrt.)")
|
||||
action == "ui_tap" -> doUiTap(params)
|
||||
action == "ui_text" -> doUiText(params)
|
||||
action == "ui_swipe" -> doUiSwipe(params)
|
||||
action == "ui_key" -> doUiKey(params)
|
||||
action == "app_launch" -> doAppLaunch(params)
|
||||
action == "notify" -> doNotify(params)
|
||||
action == "speak" -> doSpeak(params)
|
||||
action == "volume" -> doVolume(params)
|
||||
action == "listen" -> voice.startListening()
|
||||
action == "listen_stop" -> voice.stopListening("remote")
|
||||
action == "app_list" ->
|
||||
err("Aktion '$action' kommt spaeter (noch nicht implementiert).")
|
||||
else -> err("Aktion '$action' unbekannt.")
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
err("Fehler bei '$action': ${t.javaClass.simpleName}: ${t.message}")
|
||||
}
|
||||
result.put("requestId", payload.optString("requestId"))
|
||||
result.put("hostId", config.hostId())
|
||||
result.put("action", action)
|
||||
send(webSocket, "host_result", result)
|
||||
}
|
||||
|
||||
/** Steuer-Aktionen, die ein entsperrtes Gerät brauchen. */
|
||||
private val CONTROL_ACTIONS = setOf("ui_tap", "ui_text", "ui_swipe", "ui_key", "app_launch")
|
||||
|
||||
/** Message-Typen des ARIA-Sprachdialogs (kein host_command). */
|
||||
private val VOICE_TYPES = setOf(
|
||||
"chat", "audio_pcm", "stt_endpoint", "stt_stream_done", "stt_partial")
|
||||
|
||||
private fun isLocked(): Boolean = try {
|
||||
val km = appCtx.getSystemService(Context.KEYGUARD_SERVICE) as android.app.KeyguardManager
|
||||
km.isKeyguardLocked
|
||||
} catch (_: Exception) { false }
|
||||
|
||||
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: ${ScreenCapturer.lastError ?: "unbekannt"}")
|
||||
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 = a11y() ?: return a11yMissing()
|
||||
return svc.dump()
|
||||
}
|
||||
|
||||
private fun a11y(): AriaAccessibilityService? = AriaAccessibilityService.instance
|
||||
|
||||
private fun a11yMissing(): JSONObject =
|
||||
err("Bedienungshilfe nicht aktiv. In der Agent-App 'Bedienungshilfe öffnen' " +
|
||||
"antippen und 'ARIA Host-Agent' einschalten.")
|
||||
|
||||
/** Tippen auf Koordinaten (Pixel wie in ui_dump x/y). */
|
||||
private fun doUiTap(p: JSONObject): JSONObject {
|
||||
val svc = a11y() ?: return a11yMissing()
|
||||
if (!p.has("x") || !p.has("y")) return err("ui_tap braucht x und y (aus ui_dump).")
|
||||
return svc.tap(p.optInt("x"), p.optInt("y"))
|
||||
}
|
||||
|
||||
/** Text in Feld an (x,y) schreiben. */
|
||||
private fun doUiText(p: JSONObject): JSONObject {
|
||||
val svc = a11y() ?: return a11yMissing()
|
||||
val text = p.optString("text")
|
||||
if (!p.has("x") || !p.has("y")) return err("ui_text braucht x, y und text.")
|
||||
return svc.setText(p.optInt("x"), p.optInt("y"), text)
|
||||
}
|
||||
|
||||
/** Wischen/Scrollen von (x1,y1) nach (x2,y2). */
|
||||
private fun doUiSwipe(p: JSONObject): JSONObject {
|
||||
val svc = a11y() ?: return a11yMissing()
|
||||
if (!p.has("x1") || !p.has("y1") || !p.has("x2") || !p.has("y2"))
|
||||
return err("ui_swipe braucht x1,y1,x2,y2 (optional duration_ms).")
|
||||
return svc.swipe(p.optInt("x1"), p.optInt("y1"), p.optInt("x2"), p.optInt("y2"),
|
||||
p.optInt("duration_ms", 300))
|
||||
}
|
||||
|
||||
/** Globale Taste: back/home/recents/notifications. */
|
||||
private fun doUiKey(p: JSONObject): JSONObject {
|
||||
val svc = a11y() ?: return a11yMissing()
|
||||
return svc.globalKey(p.optString("key"))
|
||||
}
|
||||
|
||||
/** App starten (per Paketname oder Namens-Suche). */
|
||||
private fun doAppLaunch(p: JSONObject): JSONObject {
|
||||
val pm = appCtx.packageManager
|
||||
var pkg = p.optString("package").trim()
|
||||
val query = p.optString("query").trim()
|
||||
if (pkg.isBlank() && query.isNotBlank()) {
|
||||
pkg = resolvePackage(query) ?: return err("Keine App zu '$query' gefunden.")
|
||||
}
|
||||
if (pkg.isBlank()) return err("app_launch braucht 'package' ODER 'query' (App-Name).")
|
||||
val intent = pm.getLaunchIntentForPackage(pkg)
|
||||
?: return err("App '$pkg' nicht installiert oder ohne Start-Symbol.")
|
||||
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
appCtx.startActivity(intent)
|
||||
return JSONObject().put("ok", true)
|
||||
.put("result", JSONObject().put("message", "App '$pkg' gestartet"))
|
||||
}
|
||||
|
||||
/**
|
||||
* ARIAs Antwort auf dem Geraet ausgeben: Einblendung (Overlay) und/oder Sprache.
|
||||
* `mode` optional ("overlay" | "speech" | "both") — ohne Angabe gilt die
|
||||
* Einstellung aus der App (config.replyMode). So kann ARIA pro Anfrage
|
||||
* ueberschreiben ("...bitte als Sprache"), ohne die Voreinstellung zu aendern.
|
||||
*/
|
||||
private fun doNotify(p: JSONObject): JSONObject {
|
||||
val text = p.optString("text").ifBlank { p.optString("message") }
|
||||
val title = p.optString("title", "ARIA")
|
||||
if (text.isBlank() && !p.has("image_base64")) return err("notify braucht 'text'.")
|
||||
val mode = p.optString("mode").ifBlank { config.replyMode }
|
||||
val done = mutableListOf<String>()
|
||||
val problems = mutableListOf<String>()
|
||||
|
||||
if (mode == "overlay" || mode == "both") {
|
||||
if (!OverlayView.canDraw(appCtx)) {
|
||||
problems += "Einblendung nicht erlaubt — in der Agent-App auf dem Gerät " +
|
||||
"'Einblendung erlauben' antippen (Berechtigung 'Über anderen Apps anzeigen')"
|
||||
} else {
|
||||
OverlayView.show(appCtx, title, text,
|
||||
p.optInt("seconds", 12),
|
||||
if (p.has("image_base64")) p.optString("image_base64") else null)
|
||||
done += "eingeblendet"
|
||||
}
|
||||
}
|
||||
if (mode == "speech" || mode == "both") {
|
||||
val r = speakOut(p, text)
|
||||
r.exceptionOrNull()?.let { problems += "Sprachausgabe: ${it.message}" }
|
||||
?: run { done += "gesprochen (ARIAs Stimme)" }
|
||||
}
|
||||
if (done.isEmpty()) return err(problems.joinToString("; ").ifBlank { "nichts ausgegeben" })
|
||||
val msg = done.joinToString(" + ") + (if (problems.isEmpty()) "" else " (${problems.joinToString("; ")})")
|
||||
return JSONObject().put("ok", true).put("result", JSONObject().put("message", msg))
|
||||
}
|
||||
|
||||
/** Nur Sprachausgabe (ARIAs Audio, falls mitgeliefert — sonst Geraete-TTS). */
|
||||
private fun doSpeak(p: JSONObject): JSONObject {
|
||||
val text = p.optString("text")
|
||||
if (text.isBlank() && !p.has("audio_base64")) return err("speak braucht 'text' oder 'audio_base64'.")
|
||||
val how = if (p.optString("audio_base64").isNotBlank()) "Audio abgespielt"
|
||||
else if (p.optString("engine") == "device") "über die Geräte-Stimme gesprochen"
|
||||
else "wird mit ARIAs Stimme gesprochen (Audio kommt vom Server)"
|
||||
val r = speakOut(p, text)
|
||||
return r.fold(
|
||||
{ JSONObject().put("ok", true).put("result", JSONObject().put("message", how)) },
|
||||
{ err("Sprachausgabe fehlgeschlagen: ${it.message}") },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprachausgabe. Reihenfolge:
|
||||
* 1. mitgeliefertes Audio abspielen,
|
||||
* 2. ARIAs ECHTE Stimme ueber den Server anfordern (Standard) — Fire TV
|
||||
* Sticks haben oft gar keine (deutsche) TTS-Engine,
|
||||
* 3. geraeteeigene Sprachausgabe nur auf ausdrueckliche Anforderung
|
||||
* (engine='device') oder wenn kein Server-Audio moeglich ist.
|
||||
* focus: duck (Medien leiser, Standard) | pause (Medien anhalten) | none.
|
||||
*/
|
||||
private fun speakOut(p: JSONObject, text: String): Result<Unit> {
|
||||
val audio = p.optString("audio_base64")
|
||||
val focus = p.optString("focus").ifBlank { "duck" }
|
||||
if (audio.isNotBlank())
|
||||
return Speaker.play(appCtx, audio, p.optString("audio_format", "wav"), focus)
|
||||
if (p.optString("engine") == "device")
|
||||
return Speaker.speak(appCtx, text, focus)
|
||||
return runCatching { voice.requestTts(text); Unit }
|
||||
}
|
||||
|
||||
private fun doVolume(p: JSONObject): JSONObject {
|
||||
if (!p.has("percent")) return err("volume braucht 'percent' (0-100).")
|
||||
val v = p.optInt("percent")
|
||||
Speaker.setVolume(appCtx, v)
|
||||
return JSONObject().put("ok", true)
|
||||
.put("result", JSONObject().put("message", "Lautstärke auf $v% gesetzt"))
|
||||
}
|
||||
|
||||
/** Paketname per Label-Teilstring finden (case-insensitive). */
|
||||
private fun resolvePackage(query: String): String? {
|
||||
val pm = appCtx.packageManager
|
||||
val q = query.lowercase()
|
||||
val launch = android.content.Intent(android.content.Intent.ACTION_MAIN)
|
||||
.addCategory(android.content.Intent.CATEGORY_LAUNCHER)
|
||||
return pm.queryIntentActivities(launch, 0)
|
||||
.mapNotNull { it.activityInfo }
|
||||
.firstOrNull { pm.getApplicationLabel(it.applicationInfo).toString().lowercase().contains(q) }
|
||||
?.packageName
|
||||
}
|
||||
|
||||
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,149 @@
|
||||
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.
|
||||
*
|
||||
* Aufgenommen wird direkt in GEKAPPTER Aufloesung (max. 1280 lange Seite): die
|
||||
* MediaProjection skaliert den Bildschirminhalt auf die VirtualDisplay-Groesse.
|
||||
* Das haelt Speicher/Zeit klein (kein 10-MB-Vollbild-Bitmap -> kein OOM/Timeout).
|
||||
*
|
||||
* Der Zugriff geht bei App-Kill / Neustart verloren und muss neu erlaubt werden
|
||||
* (Android-Sicherheit — Projection-Token ist nicht persistierbar).
|
||||
*/
|
||||
object ScreenCapturer {
|
||||
private const val MAX_SIDE = 1280
|
||||
|
||||
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)
|
||||
dpi = metrics.densityDpi
|
||||
// Direkt gekappt aufnehmen (lange Seite <= MAX_SIDE), Seitenverhaeltnis wahren.
|
||||
val longSide = maxOf(metrics.widthPixels, metrics.heightPixels)
|
||||
val scale = if (longSide > MAX_SIDE) MAX_SIDE.toFloat() / longSide else 1f
|
||||
w = (metrics.widthPixels * scale).toInt().coerceAtLeast(1)
|
||||
h = (metrics.heightPixels * scale).toInt().coerceAtLeast(1)
|
||||
|
||||
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 (Grund in lastError). */
|
||||
fun capture(): ByteArray? {
|
||||
val r = reader ?: run { lastError = "kein aktiver Bildschirm-Reader"; return null }
|
||||
var image: Image? = null
|
||||
var tries = 0
|
||||
// Bis ~3s auf den ersten Frame warten (frischer VirtualDisplay braucht evtl. kurz).
|
||||
while (tries < 37) {
|
||||
image = r.acquireLatestImage()
|
||||
if (image != null) break
|
||||
try { Thread.sleep(80) } catch (_: InterruptedException) {}
|
||||
tries++
|
||||
}
|
||||
if (image == null) {
|
||||
lastError = "kein Frame erhalten (Bildschirm an? evtl. DRM-geschuetzter Inhalt)"
|
||||
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 bmpW = w + (if (pixelStride > 0) rowPadding / pixelStride else 0)
|
||||
val bmp = Bitmap.createBitmap(bmpW, h, Bitmap.Config.ARGB_8888)
|
||||
bmp.copyPixelsFromBuffer(buffer)
|
||||
val out = ByteArrayOutputStream()
|
||||
if (rowPadding == 0) {
|
||||
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
} else {
|
||||
val cropped = Bitmap.createBitmap(bmp, 0, 0, w, h)
|
||||
cropped.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
cropped.recycle()
|
||||
}
|
||||
bmp.recycle()
|
||||
lastError = null
|
||||
out.toByteArray()
|
||||
} catch (t: Throwable) {
|
||||
lastError = "Encode-Fehler: ${t.javaClass.simpleName}: ${t.message}"
|
||||
null
|
||||
} finally {
|
||||
try { image?.close() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package de.hackersoft.ariaagent
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioManager
|
||||
import android.media.MediaPlayer
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.util.Base64
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Sprachausgabe auf dem Geraet (v.a. Fire TV: ARIA spricht ueber die TV-Lautsprecher).
|
||||
*
|
||||
* Zwei Wege:
|
||||
* 1) `play(audioBase64)` — ARIAs ECHTE Stimme: fertiges Audio (WAV/MP3) aus dem
|
||||
* bestehenden TTS-Pfad (F5/XTTS) wird einfach abgespielt. Bevorzugt.
|
||||
* 2) `speak(text)` — geraeteeigene TextToSpeech als Rueckfallebene, falls kein
|
||||
* Audio mitgeliefert wurde (klingt nach Standard-Stimme, nicht nach ARIA).
|
||||
*/
|
||||
object Speaker {
|
||||
|
||||
private var tts: TextToSpeech? = null
|
||||
private var ttsReady = false
|
||||
private var player: MediaPlayer? = null
|
||||
|
||||
/** Spielt fertiges Audio (Base64, z.B. WAV aus dem ARIA-TTS) ueber die Lautsprecher.
|
||||
* [focus] = duck (Medien leiser, Standard) | pause (Medien anhalten) | none. */
|
||||
fun play(ctx: Context, audioB64: String, format: String = "wav",
|
||||
focus: String = "duck"): Result<Unit> = runCatching {
|
||||
stop()
|
||||
val bytes = Base64.decode(audioB64, Base64.DEFAULT)
|
||||
val f = File(ctx.cacheDir, "aria_speak.${format.ifBlank { "wav" }}")
|
||||
f.writeBytes(bytes)
|
||||
// Erst Fokus holen, damit Netflix & Co. leiser werden (sonst redet ARIA
|
||||
// einfach mittendrin mit und man versteht beides nicht).
|
||||
AudioFocusHelper.request(ctx, focus, "speak")
|
||||
player = MediaPlayer().apply {
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ASSISTANT)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
)
|
||||
setDataSource(f.absolutePath)
|
||||
setOnCompletionListener { mp ->
|
||||
try { mp.release() } catch (_: Exception) {}
|
||||
player = null
|
||||
AudioFocusHelper.abandon(ctx) // Medien wieder laut / weiterlaufen
|
||||
}
|
||||
setOnErrorListener { _, _, _ -> AudioFocusHelper.abandon(ctx); false }
|
||||
prepare()
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rueckfallebene: geraeteeigene Sprachausgabe. */
|
||||
fun speak(ctx: Context, text: String, focus: String = "duck"): Result<Unit> = runCatching {
|
||||
if (tts == null) {
|
||||
tts = TextToSpeech(ctx.applicationContext) { status ->
|
||||
ttsReady = status == TextToSpeech.SUCCESS
|
||||
if (ttsReady) tts?.language = Locale.GERMAN
|
||||
}
|
||||
// Kurz auf die Initialisierung warten (TTS meldet sich asynchron).
|
||||
var waited = 0
|
||||
while (!ttsReady && waited < 3000) { Thread.sleep(100); waited += 100 }
|
||||
}
|
||||
if (!ttsReady) throw IllegalStateException(
|
||||
"Geraet hat keine Sprachausgabe (TTS-Engine fehlt — auf Fire TV ueblich)")
|
||||
// Sprache pruefen: eine Engine ohne deutsche Daten wuerde sonst STILL
|
||||
// nichts sagen und wir wuerden faelschlich 'gesprochen' melden.
|
||||
val lang = tts?.setLanguage(Locale.GERMAN) ?: TextToSpeech.LANG_NOT_SUPPORTED
|
||||
if (lang == TextToSpeech.LANG_MISSING_DATA || lang == TextToSpeech.LANG_NOT_SUPPORTED)
|
||||
throw IllegalStateException(
|
||||
"Geraete-Sprachausgabe kann kein Deutsch (Sprachdaten fehlen)")
|
||||
AudioFocusHelper.request(ctx, focus, "speak")
|
||||
tts?.setOnUtteranceProgressListener(object : android.speech.tts.UtteranceProgressListener() {
|
||||
override fun onStart(id: String?) {}
|
||||
override fun onDone(id: String?) { AudioFocusHelper.abandon(ctx) }
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onError(id: String?) { AudioFocusHelper.abandon(ctx) }
|
||||
})
|
||||
val rc = tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "aria")
|
||||
if (rc != TextToSpeech.SUCCESS) {
|
||||
AudioFocusHelper.abandon(ctx)
|
||||
throw IllegalStateException("Sprachausgabe abgelehnt (Code $rc)")
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
/** Lautstaerke 0..100 (Medien-Kanal). */
|
||||
fun setVolume(ctx: Context, percent: Int) {
|
||||
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val max = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
val v = (max * percent.coerceIn(0, 100) / 100.0).toInt()
|
||||
am.setStreamVolume(AudioManager.STREAM_MUSIC, v, AudioManager.FLAG_SHOW_UI)
|
||||
}
|
||||
|
||||
fun stop(ctx: Context? = null) {
|
||||
try { player?.stop(); player?.release() } catch (_: Exception) {}
|
||||
player = null
|
||||
try { tts?.stop() } catch (_: Exception) {}
|
||||
ctx?.let { AudioFocusHelper.abandon(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package de.hackersoft.ariaagent
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioRecord
|
||||
import android.media.AudioTrack
|
||||
import android.media.MediaRecorder
|
||||
import android.media.audiofx.AcousticEchoCanceler
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import android.util.Base64
|
||||
import okhttp3.WebSocket
|
||||
import org.json.JSONObject
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Sprachdialog mit ARIA direkt auf dem Geraet (v.a. Fire TV: reden ueber die
|
||||
* Fernbedienung, Antwort ueber die TV-Lautsprecher bzw. als Einblendung).
|
||||
*
|
||||
* Nutzt GENAU das Protokoll der ARIA-App, damit serverseitig nichts angepasst
|
||||
* werden muss:
|
||||
* rein : stt_stream_start -> stt_audio_chunk (Feld 'pcm', 16 kHz mono s16le,
|
||||
* 6400-Byte-Bloecke) -> stt_stream_end
|
||||
* raus : chat (sender=aria, messageId/speak/converse) + audio_pcm (Feld
|
||||
* 'base64', 24 kHz mono s16le, chunkweise bis final=true)
|
||||
*
|
||||
* WICHTIG — Turn-Ownership: Der RVS ist ein Broadcast-Relay ohne Adressierung.
|
||||
* Jeder Client im Raum bekommt JEDE Antwort. Ohne Filter wuerden Handy-App und
|
||||
* Fire TV dieselbe Antwort gleichzeitig vorlesen. Deshalb gibt dieses Geraet nur
|
||||
* aus, was aus SEINER eigenen Frage entstanden ist (audioRequestId -> messageId).
|
||||
*/
|
||||
class VoiceSession(
|
||||
private val appCtx: Context,
|
||||
private val config: AgentConfig,
|
||||
private val send: (type: String, payload: JSONObject) -> Unit,
|
||||
private val onEvent: (String) -> Unit = {},
|
||||
) {
|
||||
companion object {
|
||||
// Aufnahme — muss exakt zur STT-Bridge passen.
|
||||
private const val IN_RATE = 16000
|
||||
private const val CHUNK_BYTES = 6400 // 3200 Samples = 200 ms
|
||||
// Wiedergabe — F5-TTS liefert 24 kHz (sampleRate steht aber im Payload).
|
||||
private const val OUT_RATE_DEFAULT = 24000
|
||||
private const val MAX_RECORD_MS = 60000
|
||||
}
|
||||
|
||||
@Volatile private var recording = false
|
||||
@Volatile private var recordThread: Thread? = null
|
||||
private var rec: AudioRecord? = null
|
||||
private var aec: AcousticEchoCanceler? = null
|
||||
private var ns: NoiseSuppressor? = null
|
||||
|
||||
private var track: AudioTrack? = null
|
||||
@Volatile private var playingMessageId: String? = null
|
||||
|
||||
/** Eigener Turn: gesetzt beim Aufnehmen, wandert ueber stt_endpoint zur messageId. */
|
||||
@Volatile private var myAudioRequestId: String? = null
|
||||
@Volatile private var myMessageId: String? = null
|
||||
@Volatile private var sessionId: String? = null
|
||||
/** Text der letzten ARIA-Antwort auf UNSERE Frage (fuer die Einblendung). */
|
||||
@Volatile private var lastReplyText: String = ""
|
||||
|
||||
/** Angeforderte TTS-Ausgaben (messageId) — deren Audio spielen wir ab, auch
|
||||
* wenn es nicht aus einem eigenen Sprach-Turn stammt. */
|
||||
private val expectedAudio = java.util.Collections.synchronizedSet(mutableSetOf<String>())
|
||||
|
||||
val isRecording: Boolean get() = recording
|
||||
|
||||
// ── Aufnehmen + streamen ────────────────────────────────────────
|
||||
|
||||
/** Startet die Aufnahme und streamt live an die STT. */
|
||||
fun startListening(): JSONObject {
|
||||
if (recording) return JSONObject().put("ok", false).put("error", "Nimmt bereits auf.")
|
||||
val stamp = System.currentTimeMillis()
|
||||
val rnd = Random.nextInt(100000, 999999)
|
||||
val sid = "sttstr_${stamp}_$rnd"
|
||||
val aid = "audio_${stamp}_$rnd"
|
||||
sessionId = sid
|
||||
myAudioRequestId = aid
|
||||
myMessageId = null
|
||||
|
||||
val minBuf = AudioRecord.getMinBufferSize(
|
||||
IN_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
|
||||
if (minBuf <= 0) return JSONObject().put("ok", false)
|
||||
.put("error", "Kein Mikrofon verfuegbar (Fire-TV-Fernbedienung aktiv? USB-Mikro?)")
|
||||
|
||||
val r = try {
|
||||
AudioRecord(MediaRecorder.AudioSource.VOICE_COMMUNICATION, IN_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
|
||||
maxOf(minBuf, CHUNK_BYTES * 4))
|
||||
} catch (e: Throwable) {
|
||||
return JSONObject().put("ok", false)
|
||||
.put("error", "Mikrofon-Zugriff fehlgeschlagen: ${e.message}")
|
||||
}
|
||||
if (r.state != AudioRecord.STATE_INITIALIZED) {
|
||||
try { r.release() } catch (_: Exception) {}
|
||||
return JSONObject().put("ok", false)
|
||||
.put("error", "Mikrofon nicht initialisiert (Berechtigung erteilt? Mikro vorhanden?)")
|
||||
}
|
||||
// Echo/Rauschen daempfen, damit der Stick nicht seine eigene Ausgabe hoert.
|
||||
try { if (AcousticEchoCanceler.isAvailable()) aec = AcousticEchoCanceler.create(r.audioSessionId)?.apply { enabled = true } } catch (_: Exception) {}
|
||||
try { if (NoiseSuppressor.isAvailable()) ns = NoiseSuppressor.create(r.audioSessionId)?.apply { enabled = true } } catch (_: Exception) {}
|
||||
|
||||
rec = r
|
||||
recording = true
|
||||
// Beim Zuhoeren laufende Medien ANHALTEN — sonst nimmt das Mikrofon den
|
||||
// Filmton mit auf und die Spracherkennung versteht nur Kauderwelsch.
|
||||
AudioFocusHelper.request(appCtx, "pause", "listen")
|
||||
|
||||
send("stt_stream_start", JSONObject().apply {
|
||||
put("requestId", sid)
|
||||
put("audioRequestId", aid)
|
||||
put("voice", "")
|
||||
put("speed", 1.0)
|
||||
put("interrupted", false)
|
||||
put("endpointMs", 2400)
|
||||
put("hardCapMs", MAX_RECORD_MS)
|
||||
put("sampleRate", IN_RATE)
|
||||
put("projectId", "")
|
||||
put("targetInstance", "")
|
||||
put("language", "de")
|
||||
})
|
||||
onEvent("hoert zu …")
|
||||
|
||||
recordThread = Thread {
|
||||
val buf = ByteArray(CHUNK_BYTES)
|
||||
var seq = 0
|
||||
try {
|
||||
r.startRecording()
|
||||
val until = System.currentTimeMillis() + MAX_RECORD_MS
|
||||
while (recording && System.currentTimeMillis() < until) {
|
||||
val n = r.read(buf, 0, buf.size)
|
||||
if (n <= 0) continue
|
||||
val slice = if (n == buf.size) buf else buf.copyOf(n)
|
||||
send("stt_audio_chunk", JSONObject().apply {
|
||||
put("requestId", sid)
|
||||
put("pcm", Base64.encodeToString(slice, Base64.NO_WRAP))
|
||||
put("seq", seq++)
|
||||
put("targetInstance", "")
|
||||
})
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
// Aufnahme abgebrochen — stopListening raeumt auf.
|
||||
} finally {
|
||||
stopListening("auto")
|
||||
}
|
||||
}.also { it.isDaemon = true; it.start() }
|
||||
|
||||
return JSONObject().put("ok", true)
|
||||
.put("result", JSONObject().put("message", "Hoere zu …"))
|
||||
}
|
||||
|
||||
/** Beendet die Aufnahme (die STT antwortet dann per stt_endpoint). */
|
||||
fun stopListening(reason: String = "user"): JSONObject {
|
||||
if (!recording) return JSONObject().put("ok", true)
|
||||
.put("result", JSONObject().put("message", "Nahm nicht auf"))
|
||||
recording = false
|
||||
try { rec?.stop() } catch (_: Exception) {}
|
||||
try { rec?.release() } catch (_: Exception) {}
|
||||
try { aec?.release() } catch (_: Exception) {}
|
||||
try { ns?.release() } catch (_: Exception) {}
|
||||
rec = null; aec = null; ns = null
|
||||
// Aufnahme vorbei -> Medien laufen weiter (die Antwort holt sich gleich
|
||||
// selbst wieder Fokus, sobald ARIAs Audio kommt).
|
||||
AudioFocusHelper.abandon(appCtx)
|
||||
sessionId?.let {
|
||||
send("stt_stream_end", JSONObject().apply {
|
||||
put("requestId", it); put("reason", reason); put("targetInstance", "")
|
||||
})
|
||||
}
|
||||
onEvent("verarbeite …")
|
||||
return JSONObject().put("ok", true)
|
||||
.put("result", JSONObject().put("message", "Aufnahme beendet"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Laesst ARIAs ECHTE Stimme etwas sagen: fordert das TTS-Audio ueber den
|
||||
* bestehenden Server-Pfad an (tts_request -> Bridge -> F5/XTTS -> audio_pcm)
|
||||
* und spielt es ab, sobald es hereinkommt.
|
||||
*
|
||||
* Besser als die geraeteeigene Sprachausgabe: Fire TV Sticks haben oft gar
|
||||
* keine (deutsche) TTS-Engine — und es klingt nach ARIA, nicht nach Roboter.
|
||||
*/
|
||||
fun requestTts(text: String): String {
|
||||
val mid = "spk_${System.currentTimeMillis()}_${Random.nextInt(100000, 999999)}"
|
||||
expectedAudio.add(mid)
|
||||
// Nicht unbegrenzt wachsen lassen, falls mal keine Antwort kommt.
|
||||
if (expectedAudio.size > 20) {
|
||||
synchronized(expectedAudio) {
|
||||
expectedAudio.iterator().let { if (it.hasNext()) { it.next(); it.remove() } }
|
||||
}
|
||||
}
|
||||
send("tts_request", JSONObject().apply {
|
||||
put("text", text)
|
||||
put("messageId", mid)
|
||||
put("voice", "")
|
||||
put("speed", 1.0)
|
||||
})
|
||||
return mid
|
||||
}
|
||||
|
||||
/** Text direkt an ARIA schicken (ohne Mikrofon) — Antwort landet hier genauso. */
|
||||
fun ask(text: String): JSONObject {
|
||||
if (text.isBlank()) return JSONObject().put("ok", false).put("error", "leerer Text")
|
||||
val stamp = System.currentTimeMillis()
|
||||
myAudioRequestId = "audio_${stamp}_${Random.nextInt(100000, 999999)}"
|
||||
myMessageId = null
|
||||
send("chat", JSONObject().apply {
|
||||
put("text", text)
|
||||
put("voice", ""); put("speed", 1.0); put("projectId", "")
|
||||
put("clientMsgId", "cmsg_${stamp}_${Random.nextInt(100000, 999999)}")
|
||||
})
|
||||
return JSONObject().put("ok", true)
|
||||
.put("result", JSONObject().put("message", "Frage gesendet"))
|
||||
}
|
||||
|
||||
// ── Eingehende Nachrichten ──────────────────────────────────────
|
||||
|
||||
/** Verarbeitet Sprach-/Chat-Nachrichten. true = war fuer uns relevant. */
|
||||
fun handle(type: String, p: JSONObject): Boolean = when (type) {
|
||||
"stt_endpoint", "stt_stream_done" -> {
|
||||
if (p.optString("requestId") == sessionId) {
|
||||
val text = p.optString("text")
|
||||
if (type == "stt_endpoint" && text.isNotBlank()) onEvent("verstanden: $text")
|
||||
if (type == "stt_stream_done") { recording = false; sessionId = null }
|
||||
}
|
||||
true
|
||||
}
|
||||
"chat" -> {
|
||||
if (p.optString("sender") == "aria" && isMyTurn(p)) {
|
||||
myMessageId = p.optString("messageId")
|
||||
lastReplyText = p.optString("text")
|
||||
deliverReply(p)
|
||||
}
|
||||
true
|
||||
}
|
||||
"audio_pcm" -> {
|
||||
// Abspielen, wenn (a) wir dieses Audio selbst angefordert haben
|
||||
// (host_speak/notify) oder (b) es die Antwort auf UNSERE Frage ist und
|
||||
// Sprachausgabe gewuenscht war. Sonst wuerde auch das Handy mitreden.
|
||||
val mid = p.optString("messageId")
|
||||
if (mid.isNotBlank() &&
|
||||
(expectedAudio.contains(mid) || (mid == myMessageId && wantSpeech()))) {
|
||||
feedAudio(p)
|
||||
if (p.optBoolean("final", false)) expectedAudio.remove(mid)
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
|
||||
/** Gehoert diese Antwort zu unserer Frage? */
|
||||
private fun isMyTurn(p: JSONObject): Boolean {
|
||||
val aid = myAudioRequestId ?: return false
|
||||
val pa = p.optString("audioRequestId")
|
||||
// Sprach-Turn: audioRequestId wird durchgereicht. Text-Turn (ask): die
|
||||
// naechste ARIA-Antwort nach unserer Frage gehoert uns.
|
||||
return if (pa.isNotBlank()) pa == aid else myMessageId == null
|
||||
}
|
||||
|
||||
private fun wantSpeech(): Boolean = config.replyMode == "speech"
|
||||
|
||||
/** Antwort ausgeben: Einblendung und/oder Sprache (je nach Einstellung). */
|
||||
private fun deliverReply(p: JSONObject) {
|
||||
val text = p.optString("text")
|
||||
if (text.isBlank()) return
|
||||
if (config.replyMode == "overlay" && OverlayView.canDraw(appCtx)) {
|
||||
OverlayView.show(appCtx, "ARIA", text, 15, null)
|
||||
} else if (config.replyMode == "overlay") {
|
||||
onEvent("Antwort (Einblendung nicht erlaubt): $text")
|
||||
}
|
||||
// Bei "speech" kommt das Audio ueber audio_pcm; falls ARIA nicht spricht
|
||||
// (speak=false), trotzdem einblenden, damit nichts verloren geht.
|
||||
if (wantSpeech() && !p.optBoolean("speak", true) && OverlayView.canDraw(appCtx)) {
|
||||
OverlayView.show(appCtx, "ARIA", text, 15, null)
|
||||
}
|
||||
}
|
||||
|
||||
// ── TTS-Wiedergabe (chunkweises PCM) ────────────────────────────
|
||||
|
||||
private fun feedAudio(p: JSONObject) {
|
||||
val mid = p.optString("messageId")
|
||||
val rate = p.optInt("sampleRate", OUT_RATE_DEFAULT)
|
||||
if (p.optBoolean("final", false)) {
|
||||
try { track?.stop() } catch (_: Exception) {}
|
||||
try { track?.release() } catch (_: Exception) {}
|
||||
track = null; playingMessageId = null
|
||||
// Fertig gesprochen -> Medien wieder hochfahren/weiterlaufen lassen.
|
||||
AudioFocusHelper.abandon(appCtx)
|
||||
onEvent("bereit")
|
||||
return
|
||||
}
|
||||
val b64 = p.optString("base64")
|
||||
if (b64.isBlank()) return
|
||||
val pcm = try { Base64.decode(b64, Base64.DEFAULT) } catch (_: Exception) { return }
|
||||
if (track == null || playingMessageId != mid) {
|
||||
try { track?.release() } catch (_: Exception) {}
|
||||
// ARIA antwortet -> laufende Medien leiser (duck), bis sie fertig ist.
|
||||
AudioFocusHelper.request(appCtx, "duck", "speak")
|
||||
val min = AudioTrack.getMinBufferSize(rate, AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT)
|
||||
track = AudioTrack.Builder()
|
||||
.setAudioAttributes(AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ASSISTANT)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build())
|
||||
.setAudioFormat(AudioFormat.Builder()
|
||||
.setSampleRate(rate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT).build())
|
||||
.setBufferSizeInBytes(maxOf(min, 16384))
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.build().also { it.play() }
|
||||
playingMessageId = mid
|
||||
onEvent("spricht …")
|
||||
}
|
||||
try { track?.write(pcm, 0, pcm.size) } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
stopListening("shutdown")
|
||||
try { track?.stop(); track?.release() } catch (_: Exception) {}
|
||||
track = null
|
||||
AudioFocusHelper.abandon(appCtx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<!-- Banner-Kachel fuer den Fire-TV-/Android-TV-Launcher (320x180 dp).
|
||||
TV-Launcher zeigen Banner statt Icons — ohne das fehlt die Kachel. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="320dp"
|
||||
android:height="180dp"
|
||||
android:viewportWidth="320"
|
||||
android:viewportHeight="180">
|
||||
<path
|
||||
android:fillColor="#0D0D1A"
|
||||
android:pathData="M0,0h320v180h-320z" />
|
||||
<!-- Ring (ARIA-Auge) -->
|
||||
<path
|
||||
android:fillColor="#3FFF9F"
|
||||
android:pathData="M74,90 m-34,0 a34,34 0 1,0 68,0 a34,34 0 1,0 -68,0 Z M74,90 m-17,0 a17,17 0 1,1 34,0 a17,17 0 1,1 -34,0 Z" />
|
||||
<!-- Schriftzug "ARIA" als Pfade (keine Font-Abhaengigkeit im Vector) -->
|
||||
<path
|
||||
android:fillColor="#E0E0F0"
|
||||
android:pathData="M140,110 L152,64 L162,64 L174,110 L164,110 L161,99 L153,99 L150,110 Z M155,91 L159,91 L157,82 Z" />
|
||||
<path
|
||||
android:fillColor="#E0E0F0"
|
||||
android:pathData="M180,110 L180,64 L196,64 A13,13 0 0,1 196,90 L200,110 L190,110 L187,92 L189,92 L189,110 Z M189,72 L189,84 L195,84 A6,6 0 0,0 195,72 Z" />
|
||||
<path
|
||||
android:fillColor="#E0E0F0"
|
||||
android:pathData="M208,64 L217,64 L217,110 L208,110 Z" />
|
||||
<path
|
||||
android:fillColor="#E0E0F0"
|
||||
android:pathData="M224,110 L236,64 L246,64 L258,110 L248,110 L245,99 L237,99 L234,110 Z M239,91 L243,91 L241,82 Z" />
|
||||
<!-- Untertitel-Balken -->
|
||||
<path
|
||||
android:fillColor="#3FFF9F"
|
||||
android:pathData="M140,122 h118 v4 h-118 z" />
|
||||
</vector>
|
||||
@@ -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,181 @@
|
||||
<?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:focusable="true"
|
||||
android:focusableInTouchMode="false"
|
||||
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" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnTalk"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:focusable="true"
|
||||
android:text="🎤 Mit ARIA sprechen"
|
||||
android:paddingTop="16dp" />
|
||||
|
||||
<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" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Antworten (Fernseher)"
|
||||
android:textStyle="bold"
|
||||
android:paddingTop="24dp"
|
||||
android:paddingBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Wie ARIA hier standardmäßig antwortet. Sie kann das pro Anfrage überschreiben (z.B. mit dem Zusatz: bitte als Sprache)."
|
||||
android:textSize="13sp"
|
||||
android:paddingBottom="8dp" />
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/replyMode"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/modeOverlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:focusable="true"
|
||||
android:text="Einblendung über dem Bild (Standard)" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/modeSpeech"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:focusable="true"
|
||||
android:text="Sprachausgabe über die Lautsprecher" />
|
||||
</RadioGroup>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnOverlayPerm"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Einblendung erlauben"
|
||||
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,9 @@
|
||||
<?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:canPerformGestures="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,739 @@
|
||||
"""
|
||||
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.1.2"
|
||||
|
||||
HEARTBEAT_SEC = 25
|
||||
|
||||
|
||||
def _gui_input_method() -> str:
|
||||
"""Welche Methode zur GUI-Steuerung (Maus/Tastatur) ist verfuegbar?
|
||||
'' = keine (z.B. Linux-Server ohne X/Wayland -> nur Terminal/exec).
|
||||
windows -> PowerShell (Bordmittel)
|
||||
macos -> osascript (Text/Tasten) + cliclick (Maus, falls installiert)
|
||||
xdotool -> Linux/X11
|
||||
ydotool -> Linux/Wayland (Daemon noetig)"""
|
||||
if IS_WINDOWS:
|
||||
return "windows"
|
||||
if IS_MAC:
|
||||
return "macos"
|
||||
if os.environ.get("DISPLAY") and shutil.which("xdotool"):
|
||||
return "xdotool"
|
||||
if os.environ.get("WAYLAND_DISPLAY") and shutil.which("ydotool"):
|
||||
return "ydotool"
|
||||
return ""
|
||||
|
||||
|
||||
GUI_METHOD = _gui_input_method()
|
||||
# macOS: Maus braucht cliclick; Text/Tasten/App gehen per osascript (Bordmittel).
|
||||
_MAC_MOUSE = IS_MAC and shutil.which("cliclick") is not None
|
||||
|
||||
CAPS = ["exec", "read", "write", "info", "screenshot"]
|
||||
if GUI_METHOD:
|
||||
# Tastatur/Text/App gehen bei jeder GUI-Methode; Maus (tap/swipe) auf dem Mac
|
||||
# nur mit cliclick.
|
||||
CAPS += ["ui_text", "ui_key", "app_launch"]
|
||||
if GUI_METHOD != "macos" or _MAC_MOUSE:
|
||||
CAPS += ["ui_tap", "ui_swipe"]
|
||||
|
||||
|
||||
# ─── 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(),
|
||||
"gui": GUI_METHOD or "none", # '' => nur Terminal, keine GUI-Steuerung
|
||||
}
|
||||
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?"}
|
||||
|
||||
|
||||
# ─── GUI-Steuerung (Maus/Tastatur/App) — nur mit grafischer Session ──
|
||||
|
||||
def _gui_unavailable() -> dict:
|
||||
return {"ok": False, "error":
|
||||
"Keine grafische Session zum Steuern. Linux: X11 (DISPLAY + xdotool) "
|
||||
"oder Wayland (WAYLAND_DISPLAY + ydotool-Daemon). macOS: cliclick fuer "
|
||||
"Maus. Auf einem reinen Terminal-/Server-System gibt es keine GUI."}
|
||||
|
||||
|
||||
def _run_gui(argv: list, desc: str) -> dict:
|
||||
try:
|
||||
r = subprocess.run(argv, capture_output=True, text=True, timeout=15)
|
||||
if r.returncode == 0:
|
||||
return {"ok": True, "result": {"message": f"{desc} ausgefuehrt"}}
|
||||
return {"ok": False, "error": f"{desc} fehlgeschlagen: "
|
||||
+ (r.stderr or r.stdout or f"exit {r.returncode}").strip()[:200]}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"{desc}: {exc}"}
|
||||
|
||||
|
||||
def _ps(script: str, desc: str) -> dict:
|
||||
return _run_gui(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], desc)
|
||||
|
||||
|
||||
def _osa(script: str, desc: str) -> dict:
|
||||
return _run_gui(["osascript", "-e", script], desc)
|
||||
|
||||
|
||||
_WIN_MOUSE_DECL = (
|
||||
"Add-Type -Name U -Namespace W -MemberDefinition '"
|
||||
"[DllImport(\"user32.dll\")] public static extern bool SetCursorPos(int x,int y);"
|
||||
"[DllImport(\"user32.dll\")] public static extern void mouse_event("
|
||||
"uint f,uint x,uint y,uint d,int e);';"
|
||||
)
|
||||
|
||||
|
||||
def _do_ui_tap(params: dict) -> dict:
|
||||
if not GUI_METHOD:
|
||||
return _gui_unavailable()
|
||||
x = _to_int(params.get("x")); y = _to_int(params.get("y"))
|
||||
if x is None or y is None:
|
||||
return {"ok": False, "error": "ui_tap braucht x,y (Bildschirm-Pixel aus dem Screenshot)."}
|
||||
button = str(params.get("button", "left")).lower()
|
||||
double = bool(params.get("double"))
|
||||
desc = f"Klick ({x},{y})"
|
||||
if GUI_METHOD == "xdotool":
|
||||
btn = {"left": "1", "middle": "2", "right": "3"}.get(button, "1")
|
||||
argv = ["xdotool", "mousemove", "--sync", str(x), str(y), "click"]
|
||||
if double:
|
||||
argv += ["--repeat", "2", "--delay", "120"]
|
||||
return _run_gui(argv + [btn], desc)
|
||||
if GUI_METHOD == "ydotool":
|
||||
code = {"left": "0xC0", "right": "0xC1", "middle": "0xC2"}.get(button, "0xC0")
|
||||
_run_gui(["ydotool", "mousemove", "-a", str(x), str(y)], "move")
|
||||
r = _run_gui(["ydotool", "click", code], desc)
|
||||
if double and r.get("ok"):
|
||||
_run_gui(["ydotool", "click", code], desc)
|
||||
return r
|
||||
if GUI_METHOD == "macos":
|
||||
if not _MAC_MOUSE:
|
||||
return {"ok": False, "error": "Maus-Steuerung braucht cliclick (brew install cliclick)."}
|
||||
cmd = ("dc:" if double else ("rc:" if button == "right" else "c:")) + f"{x},{y}"
|
||||
return _run_gui(["cliclick", cmd], desc)
|
||||
if GUI_METHOD == "windows":
|
||||
down, up = ("0x0002", "0x0004") if button != "right" else ("0x0008", "0x0010")
|
||||
click = f"[W.U]::mouse_event({down},0,0,0,0);[W.U]::mouse_event({up},0,0,0,0);"
|
||||
script = _WIN_MOUSE_DECL + f"[W.U]::SetCursorPos({x},{y});" + click + (click if double else "")
|
||||
return _ps(script, desc)
|
||||
return _gui_unavailable()
|
||||
|
||||
|
||||
def _do_ui_text(params: dict) -> dict:
|
||||
if not GUI_METHOD:
|
||||
return _gui_unavailable()
|
||||
text = str(params.get("text", ""))
|
||||
if not text:
|
||||
return {"ok": False, "error": "ui_text braucht 'text'."}
|
||||
desc = f"Text ({len(text)} Zeichen)"
|
||||
if GUI_METHOD == "xdotool":
|
||||
return _run_gui(["xdotool", "type", "--clearmodifiers", "--", text], desc)
|
||||
if GUI_METHOD == "ydotool":
|
||||
return _run_gui(["ydotool", "type", "--", text], desc)
|
||||
if GUI_METHOD == "macos":
|
||||
esc = text.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return _osa(f'tell application "System Events" to keystroke "{esc}"', desc)
|
||||
if GUI_METHOD == "windows":
|
||||
safe = re.sub(r"([+^%~(){}\[\]])", r"{\1}", text).replace('"', '`"')
|
||||
return _ps("Add-Type -AssemblyName System.Windows.Forms;"
|
||||
f"[System.Windows.Forms.SendKeys]::SendWait(\"{safe}\")", desc)
|
||||
return _gui_unavailable()
|
||||
|
||||
|
||||
def _do_ui_key(params: dict) -> dict:
|
||||
if not GUI_METHOD:
|
||||
return _gui_unavailable()
|
||||
key = str(params.get("key", "")).strip()
|
||||
if not key:
|
||||
return {"ok": False, "error": "ui_key braucht 'key' (z.B. Return, Escape, ctrl+c)."}
|
||||
desc = f"Taste '{key}'"
|
||||
if GUI_METHOD == "xdotool":
|
||||
return _run_gui(["xdotool", "key", "--clearmodifiers", key], desc)
|
||||
if GUI_METHOD == "ydotool":
|
||||
return _run_gui(["ydotool", "key", key], desc) # best effort (Keycodes)
|
||||
if GUI_METHOD == "macos":
|
||||
codes = {"return": 36, "enter": 36, "tab": 48, "space": 49, "delete": 51,
|
||||
"escape": 53, "esc": 53, "left": 123, "right": 124, "down": 125, "up": 126,
|
||||
"home": 115, "end": 119, "pageup": 116, "pagedown": 121}
|
||||
k = key.lower()
|
||||
if k in codes:
|
||||
return _osa(f'tell application "System Events" to key code {codes[k]}', desc)
|
||||
return _osa(f'tell application "System Events" to keystroke "{key}"', desc)
|
||||
if GUI_METHOD == "windows":
|
||||
m = {"return": "{ENTER}", "enter": "{ENTER}", "escape": "{ESC}", "esc": "{ESC}",
|
||||
"tab": "{TAB}", "backspace": "{BACKSPACE}", "delete": "{DEL}",
|
||||
"up": "{UP}", "down": "{DOWN}", "left": "{LEFT}", "right": "{RIGHT}",
|
||||
"home": "{HOME}", "end": "{END}", "pageup": "{PGUP}", "pagedown": "{PGDN}"}
|
||||
send = m.get(key.lower(), key)
|
||||
return _ps("Add-Type -AssemblyName System.Windows.Forms;"
|
||||
f"[System.Windows.Forms.SendKeys]::SendWait('{send}')", desc)
|
||||
return _gui_unavailable()
|
||||
|
||||
|
||||
def _do_ui_swipe(params: dict) -> dict:
|
||||
"""Auf dem Desktop = Maus ziehen von (x1,y1) nach (x2,y2)."""
|
||||
if not GUI_METHOD:
|
||||
return _gui_unavailable()
|
||||
x1 = _to_int(params.get("x1")); y1 = _to_int(params.get("y1"))
|
||||
x2 = _to_int(params.get("x2")); y2 = _to_int(params.get("y2"))
|
||||
if None in (x1, y1, x2, y2):
|
||||
return {"ok": False, "error": "ui_swipe braucht x1,y1,x2,y2."}
|
||||
desc = f"Ziehen ({x1},{y1} -> {x2},{y2})"
|
||||
if GUI_METHOD == "xdotool":
|
||||
return _run_gui(["xdotool", "mousemove", "--sync", str(x1), str(y1),
|
||||
"mousedown", "1", "mousemove", "--sync", str(x2), str(y2),
|
||||
"mouseup", "1"], desc)
|
||||
if GUI_METHOD == "ydotool":
|
||||
_run_gui(["ydotool", "mousemove", "-a", str(x1), str(y1)], "move")
|
||||
_run_gui(["ydotool", "click", "0x40"], "down")
|
||||
_run_gui(["ydotool", "mousemove", "-a", str(x2), str(y2)], "move")
|
||||
return _run_gui(["ydotool", "click", "0x80"], desc)
|
||||
if GUI_METHOD == "macos":
|
||||
if not _MAC_MOUSE:
|
||||
return {"ok": False, "error": "Ziehen braucht cliclick."}
|
||||
return _run_gui(["cliclick", f"dd:{x1},{y1}", f"du:{x2},{y2}"], desc)
|
||||
if GUI_METHOD == "windows":
|
||||
script = (_WIN_MOUSE_DECL +
|
||||
f"[W.U]::SetCursorPos({x1},{y1});[W.U]::mouse_event(0x0002,0,0,0,0);"
|
||||
f"Start-Sleep -Milliseconds 80;[W.U]::SetCursorPos({x2},{y2});"
|
||||
"[W.U]::mouse_event(0x0004,0,0,0,0);")
|
||||
return _ps(script, desc)
|
||||
return _gui_unavailable()
|
||||
|
||||
|
||||
def _do_app_launch(params: dict) -> dict:
|
||||
"""Startet eine App/ein Programm (nicht-blockierend). 'app' = Name/Pfad,
|
||||
'command' = beliebiger Startbefehl."""
|
||||
app = str(params.get("app") or params.get("query") or "").strip()
|
||||
command = str(params.get("command") or "").strip()
|
||||
if not app and not command:
|
||||
return {"ok": False, "error": "app_launch braucht 'app' (Name) oder 'command'."}
|
||||
try:
|
||||
if command:
|
||||
argv = command if IS_WINDOWS else ["/bin/sh", "-c", command]
|
||||
subprocess.Popen(argv, shell=bool(IS_WINDOWS),
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return {"ok": True, "result": {"message": f"Befehl gestartet: {command}"}}
|
||||
if IS_MAC:
|
||||
subprocess.Popen(["open", "-a", app])
|
||||
elif IS_WINDOWS:
|
||||
subprocess.Popen(["cmd", "/c", "start", "", app])
|
||||
else:
|
||||
launcher = shutil.which(app) or app
|
||||
head = ["setsid"] if shutil.which("setsid") else []
|
||||
subprocess.Popen(head + [launcher],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return {"ok": True, "result": {"message": f"App gestartet: {app}"}}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"Start fehlgeschlagen: {exc}"}
|
||||
|
||||
|
||||
ACTIONS = {
|
||||
"exec": _do_exec, "read": _do_read, "write": _do_write,
|
||||
"info": _do_info, "screenshot": _do_screenshot,
|
||||
"ui_tap": _do_ui_tap, "ui_text": _do_ui_text, "ui_key": _do_ui_key,
|
||||
"ui_swipe": _do_ui_swipe, "app_launch": _do_app_launch,
|
||||
}
|
||||
|
||||
|
||||
# ─── 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)
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Claude Code CLI Subprocess Manager — ARIA-Patch
|
||||
*
|
||||
* Basis: claude-max-api-proxy dist/subprocess/manager.js, plus die bisher per
|
||||
* sed in docker-compose.yml eingespielten Anpassungen (dangerously-skip-
|
||||
* permissions, system-prompt, 24h-Timeout, Prompt via stdin) — hier fest im
|
||||
* File, damit der groessere Zwischenruf-Umbau nicht per sed gefrickelt werden
|
||||
* muss. Wird per `cp` ueber die npm-Version gelegt (siehe docker-compose.yml).
|
||||
*
|
||||
* ZWISCHENRUF (interject): Statt den Prompt als Text zu schreiben und stdin
|
||||
* sofort zu schliessen (--print/text), laeuft claude jetzt im
|
||||
* `--input-format stream-json`-Modus. Der initiale Prompt geht als
|
||||
* stream-json User-Message rein, stdin bleibt OFFEN — so kann waehrend des
|
||||
* laufenden Turns per sendMessage() eine weitere User-Message reingeschoben
|
||||
* werden, die claude an der naechsten Tool-Grenze aufgreift (kein Abbruch).
|
||||
* Bei 'result' (Turn fertig) wird stdin geschlossen, damit claude sauber
|
||||
* beendet und die HTTP-Response (in routes.js an 'close' gebunden) rausgeht.
|
||||
*/
|
||||
import { spawn } from "child_process";
|
||||
import { EventEmitter } from "events";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { isAssistantMessage, isResultMessage, isContentDelta } from "../types/claude-cli.js";
|
||||
const DEFAULT_TIMEOUT = 86400000; // 24h — lange Agent-Loops (Pentests etc.)
|
||||
export class ClaudeSubprocess extends EventEmitter {
|
||||
process = null;
|
||||
buffer = "";
|
||||
timeoutId = null;
|
||||
isKilled = false;
|
||||
_stdinClosed = false;
|
||||
_systemPromptFile = null;
|
||||
_systemPromptDir = null;
|
||||
/**
|
||||
* Start the Claude CLI subprocess with the given prompt
|
||||
*/
|
||||
async start(prompt, options) {
|
||||
const args = this.buildArgs(prompt, options);
|
||||
const timeout = options.timeout || DEFAULT_TIMEOUT;
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Use spawn() for security - no shell interpretation
|
||||
this.process = spawn("claude", args, {
|
||||
cwd: options.cwd || process.cwd(),
|
||||
env: { ...process.env },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
// Set timeout
|
||||
this.timeoutId = setTimeout(() => {
|
||||
if (!this.isKilled) {
|
||||
this.isKilled = true;
|
||||
this.process?.kill("SIGTERM");
|
||||
this.emit("error", new Error(`Request timed out after ${timeout}ms`));
|
||||
}
|
||||
}, timeout);
|
||||
// Handle spawn errors (e.g., claude not found)
|
||||
this.process.on("error", (err) => {
|
||||
this.clearTimeout();
|
||||
this._cleanupSystemPrompt();
|
||||
if (err.message.includes("ENOENT")) {
|
||||
reject(new Error("Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code"));
|
||||
}
|
||||
else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
// stdin BLEIBT OFFEN: initialen Prompt als stream-json User-
|
||||
// Message schreiben; spaetere Zwischenrufe kommen via
|
||||
// sendMessage(). Geschlossen wird bei 'result' (s. processBuffer).
|
||||
this._writeUserMessage(prompt);
|
||||
// Falls stdin (z.B. EPIPE) frueh stirbt: nicht crashen.
|
||||
this.process.stdin?.on("error", () => {});
|
||||
console.error(`[Subprocess] Process spawned with PID: ${this.process.pid}`);
|
||||
// Parse JSON stream from stdout
|
||||
this.process.stdout?.on("data", (chunk) => {
|
||||
const data = chunk.toString();
|
||||
console.error(`[Subprocess] Received ${data.length} bytes of stdout`);
|
||||
this.buffer += data;
|
||||
this.processBuffer();
|
||||
});
|
||||
// Capture stderr for debugging
|
||||
this.process.stderr?.on("data", (chunk) => {
|
||||
const errorText = chunk.toString().trim();
|
||||
if (errorText) {
|
||||
// Don't emit as error unless it's actually an error
|
||||
// Claude CLI may write debug info to stderr
|
||||
console.error("[Subprocess stderr]:", errorText.slice(0, 200));
|
||||
}
|
||||
});
|
||||
// Handle process close
|
||||
this.process.on("close", (code) => {
|
||||
console.error(`[Subprocess] Process closed with code: ${code}`);
|
||||
this.clearTimeout();
|
||||
this._cleanupSystemPrompt();
|
||||
// Process any remaining buffer
|
||||
if (this.buffer.trim()) {
|
||||
this.processBuffer();
|
||||
}
|
||||
this.emit("close", code);
|
||||
});
|
||||
// Resolve immediately since we're streaming
|
||||
resolve();
|
||||
}
|
||||
catch (err) {
|
||||
this.clearTimeout();
|
||||
this._cleanupSystemPrompt();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Build CLI arguments array
|
||||
*/
|
||||
buildArgs(prompt, options) {
|
||||
const args = [
|
||||
"--print", // Non-interactive mode
|
||||
"--output-format",
|
||||
"stream-json", // JSON streaming output
|
||||
"--verbose", // Required for stream-json
|
||||
"--include-partial-messages", // Enable streaming chunks
|
||||
"--input-format",
|
||||
"stream-json", // ARIA: User-Messages via stdin (Zwischenruf)
|
||||
"--model",
|
||||
options.model, // Model alias (opus/sonnet/haiku)
|
||||
"--no-session-persistence", "--dangerously-skip-permissions", "--safe-mode",
|
||||
];
|
||||
// ARIA: System-Prompt ueber eine DATEI statt als Kommandozeilen-Argument.
|
||||
// Grund: execve() begrenzt argv+envp zusammen auf ARG_MAX — im Container
|
||||
// nur 128 KiB. Ein gewachsener System-Prompt (gepinnte Erinnerungen,
|
||||
// Skills, Projektkontext; real gesehen ~77k Zeichen) sprengte das und der
|
||||
// Start brach mit "spawn E2BIG" ab: der Turn ging verloren, die App bekam
|
||||
// 502 und die Nachricht blieb unbeantwortet. Ueber die Datei gibt es
|
||||
// praktisch keine Groessengrenze mehr.
|
||||
this._systemPromptFile = null;
|
||||
const sysPrompt = options.systemPrompt || "";
|
||||
if (sysPrompt) {
|
||||
try {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aria-sysprompt-"));
|
||||
const file = path.join(dir, "system-prompt.txt");
|
||||
fs.writeFileSync(file, sysPrompt, "utf-8");
|
||||
this._systemPromptFile = file;
|
||||
this._systemPromptDir = dir;
|
||||
args.push("--system-prompt-file", file);
|
||||
}
|
||||
catch (err) {
|
||||
// Notfalls wie bisher (kleine Prompts funktionieren so weiterhin).
|
||||
console.error(`[Subprocess] System-Prompt-Datei fehlgeschlagen (${err.message}) — nutze Argument`);
|
||||
args.push("--system-prompt", sysPrompt);
|
||||
}
|
||||
}
|
||||
if (options.sessionId) {
|
||||
args.push("--session-id", options.sessionId);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
/** Temporaere System-Prompt-Datei wieder entfernen. */
|
||||
_cleanupSystemPrompt() {
|
||||
try {
|
||||
if (this._systemPromptFile)
|
||||
fs.rmSync(this._systemPromptDir || this._systemPromptFile, { recursive: true, force: true });
|
||||
}
|
||||
catch (_) { }
|
||||
this._systemPromptFile = null;
|
||||
this._systemPromptDir = null;
|
||||
}
|
||||
/**
|
||||
* Eine User-Message im stream-json-Input-Format an stdin schreiben.
|
||||
* Genutzt fuer den initialen Prompt UND fuer Zwischenrufe (sendMessage).
|
||||
*/
|
||||
_writeUserMessage(text) {
|
||||
const p = this.process;
|
||||
if (!p || !p.stdin || p.stdin.destroyed || this._stdinClosed)
|
||||
return false;
|
||||
try {
|
||||
p.stdin.write(JSON.stringify({ type: "user", message: { role: "user", content: String(text) } }) + "\n");
|
||||
return true;
|
||||
}
|
||||
catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Zwischenruf: waehrend eines laufenden Turns eine weitere User-Message
|
||||
* reinschieben. claude greift sie an der naechsten Tool-Grenze auf, ohne
|
||||
* den Turn abzubrechen. Kein Effekt, wenn stdin schon geschlossen ist
|
||||
* (Turn praktisch fertig) — dann ist der Zwischenruf schlicht zu spaet.
|
||||
*/
|
||||
sendMessage(text) {
|
||||
return this._writeUserMessage(text);
|
||||
}
|
||||
/**
|
||||
* stdin schliessen → claude beendet den stream-json-Input und exit't.
|
||||
*/
|
||||
_closeStdin() {
|
||||
if (this._stdinClosed)
|
||||
return;
|
||||
this._stdinClosed = true;
|
||||
try {
|
||||
this.process?.stdin?.end();
|
||||
}
|
||||
catch (_) { }
|
||||
}
|
||||
/**
|
||||
* Process the buffer and emit parsed messages
|
||||
*/
|
||||
processBuffer() {
|
||||
const lines = this.buffer.split("\n");
|
||||
this.buffer = lines.pop() || ""; // Keep incomplete line
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed)
|
||||
continue;
|
||||
try {
|
||||
const message = JSON.parse(trimmed);
|
||||
this.emit("message", message);
|
||||
if (isContentDelta(message)) {
|
||||
// Emit content delta for streaming
|
||||
this.emit("content_delta", message);
|
||||
}
|
||||
else if (isAssistantMessage(message)) {
|
||||
this.emit("assistant", message);
|
||||
}
|
||||
else if (isResultMessage(message)) {
|
||||
this.emit("result", message);
|
||||
// Turn fertig → stdin schliessen, sonst wartet claude im
|
||||
// stream-json-Input auf weitere Messages und der Prozess
|
||||
// (und damit die HTTP-Response) haengt fuer immer.
|
||||
this._closeStdin();
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Non-JSON output, emit as raw
|
||||
this.emit("raw", trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Clear the timeout timer
|
||||
*/
|
||||
clearTimeout() {
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId);
|
||||
this.timeoutId = null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Kill the subprocess
|
||||
*/
|
||||
kill(signal = "SIGTERM") {
|
||||
if (!this.isKilled && this.process) {
|
||||
this.isKilled = true;
|
||||
this.clearTimeout();
|
||||
this.process.kill(signal);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Check if the process is still running
|
||||
*/
|
||||
isRunning() {
|
||||
return this.process !== null && !this.isKilled && this.process.exitCode === null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Verify that Claude CLI is installed and accessible
|
||||
*/
|
||||
export async function verifyClaude() {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("claude", ["--version"], { stdio: "pipe" });
|
||||
let output = "";
|
||||
proc.stdout?.on("data", (chunk) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
proc.on("error", () => {
|
||||
resolve({
|
||||
ok: false,
|
||||
error: "Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code",
|
||||
});
|
||||
});
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ ok: true, version: output.trim() });
|
||||
}
|
||||
else {
|
||||
resolve({
|
||||
ok: false,
|
||||
error: "Claude CLI returned non-zero exit code",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Check if Claude CLI is authenticated
|
||||
*/
|
||||
export async function verifyAuth() {
|
||||
return { ok: true };
|
||||
}
|
||||
//# sourceMappingURL=manager.js.map
|
||||
@@ -25,6 +25,13 @@ const MODEL_MAP = {
|
||||
"opus": "opus",
|
||||
"sonnet": "sonnet",
|
||||
"haiku": "haiku",
|
||||
"fable": "fable",
|
||||
"claude-fable-5": "fable",
|
||||
"claude-code-cli/fable": "fable",
|
||||
// Volle aktuelle IDs (falls die App/Diagnostic sie mal direkt setzt)
|
||||
"claude-opus-5": "opus",
|
||||
"claude-sonnet-5": "sonnet",
|
||||
"claude-haiku-4-5": "haiku",
|
||||
};
|
||||
|
||||
export function extractModel(model) {
|
||||
|
||||
+46
-4
@@ -514,12 +514,18 @@ async function handleNonStreamingResponse(res, subprocess, cliInput, requestId)
|
||||
// Datei, greifen die eingebauten Defaults; die Datei wird dann einmalig mit
|
||||
// diesen Defaults angelegt, damit es was zu editieren gibt.
|
||||
const MODELS_FILE = process.env.ARIA_MODELS_FILE || "/shared/config/models.json";
|
||||
// Tier-Aliase als id (opus/sonnet/haiku/fable) — die CLI loest sie automatisch
|
||||
// auf die AKTUELLE Version des Tiers auf (Stand 2026-07: fable→Fable 5,
|
||||
// opus→Opus 5, sonnet→Sonnet 5, haiku→Haiku 4.5). So bleibt die Liste
|
||||
// versions-robust; die display_name-Texte nur bei Tier-Wechsel anpassen.
|
||||
const DEFAULT_MODELS = [
|
||||
{ id: "claude-sonnet-4", tier: "sonnet", display_name: "Sonnet (aktuell: Sonnet 5)",
|
||||
{ id: "fable", tier: "fable", display_name: "Fable (aktuell: Fable 5)",
|
||||
description: "Staerkstes Modell — fuer die haertesten Aufgaben (Software-Entwicklung, lange Agent-Laeufe)." },
|
||||
{ id: "opus", tier: "opus", display_name: "Opus (aktuell: Opus 5)",
|
||||
description: "Sehr schlau, schneller als Fable — fuer schwere/lange Aufgaben." },
|
||||
{ id: "sonnet", tier: "sonnet", display_name: "Sonnet (aktuell: Sonnet 5)",
|
||||
description: "Schnell & gut — Standard fuer den Alltag." },
|
||||
{ id: "claude-opus-4", tier: "opus", display_name: "Opus (aktuell: Opus 4.8)",
|
||||
description: "Langsamer, aber am schlausten — fuer schwere/lange Aufgaben." },
|
||||
{ id: "claude-haiku-4", tier: "haiku", display_name: "Haiku (aktuell: Haiku 4.5)",
|
||||
{ id: "haiku", tier: "haiku", display_name: "Haiku (aktuell: Haiku 4.5)",
|
||||
description: "Sehr schnell & guenstig, kleinerer Kontext — fuer einfache Tasks." },
|
||||
];
|
||||
|
||||
@@ -621,6 +627,28 @@ function _cancelByProject(projectId) {
|
||||
return { killed, requestIds: ids, projectId: pid };
|
||||
}
|
||||
|
||||
// Zwischenruf: schiebt eine User-Message in den/die laufenden Subprozess(e)
|
||||
// eines Kontexts, OHNE sie zu killen. claude greift sie an der naechsten Tool-
|
||||
// Grenze auf (stream-json-Input, s. manager.js). Kein Treffer / stdin schon
|
||||
// zu (Turn quasi fertig) → delivered=0.
|
||||
function _interjectByProject(projectId, text) {
|
||||
const pid = String(projectId || "");
|
||||
const ids = [];
|
||||
let delivered = 0;
|
||||
for (const [id, entry] of Array.from(_activeSubprocesses)) {
|
||||
if (entry.projectId !== pid) continue;
|
||||
try {
|
||||
if (typeof entry.subprocess.sendMessage === "function" && entry.subprocess.sendMessage(text)) {
|
||||
delivered++;
|
||||
ids.push(id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[aria-interject] sendMessage failed for", id, e?.message);
|
||||
}
|
||||
}
|
||||
return { delivered, requestIds: ids, projectId: pid };
|
||||
}
|
||||
|
||||
try {
|
||||
const internalServer = http.createServer((req, res) => {
|
||||
if (req.method === "POST" && req.url === "/cancel-all") {
|
||||
@@ -645,6 +673,20 @@ try {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && req.url === "/interject") {
|
||||
// Body: {projectId, text}. Zwischenruf in den laufenden Turn.
|
||||
let raw = "";
|
||||
req.on("data", (c) => { raw += c; if (raw.length > 65536) req.destroy(); });
|
||||
req.on("end", () => {
|
||||
let projectId = "", text = "";
|
||||
try { const b = JSON.parse(raw || "{}"); projectId = String(b.projectId || ""); text = String(b.text || ""); } catch (_) {}
|
||||
const result = text ? _interjectByProject(projectId, text) : { delivered: 0, requestIds: [], projectId };
|
||||
console.warn("[aria-interject] /interject project=%s — delivered %d", projectId || "(main)", result.delivered);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, ...result }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && req.url === "/health") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, active: _activeSubprocesses.size }));
|
||||
|
||||
+68
-2
@@ -17,7 +17,7 @@ const ALLOWED_TYPES = new Set([
|
||||
"file_request", "file_response", "file_saved", "stt_result", "config", "tts_request",
|
||||
"xtts_request", "xtts_response", "xtts_list_voices", "xtts_voices_list", "voice_upload", "xtts_voice_saved",
|
||||
"update_check", "update_available", "update_download", "update_data",
|
||||
"agent_activity", "cancel_request",
|
||||
"agent_activity", "cancel_request", "interject",
|
||||
"audio_pcm",
|
||||
"file_from_aria",
|
||||
"container_restart",
|
||||
@@ -63,19 +63,62 @@ 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)
|
||||
// spiegelt ARIAs Datei-Writes, und QEMU-VNC wird als RFB-Bytes durch RVS
|
||||
// getunnelt (Base64-in-JSON wie audio_pcm — kein Binaer-Handling noetig).
|
||||
"code_file", "code_file_edit",
|
||||
// M1 Generatives Cockpit: ARIA komponiert via present_view eine View-Spec
|
||||
// (Orb + Karten), die App/Web/Diagnostic mit ihrem jeweiligen Renderer
|
||||
// materialisieren. Brain → Bridge → RVS → Clients.
|
||||
"aria_view",
|
||||
"check_desktop", "desktop_status",
|
||||
"vnc_open", "vnc_close", "vnc_data", "vnc_input",
|
||||
// Satelliten (Info-/Gateway-Aussenposten in fremden Netzen): melden sich mit
|
||||
// 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",
|
||||
// Agent-Historie: Brain schickt pro Host-Aktion ein agent_history-Event (via
|
||||
// Bridge), Diagnostic fragt die gespeicherte Historie per agent_history_query
|
||||
// ab, die Bridge antwortet mit agent_history_list. OHNE diese Typen verwirft
|
||||
// der RVS sie still an der Allow-List und die Historie bleibt leer.
|
||||
"agent_history", "agent_history_query", "agent_history_list",
|
||||
// STT-Lease: die App fragt vor einer Aufnahme, WELCHE STT-Instanz sie nehmen
|
||||
// soll (stt_lease_request -> stt_lease). Fehlten diese Typen hier, lief die
|
||||
// Anfrage IMMER in den 250ms-Timeout: jede Aufnahme startete verspaetet (erste
|
||||
// Silben weg) und ohne targetInstance nahmen ALLE STT-Worker dieselbe Session
|
||||
// an -> doppelte stt_endpoint. chat_ack quittiert getippte Nachrichten; ohne
|
||||
// ihn liefen sie in der App nach 3 Versuchen auf "fehlgeschlagen", obwohl sie
|
||||
// laengst verarbeitet waren. stt_transcribe_* ist die Wake-Wort-Bestaetigung.
|
||||
"stt_lease", "stt_lease_request", "stt_transcribe_blob", "stt_transcribe_result",
|
||||
"chat_ack",
|
||||
// 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",
|
||||
// OpenAI-Endpunkt einer LLM-Box: Diagnostic setzt den API-Key (der PORT kommt
|
||||
// immer aus der .env der Box — er ist ein Docker-Port-Mapping).
|
||||
"llm_openai_config", "llm_openai_config_result",
|
||||
"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> }
|
||||
@@ -327,6 +370,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();
|
||||
|
||||
+59
-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,59 @@ 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')
|
||||
# adb.install APK auf ein Android-Geraet/Fire TV ausrollen (params: ip, url)
|
||||
# adb.connect ADB-Verbindung aufbauen (params: ip)
|
||||
# adb.devices verbundene ADB-Geraete auflisten
|
||||
# adb.shell Shell-Kommando auf dem Android-Geraet (params: ip, cmd)
|
||||
# adb.provision Agent FERN einrichten: RVS-Zugang uebergeben, statt ihn mit der
|
||||
# Fernbedienung abzutippen (params: ip, rvs_host, rvs_token, ...)
|
||||
# adb.status laeuft/installiert der Agent auf dem Geraet? (params: ip)
|
||||
# adb.permissions Sonderrechte per ADB erteilen (Bildschirmaufnahme/Overlay/
|
||||
# Mikro) — spart das Zusammenklicken mit der Fernbedienung
|
||||
# ACHTUNG: Diese Liste ueberschreibt den Code-Default. Wird eine neue Aktion
|
||||
# ergaenzt, MUSS sie hier nachgetragen werden — sonst lehnt der Satellit sie ab.
|
||||
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec,adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status,adb.permissions
|
||||
|
||||
# ─── ADB / Android-Rollout (optional) ──────────────────────────────
|
||||
# Fire TV Sticks & Android-Geraete im LAN: APK ausrollen ohne Datei-Kopieren.
|
||||
# Voraussetzung am Geraet: ADB-Debugging AN (Fire TV: Einstellungen > Mein Fire TV
|
||||
# > Entwickleroptionen > ADB-Debugging) und den Dialog "USB-Debugging zulassen?"
|
||||
# einmalig bestaetigen (Haken "Immer erlauben").
|
||||
ADB_PORT=5555
|
||||
ADB_TIMEOUT_SEC=180 # install dauert 30-60s, grosszuegig lassen
|
||||
APK_CACHE_DIR=/data/apk # heruntergeladene APKs (1x laden, N Geraete)
|
||||
|
||||
# ─── 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,15 @@ 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.
|
||||
# adb: Android-Geraete im LAN (v.a. Fire TV Sticks) per Netzwerk-Debugging
|
||||
# erreichen und APKs ausrollen (adb.*-Aktionen). In Debian bookworm heisst das
|
||||
# Paket schlicht "adb" (nicht mehr android-tools-adb).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends snmp adb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
@@ -32,6 +32,18 @@ docker compose logs -f # "Netz: primary_ip=192.168.0.x" + "[scan] N Ger
|
||||
```
|
||||
`network_mode: host` (schon gesetzt) gibt hier echtes LAN + Multicast.
|
||||
|
||||
> **Fehler `KeyError: 'ContainerConfig'` beim Neubauen?**
|
||||
> Das ist ein **Bug im alten `docker-compose` 1.29.2** (Python-Version), nicht im
|
||||
> Satelliten: Beim *Ersetzen* eines bestehenden Containers mit Volume liest es ein
|
||||
> Feld aus dem Image, das neuere Docker-Versionen nicht mehr schreiben. Zwei Fixes:
|
||||
> ```bash
|
||||
> docker compose up -d --build # v2 (mit LEERZEICHEN) — falls vorhanden
|
||||
> # oder mit der alten Version: alten Container erst weg, dann neu anlegen
|
||||
> docker-compose rm -sf satellite && docker-compose up -d --build
|
||||
> ```
|
||||
> Der `./data`-Ordner (Credentials, ADB-Schlüssel, APK-Cache) bleibt dabei erhalten —
|
||||
> er ist ein Bind-Mount, kein Container-Volume.
|
||||
|
||||
**B) Nativ als Python-Prozess** (für Mac/Windows-Test oder ohne Docker) — läuft direkt
|
||||
auf einer Maschine im Ziel-LAN. Das Script lädt die `.env` **selbst** (muss im
|
||||
`satellite/`-Ordner liegen):
|
||||
@@ -48,6 +60,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,12 @@ services:
|
||||
network_mode: host
|
||||
env_file: .env
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# ADB-Schluessel in /data persistieren: sonst erzeugt adb nach jedem
|
||||
# Rebuild einen NEUEN Schluessel und das Fire TV fragt erneut
|
||||
# "USB-Debugging zulassen?" (Autorisierung geht verloren).
|
||||
- HOME=/data
|
||||
- ANDROID_ADB_SERVER_PORT=5038 # 5037 kann auf dem Host belegt sein
|
||||
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)
|
||||
|
||||
+1274
-13
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user