Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3693157210 | ||
|
|
64670cdd12 | ||
|
|
c82616ebbd | ||
|
|
b226e1da11 | ||
|
|
0b7ed241b4 | ||
|
|
a2b7e3a48d | ||
|
|
7b72149671 | ||
|
|
e9439dbccb | ||
|
|
219de091d2 | ||
|
|
5992a7e441 | ||
|
|
07ccf05429 | ||
|
|
4ab0e68245 | ||
|
|
9636a702d3 | ||
|
|
5f09e2bca3 | ||
|
|
ebe0e8065f | ||
|
|
9d2c07d8d1 | ||
|
|
9aae5af6a9 | ||
|
|
a8ff73f93d | ||
|
|
0e9adeee5c | ||
|
|
6addb2f8fe | ||
|
|
9bdfb7193e | ||
|
|
9e78d75149 | ||
|
|
517c993ac8 | ||
|
|
c1bd13687d | ||
|
|
d9bb7239c6 | ||
|
|
0265aabb5e | ||
|
|
17bc50b847 | ||
|
|
1f2be4299d | ||
|
|
0ca8a82013 | ||
|
|
7bc3f827d0 | ||
|
|
e7da9cf9c4 | ||
|
|
353fd98d3f | ||
|
|
0350dd33c8 | ||
|
|
7b956c6606 | ||
|
|
36f04f83ff | ||
|
|
01df26e6df | ||
|
|
f226c91973 | ||
|
|
3324d39d50 | ||
|
|
fff2e7df34 | ||
|
|
0aac114142 | ||
|
|
ba60f793fb | ||
|
|
a7c2f07361 | ||
|
|
ccf6dd84fb | ||
|
|
75675ed3aa | ||
|
|
73fee27e90 | ||
|
|
03021a6787 | ||
|
|
b6a5d7029f | ||
|
|
3a8202d2de | ||
|
|
7bbb75481c | ||
|
|
ee6c4f34db | ||
|
|
75daadf72d | ||
|
|
37aaa90239 | ||
|
|
03e6d784b6 | ||
|
|
762f1a2dd9 | ||
|
|
49f6b26ab8 | ||
|
|
c340b9d683 | ||
|
|
a0429fc91e | ||
|
|
174d6d643d | ||
|
|
fd6ba73f59 | ||
|
|
f5b22253b2 | ||
|
|
761f4c8903 | ||
|
|
091a1b7755 | ||
|
|
e2b1eced3c | ||
|
|
fe804fa40e | ||
|
|
18eb94e942 | ||
|
|
74c7ea0a2d | ||
|
|
648e3b04fd | ||
|
|
2ad8c2f245 | ||
|
|
85d190e98c | ||
|
|
70705269fd | ||
|
|
2aef0347ae | ||
|
|
a49c022308 | ||
|
|
2d3ba024a4 | ||
|
|
1c7157327c | ||
|
|
6c27097c96 | ||
|
|
a91325a04f | ||
|
|
ef826d1ed1 | ||
|
|
933836f0a6 | ||
|
|
e04d8f360b | ||
|
|
4685632294 | ||
|
|
769025c41b | ||
|
|
2005e9b85e | ||
|
|
25abd220ad | ||
|
|
3c6bf0783e | ||
|
|
2832ab3dc9 | ||
|
|
d38d62ba21 | ||
|
|
5890c17ec0 | ||
|
|
239f1094f9 | ||
|
|
055db7c059 | ||
|
|
2a8cbc6c15 | ||
|
|
7e14107361 | ||
|
|
e64043dca5 | ||
|
|
0efb8d0848 | ||
|
|
5fa5d79ad8 | ||
|
|
9dd7a2cb14 | ||
|
|
2811379684 | ||
|
|
902316566e | ||
|
|
1f94b4eab5 | ||
|
|
57e13800e0 | ||
|
|
1dc8c0936f | ||
|
|
071e38f464 |
@@ -10,6 +10,27 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c
|
||||
|
||||
---
|
||||
|
||||
## [0.2.3.0] — 2026-07-19 — Satelliten: ARIAs Augen & Hände in fremden Netzen 🛰️
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
**Neuer eigenständiger Container `satellite/`** — ein Außenposten, den du in einem beliebigen Netz (Büro, Werkstatt …) deployst. Er verbindet sich als RVS-Client in deinen Raum und gibt ARIA Zugriff auf **genau dieses Netz**, ohne dass der Haupt-Stack dort steht.
|
||||
- **Entdeckung (Info):** mDNS/Zeroconf (Chromecast, AirPlay, Sonos, Drucker, NAS …), SSDP/UPnP + **DIAL** (Smart-TVs, Fire TV), ARP-Tabelle (rohe Hosts) → Live-Inventar.
|
||||
- **Steuerung (mit Guards):** **DIAL-App-Launch** (z.B. „ARIA, spiel YouTube-Video X auf dem Büro-Stick" → Fire TV), **Wake-on-LAN**, generisches **HTTP**. Nur wenn `CONTROL_ENABLED=true`, nur Aktionen aus der `CONTROL_ALLOWLIST`, alles geloggt, read-only per `.env` abschaltbar. Reagiert nur auf den eigenen RVS-Raum (Token). Keine offenen Ports.
|
||||
- **Adressierung** über `SATELLITE_LOCATION` (z.B. „Büro") — mehrere Satelliten im selben Raum, jeder mit eigenem Namen.
|
||||
|
||||
**End-to-end verdrahtet:**
|
||||
- `satellite/`: eigener Stack (`docker compose` mit `network_mode: host`), `.env.example`, README.
|
||||
- RVS: neue Message-Typen `sat_hello / sat_discover / sat_devices / sat_command / sat_result`.
|
||||
- Bridge: Satelliten-Registry (`sat_hello`) + Future-Relay (`/internal/satellite`, `/internal/satellite-list`) analog zum flux-Muster.
|
||||
- Brain: Tools `satellite_list`, `satellite_devices`, `satellite_command` + Seed-Regel, die ARIA den Ablauf beibringt (erst list, dann devices, dann command).
|
||||
- **Diagnostic: neuer Tab „Satelliten"** — zeigt live welche Satelliten verbunden sind (online/offline, Standort, Capabilities, read-only vs. steuerbar) und pro Satellit ein „Geräte scannen" (löst `sat_discover` aus → erkannte Geräte mit Typ/IP/Modell/DIAL/MAC).
|
||||
|
||||
### Deploy
|
||||
Satellit im Ziel-Netz: `cd satellite && cp .env.example .env && docker compose up -d --build`. Haupt-Stack: `git pull && docker compose up -d --build brain bridge` + RVS-Stack `up -d --build`. Kein APK-Rebuild.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.2.3] — 2026-07-17 — ARIA liest andere Projekt-Chats wirklich (volle Historie)
|
||||
|
||||
### Behoben
|
||||
|
||||
@@ -96,6 +96,7 @@ 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` |
|
||||
| Satellit(en) 🛰️ (optional) | Fremdes Netz (Büro …) | `cd satellite && cp .env.example .env && docker compose up -d --build` |
|
||||
| Android App | Stefans Handy | APK installieren (Auto-Update via RVS) |
|
||||
|
||||
> Der Gamebox-Stack ist optional: ohne ihn faellt STT auf lokales Whisper (CPU,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# ai-box — KI-Box (gpubox) Bootstrap
|
||||
|
||||
Macht aus einem frisch installierten **Debian Trixie** (headless, nur SSH) einen
|
||||
startklaren GPU-Satelliten-Host für den `xtts`-Stack (STT/TTS/LLM).
|
||||
|
||||
## Was das Script tut
|
||||
|
||||
1. Basis-Pakete (curl, gnupg, git …)
|
||||
2. `contrib non-free non-free-firmware` aktivieren (Trixie-**deb822**-Format berücksichtigt)
|
||||
3. **NVIDIA-Treiber** installieren (`nvidia-driver` + firmware)
|
||||
4. **Docker** Engine + Compose-Plugin
|
||||
5. **NVIDIA Container Toolkit** + Docker-Runtime auf NVIDIA konfigurieren
|
||||
6. `xtts/.env` aus `.env.example` anlegen (RVS_TOKEN optional gleich setzen)
|
||||
7. **GPU-im-Container-Test** (`docker run --gpus all … nvidia-smi`)
|
||||
8. optional (`--up`): den `xtts`-Stack hochziehen
|
||||
|
||||
Alles **idempotent** — mehrfach ausführbar.
|
||||
|
||||
## Ablauf
|
||||
|
||||
```bash
|
||||
git clone <repo> ARIA-AGENT
|
||||
cd ARIA-AGENT/ai-box
|
||||
|
||||
sudo ./bootstrap.sh
|
||||
# → Wenn der Treiber frisch installiert wurde: einmal neu starten, dann nochmal:
|
||||
sudo reboot
|
||||
# … nach dem Boot:
|
||||
cd ARIA-AGENT/ai-box
|
||||
sudo ./bootstrap.sh --up --token <DEIN_RVS_TOKEN>
|
||||
```
|
||||
|
||||
Der Treiber-Reboot ist normal (Kernel-Modul wird erst beim Boot geladen). Beim
|
||||
zweiten Lauf überspringt das Script alles Erledigte und macht nur noch den
|
||||
GPU-Test + Stack-Start.
|
||||
|
||||
## Optionen
|
||||
|
||||
| Option | Wirkung |
|
||||
|---|---|
|
||||
| `--up` | am Ende `docker compose up -d --build` (Default-Profil) |
|
||||
| `--token <TOK>` | `RVS_TOKEN` in `xtts/.env` eintragen (auch via `RVS_TOKEN=…` env) |
|
||||
| `--rvs-host <H>` | `RVS_HOST` setzen |
|
||||
|
||||
## Wichtig
|
||||
|
||||
- **Stimm-Daten** (nicht in git): falls von der alten Box noch vorhanden,
|
||||
`xtts/voice-id/` (Speaker-Fingerprint) + `xtts/voices/` (F5-Referenz) herkopieren.
|
||||
Sonst egal — in der App neu anlegen: Stimme neu enrollen (ohne Fingerprint läuft
|
||||
die Speaker-ID fail-open, alles geht durch) + F5-Voice-Referenz neu hochladen.
|
||||
- **Voxtral bleibt aus** auf der 3060 (braucht ≥16 GB VRAM). Das Default-Profil
|
||||
fährt Whisper (mit dem M0.1-Fix) + F5-TTS + lokales LLM. Voxtral erst mit der
|
||||
24-GB-Karte: `docker compose stop whisper-bridge && docker compose --profile voxtral up -d --build`.
|
||||
- **Erster Start lädt Modelle** (mehrere GB via HuggingFace nach `xtts/hf-cache`
|
||||
+ `xtts/models`) — genug Platz (1 TB NVMe ✓) und etwas Geduld.
|
||||
|
||||
## Verifizieren
|
||||
|
||||
```bash
|
||||
nvidia-smi # Host sieht die GPU
|
||||
docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi # Container auch
|
||||
docker logs -f aria-whisper-bridge # "RVS verbunden" + service_status ready
|
||||
```
|
||||
Executable
+277
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# ARIA KI-Box (gpubox) Bootstrap — frisches Debian Trixie → startklarer
|
||||
# GPU-Satelliten-Host fuer den xtts-Stack (Voxtral/Whisper STT, F5-TTS, lokales LLM).
|
||||
#
|
||||
# Ablauf nach `git clone`:
|
||||
# cd ARIA-AGENT/ai-box
|
||||
# sudo ./bootstrap.sh # richtet Treiber + Docker + NVIDIA-Toolkit ein
|
||||
# # (falls Treiber frisch installiert: einmal `sudo reboot`, dann Script erneut)
|
||||
# sudo ./bootstrap.sh --up # dazu: xtts-Stack (Whisper+F5+LLM) hochziehen
|
||||
#
|
||||
# Optionen:
|
||||
# --up am Ende den xtts-Stack starten (Default-Profil, OHNE voxtral)
|
||||
# --upgrade-driver NVIDIA-Treiber aus trixie-backports (modernes CUDA fuer Voxtral).
|
||||
# Danach REBOOT noetig. Default-Bootstrap laesst 550 unangetastet.
|
||||
# --token <TOK> RVS_TOKEN in xtts/.env eintragen (alternativ: env RVS_TOKEN=...)
|
||||
# --rvs-host <H> RVS_HOST setzen (Default aus .env.example)
|
||||
#
|
||||
# IDEMPOTENT: bereits erledigte Schritte werden uebersprungen. Nach dem
|
||||
# Treiber-Reboot einfach nochmal ausfuehren — der Rest laeuft dann durch.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# ── CLI ──
|
||||
DO_UP=0
|
||||
DO_UPGRADE_DRIVER=0
|
||||
RVS_TOKEN_ARG="${RVS_TOKEN:-}"
|
||||
RVS_HOST_ARG="${RVS_HOST:-}"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--up) DO_UP=1; shift ;;
|
||||
--upgrade-driver) DO_UPGRADE_DRIVER=1; shift ;;
|
||||
--token) RVS_TOKEN_ARG="${2:-}"; shift 2 ;;
|
||||
--rvs-host) RVS_HOST_ARG="${2:-}"; shift 2 ;;
|
||||
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "Unbekannte Option: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Log-Helfer ──
|
||||
c_g="\033[1;32m"; c_y="\033[1;33m"; c_r="\033[1;31m"; c_b="\033[1;34m"; c_0="\033[0m"
|
||||
STEP=0
|
||||
step() { STEP=$((STEP+1)); echo -e "\n${c_b}[${STEP}] $*${c_0}"; }
|
||||
ok() { echo -e " ${c_g}✓${c_0} $*"; }
|
||||
warn() { echo -e " ${c_y}!${c_0} $*"; }
|
||||
die() { echo -e "${c_r}✗ $*${c_0}" >&2; exit 1; }
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
XTTS_DIR="$REPO_ROOT/xtts"
|
||||
|
||||
# ── Preflight ──
|
||||
[[ "$(id -u)" -eq 0 ]] || die "Bitte als root ausfuehren (sudo ./bootstrap.sh)."
|
||||
command -v apt-get >/dev/null || die "Kein apt-get — dieses Script ist fuer Debian/Trixie."
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
echo -e "${c_b}=== ARIA KI-Box Bootstrap ===${c_0}"
|
||||
echo "Repo: $REPO_ROOT"
|
||||
echo "xtts: $XTTS_DIR"
|
||||
|
||||
# ── 1. Basis-Pakete ──
|
||||
step "Basis-Pakete"
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq ca-certificates curl gnupg git lsb-release >/dev/null
|
||||
ok "ca-certificates, curl, gnupg, git"
|
||||
|
||||
# ── 2. non-free Repos aktivieren (Trixie deb822 + Legacy) ──
|
||||
step "APT-Komponenten (contrib non-free non-free-firmware)"
|
||||
# Ergaenzt die drei Komponenten in JEDER Components:-Zeile einer deb822-Datei —
|
||||
# pro Zeile nur was fehlt, reihenfolge-robust, idempotent. Die Adressen pruefen
|
||||
# ganze Woerter (non-free-firmware zaehlt NICHT als non-free).
|
||||
add_components() {
|
||||
sed -i -E '/^[Cc]omponents:/{
|
||||
/(^|[[:space:]])contrib([[:space:]]|$)/!s/$/ contrib/
|
||||
/(^|[[:space:]])non-free([[:space:]]|$)/!s/$/ non-free/
|
||||
/(^|[[:space:]])non-free-firmware([[:space:]]|$)/!s/$/ non-free-firmware/
|
||||
}' "$1"
|
||||
}
|
||||
ENABLED_ANY=0
|
||||
shopt -s nullglob
|
||||
for f in /etc/apt/sources.list.d/*.sources; do
|
||||
grep -qE '^[Cc]omponents:' "$f" || continue
|
||||
b="$(md5sum "$f")"; add_components "$f"; a="$(md5sum "$f")"
|
||||
if [[ "$b" != "$a" ]]; then ENABLED_ANY=1; ok "aktualisiert: $(basename "$f")"; fi
|
||||
done
|
||||
shopt -u nullglob
|
||||
# Legacy /etc/apt/sources.list (deb-Zeilen)
|
||||
if [[ -f /etc/apt/sources.list ]] && grep -qE '^deb ' /etc/apt/sources.list; then
|
||||
if ! grep -qE '^deb .*[[:space:]]non-free([[:space:]]|$)' /etc/apt/sources.list; then
|
||||
sed -i -E '/^deb .*debian/ s/$/ contrib non-free non-free-firmware/' /etc/apt/sources.list
|
||||
ENABLED_ANY=1; ok "aktualisiert: sources.list"
|
||||
fi
|
||||
fi
|
||||
[[ $ENABLED_ANY -eq 0 ]] && ok "non-free schon aktiv"
|
||||
apt-get update -qq # immer neu einlesen, damit der Kandidat sicher da ist
|
||||
|
||||
# ── 2b. (opt-in) Treiber-Upgrade via trixie-backports — fuer Voxtral/modernes CUDA ──
|
||||
# Der Trixie-Standardtreiber (550, CUDA 12.4) ist zu alt fuer den modernen Stack
|
||||
# (torch 2.13, vLLM). Backports bringt einen neueren, apt-verwalteten Treiber.
|
||||
# NUR mit --upgrade-driver, damit ein laufendes Setup nicht ungewollt angefasst wird.
|
||||
if [[ "$DO_UPGRADE_DRIVER" -eq 1 ]]; then
|
||||
step "NVIDIA-Treiber-Upgrade (trixie-backports)"
|
||||
BP_FILE="/etc/apt/sources.list.d/backports.sources"
|
||||
if ! grep -rqs "trixie-backports" /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null; then
|
||||
cat > "$BP_FILE" <<'EOF'
|
||||
Types: deb
|
||||
URIs: http://deb.debian.org/debian
|
||||
Suites: trixie-backports
|
||||
Components: main contrib non-free non-free-firmware
|
||||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||||
EOF
|
||||
ok "trixie-backports hinzugefuegt"
|
||||
else
|
||||
ok "trixie-backports bereits aktiv"
|
||||
fi
|
||||
apt-get update
|
||||
apt-get install -y linux-headers-amd64 || true
|
||||
apt-get install -y "linux-headers-$(uname -r)" || true
|
||||
if apt-get install -y -t trixie-backports nvidia-driver; then
|
||||
dkms autoinstall >/dev/null 2>&1 || true
|
||||
NEWV="$(dpkg-query -W -f='${Version}' nvidia-driver 2>/dev/null || echo '?')"
|
||||
ok "nvidia-driver aus backports installiert: ${NEWV}"
|
||||
echo
|
||||
echo -e "${c_y}==> REBOOT noetig, damit der neue Treiber laedt:${c_0}"
|
||||
echo -e "${c_y} sudo reboot && danach: cd ai-box && sudo ./bootstrap.sh --up --token <TOKEN>${c_0}"
|
||||
echo -e "${c_y} (nvidia-smi zeigt dann die neue Version + CUDA-Level)${c_0}"
|
||||
exit 0
|
||||
else
|
||||
warn "backports-Install fehlgeschlagen — 550er bleibt aktiv."
|
||||
warn "Alternative fuer den neuesten Treiber: NVIDIAs CUDA-Repo fuer debian13"
|
||||
warn " (developer.download.nvidia.com/compute/cuda/repos/debian13/x86_64) → Paket 'cuda-drivers'."
|
||||
die "Treiber-Upgrade nicht moeglich — siehe oben."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 3. NVIDIA-Treiber ──
|
||||
step "NVIDIA-Treiber"
|
||||
if nvidia-smi >/dev/null 2>&1; then
|
||||
ok "Treiber aktiv: $(nvidia-smi --query-gpu=name --format=csv,noheader | paste -sd', ')"
|
||||
DRIVER_ACTIVE=1
|
||||
else
|
||||
if dpkg -l | grep -q '^ii nvidia-driver '; then
|
||||
warn "Treiber installiert, aber nvidia-smi antwortet nicht → REBOOT noetig."
|
||||
DRIVER_ACTIVE=0
|
||||
else
|
||||
# KEIN separater Kandidaten-Check — die apt-cache-Ausgabe ist locale-/pipe-
|
||||
# fragil (hat faelschlich "kein Kandidat" gemeldet). Der Install IST der Test:
|
||||
# direkt installieren; schlaegt er fehl, einmal volles apt-get update + Retry,
|
||||
# dann erst mit Diagnose abbrechen.
|
||||
# Kernel-Header ZUERST — sonst ueberspringt DKMS den Modulbau ("No kernel
|
||||
# headers were found") und nvidia-smi kann spaeter nicht mit dem Treiber reden.
|
||||
warn "Kernel-Header + nvidia-driver installieren (DKMS-Build, dauert)…"
|
||||
apt-get install -y linux-headers-amd64 || true
|
||||
apt-get install -y "linux-headers-$(uname -r)" || true
|
||||
if ! apt-get install -y nvidia-driver firmware-misc-nonfree; then
|
||||
warn "Install fehlgeschlagen — volles 'apt-get update' + zweiter Versuch…"
|
||||
apt-get update
|
||||
if ! apt-get install -y nvidia-driver firmware-misc-nonfree; then
|
||||
echo
|
||||
warn "nvidia-driver liess sich nicht installieren. Aktive Quellen:"
|
||||
grep -rhE '^[Cc]omponents:' /etc/apt/sources.list.d/*.sources 2>/dev/null | sed 's/^/ /' || true
|
||||
grep -E '^deb ' /etc/apt/sources.list 2>/dev/null | sed 's/^/ /' || true
|
||||
die "Pruefe 'apt-cache policy nvidia-driver' + Netz/non-free."
|
||||
fi
|
||||
fi
|
||||
# Falls nvidia-kernel-dkms schon (ohne Header) installiert war: Modul jetzt bauen.
|
||||
dkms autoinstall >/dev/null 2>&1 || true
|
||||
ok "nvidia-driver + Kernel-Modul installiert"
|
||||
DRIVER_ACTIVE=0
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 4. Docker Engine + Compose-Plugin ──
|
||||
step "Docker"
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
ok "Docker vorhanden: $(docker --version)"
|
||||
else
|
||||
warn "Installiere Docker (offizielles get.docker.com)…"
|
||||
curl -fsSL https://get.docker.com | sh >/dev/null
|
||||
systemctl enable --now docker >/dev/null 2>&1 || true
|
||||
ok "Docker installiert: $(docker --version)"
|
||||
fi
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
ok "Compose-Plugin: $(docker compose version | head -1)"
|
||||
else
|
||||
warn "Compose-Plugin fehlt — installiere docker-compose-plugin…"
|
||||
apt-get install -y -qq docker-compose-plugin >/dev/null || \
|
||||
warn "Konnte docker-compose-plugin nicht via apt holen — get.docker.com bringt es normalerweise mit."
|
||||
fi
|
||||
|
||||
# ── 5. NVIDIA Container Toolkit ──
|
||||
step "NVIDIA Container Toolkit"
|
||||
NCT_LIST="/etc/apt/sources.list.d/nvidia-container-toolkit.list"
|
||||
NCT_KEY="/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg"
|
||||
if ! dpkg -l | grep -q '^ii nvidia-container-toolkit '; then
|
||||
[[ -f "$NCT_KEY" ]] || curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
|
||||
| gpg --dearmor -o "$NCT_KEY"
|
||||
if [[ ! -f "$NCT_LIST" ]]; then
|
||||
curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
|
||||
| sed "s#deb https://#deb [signed-by=${NCT_KEY}] https://#g" > "$NCT_LIST"
|
||||
fi
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq nvidia-container-toolkit >/dev/null
|
||||
ok "nvidia-container-toolkit installiert"
|
||||
else
|
||||
ok "nvidia-container-toolkit vorhanden"
|
||||
fi
|
||||
# Docker-Runtime auf NVIDIA konfigurieren (idempotent)
|
||||
if ! grep -q '"nvidia"' /etc/docker/daemon.json 2>/dev/null; then
|
||||
nvidia-ctk runtime configure --runtime=docker >/dev/null
|
||||
systemctl restart docker
|
||||
ok "Docker-Runtime auf NVIDIA konfiguriert + Docker neugestartet"
|
||||
else
|
||||
ok "Docker-Runtime bereits NVIDIA-konfiguriert"
|
||||
fi
|
||||
|
||||
# ── 6. xtts/.env vorbereiten ──
|
||||
step "xtts/.env"
|
||||
if [[ -f "$XTTS_DIR/.env" ]]; then
|
||||
ok ".env existiert bereits (unangetastet)"
|
||||
else
|
||||
cp "$XTTS_DIR/.env.example" "$XTTS_DIR/.env"
|
||||
ok ".env aus .env.example erstellt"
|
||||
fi
|
||||
if [[ -n "$RVS_TOKEN_ARG" ]]; then
|
||||
sed -i -E "s#^RVS_TOKEN=.*#RVS_TOKEN=${RVS_TOKEN_ARG}#" "$XTTS_DIR/.env"
|
||||
ok "RVS_TOKEN eingetragen"
|
||||
fi
|
||||
if [[ -n "$RVS_HOST_ARG" ]]; then
|
||||
sed -i -E "s#^RVS_HOST=.*#RVS_HOST=${RVS_HOST_ARG}#" "$XTTS_DIR/.env"
|
||||
ok "RVS_HOST=${RVS_HOST_ARG} eingetragen"
|
||||
fi
|
||||
if grep -q '^RVS_TOKEN=dein_token_hier' "$XTTS_DIR/.env"; then
|
||||
warn "RVS_TOKEN ist noch der Platzhalter — vor dem Start setzen:"
|
||||
warn " nano $XTTS_DIR/.env (oder: sudo ./bootstrap.sh --token <TOKEN>)"
|
||||
fi
|
||||
warn "Stimm-Daten (nicht in git): falls von der alten Box noch vorhanden, xtts/voice-id/"
|
||||
warn " + xtts/voices/ herkopieren. Sonst egal — in der App neu anlegen:"
|
||||
warn " Sprache neu enrollen (Speaker-ID, sonst fail-open) + F5-Referenz neu hochladen."
|
||||
|
||||
# ── 7. GPU-im-Container verifizieren ──
|
||||
step "GPU-im-Container Test"
|
||||
if [[ "${DRIVER_ACTIVE:-0}" -eq 1 ]]; then
|
||||
if docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi >/dev/null 2>&1; then
|
||||
ok "Docker sieht die GPU — KI-Box ist einsatzbereit."
|
||||
GPU_READY=1
|
||||
else
|
||||
warn "Host-Treiber ok, aber Container sieht die GPU nicht — Toolkit/Runtime pruefen."
|
||||
GPU_READY=0
|
||||
fi
|
||||
else
|
||||
warn "Treiber noch nicht aktiv → Test uebersprungen."
|
||||
echo
|
||||
echo -e "${c_y}==> REBOOT noetig, dann Script erneut ausfuehren:${c_0}"
|
||||
echo -e "${c_y} sudo reboot && (nach dem Boot) sudo ./bootstrap.sh${c_0}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 8. Optional: xtts-Stack hochziehen ──
|
||||
if [[ $DO_UP -eq 1 && "${GPU_READY:-0}" -eq 1 ]]; then
|
||||
step "xtts-Stack starten (Default-Profil: Whisper + F5 + LLM — OHNE voxtral)"
|
||||
warn "Erster Start laedt Modelle (mehrere GB via HuggingFace) — kann dauern."
|
||||
( cd "$XTTS_DIR" && docker compose up -d --build )
|
||||
ok "Stack laeuft. Logs: docker logs -f aria-whisper-bridge"
|
||||
echo
|
||||
echo " voxtral (STT via Voxtral) braucht >=16 GB VRAM → erst mit der 24-GB-Karte:"
|
||||
echo " cd $XTTS_DIR && docker compose stop whisper-bridge && docker compose --profile voxtral up -d --build"
|
||||
fi
|
||||
|
||||
# ── Abschluss ──
|
||||
echo
|
||||
echo -e "${c_g}=== Fertig. KI-Box startklar. ===${c_0}"
|
||||
if [[ $DO_UP -eq 0 ]]; then
|
||||
echo "Naechster Schritt — Stack starten:"
|
||||
echo " cd $XTTS_DIR && docker compose up -d --build"
|
||||
echo "oder direkt: sudo ./bootstrap.sh --up"
|
||||
fi
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20202
|
||||
versionName "0.2.2.2"
|
||||
versionCode 20404
|
||||
versionName "0.2.4.4"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ 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
|
||||
}
|
||||
|
||||
private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.2.2.2",
|
||||
"version": "0.2.4.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
|
||||
import brainApi, { Project } from '../services/brainApi';
|
||||
import rvs from '../services/rvs';
|
||||
import projectFocus from '../services/projectFocus';
|
||||
|
||||
interface Props {
|
||||
/** Optional — wenn als Modal genutzt, sonst inline */
|
||||
@@ -75,6 +76,7 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
|
||||
const [editing, setEditing] = useState<Project | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editDesc, setEditDesc] = useState('');
|
||||
const [editKind, setEditKind] = useState<'code' | 'chat'>('chat');
|
||||
// Versteckte Projekte standardmaessig ausblenden; Toggle blendet sie
|
||||
// temporaer (gedimmt) ein — zum Ansehen/Auswaehlen oder Wieder-Sichtbarmachen.
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
@@ -148,18 +150,25 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
|
||||
setEditing(p);
|
||||
setEditName(p.name);
|
||||
setEditDesc(p.description || '');
|
||||
setEditKind(p.kind === 'code' ? 'code' : 'chat');
|
||||
}, []);
|
||||
|
||||
const saveEdit = useCallback(() => {
|
||||
if (!editing) return;
|
||||
const patch: Partial<Pick<Project, 'name' | 'description'>> = {};
|
||||
const patch: Partial<Pick<Project, 'name' | 'description' | 'kind'>> = {};
|
||||
if (editName.trim() && editName.trim() !== editing.name) patch.name = editName.trim();
|
||||
if (editDesc.trim() !== (editing.description || '')) patch.description = editDesc.trim();
|
||||
const curKind = editing.kind === 'code' ? 'code' : 'chat';
|
||||
if (editKind !== curKind) patch.kind = editKind;
|
||||
if (Object.keys(patch).length === 0) { setEditing(null); return; }
|
||||
brainApi.updateProject(editing.id, patch)
|
||||
.then(() => { setEditing(null); load(); })
|
||||
.then(() => {
|
||||
// Kind sofort in den Workspace spiegeln (Editor/Desktop-Panels).
|
||||
if (patch.kind) projectFocus.setKind(editing.id, patch.kind);
|
||||
setEditing(null); load();
|
||||
})
|
||||
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
||||
}, [editing, editName, editDesc, load]);
|
||||
}, [editing, editName, editDesc, editKind, load]);
|
||||
|
||||
const endProject = useCallback((p: Project) => {
|
||||
Alert.alert(`"${p.name}" beenden?`,
|
||||
@@ -216,6 +225,9 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
|
||||
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
|
||||
)}
|
||||
<Text style={[s.rowName, isActive && { color: '#34C759' }]}>{item.name}</Text>
|
||||
{item.has_files && (
|
||||
<Text style={{ fontSize: 12 }} accessibilityLabel="hat Dateien">📄{item.file_count ? ` ${item.file_count}` : ''}</Text>
|
||||
)}
|
||||
{hidden && <Text style={s.hiddenBadge}>versteckt</Text>}
|
||||
{item.status === 'ended' && <Text style={s.statusBadge}>beendet</Text>}
|
||||
{isActive && <Text style={s.activeBadge}>✓ FOCUS</Text>}
|
||||
@@ -375,6 +387,21 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
|
||||
style={[s.input, { height: 70 }]}
|
||||
multiline
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => setEditKind(k => (k === 'code' ? 'chat' : 'code'))}
|
||||
style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 }}
|
||||
>
|
||||
<Text style={{ color: '#E0E0F0', fontSize: 14 }}>💻 Code-Projekt{'\n'}
|
||||
<Text style={{ color: '#8888AA', fontSize: 11 }}>zeigt Editor + Desktop im Cockpit</Text>
|
||||
</Text>
|
||||
<View style={{
|
||||
width: 46, height: 26, borderRadius: 13, padding: 3,
|
||||
backgroundColor: editKind === 'code' ? '#0096FF' : '#2A2A3E',
|
||||
alignItems: editKind === 'code' ? 'flex-end' : 'flex-start',
|
||||
}}>
|
||||
<View style={{ width: 20, height: 20, borderRadius: 10, backgroundColor: '#FFFFFF' }} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={{ flexDirection: 'row', gap: 8, marginTop: 12 }}>
|
||||
<TouchableOpacity onPress={() => setEditing(null)} style={[s.modalBtn, { backgroundColor: '#2A2A3E' }]}>
|
||||
<Text style={s.modalBtnText}>Abbrechen</Text>
|
||||
|
||||
@@ -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';
|
||||
@@ -50,7 +50,7 @@ import VoiceButton from '../components/VoiceButton';
|
||||
import FileUpload, { FileData } from '../components/FileUpload';
|
||||
import CameraUpload, { PhotoData } from '../components/CameraUpload';
|
||||
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 +93,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;
|
||||
@@ -184,6 +187,14 @@ function stripSystemHints(text: string): string {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
/** 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';
|
||||
|
||||
@@ -373,6 +384,8 @@ 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);
|
||||
// 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 +664,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');
|
||||
@@ -1387,13 +1401,30 @@ 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 _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 (_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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1648,7 +1679,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 +1701,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 +1733,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') {
|
||||
@@ -1716,12 +1760,12 @@ const ChatScreen: React.FC = () => {
|
||||
!(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.
|
||||
// Kein Re-Arm mehr: nach ARIAs Antwort gab es EIN Stille-Fenster (=
|
||||
// Stille-Toleranz). Kam nichts, ist Schluss → zurück aufs Wake-Word.
|
||||
// Kein 30s-Nachlauschen. (speaker_mismatch/no-speech landen beide hier.)
|
||||
if (wakeWordService.getState() === 'listening') {
|
||||
console.log('[Chat] Passive-Listen: leeres Endpoint — naechste passive Aufnahme');
|
||||
startPassiveStreamingRecording();
|
||||
console.log('[Chat] Passive-Listen: leeres Endpoint — Ende, zurueck aufs Wake-Word');
|
||||
wakeWordService.exitPassiveListening('timeout').catch(() => {});
|
||||
} else {
|
||||
wakeWordService.endConversation();
|
||||
if (!wakeWordService.isActive()) setWakeWordActive(false);
|
||||
@@ -1751,7 +1795,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 +1813,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 +1834,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 +1875,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) {
|
||||
@@ -2210,15 +2261,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 +2307,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 +2319,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
|
||||
@@ -2371,6 +2462,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 }),
|
||||
});
|
||||
@@ -2440,6 +2535,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 +2738,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}
|
||||
@@ -3214,9 +3337,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
|
||||
@@ -3734,6 +3867,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,8 @@ import wakeWordService, {
|
||||
WAKE_THRESHOLD_MAX,
|
||||
loadWakeThreshold,
|
||||
saveWakeThreshold,
|
||||
PASSIVE_LISTEN_DEFAULT_MS,
|
||||
loadPassiveListenMs,
|
||||
savePassiveListenMs,
|
||||
loadBgWakeEnabled,
|
||||
saveBgWakeEnabled,
|
||||
} from '../services/wakeword';
|
||||
import ModeSelector from '../components/ModeSelector';
|
||||
import QRScanner from '../components/QRScanner';
|
||||
@@ -195,8 +196,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 +214,8 @@ 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);
|
||||
const [editingPath, setEditingPath] = useState(false);
|
||||
const [xttsVoice, setXttsVoice] = useState('');
|
||||
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
|
||||
@@ -298,11 +304,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 +320,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 +340,7 @@ const SettingsScreen: React.FC = () => {
|
||||
});
|
||||
isWakeReadySoundEnabled().then(setWakeReadySound);
|
||||
loadWakeThreshold().then(setWakeThreshold).catch(() => {});
|
||||
loadPassiveListenMs().then(ms => setPassiveSec(Math.round(ms / 1000))).catch(() => {});
|
||||
loadBgWakeEnabled().then(setBgWake).catch(() => {});
|
||||
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
|
||||
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
|
||||
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
|
||||
@@ -1651,72 +1658,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 +1740,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 +1899,36 @@ 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>
|
||||
|
||||
<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;
|
||||
@@ -143,23 +143,34 @@ 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 {}
|
||||
}
|
||||
|
||||
export async function loadSttEndpointMs(): Promise<number> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);
|
||||
@@ -189,18 +200,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 {
|
||||
@@ -1145,7 +1144,7 @@ class AudioService {
|
||||
speed: typeof opts.speed === 'number' ? opts.speed : 1.0,
|
||||
interrupted: !!opts.interrupted,
|
||||
location: opts.location || null,
|
||||
endpointMs: typeof opts.endpointMs === 'number' ? opts.endpointMs : 1500,
|
||||
endpointMs: typeof opts.endpointMs === 'number' ? opts.endpointMs : STT_ENDPOINT_DEFAULT_MS,
|
||||
hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000,
|
||||
sampleRate: 16000,
|
||||
projectId: opts.projectId || '',
|
||||
|
||||
@@ -168,6 +168,9 @@ export interface Project {
|
||||
// Optionale absolute noVNC-URL (falls der Desktop direkt erreichbar ist,
|
||||
// sonst laeuft der VNC-Stream als RFB-Bytes durch RVS).
|
||||
desktop_url?: string;
|
||||
// Automatisch: hat das Projekt Dateien in /shared/projects/<id>/? → Datei-Symbol.
|
||||
has_files?: boolean;
|
||||
file_count?: number;
|
||||
}
|
||||
|
||||
export interface ProjectStatus {
|
||||
@@ -176,6 +179,19 @@ export interface ProjectStatus {
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
/** QEMU-VM eines Projekts (Registry + Live-Status). */
|
||||
export interface ProjectVm {
|
||||
name: string;
|
||||
arch: string;
|
||||
iso?: string;
|
||||
vnc_display: number;
|
||||
mem: number;
|
||||
running?: boolean;
|
||||
vnc_port?: number;
|
||||
boot_cmd?: string;
|
||||
created_at?: number;
|
||||
}
|
||||
|
||||
/** Queue-Status pro Kontext — was gerade arbeitet, was wartet.
|
||||
* Key "__main__" = Hauptchat, sonst project_id. */
|
||||
export interface QueueContextStatus {
|
||||
@@ -600,14 +616,58 @@ export const brainApi = {
|
||||
});
|
||||
},
|
||||
|
||||
/** Projekt-Metadaten patchen (name / description / hidden). */
|
||||
updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description' | 'hidden'>>): Promise<Project> {
|
||||
/** Projekt-Metadaten patchen (name / description / hidden / kind). */
|
||||
updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description' | 'hidden' | 'kind'>>): Promise<Project> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}`, {
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
});
|
||||
},
|
||||
|
||||
/** Projekt manuell als Code-Projekt / normalen Chat markieren. */
|
||||
setProjectKind(projectId: string, kind: 'code' | 'chat'): Promise<Project> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}`, {
|
||||
method: 'PATCH',
|
||||
body: { kind },
|
||||
});
|
||||
},
|
||||
|
||||
/** Vorhandene Code-Dateien eines Projekts auflisten (/shared/projects/<id>/). */
|
||||
listProjectFiles(projectId: string): Promise<{ projectId: string; files: { path: string; size: number }[] }> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/files`);
|
||||
},
|
||||
|
||||
/** Inhalt einer Projekt-Datei laden (Text). */
|
||||
readProjectFile(projectId: string, path: string): Promise<{ projectId: string; path: string; content: string }> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/file?path=${encodeURIComponent(path)}`);
|
||||
},
|
||||
|
||||
/** Binaere Projekt-Datei (z.B. Bild) als Base64 + MIME laden. */
|
||||
readProjectFileBinary(projectId: string, path: string): Promise<{ path: string; mime: string; base64: string }> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/file?binary=1&path=${encodeURIComponent(path)}`, { timeoutMs: 30000 });
|
||||
},
|
||||
|
||||
// ── QEMU-VMs pro Projekt ─────────────────────────────────────────
|
||||
listProjectVms(projectId: string): Promise<{ projectId: string; vms: ProjectVm[] }> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/vms`, { timeoutMs: 20000 });
|
||||
},
|
||||
addProjectVm(projectId: string, body: { name: string; arch?: string; iso?: string; vnc_display?: number; mem?: number; create_disk?: boolean; size?: string }): Promise<ProjectVm> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/vms`, { method: 'POST', body, timeoutMs: 30000 });
|
||||
},
|
||||
removeProjectVm(projectId: string, name: string, purge = false): Promise<{ ok: boolean }> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}?purge=${purge ? 'true' : 'false'}`, { method: 'DELETE' });
|
||||
},
|
||||
bootProjectVm(projectId: string, name: string): Promise<{ ok: boolean; vnc_port: number; output: string }> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/boot`, { method: 'POST', timeoutMs: 45000 });
|
||||
},
|
||||
stopProjectVm(projectId: string, name: string): Promise<{ ok: boolean; output: string }> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/stop`, { method: 'POST', timeoutMs: 30000 });
|
||||
},
|
||||
/** Screenshot der laufenden VM (Base64-PNG) — VM-Bildschirm ohne Live-VNC. */
|
||||
screenshotProjectVm(projectId: string, name: string): Promise<{ ok: boolean; filename: string; base64: string }> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/screenshot`, { method: 'POST', timeoutMs: 30000 });
|
||||
},
|
||||
|
||||
/** Projekt verstecken / wieder sichtbar machen (bleibt voll nutzbar). */
|
||||
setProjectHidden(projectId: string, hidden: boolean): Promise<Project> {
|
||||
return _send(`/projects/${encodeURIComponent(projectId)}`, {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,28 @@ 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 {}
|
||||
}
|
||||
|
||||
/** 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 +113,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 +155,10 @@ 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;
|
||||
/** 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 +166,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,7 +240,8 @@ class WakeWordService {
|
||||
this.initInProgress = (async () => {
|
||||
try {
|
||||
const threshold = await loadWakeThreshold();
|
||||
console.log('[WakeWord] init mit threshold=%s', threshold);
|
||||
this.bgWakeEnabled = await loadBgWakeEnabled();
|
||||
console.log('[WakeWord] init mit threshold=%s, bgWake=%s', threshold, this.bgWakeEnabled);
|
||||
await OpenWakeWord.init(this.keyword, threshold, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
|
||||
// Subscribe nur einmal
|
||||
if (!this.eventSub) {
|
||||
@@ -310,7 +328,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 +338,31 @@ 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);
|
||||
}
|
||||
|
||||
/** 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(()=>{});
|
||||
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.
|
||||
@@ -486,13 +512,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 +559,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);
|
||||
@@ -15,8 +15,9 @@ import { TileId } from './layout';
|
||||
import { useWorkspaceLayout } from './useWorkspaceLayout';
|
||||
import WorkspaceDock from './WorkspaceDock';
|
||||
import ChatTile from './tiles/ChatTile';
|
||||
import FilesTile from './tiles/FilesTile';
|
||||
import CodeEditorTile from './tiles/CodeEditorTile';
|
||||
import VncTile from './tiles/VncTile';
|
||||
import DesktopTile from './tiles/DesktopTile';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
@@ -57,8 +58,9 @@ const WorkspaceDeck: React.FC<Props> = ({ projectId, panels, badges }) => {
|
||||
const render = (id: TileId) => {
|
||||
switch (id) {
|
||||
case 'chat': return <ChatTile />;
|
||||
case 'files': return <FilesTile projectId={projectId} focused={active === 'files'} />;
|
||||
case 'editor': return <CodeEditorTile projectId={projectId} />;
|
||||
case 'vnc': return <VncTile projectId={projectId} focused={active === 'vnc'} />;
|
||||
case 'vnc': return <DesktopTile projectId={projectId} focused={active === 'vnc'} />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,31 +5,42 @@
|
||||
* 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', 'editor', 'vnc'];
|
||||
const COCKPIT_PANELS: TileId[] = ['chat', 'files', 'editor', 'vnc'];
|
||||
|
||||
const WorkspaceScreen: React.FC = () => {
|
||||
const [mode, setMode] = useState<ViewModeValue>(viewMode.get());
|
||||
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,14 +77,13 @@ export const NOVNC_HTML = `<!doctype html><html><head><meta charset="utf-8">
|
||||
|
||||
var msg=document.getElementById('msg');
|
||||
|
||||
// Verstecktes Eingabefeld → Software-Tastatur des Handys tippt in die VM.
|
||||
var kbd=document.createElement('input');
|
||||
kbd.setAttribute('autocomplete','off'); kbd.setAttribute('autocorrect','off');
|
||||
kbd.setAttribute('autocapitalize','off'); kbd.spellcheck=false;
|
||||
kbd.style.cssText='position:absolute;left:-1000px;top:0;width:1px;height:1px;opacity:0;';
|
||||
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;
|
||||
@@ -99,19 +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(_){} }
|
||||
kbd.addEventListener('keydown', function(e){
|
||||
if(SPECIAL[e.key]!==undefined){ tap(SPECIAL[e.key], e.code); e.preventDefault(); }
|
||||
});
|
||||
kbd.addEventListener('input', function(){
|
||||
var v=kbd.value; for(var i=0;i<v.length;i++){ tap(v.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.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}); }
|
||||
};
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
* layout — Panel-Definitionen der Workbench (Metadaten fuer das Dock).
|
||||
*/
|
||||
|
||||
export type TileId = 'chat' | 'editor' | 'vnc' | 'preview';
|
||||
export type TileId = 'chat' | 'files' | 'editor' | 'vnc' | 'preview';
|
||||
|
||||
export interface TileDef { id: TileId; title: string; icon: string }
|
||||
|
||||
export const TILE_META: Record<TileId, TileDef> = {
|
||||
chat: { id: 'chat', title: 'Chat', icon: '💬' },
|
||||
files: { id: 'files', title: 'Dateien', icon: '📁' },
|
||||
editor: { id: 'editor', title: 'Code', icon: '📝' },
|
||||
vnc: { id: 'vnc', title: 'Desktop', icon: '🖥️' },
|
||||
preview: { id: 'preview', title: 'Vorschau', icon: '🖼️' },
|
||||
|
||||
@@ -1,57 +1,97 @@
|
||||
/**
|
||||
* CodeEditorTile — Live-Code-Editor (WebView, editorHtml.ts).
|
||||
*
|
||||
* Zeigt live, was ARIA in diesem Projekt schreibt (aus dem codeFile-Spiegel)
|
||||
* und laesst Stefan selbst editieren — Aenderungen gehen als code_file_edit
|
||||
* zurueck an die Bridge. Datei-Tabs oben zum Umschalten.
|
||||
* Zeigt die Dateien eines Code-Projekts aus /shared/projects/<id>/:
|
||||
* - beim Oeffnen werden die BEREITS vorhandenen Dateien vom Brain geladen
|
||||
* (listProjectFiles/readProjectFile) — sonst waere der Editor leer, obwohl
|
||||
* ARIA schon Dateien geschrieben hat.
|
||||
* - live schreibt ARIA weiter → code_file-Stream aktualisiert die offene Datei.
|
||||
* Stefan kann selbst editieren → code_file_edit zurueck an die Bridge.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
||||
import codeFile, { CodeFileState } from '../../services/codeFile';
|
||||
import codeFile from '../../services/codeFile';
|
||||
import brainApi from '../../services/brainApi';
|
||||
import { EDITOR_HTML } from '../assets/editorHtml';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
function guessLang(path: string): string {
|
||||
const ext = (path.split('.').pop() || '').toLowerCase();
|
||||
const map: Record<string, string> = {
|
||||
js: 'javascript', ts: 'typescript', tsx: 'typescript', py: 'python',
|
||||
c: 'c', h: 'c', cpp: 'cpp', asm: 'asm', s: 'asm', sh: 'shell', bash: 'shell',
|
||||
html: 'html', css: 'css', json: 'json', yaml: 'yaml', yml: 'yaml', md: 'markdown',
|
||||
go: 'go', rs: 'rust', java: 'java', kt: 'kotlin', txt: 'text',
|
||||
};
|
||||
return map[ext] || 'text';
|
||||
}
|
||||
|
||||
const CodeEditorTile: React.FC<Props> = ({ projectId }) => {
|
||||
const webRef = useRef<WebView>(null);
|
||||
const [files, setFiles] = useState<CodeFileState[]>(() => codeFile.getFiles(projectId));
|
||||
const [currentPath, setCurrentPath] = useState<string | null>(files[0]?.path ?? null);
|
||||
// Pfade aus dem Brain (vorhandene Dateien) — mit Live-Dateien gemergt.
|
||||
const [serverPaths, setServerPaths] = useState<string[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||
const [loadErr, setLoadErr] = useState<string>('');
|
||||
|
||||
const readyRef = useRef(false);
|
||||
const currentPathRef = useRef<string | null>(currentPath);
|
||||
currentPathRef.current = currentPath;
|
||||
|
||||
// Vereinigte, sortierte Dateiliste (Live-Spiegel + Server-Dateien).
|
||||
const files = useMemo(() => {
|
||||
const set = new Set<string>(serverPaths);
|
||||
for (const f of codeFile.getFiles(projectId)) set.add(f.path);
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||
}, [serverPaths, projectId]);
|
||||
|
||||
const sendToWeb = useCallback((payload: Record<string, unknown>) => {
|
||||
const js = `window.ariaBridge && window.ariaBridge.onMessage(${JSON.stringify(JSON.stringify(payload))}); true;`;
|
||||
webRef.current?.injectJavaScript(js);
|
||||
}, []);
|
||||
|
||||
const loadFileIntoEditor = useCallback((path: string | null) => {
|
||||
const loadFileIntoEditor = useCallback(async (path: string | null) => {
|
||||
if (!path) { sendToWeb({ cmd: 'setContent', content: '', language: 'text', version: 0 }); return; }
|
||||
const f = codeFile.getFile(projectId, path);
|
||||
sendToWeb({ cmd: 'setContent', content: f?.content ?? '', language: f?.language ?? 'text', version: f?.version ?? 0 });
|
||||
// Live-Version bevorzugen (falls ARIA gerade schreibt), sonst vom Brain holen.
|
||||
const live = codeFile.getFile(projectId, path);
|
||||
if (live) {
|
||||
sendToWeb({ cmd: 'setContent', content: live.content, language: live.language, version: live.version });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await brainApi.readProjectFile(projectId, path);
|
||||
sendToWeb({ cmd: 'setContent', content: res.content ?? '', language: guessLang(path), version: 0 });
|
||||
} catch (e: any) {
|
||||
sendToWeb({ cmd: 'setContent', content: `// Konnte ${path} nicht laden: ${e?.message || e}`, language: 'text', version: 0 });
|
||||
}
|
||||
}, [projectId, sendToWeb]);
|
||||
|
||||
// Projektwechsel: Dateiliste + Auswahl neu.
|
||||
// Projektwechsel: vorhandene Dateien vom Brain laden.
|
||||
useEffect(() => {
|
||||
const list = codeFile.getFiles(projectId);
|
||||
setFiles(list);
|
||||
setCurrentPath((prev) => (prev && list.some((f) => f.path === prev) ? prev : list[0]?.path ?? null));
|
||||
let cancelled = false;
|
||||
setLoadErr('');
|
||||
brainApi.listProjectFiles(projectId)
|
||||
.then(res => {
|
||||
if (cancelled) return;
|
||||
const paths = (res.files || []).map(f => f.path);
|
||||
setServerPaths(paths);
|
||||
setCurrentPath(prev => (prev && paths.includes(prev)) ? prev : (paths[0] ?? codeFile.getFiles(projectId)[0]?.path ?? null));
|
||||
})
|
||||
.catch(e => { if (!cancelled) setLoadErr(String(e?.message || e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [projectId]);
|
||||
|
||||
// Eingehende Updates aus dem Spiegel.
|
||||
// Live-Updates aus dem Spiegel.
|
||||
useEffect(() => {
|
||||
return codeFile.subscribe((u) => {
|
||||
if ((u.projectId || '') !== (projectId || '')) return;
|
||||
setFiles(codeFile.getFiles(projectId));
|
||||
// Noch keine Datei gewaehlt → diese oeffnen.
|
||||
setServerPaths(prev => prev.includes(u.path) ? prev : [...prev, u.path]);
|
||||
if (!currentPathRef.current) { setCurrentPath(u.path); return; }
|
||||
if (u.path !== currentPathRef.current) return;
|
||||
if (!readyRef.current) return;
|
||||
if (u.path !== currentPathRef.current || !readyRef.current) return;
|
||||
if (u.patch) {
|
||||
sendToWeb({ cmd: 'applyPatch', from: u.patch.from, to: u.patch.to, insert: u.patch.insert, version: u.version });
|
||||
} else {
|
||||
@@ -60,7 +100,7 @@ const CodeEditorTile: React.FC<Props> = ({ projectId }) => {
|
||||
});
|
||||
}, [projectId, sendToWeb]);
|
||||
|
||||
// Datei-Auswahl gewechselt → in den Editor laden (falls WebView bereit).
|
||||
// Datei-Auswahl gewechselt → laden (falls WebView bereit).
|
||||
useEffect(() => {
|
||||
if (readyRef.current) loadFileIntoEditor(currentPath);
|
||||
}, [currentPath, loadFileIntoEditor]);
|
||||
@@ -82,14 +122,14 @@ const CodeEditorTile: React.FC<Props> = ({ projectId }) => {
|
||||
<View style={styles.container}>
|
||||
<View style={styles.tabsRow}>
|
||||
{files.length === 0 ? (
|
||||
<Text style={styles.noFiles}>Noch keine Datei</Text>
|
||||
<Text style={styles.noFiles}>{loadErr ? `Fehler: ${loadErr}` : 'Noch keine Datei in diesem Projekt'}</Text>
|
||||
) : (
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.tabs}>
|
||||
{files.map((f) => {
|
||||
const active = f.path === currentPath;
|
||||
const name = f.path.split('/').pop() || f.path;
|
||||
{files.map((path) => {
|
||||
const active = path === currentPath;
|
||||
const name = path.split('/').pop() || path;
|
||||
return (
|
||||
<TouchableOpacity key={f.path} onPress={() => setCurrentPath(f.path)} style={[styles.tab, active && styles.tabActive]}>
|
||||
<TouchableOpacity key={path} onPress={() => setCurrentPath(path)} style={[styles.tab, active && styles.tabActive]}>
|
||||
<Text style={[styles.tabText, active && styles.tabTextActive]} numberOfLines={1}>{name}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* DesktopTile — das Desktop-Panel eines Code-Projekts.
|
||||
*
|
||||
* Zeigt die (pro Projekt gefuehrte) QEMU-VM-Liste: leer, bis ARIA per
|
||||
* vm_register eine VM eintraegt. Pro VM: Start / Stop / Verbinden. „Verbinden"
|
||||
* oeffnet die noVNC-Ansicht (VncTile) fuer den VNC-Port dieser VM.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, Image, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import brainApi, { ProjectVm } from '../../services/brainApi';
|
||||
import VncTile from './VncTile';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
const [vms, setVms] = useState<ProjectVm[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [busy, setBusy] = useState(''); // VM-Name, der gerade bootet/stoppt
|
||||
const [connected, setConnected] = useState<ProjectVm | null>(null);
|
||||
const [shotBusy, setShotBusy] = useState('');
|
||||
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 || []))
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focused && !connected) load();
|
||||
}, [focused, projectId, connected, load]);
|
||||
|
||||
const boot = useCallback((vm: ProjectVm) => {
|
||||
setBusy(vm.name);
|
||||
brainApi.bootProjectVm(projectId, vm.name)
|
||||
.then(() => load())
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setBusy(''));
|
||||
}, [projectId, load]);
|
||||
|
||||
const stop = useCallback((vm: ProjectVm) => {
|
||||
setBusy(vm.name);
|
||||
brainApi.stopProjectVm(projectId, vm.name)
|
||||
.then(() => load())
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setBusy(''));
|
||||
}, [projectId, load]);
|
||||
|
||||
const screenshot = useCallback((vm: ProjectVm) => {
|
||||
setShotBusy(vm.name); setErr('');
|
||||
brainApi.screenshotProjectVm(projectId, vm.name)
|
||||
.then(r => setShot({ name: vm.name, b64: r.base64 }))
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setShotBusy(''));
|
||||
}, [projectId]);
|
||||
|
||||
if (!focused) {
|
||||
return (
|
||||
<View style={styles.placeholder}>
|
||||
<Text style={styles.icon}>🖥️</Text>
|
||||
<Text style={styles.text}>Desktop</Text>
|
||||
<Text style={styles.sub}>Panel öffnen für VM-Liste</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.bar}>
|
||||
<Text style={styles.barTitle}>Virtuelle Maschinen</Text>
|
||||
<TouchableOpacity onPress={load} style={styles.barBtn}><Text style={styles.barBtnText}>↻</Text></TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={{ padding: 12 }}>
|
||||
{loading && vms.length === 0 ? (
|
||||
<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'}
|
||||
Sag ARIA z.B. „bau eine QEMU-VM zum Testen" — sie registriert sie hier,
|
||||
dann kannst du sie starten und verbinden.
|
||||
</Text>
|
||||
) : (
|
||||
vms.map(vm => {
|
||||
const isBusy = busy === vm.name;
|
||||
return (
|
||||
<View key={vm.name} style={styles.vmRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.vmName}>
|
||||
<Text style={{ color: vm.running ? '#34C759' : '#555570' }}>●</Text> {vm.name}
|
||||
<Text style={styles.vmMeta}> {vm.arch} · {vm.running ? 'läuft' : 'gestoppt'}</Text>
|
||||
</Text>
|
||||
<Text style={styles.vmCmd} numberOfLines={2}>{vm.boot_cmd || `aria-vm boot ${vm.name} --vnc-display ${vm.vnc_display}`}</Text>
|
||||
</View>
|
||||
<View style={styles.vmBtns}>
|
||||
{isBusy ? (
|
||||
<ActivityIndicator color="#0096FF" />
|
||||
) : vm.running ? (
|
||||
<>
|
||||
<TouchableOpacity onPress={() => screenshot(vm)} style={[styles.vmBtn, { borderColor: '#8888AA' }]} disabled={shotBusy === vm.name}>
|
||||
{shotBusy === vm.name
|
||||
? <ActivityIndicator color="#8888AA" size="small" />
|
||||
: <Text style={[styles.vmBtnText, { color: '#C8C8E0' }]}>📷</Text>}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setConnected(vm)} style={[styles.vmBtn, { borderColor: '#0096FF' }]}>
|
||||
<Text style={[styles.vmBtnText, { color: '#0096FF' }]}>Verbinden</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => stop(vm)} style={[styles.vmBtn, { borderColor: '#E55C5C' }]}>
|
||||
<Text style={[styles.vmBtnText, { color: '#E55C5C' }]}>Stop</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : (
|
||||
<TouchableOpacity onPress={() => boot(vm)} style={[styles.vmBtn, { borderColor: '#34C759' }]}>
|
||||
<Text style={[styles.vmBtnText, { color: '#34C759' }]}>Start</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<Modal visible={!!shot} transparent animationType="fade" onRequestClose={() => setShot(null)}>
|
||||
<TouchableOpacity style={styles.shotOverlay} activeOpacity={1} onPress={() => setShot(null)}>
|
||||
<Text style={styles.shotTitle}>{shot?.name} — Screenshot</Text>
|
||||
{shot && (
|
||||
<Image
|
||||
source={{ uri: `data:image/png;base64,${shot.b64}` }}
|
||||
style={styles.shotImg}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
)}
|
||||
<Text style={styles.shotHint}>Tippen zum Schließen</Text>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
|
||||
{/* Vollbild-VNC — randlos ueber das ganze Display (Header + Dock weg). */}
|
||||
{connected && (
|
||||
<Modal visible animationType="slide" onRequestClose={() => setConnected(null)} supportedOrientations={['portrait', 'landscape']}>
|
||||
<View style={styles.fs}>
|
||||
<VncTile projectId={projectId} focused port={connected.vnc_port || (5900 + (connected.vnc_display || 1))} />
|
||||
<TouchableOpacity style={styles.fsBack} onPress={() => setConnected(null)} activeOpacity={0.8}>
|
||||
<Text style={styles.fsBackText}>‹ VMs</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Modal>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#0D0D1A' },
|
||||
placeholder: { flex: 1, backgroundColor: '#000', alignItems: 'center', justifyContent: 'center' },
|
||||
icon: { fontSize: 64, marginBottom: 16 },
|
||||
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
|
||||
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
||||
bar: { height: 40, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, backgroundColor: '#12122A', borderBottomColor: '#1E1E2E', borderBottomWidth: 1 },
|
||||
barTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', flex: 1 },
|
||||
barBtn: { paddingHorizontal: 10, paddingVertical: 4 },
|
||||
barBtnText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
|
||||
empty: { color: '#8888AA', fontSize: 13, lineHeight: 20, textAlign: 'center', marginTop: 24 },
|
||||
err: { color: '#FF6E6E', fontSize: 13, marginTop: 16 },
|
||||
vmRow: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#12122A', borderRadius: 10, padding: 12, marginBottom: 8 },
|
||||
vmName: { color: '#E0E0F0', fontSize: 15, fontWeight: '700' },
|
||||
vmMeta: { color: '#8888AA', fontSize: 12, fontWeight: '400' },
|
||||
vmCmd: { color: '#6A9BD0', fontSize: 11, fontFamily: 'monospace', marginTop: 4 },
|
||||
vmBtns: { flexDirection: 'row', gap: 6, alignItems: 'center' },
|
||||
vmBtn: { borderWidth: 1, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6, minWidth: 34, alignItems: 'center' },
|
||||
vmBtnText: { fontSize: 12, fontWeight: '700' },
|
||||
fs: { flex: 1, backgroundColor: '#000000' },
|
||||
fsBack: { position: 'absolute', top: 34, left: 10, backgroundColor: 'rgba(18,18,42,0.9)', borderColor: '#2A2A3E', borderWidth: 1, borderRadius: 10, paddingHorizontal: 12, paddingVertical: 7 },
|
||||
fsBackText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
|
||||
shotOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', alignItems: 'center', justifyContent: 'center', padding: 12 },
|
||||
shotTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', marginBottom: 10 },
|
||||
shotImg: { width: '100%', height: '78%', backgroundColor: '#000' },
|
||||
shotHint: { color: '#8888AA', fontSize: 12, marginTop: 12 },
|
||||
});
|
||||
|
||||
export default DesktopTile;
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* FilesTile — Datei-Browser eines Projekts (/shared/projects/<id>/).
|
||||
*
|
||||
* Listet ALLE Dateien (nicht nur Code): erzeugte Bilder, Logs, Assets … — die
|
||||
* gleichen, die in der Projektliste als 📄 gezaehlt werden. Tippen auf ein Bild
|
||||
* zeigt es; tippen auf eine Textdatei zeigt eine Vorschau.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, Image, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import brainApi from '../../services/brainApi';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
interface FileEntry { path: string; size: number }
|
||||
|
||||
const IMG_EXT = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'];
|
||||
|
||||
function ext(path: string): string { return (path.split('.').pop() || '').toLowerCase(); }
|
||||
function isImage(path: string): boolean { return IMG_EXT.includes(ext(path)); }
|
||||
function iconFor(path: string): string {
|
||||
const e = ext(path);
|
||||
if (isImage(path)) return '🖼️';
|
||||
if (['md', 'txt', 'readme'].includes(e)) return '📄';
|
||||
if (['asm', 's', 'c', 'h', 'cpp', 'py', 'js', 'ts', 'sh', 'go', 'rs'].includes(e)) return '📝';
|
||||
if (['zip', 'tar', 'gz', 'img', 'iso', 'qcow2'].includes(e)) return '📦';
|
||||
return '📄';
|
||||
}
|
||||
function humanSize(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const FilesTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
const [files, setFiles] = useState<FileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [preview, setPreview] = useState<{ path: string; kind: 'image' | 'text'; data: string } | null>(null);
|
||||
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))))
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => { if (focused) load(); }, [focused, projectId, load]);
|
||||
|
||||
const open = useCallback((f: FileEntry) => {
|
||||
setPreviewBusy(f.path); setErr('');
|
||||
if (isImage(f.path)) {
|
||||
brainApi.readProjectFileBinary(projectId, f.path)
|
||||
.then(r => setPreview({ path: f.path, kind: 'image', data: `data:${r.mime};base64,${r.base64}` }))
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setPreviewBusy(''));
|
||||
} else {
|
||||
brainApi.readProjectFile(projectId, f.path)
|
||||
.then(r => setPreview({ path: f.path, kind: 'text', data: r.content ?? '' }))
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setPreviewBusy(''));
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
if (!focused) {
|
||||
return (
|
||||
<View style={styles.placeholder}>
|
||||
<Text style={styles.icon}>📁</Text>
|
||||
<Text style={styles.text}>Dateien</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.bar}>
|
||||
<Text style={styles.barTitle}>Dateien{files.length ? ` (${files.length})` : ''}</Text>
|
||||
<TouchableOpacity onPress={load} style={styles.barBtn}><Text style={styles.barBtnText}>↻</Text></TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={{ padding: 8 }}>
|
||||
{loading && files.length === 0 ? (
|
||||
<ActivityIndicator color="#0096FF" style={{ marginTop: 20 }} />
|
||||
) : err ? (
|
||||
<Text style={styles.err}>{err}</Text>
|
||||
) : files.length === 0 ? (
|
||||
<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}>
|
||||
<Text style={styles.rowIcon}>{iconFor(f.path)}</Text>
|
||||
<Text style={styles.rowName} numberOfLines={1}>{f.path}</Text>
|
||||
{previewBusy === f.path
|
||||
? <ActivityIndicator color="#8888AA" size="small" />
|
||||
: <Text style={styles.rowSize}>{humanSize(f.size)}</Text>}
|
||||
</TouchableOpacity>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<Modal visible={!!preview} transparent animationType="fade" onRequestClose={() => setPreview(null)}>
|
||||
<View style={styles.pvOverlay}>
|
||||
<View style={styles.pvBar}>
|
||||
<Text style={styles.pvTitle} numberOfLines={1}>{preview?.path}</Text>
|
||||
<TouchableOpacity onPress={() => setPreview(null)}><Text style={styles.pvClose}>✕</Text></TouchableOpacity>
|
||||
</View>
|
||||
{preview?.kind === 'image' ? (
|
||||
<Image source={{ uri: preview.data }} style={styles.pvImg} resizeMode="contain" />
|
||||
) : (
|
||||
<ScrollView style={styles.pvTextWrap} horizontal>
|
||||
<ScrollView><Text style={styles.pvText}>{preview?.data}</Text></ScrollView>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#0D0D1A' },
|
||||
placeholder: { flex: 1, backgroundColor: '#0D0D1A', alignItems: 'center', justifyContent: 'center' },
|
||||
icon: { fontSize: 56, marginBottom: 10 },
|
||||
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
|
||||
bar: { height: 40, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, backgroundColor: '#12122A', borderBottomColor: '#1E1E2E', borderBottomWidth: 1 },
|
||||
barTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', flex: 1 },
|
||||
barBtn: { paddingHorizontal: 10, paddingVertical: 4 },
|
||||
barBtnText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
|
||||
empty: { color: '#8888AA', fontSize: 13, textAlign: 'center', marginTop: 24 },
|
||||
err: { color: '#FF6E6E', fontSize: 13, marginTop: 16, paddingHorizontal: 8 },
|
||||
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, paddingHorizontal: 8, borderBottomColor: '#161628', borderBottomWidth: 1, gap: 10 },
|
||||
rowIcon: { fontSize: 18 },
|
||||
rowName: { color: '#E0E0F0', fontSize: 13, flex: 1 },
|
||||
rowSize: { color: '#555570', fontSize: 11 },
|
||||
pvOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.94)' },
|
||||
pvBar: { flexDirection: 'row', alignItems: 'center', padding: 12, gap: 10 },
|
||||
pvTitle: { color: '#E0E0F0', fontSize: 13, fontWeight: '700', flex: 1 },
|
||||
pvClose: { color: '#E0E0F0', fontSize: 20, paddingHorizontal: 6 },
|
||||
pvImg: { flex: 1, width: '100%' },
|
||||
pvTextWrap: { flex: 1, padding: 12 },
|
||||
pvText: { color: '#C8C8E0', fontSize: 12, fontFamily: 'monospace' },
|
||||
});
|
||||
|
||||
export default FilesTile;
|
||||
@@ -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';
|
||||
@@ -16,11 +19,33 @@ import { NOVNC_HTML } from '../assets/novncHtml';
|
||||
interface Props {
|
||||
projectId: string;
|
||||
focused: boolean;
|
||||
port?: number; // VNC-Port der zu verbindenden VM (Default 5901 = Display :1)
|
||||
}
|
||||
|
||||
const VncTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
// 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(() => {
|
||||
@@ -33,10 +58,87 @@ const VncTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
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; }
|
||||
@@ -46,7 +148,7 @@ const VncTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
const js = `window.ariaVnc && window.ariaVnc.onData(${JSON.stringify(b64)}); true;`;
|
||||
webRef.current?.injectJavaScript(js);
|
||||
});
|
||||
desktop.openVnc(projectId);
|
||||
desktop.openVnc(projectId, port);
|
||||
} else if (m.event === 'vnc_send') {
|
||||
desktop.sendInput(m.b64);
|
||||
} else if (m.event === 'vnc_close') {
|
||||
@@ -55,7 +157,7 @@ const VncTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
if (m.state === 'connected') setStatus('connected');
|
||||
else if (m.state === 'disconnected') setStatus('disconnected');
|
||||
}
|
||||
}, [projectId]);
|
||||
}, [projectId, port]);
|
||||
|
||||
if (!focused) {
|
||||
return (
|
||||
@@ -83,14 +185,34 @@ const VncTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
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>
|
||||
@@ -98,6 +220,26 @@ const VncTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
</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}>
|
||||
@@ -118,7 +260,7 @@ const styles = StyleSheet.create({
|
||||
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
||||
ctlBar: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
top: 34,
|
||||
right: 8,
|
||||
flexDirection: 'row',
|
||||
gap: 6,
|
||||
@@ -134,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' },
|
||||
});
|
||||
|
||||
+430
-12
@@ -37,6 +37,7 @@ import triggers as triggers_mod
|
||||
import watcher as watcher_mod
|
||||
import oauth as oauth_mod
|
||||
import projects as projects_mod
|
||||
import project_vms as project_vms_mod
|
||||
|
||||
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
||||
# SearXNG (self-hosted Meta-Suche) — Backend fuers web_search-Tool (B1b).
|
||||
@@ -626,10 +627,11 @@ META_TOOLS = [
|
||||
"name": "request_location_tracking",
|
||||
"description": (
|
||||
"Bittet die App, das kontinuierliche GPS-Tracking zu aktivieren oder zu "
|
||||
"deaktivieren. Default ist AUS (Akku-Schutz). Nutze das wenn du einen "
|
||||
"GPS-basierten Watcher anlegst (z.B. `near(...)`), sonst hat die App "
|
||||
"veraltete Position und der Watcher feuert nie. Auch wieder ausschalten "
|
||||
"wenn der letzte GPS-Watcher geloescht wurde."
|
||||
"deaktivieren. Default ist AUS (Akku-Schutz). HINWEIS: Beim Anlegen/Loeschen "
|
||||
"eines Standort-Watchers (`near/entered_near/left_near`) schaltet das System "
|
||||
"das Tracking bereits AUTOMATISCH mit an bzw. aus — dieses Tool brauchst du "
|
||||
"dafuer nicht mehr. Nutze es nur fuer manuelle/explizite Faelle (z.B. Tracking "
|
||||
"kurz einschalten ohne Watcher)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
@@ -641,6 +643,48 @@ META_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "present_view",
|
||||
"description": (
|
||||
"Komponiere eine RAEUMLICHE Ansicht, die auf ARIAs Flaeche materialisiert "
|
||||
"(Orb + Karten). Nutze das, wenn eine visuelle Anordnung besser ist als reiner "
|
||||
"Text — z.B. eine Akte/Datei zeigen (Bild links, Text rechts, Karte unten), "
|
||||
"einen Vergleich, eine Liste, eine Karte mit Orten, oder Code. NICHT fuer "
|
||||
"normale Gespraechsantworten. Die Karten erscheinen ZUSAETZLICH zu deiner "
|
||||
"(kurzen) Sprachantwort — halte die Antwort dann knapp, das Visuelle traegt."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cards": {
|
||||
"type": "array",
|
||||
"description": "Die Karten der Ansicht, in sinnvoller Anordnung.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"type": "string", "enum": ["text", "image", "map", "code", "list"],
|
||||
"description": "Kartentyp"},
|
||||
"title": {"type": "string", "description": "Optionaler Kartentitel"},
|
||||
"md": {"type": "string", "description": "text/list: Markdown-Inhalt"},
|
||||
"src": {"type": "string", "description": "image: Bild-URL oder /shared-Pfad"},
|
||||
"markers": {"type": "array", "items": {"type": "object"},
|
||||
"description": "map: Liste [{lat, lon, label}]"},
|
||||
"path": {"type": "string", "description": "code: Datei-Pfad im Projekt"},
|
||||
"lang": {"type": "string", "description": "code: Sprache fuers Highlighting"},
|
||||
},
|
||||
"required": ["type"],
|
||||
},
|
||||
},
|
||||
"orb": {"type": "string", "enum": ["idle", "speaking", "working"],
|
||||
"description": "Orb-Zustand nach dem Rendern (default: speaking)"},
|
||||
"title": {"type": "string", "description": "Optionaler Titel der ganzen Ansicht"},
|
||||
},
|
||||
"required": ["cards"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -1105,6 +1149,99 @@ META_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "satellite_list",
|
||||
"description": (
|
||||
"Zeigt welche ARIA-Satelliten (Aussenposten-Container in FREMDEN Netzen, "
|
||||
"z.B. 'Buero') gerade ONLINE sind und was sie koennen (discover / "
|
||||
"dial.launch / wol / http). Nutze das ZUERST, wenn Stefan etwas 'im "
|
||||
"Buero' / 'im Netz X' / 'auf dem <Geraet> dort' machen will — so weisst "
|
||||
"Du welche Netze erreichbar sind."
|
||||
),
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "satellite_devices",
|
||||
"description": (
|
||||
"Listet die Geraete im Netz eines Satelliten (Fire TV, Chromecast, "
|
||||
"Smart-TVs, Drucker, NAS, Hosts ...). Nutze es um herauszufinden welches "
|
||||
"Geraet gemeint ist, BEVOR Du satellite_command aufrufst."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"satellite": {"type": "string", "description": "Satelliten-Name/Location/ID (z.B. 'Buero')."},
|
||||
},
|
||||
"required": ["satellite"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "satellite_command",
|
||||
"description": (
|
||||
"Fuehrt eine Aktion auf einem Geraet im Netz eines Satelliten aus. "
|
||||
"Beispiel YouTube-Video auf Fire TV: action='dial.launch', "
|
||||
"device='Fire TV', params={'app':'YouTube','v':'<videoId>'}. Weitere "
|
||||
"Aktionen: 'wol' (params={'mac':'...'}) zum Aufwecken, "
|
||||
"'http.get'/'http.post' (params={'url':'...'}) fuer lokale Webhooks. "
|
||||
"Geht nur, wenn der Satellit Steuerung erlaubt (siehe satellite_list)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"satellite": {"type": "string", "description": "Satelliten-Name/Location/ID."},
|
||||
"device": {"type": "string", "description": "Geraet (Name/ID/IP) aus satellite_devices."},
|
||||
"action": {"type": "string", "description": "z.B. 'dial.launch', 'wol', 'http.get'."},
|
||||
"params": {"type": "object", "description": "Aktions-Parameter, z.B. {'app':'YouTube','v':'<id>'}."},
|
||||
},
|
||||
"required": ["satellite", "action"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "vm_register",
|
||||
"description": (
|
||||
"Traegt eine QEMU-VM in die VM-Liste des AKTUELLEN Projekts ein, "
|
||||
"damit sie in Stefans Desktop-Panel (Cockpit) erscheint und er sie "
|
||||
"starten/stoppen/verbinden kann. Rufe das auf, NACHDEM Du mit aria-vm "
|
||||
"eine VM gebaut/gebootet hast — mit den Medien, die zum Task passen: "
|
||||
"disk (Festplatte, z.B. DOS-Spiele), floppy (Diskette, OS-Dev), iso "
|
||||
"(Installer). Leer lassen = aria-vm erkennt disk.qcow2/floppy.img/"
|
||||
"cdrom.iso im VM-Ordner selbst. vnc_display → Port 5900+display "
|
||||
"(mehrere VMs = verschiedene Displays)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "VM-Name wie bei aria-vm (a-z0-9_-)."},
|
||||
"arch": {"type": "string", "description": "z.B. i386, x86_64, aarch64, mips (Architektur zum Task waehlen)."},
|
||||
"disk": {"type": "string", "description": "optional: qcow2-Pfad (Festplatte)."},
|
||||
"floppy": {"type": "string", "description": "optional: Disketten-Image-Pfad (-fda)."},
|
||||
"iso": {"type": "string", "description": "optional: Boot-ISO-Pfad."},
|
||||
"vnc_display": {"type": "integer", "description": "meist WEGLASSEN — das VNC-Display wird global eindeutig auto-vergeben (kein Port-Konflikt bei mehreren VMs). Nur setzen wenn Du ein bestimmtes willst."},
|
||||
"mem": {"type": "integer", "description": "RAM in MB (Default 1024)."},
|
||||
},
|
||||
"required": ["name", "arch"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "vm_list",
|
||||
"description": "Listet die registrierten QEMU-VMs des aktuellen Projekts (mit Lauf-Status).",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1210,6 +1347,101 @@ def _extract_await_marker(text: str) -> tuple:
|
||||
return text, False
|
||||
|
||||
|
||||
# ── Sprach-/Gespraechs-Steuermarker (ARIA deklariert die Phase SELBST) ──
|
||||
#
|
||||
# Voice-First: ARIA erkennt aus dem Text, ob Stefan einen BEFEHL gibt (etwas tun)
|
||||
# oder eine FRAGE stellt (etwas wissen), und ob das Gespraech/eine Befehlskette
|
||||
# weiterlaeuft oder endet. Sie haengt dazu Marker ans Ende ihrer Antwort — genau
|
||||
# wie [[AWAIT]], und sie werden ebenso entfernt (nicht angezeigt/vorgelesen/in
|
||||
# History). Der Marker ist AUTORITATIV — er ueberschreibt das Skill-Manifest-Flag,
|
||||
# denn dasselbe Skill (z.B. VM-/GUI-Steuerung) ist mal Befehl, mal Auskunft; nur
|
||||
# ARIA weiss aus dem Kontext, was gerade gemeint ist.
|
||||
#
|
||||
# [[STUMM]] -> reiner Steuerbefehl: NICHT vorlesen (speak=false). Allein =
|
||||
# Einzelbefehl → danach zurueck aufs Wake-Word (converse=false).
|
||||
# [[WEITER]] -> Konversation/Befehlskette laeuft weiter: Mikro offen halten
|
||||
# (converse=true) — kein erneutes "Computer" noetig.
|
||||
# [[ENDE]] -> Konversation/Kette beenden: zurueck aufs Wake-Word (converse=false).
|
||||
_SILENT_MARKER_RE = re.compile(r"\[\[\s*STUMM\s*\]\]", re.IGNORECASE)
|
||||
_CONT_MARKER_RE = re.compile(r"\[\[\s*WEITER\s*\]\]", re.IGNORECASE)
|
||||
_END_MARKER_RE = re.compile(r"\[\[\s*ENDE\s*\]\]", re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_flow_markers(text: str) -> tuple:
|
||||
"""Zieht [[STUMM]]/[[WEITER]]/[[ENDE]] aus dem finalen Text.
|
||||
Gibt (clean_text, speak_override, converse_override) zurueck; ein Override ist
|
||||
None, wenn der jeweilige Marker fehlt (dann gilt Default/Skill-Flag).
|
||||
Regeln: [[STUMM]] alleine = Einzelbefehl → auch converse=false (Mikro zu),
|
||||
ausser [[WEITER]] haelt es explizit offen. [[ENDE]] gewinnt gegen [[WEITER]]."""
|
||||
if not text:
|
||||
return text, None, None
|
||||
speak_ov = None
|
||||
conv_ov = None
|
||||
if _SILENT_MARKER_RE.search(text):
|
||||
speak_ov = False
|
||||
text = _SILENT_MARKER_RE.sub("", text)
|
||||
if _END_MARKER_RE.search(text):
|
||||
conv_ov = False
|
||||
text = _END_MARKER_RE.sub("", text)
|
||||
if _CONT_MARKER_RE.search(text):
|
||||
# [[ENDE]] hat Vorrang — widerspruechliche Marker → beenden.
|
||||
if conv_ov is None:
|
||||
conv_ov = True
|
||||
text = _CONT_MARKER_RE.sub("", text)
|
||||
# Stiller Einzelbefehl ohne explizites Weiterlauschen → Mikro zu.
|
||||
if speak_ov is False and conv_ov is None:
|
||||
conv_ov = False
|
||||
return text.strip(), speak_ov, conv_ov
|
||||
|
||||
|
||||
# Explizite "Konversation beenden"-Phrasen vom USER — deterministisch, NICHT auf
|
||||
# ARIAs [[ENDE]]-Marker angewiesen. Stefan will "Konversation Ende" o.ae. als
|
||||
# festen Trigger: danach zurueck aufs Wake-Word, egal was ARIA sonst tut. Eine in
|
||||
# derselben Nachricht enthaltene Frage beantwortet sie normal (wird vorgelesen),
|
||||
# aber converse wird auf false gezwungen. Nomen + Ende-Wort in EINEM Satzteil
|
||||
# ([^.!?]{0,15}) in beliebiger Reihenfolge; "befehls?kette" damit "Lieferkette"
|
||||
# o.ae. nicht faelschlich matcht.
|
||||
_CONV_NOUN = r"(?:konversation|gespr[aä]ch|befehls?kette)"
|
||||
_CONV_END_VERB = r"(?:ende|beend\w*|aus|stop\w*|schluss)"
|
||||
_END_CONVERSATION_RE = re.compile(
|
||||
rf"\b{_CONV_NOUN}\b[^.!?]{{0,15}}\b{_CONV_END_VERB}\b"
|
||||
rf"|\b(?:beend\w*|schlie(?:ß|ss)\w*)\b[^.!?]{{0,15}}\b{_CONV_NOUN}\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _user_wants_conversation_end(text: str) -> bool:
|
||||
"""True, wenn der User in dieser Nachricht explizit die Konversation/Kette
|
||||
beenden will (deterministisch, unabhaengig vom LLM-Marker)."""
|
||||
if not text:
|
||||
return False
|
||||
return bool(_END_CONVERSATION_RE.search(_strip_leading_hint_blocks(text)))
|
||||
|
||||
|
||||
# Gegenstueck zu _END: expliziter "Konversation OFFEN halten / fortfuehren"-Wunsch.
|
||||
# Wichtig fuer BEFEHLE die den Fast-Path treffen: "spiel Spotify ab ABER Konversation
|
||||
# fortfuehren" — der Fast-Path (Regex) versteht den Satz-Rest nicht und wuerde mit
|
||||
# converse=false schliessen. Dieser Detektor erzwingt converse=true, auch am
|
||||
# Fast-Path, egal was das Skill-Manifest sagt. Nomen+Verb in einem Satzteil, plus
|
||||
# "weiter reden/sprechen" ohne Nomen.
|
||||
_CONT_VERB = (r"(?:fortf[uü]hr\w*|fortsetz\w*|weiterf[uü]hr\w*|weiter\s*mach\w*|"
|
||||
r"weiter\b|fort\b|offen\s+(?:halten|lassen)|nicht\s+beenden|weiterlauf\w*)")
|
||||
_CONTINUE_CONVERSATION_RE = re.compile(
|
||||
rf"\b{_CONV_NOUN}\b[^.!?]{{0,20}}\b{_CONT_VERB}"
|
||||
rf"|\b{_CONT_VERB}[^.!?]{{0,20}}\b{_CONV_NOUN}\b"
|
||||
rf"|\bweiter\s*(?:reden|sprechen|quatschen|plaudern|labern)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _user_wants_conversation_continue(text: str) -> bool:
|
||||
"""True, wenn der User explizit weiter im Gespraech bleiben will (converse=true
|
||||
erzwingen — auch bei einem Fast-Path-Befehl). [[ENDE]]/_wants_end hat Vorrang."""
|
||||
if not text:
|
||||
return False
|
||||
return bool(_CONTINUE_CONVERSATION_RE.search(_strip_leading_hint_blocks(text)))
|
||||
|
||||
|
||||
def _normalize_for_fast_match(text: str) -> str:
|
||||
norm = _strip_leading_hint_blocks(text).lower()
|
||||
norm = _fold_umlauts(norm)
|
||||
@@ -1261,6 +1493,17 @@ def _skill_to_tool(s: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# Standort-Funktionen in Watcher-Conditions — brauchen aktives GPS-Tracking.
|
||||
_LOCATION_FUNCS = ("near(", "entered_near(", "left_near(")
|
||||
|
||||
|
||||
def _condition_needs_location(condition: str) -> bool:
|
||||
"""True, wenn die Watcher-Condition eine Standort-Funktion nutzt und damit
|
||||
laufende GPS-Updates der App braucht."""
|
||||
c = condition or ""
|
||||
return any(fn in c for fn in _LOCATION_FUNCS)
|
||||
|
||||
|
||||
class Agent:
|
||||
# Mindest-Score den ein Cold-Memory-Treffer haben muss um in den
|
||||
# System-Prompt aufgenommen zu werden. Unter dieser Schwelle ist's
|
||||
@@ -1607,6 +1850,14 @@ class Agent:
|
||||
if not user_message:
|
||||
raise ValueError("Leere Nachricht")
|
||||
|
||||
# Explizite Gespraechs-Steuerung vom USER (deterministisch, an JEDEM Return
|
||||
# angewendet — auch am Fast-Path, den die LLM-Marker nicht erreichen):
|
||||
# _wants_end → converse=false ("Konversation Ende")
|
||||
# _wants_continue → converse=true ("... aber Konversation fortfuehren")
|
||||
# End hat Vorrang bei Widerspruch.
|
||||
_wants_end = _user_wants_conversation_end(user_message)
|
||||
_wants_continue = (not _wants_end) and _user_wants_conversation_continue(user_message)
|
||||
|
||||
# Events vom letzten Turn weglassen
|
||||
self._pending_events = []
|
||||
|
||||
@@ -1634,6 +1885,10 @@ class Agent:
|
||||
speak = bool(getattr(self, "_fast_path_speak", False))
|
||||
# converse folgt dem Skill (Manifest/Output) — nicht mehr generell False.
|
||||
converse = bool(getattr(self, "_fast_path_converse", False))
|
||||
if _wants_end:
|
||||
converse = False
|
||||
elif _wants_continue:
|
||||
converse = True
|
||||
# Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False.
|
||||
return fast_reply, "fast-path", speak, converse, False
|
||||
|
||||
@@ -1653,6 +1908,10 @@ class Agent:
|
||||
# dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s).
|
||||
speak = getattr(self, "_local_turn_speak", True)
|
||||
converse = getattr(self, "_local_turn_converse", True)
|
||||
if _wants_end:
|
||||
converse = False
|
||||
elif _wants_continue:
|
||||
converse = True
|
||||
# Local ist tool-loses Reden; blockierende Rueckfragen macht Claude.
|
||||
return local_reply, "local", speak, converse, False
|
||||
|
||||
@@ -1670,6 +1929,15 @@ class Agent:
|
||||
logger.warning("Cold-Search fehlgeschlagen: %s", exc)
|
||||
cold = []
|
||||
|
||||
# 3b. Titel-Index des kalten Gedaechtnisses — ARIA sieht WAS sie an
|
||||
# Nachschlage-Wissen hat (Zugangsdaten, Infra, Projekte) und holt es via
|
||||
# memory_search, statt Stefan danach zu fragen. Nur Titel = billig.
|
||||
try:
|
||||
memory_index = self.store.list_index_titles()
|
||||
except Exception as exc:
|
||||
logger.warning("Titel-Index laden fehlgeschlagen: %s", exc)
|
||||
memory_index = []
|
||||
|
||||
# 4. Aktive Skills holen + Tool-Liste bauen
|
||||
all_skills = skills_mod.list_skills(active_only=False)
|
||||
active_skills = [s for s in all_skills if s.get("active", True)]
|
||||
@@ -1692,7 +1960,8 @@ class Agent:
|
||||
oauth_port = os.environ.get("RVS_PORT_PUBLIC", os.environ.get("RVS_PORT", "443")).strip()
|
||||
oauth_tls = os.environ.get("RVS_TLS", "true").strip().lower() != "false"
|
||||
|
||||
system_prompt = build_system_prompt(hot, cold, skills=all_skills,
|
||||
system_prompt = build_system_prompt(hot, cold, memory_index=memory_index,
|
||||
skills=all_skills,
|
||||
triggers=all_triggers,
|
||||
condition_vars=condition_vars,
|
||||
condition_funcs=condition_funcs,
|
||||
@@ -1885,16 +2154,30 @@ class Agent:
|
||||
# Rueckfrage-Marker aus dem finalen Text ziehen (vor History/Return, damit
|
||||
# er nicht angezeigt/vorgelesen wird und nicht die Conversation vergiftet).
|
||||
final_reply, awaiting_reply = _extract_await_marker(final_reply)
|
||||
# ARIAs Phasen-Marker ([[STUMM]]/[[WEITER]]/[[ENDE]]) ziehen — VOR History,
|
||||
# damit sie nicht angezeigt/vorgelesen/gespeichert werden.
|
||||
final_reply, _speak_ov, _conv_ov = _extract_flow_markers(final_reply)
|
||||
|
||||
# 7. Assistant-Turn (final reply) in die Conversation
|
||||
self.conversation.add("assistant", final_reply,
|
||||
project_id=active_project_id)
|
||||
# speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech);
|
||||
# speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech).
|
||||
# ARIAs Phasen-Marker sind AUTORITATIV: sie kennt aus dem Text den Unter-
|
||||
# schied Befehl/Frage und Kette/Ende, den das Skill-Manifest nicht kennt.
|
||||
speak = bool(getattr(self, "_claude_turn_speak", True))
|
||||
converse = bool(getattr(self, "_claude_turn_converse", True))
|
||||
if _speak_ov is not None:
|
||||
speak = _speak_ov
|
||||
if _conv_ov is not None:
|
||||
converse = _conv_ov
|
||||
# Explizite User-Woerter gewinnen ueber Marker/Manifest: "Konversation
|
||||
# beenden" → zu; "... fortfuehren" → offen halten. End hat Vorrang.
|
||||
if _wants_end:
|
||||
converse = False
|
||||
elif _wants_continue:
|
||||
converse = True
|
||||
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
|
||||
return (final_reply, "claude",
|
||||
bool(getattr(self, "_claude_turn_speak", True)),
|
||||
bool(getattr(self, "_claude_turn_converse", True)),
|
||||
awaiting_reply)
|
||||
return (final_reply, "claude", speak, converse, awaiting_reply)
|
||||
|
||||
# ── Tool-Dispatcher ───────────────────────────────────────
|
||||
|
||||
@@ -1938,6 +2221,70 @@ class Agent:
|
||||
return []
|
||||
return out[-limit:]
|
||||
|
||||
def _dispatch_satellite(self, name: str, arguments: dict) -> str:
|
||||
"""satellite_list / satellite_devices / satellite_command — geht via
|
||||
Bridge (/internal/satellite*) → RVS → Satellit. Muster wie flux_generate."""
|
||||
def _post(path: str, body: dict, timeout: float) -> dict:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(f"{BRIDGE_URL}{path}", data=data, method="POST",
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8", "ignore"))
|
||||
try:
|
||||
if name == "satellite_list":
|
||||
result = _post("/internal/satellite-list", {}, 10)
|
||||
sats = result.get("satellites") or []
|
||||
if not sats:
|
||||
return ("Gerade ist kein Satellit online. (Ein Satellit ist ein "
|
||||
"Aussenposten-Container in einem fremden Netz, der sich mit "
|
||||
"RVS verbindet und Dir dort Geraete zeigt + steuerbar macht.)")
|
||||
lines = []
|
||||
for s in sats:
|
||||
status = "online" if s.get("online") else "offline"
|
||||
caps = ", ".join(s.get("caps") or []) or "nur beobachten"
|
||||
lines.append(f"- {s.get('location')} (id={s.get('id')}, {status}; kann: {caps})")
|
||||
return "Satelliten / erreichbare Netze:\n" + "\n".join(lines)
|
||||
|
||||
if name == "satellite_devices":
|
||||
sat = (arguments.get("satellite") or "").strip()
|
||||
if not sat:
|
||||
return "FEHLER: satellite ist Pflicht."
|
||||
result = _post("/internal/satellite", {"op": "discover", "satellite": sat}, 30)
|
||||
if result.get("error"):
|
||||
return f"FEHLER: {result['error']}"
|
||||
devices = result.get("devices") or []
|
||||
if not devices:
|
||||
return f"Im Netz von '{sat}' wurden keine Geraete gefunden (oder der Satellit ist offline)."
|
||||
lines = []
|
||||
for d in devices[:40]:
|
||||
tags = []
|
||||
if d.get("model"):
|
||||
tags.append(d["model"])
|
||||
if d.get("dialAppUrl"):
|
||||
tags.append("DIAL/App-Launch")
|
||||
if d.get("mac"):
|
||||
tags.append(d["mac"])
|
||||
extra = f" ({'; '.join(tags)})" if tags else ""
|
||||
lines.append(f"- {d.get('name')} [{d.get('type')}] {d.get('ip', '')}{extra} id={d.get('id')}")
|
||||
return f"Geraete im Netz '{result.get('location', sat)}':\n" + "\n".join(lines)
|
||||
|
||||
# satellite_command
|
||||
sat = (arguments.get("satellite") or "").strip()
|
||||
action = (arguments.get("action") or "").strip()
|
||||
if not sat or not action:
|
||||
return "FEHLER: satellite und action sind Pflicht."
|
||||
params = arguments.get("params") if isinstance(arguments.get("params"), dict) else {}
|
||||
result = _post("/internal/satellite", {
|
||||
"op": "command", "satellite": sat,
|
||||
"device": arguments.get("device") or "", "action": action, "params": params,
|
||||
}, 30)
|
||||
if result.get("ok"):
|
||||
r = result.get("result")
|
||||
return f"OK — {r}" if r else "OK — Aktion ausgefuehrt."
|
||||
return f"FEHLER: {result.get('error') or 'Aktion fehlgeschlagen'}"
|
||||
except Exception as exc:
|
||||
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
|
||||
|
||||
def _dispatch_tool(self, name: str, arguments: dict, project_id: str = "") -> str:
|
||||
"""Fuehrt einen Tool-Call aus und gibt ein kurzes Text-Resultat zurueck.
|
||||
Niemals werfen — Fehler werden als Text-Resultat reportet damit Claude
|
||||
@@ -2214,13 +2561,41 @@ class Agent:
|
||||
"trigger": {"name": t["name"], "type": "watcher",
|
||||
"condition": t["condition"], "message": t["message"]},
|
||||
})
|
||||
return f"OK — Watcher '{t['name']}' angelegt: feuert wenn '{t['condition']}'."
|
||||
# GPS-Kopplung: ein Standort-Watcher (near/entered_near/left_near)
|
||||
# ist tot, wenn die App kein Tracking sendet. Frueher musste ARIA
|
||||
# separat request_location_tracking aufrufen — wurde oft vergessen,
|
||||
# dann feuerte der Trigger nie. Jetzt automatisch mit-anschalten.
|
||||
extra = ""
|
||||
if _condition_needs_location(t["condition"]):
|
||||
self._pending_events.append({
|
||||
"type": "location_tracking",
|
||||
"on": True,
|
||||
"reason": f"watcher:{t['name']}",
|
||||
})
|
||||
extra = " GPS-Tracking automatisch aktiviert."
|
||||
return f"OK — Watcher '{t['name']}' angelegt: feuert wenn '{t['condition']}'.{extra}"
|
||||
if name == "trigger_cancel":
|
||||
try:
|
||||
triggers_mod.delete(arguments["name"])
|
||||
return f"OK — Trigger '{arguments['name']}' geloescht."
|
||||
except ValueError as e:
|
||||
return f"FEHLER: {e}"
|
||||
# GPS-Kopplung (Gegenstueck): existiert kein Standort-Watcher mehr,
|
||||
# Tracking wieder ausschalten (Akku schonen).
|
||||
extra = ""
|
||||
remaining = triggers_mod.list_triggers(active_only=False)
|
||||
still_needs_gps = any(
|
||||
r.get("type") == "watcher"
|
||||
and _condition_needs_location(r.get("condition") or "")
|
||||
for r in remaining
|
||||
)
|
||||
if not still_needs_gps:
|
||||
self._pending_events.append({
|
||||
"type": "location_tracking",
|
||||
"on": False,
|
||||
"reason": "no-location-watchers",
|
||||
})
|
||||
extra = " GPS-Tracking deaktiviert (kein Standort-Watcher mehr)."
|
||||
return f"OK — Trigger '{arguments['name']}' geloescht.{extra}"
|
||||
if name == "request_location_tracking":
|
||||
on = bool(arguments.get("on", False))
|
||||
reason = (arguments.get("reason") or "").strip()
|
||||
@@ -2230,6 +2605,22 @@ class Agent:
|
||||
"reason": reason,
|
||||
})
|
||||
return f"OK — Tracking-Request gesendet (on={on}). App wird in Kuerze umschalten."
|
||||
if name == "present_view":
|
||||
cards = arguments.get("cards") or []
|
||||
if not isinstance(cards, list) or not cards:
|
||||
return "FEHLER: present_view braucht mindestens eine Karte in 'cards'."
|
||||
view = {
|
||||
"cards": cards,
|
||||
"orb": arguments.get("orb") or "speaking",
|
||||
"title": arguments.get("title") or "",
|
||||
}
|
||||
self._pending_events.append({
|
||||
"type": "aria_view",
|
||||
"view": view,
|
||||
"project_id": project_id or "",
|
||||
})
|
||||
return (f"OK — Ansicht mit {len(cards)} Karte(n) an die Flaeche geschickt "
|
||||
f"(Kontext: {project_id or 'Hauptchat'}).")
|
||||
if name == "trigger_list":
|
||||
items = triggers_mod.list_triggers(active_only=False)
|
||||
if not items:
|
||||
@@ -2675,6 +3066,33 @@ class Agent:
|
||||
f"Editor + Desktop erscheinen in der App. Code-Dateien unter "
|
||||
f"/shared/projects/{active_id}/ schreiben, damit sie im Editor auftauchen.")
|
||||
return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat."
|
||||
if name in ("satellite_list", "satellite_devices", "satellite_command"):
|
||||
return self._dispatch_satellite(name, arguments)
|
||||
if name == "vm_register":
|
||||
pid = (project_id or "").strip()
|
||||
if not pid:
|
||||
return "Kein aktives Projekt — VMs werden pro Projekt gefuehrt. Erst ein Projekt betreten."
|
||||
try:
|
||||
vm = project_vms_mod.add_vm(
|
||||
pid, (arguments.get("name") or "").strip(),
|
||||
(arguments.get("arch") or "i386").strip(),
|
||||
(arguments.get("iso") or "").strip(),
|
||||
(arguments.get("floppy") or "").strip(),
|
||||
(arguments.get("disk") or "").strip(),
|
||||
int(arguments.get("vnc_display") or 0),
|
||||
int(arguments.get("mem") or 1024),
|
||||
)
|
||||
except (ValueError, TypeError) as exc:
|
||||
return f"FEHLER: {exc}"
|
||||
return (f"OK — VM '{vm['name']}' ({vm['arch']}, Display :{vm['vnc_display']}) "
|
||||
f"in Projekt '{pid}' registriert. Erscheint in Stefans Desktop-Panel.")
|
||||
if name == "vm_list":
|
||||
pid = (project_id or "").strip()
|
||||
vms = project_vms_mod.list_vms(pid)
|
||||
if not vms:
|
||||
return f"Projekt '{pid or 'Hauptchat'}' hat noch keine registrierten VMs."
|
||||
return "VMs:\n" + "\n".join(
|
||||
f"- {v['name']} ({v.get('arch')}, Display :{v.get('vnc_display', 1)})" for v in vms)
|
||||
return f"Unbekanntes Tool: {name}"
|
||||
except Exception as exc:
|
||||
logger.exception("Tool '%s' fehlgeschlagen", name)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+327
-15
@@ -39,6 +39,7 @@ import background as background_mod
|
||||
import oauth as oauth_mod
|
||||
import seed_rules as seed_rules_mod
|
||||
import projects as projects_mod
|
||||
import project_vms as project_vms_mod
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
logger = logging.getLogger("aria-brain")
|
||||
@@ -189,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
|
||||
@@ -202,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
|
||||
|
||||
|
||||
@@ -213,6 +216,7 @@ class MemoryOut(BaseModel):
|
||||
pinned: bool
|
||||
category: str
|
||||
source: str
|
||||
scope: str = "personal"
|
||||
tags: List[str]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
@@ -327,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 [],
|
||||
@@ -352,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
|
||||
|
||||
@@ -536,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(),
|
||||
@@ -554,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
|
||||
@@ -563,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.
|
||||
@@ -579,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"),
|
||||
@@ -604,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 ──────────────────────────────────────────────
|
||||
@@ -769,15 +802,49 @@ def projects_queue_status():
|
||||
|
||||
# ── Projekte ────────────────────────────────────────────────────────
|
||||
|
||||
def _project_file_count(pid: str) -> int:
|
||||
"""Anzahl Dateien in /shared/projects/<pid>/ (rekursiv, gecappt). 0 = leer."""
|
||||
base = os.path.join("/shared/projects", pid or "")
|
||||
if not os.path.isdir(base):
|
||||
return 0
|
||||
cnt = 0
|
||||
try:
|
||||
for _dp, dns, fns in os.walk(base):
|
||||
dns[:] = [d for d in dns if d not in (".git", "node_modules", "__pycache__", ".venv", "venv")]
|
||||
cnt += len(fns)
|
||||
if cnt > 999:
|
||||
return 999
|
||||
except Exception:
|
||||
return 0
|
||||
return cnt
|
||||
|
||||
|
||||
def _enrich_projects(projects: list) -> list:
|
||||
"""Ergaenzt has_files + file_count pro Projekt (fuer das Datei-Symbol in der
|
||||
Liste). Das ersetzt das manuelle Code-Flag als primaeren Code-Indikator."""
|
||||
for p in projects or []:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
c = _project_file_count(p.get("id") or "")
|
||||
p["file_count"] = c
|
||||
p["has_files"] = c > 0
|
||||
return projects
|
||||
|
||||
|
||||
@app.get("/projects/status")
|
||||
def projects_status():
|
||||
"""Komplett-Status: aktives Projekt + Liste aller (nicht-archivierten)."""
|
||||
return projects_mod.status()
|
||||
st = projects_mod.status()
|
||||
_enrich_projects(st.get("projects", []))
|
||||
if st.get("active"):
|
||||
_enrich_projects([st["active"]])
|
||||
return st
|
||||
|
||||
|
||||
@app.get("/projects/list")
|
||||
def projects_list(include_archived: bool = False):
|
||||
return {"projects": projects_mod.list_projects(include_archived=include_archived)}
|
||||
return {"projects": _enrich_projects(
|
||||
projects_mod.list_projects(include_archived=include_archived))}
|
||||
|
||||
|
||||
class ProjectCreateBody(BaseModel):
|
||||
@@ -827,17 +894,262 @@ class ProjectUpdateBody(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
hidden: Optional[bool] = None
|
||||
kind: Optional[str] = None # 'code' | 'chat' — manuell setzbar (App/Diagnostic)
|
||||
|
||||
|
||||
@app.patch("/projects/{project_id}")
|
||||
def projects_update(project_id: str, body: ProjectUpdateBody):
|
||||
patch = body.dict(exclude_unset=True)
|
||||
if "kind" in patch and patch["kind"] not in ("code", "chat", None):
|
||||
raise HTTPException(status_code=400, detail="kind muss 'code' oder 'chat' sein")
|
||||
p = projects_mod.update_project(project_id, patch)
|
||||
if p is None:
|
||||
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
|
||||
return p
|
||||
|
||||
|
||||
# ── Code-Dateien eines Projekts (/shared/projects/<pid>/) ───────────
|
||||
# Der Live-Editor streamt ARIAs Writes; diese Endpoints liefern zusaetzlich die
|
||||
# BEREITS vorhandenen Dateien, damit der Editor beim Oeffnen nicht leer ist.
|
||||
_PROJECT_FILES_ROOT = "/shared/projects"
|
||||
_PROJECT_FILE_MAX = 512 * 1024
|
||||
|
||||
|
||||
def _project_dir(project_id: str) -> str:
|
||||
base = os.path.realpath(os.path.join(_PROJECT_FILES_ROOT, project_id or ""))
|
||||
root = os.path.realpath(_PROJECT_FILES_ROOT)
|
||||
if base != root and not base.startswith(root + os.sep):
|
||||
raise HTTPException(status_code=400, detail="ungueltige project_id")
|
||||
return base
|
||||
|
||||
|
||||
@app.get("/projects/{project_id}/files")
|
||||
def project_files(project_id: str):
|
||||
base = _project_dir(project_id)
|
||||
out = []
|
||||
if os.path.isdir(base):
|
||||
for dirpath, dirs, files in os.walk(base):
|
||||
dirs[:] = [d for d in dirs if d not in
|
||||
(".git", "node_modules", "__pycache__", ".venv", "venv")]
|
||||
for f in files:
|
||||
full = os.path.join(dirpath, f)
|
||||
rel = os.path.relpath(full, base).replace("\\", "/")
|
||||
try:
|
||||
sz = os.path.getsize(full)
|
||||
except OSError:
|
||||
sz = 0
|
||||
out.append({"path": rel, "size": sz})
|
||||
out.sort(key=lambda x: x["path"])
|
||||
return {"projectId": project_id, "files": out}
|
||||
|
||||
|
||||
@app.get("/projects/{project_id}/file")
|
||||
def project_file(project_id: str, path: str, binary: bool = False):
|
||||
base = _project_dir(project_id)
|
||||
target = os.path.realpath(os.path.join(base, path))
|
||||
if target != base and not target.startswith(base + os.sep):
|
||||
raise HTTPException(status_code=400, detail="Pfad ausserhalb des Projekts")
|
||||
if not os.path.isfile(target):
|
||||
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
|
||||
# Binaer (z.B. Bilder) → Base64. Grosszuegigeres Limit als beim Text-Editor.
|
||||
if binary:
|
||||
import base64
|
||||
import mimetypes
|
||||
if os.path.getsize(target) > 8 * 1024 * 1024:
|
||||
raise HTTPException(status_code=413, detail="Datei zu gross (max 8 MB)")
|
||||
with open(target, "rb") as f:
|
||||
data = f.read()
|
||||
mime, _ = mimetypes.guess_type(target)
|
||||
return {"projectId": project_id, "path": path, "mime": mime or "application/octet-stream",
|
||||
"base64": base64.b64encode(data).decode("ascii")}
|
||||
if os.path.getsize(target) > _PROJECT_FILE_MAX:
|
||||
raise HTTPException(status_code=413, detail="Datei zu gross fuer den Editor")
|
||||
try:
|
||||
with open(target, "r", encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
return {"projectId": project_id, "path": path, "content": content}
|
||||
|
||||
|
||||
# ── QEMU-VMs pro Projekt ────────────────────────────────────────────
|
||||
# Registry (project_vms) + echter Start/Stop via `aria-vm` auf dem Host (SSH
|
||||
# aria-wohnung). Das Desktop-Panel der App zeigt pro Projekt die Liste.
|
||||
_ARIA_VM_HOST = os.environ.get("ARIA_VM_SSH_HOST", "aria-wohnung")
|
||||
|
||||
|
||||
def _docker_gateway() -> str:
|
||||
"""Docker-Gateway-IP (= Host-IP auf dem Container-Netz), an die QEMU sein VNC
|
||||
binden soll: von der Bridge erreichbar, aber NICHT im LAN/Internet. Aus
|
||||
/proc/net/route (Default-Route), kein `ip`-Tool noetig."""
|
||||
try:
|
||||
import socket as _sock
|
||||
import struct as _struct
|
||||
with open("/proc/net/route") as f:
|
||||
for line in f.readlines()[1:]:
|
||||
fields = line.strip().split()
|
||||
if len(fields) >= 3 and fields[1] == "00000000" and int(fields[3], 16) & 2:
|
||||
return _sock.inet_ntoa(_struct.pack("<L", int(fields[2], 16)))
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _ssh_host(*cmd: str, timeout: int = 25):
|
||||
import subprocess
|
||||
full = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=8",
|
||||
_ARIA_VM_HOST, *[str(c) for c in cmd]]
|
||||
try:
|
||||
r = subprocess.run(full, capture_output=True, text=True, timeout=timeout)
|
||||
return r.returncode, r.stdout or "", r.stderr or ""
|
||||
except Exception as exc:
|
||||
return 1, "", str(exc)
|
||||
|
||||
|
||||
def _ssh_aria_vm(*args: str, timeout: int = 25):
|
||||
return _ssh_host("aria-vm", *args, timeout=timeout)
|
||||
|
||||
|
||||
def _vm_running_names() -> set:
|
||||
rc, out, _err = _ssh_aria_vm("list", timeout=15)
|
||||
names = set()
|
||||
if rc == 0:
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if parts and "laeuft" in line:
|
||||
names.add(parts[0])
|
||||
return names
|
||||
|
||||
|
||||
class VmAddBody(BaseModel):
|
||||
name: str
|
||||
arch: str = "i386"
|
||||
iso: str = ""
|
||||
floppy: str = ""
|
||||
disk: str = ""
|
||||
vnc_display: int = 1
|
||||
mem: int = 1024
|
||||
create_disk: bool = False
|
||||
size: str = "10G"
|
||||
|
||||
|
||||
def _vm_boot_args(v: dict) -> list:
|
||||
args = ["boot", v.get("name", "?"),
|
||||
"--vnc-display", str(v.get("vnc_display", 1)),
|
||||
"--mem", str(v.get("mem", 1024))]
|
||||
if v.get("disk"):
|
||||
args += ["--disk", v["disk"]]
|
||||
if v.get("floppy"):
|
||||
args += ["--floppy", v["floppy"]]
|
||||
if v.get("iso"):
|
||||
args += ["--iso", v["iso"]]
|
||||
return args
|
||||
|
||||
|
||||
def _vm_boot_cmd(v: dict) -> str:
|
||||
"""Lesbarer Start-Befehl (aria-vm) als 'Wert' hinter dem VM-Eintrag."""
|
||||
return "aria-vm " + " ".join(_vm_boot_args(v))
|
||||
|
||||
|
||||
@app.get("/projects/{project_id}/vms")
|
||||
def project_vms_list(project_id: str):
|
||||
vms = [dict(v) for v in project_vms_mod.list_vms(project_id)]
|
||||
running = _vm_running_names()
|
||||
for v in vms:
|
||||
v["running"] = v.get("name") in running
|
||||
v["vnc_port"] = 5900 + int(v.get("vnc_display", 1))
|
||||
v["boot_cmd"] = _vm_boot_cmd(v)
|
||||
return {"projectId": project_id, "vms": vms}
|
||||
|
||||
|
||||
@app.post("/projects/{project_id}/vms")
|
||||
def project_vm_add(project_id: str, body: VmAddBody):
|
||||
try:
|
||||
vm = project_vms_mod.add_vm(project_id, body.name, body.arch, body.iso,
|
||||
body.floppy, body.disk, body.vnc_display, body.mem)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
if body.create_disk:
|
||||
rc, out, err = _ssh_aria_vm("create", body.name, body.arch, body.size)
|
||||
vm["create_result"] = out.strip() or err.strip()
|
||||
vm["create_ok"] = (rc == 0)
|
||||
return vm
|
||||
|
||||
|
||||
@app.delete("/projects/{project_id}/vms/{name}")
|
||||
def project_vm_remove(project_id: str, name: str, purge: bool = False):
|
||||
ok = project_vms_mod.remove_vm(project_id, name)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail=f"VM '{name}' nicht in Projekt {project_id}")
|
||||
if purge:
|
||||
_ssh_aria_vm("rm", name)
|
||||
return {"ok": True, "name": name}
|
||||
|
||||
|
||||
@app.post("/projects/{project_id}/vms/{name}/boot")
|
||||
def project_vm_boot(project_id: str, name: str):
|
||||
vm = project_vms_mod.get_vm(project_id, name)
|
||||
if not vm:
|
||||
raise HTTPException(status_code=404, detail=f"VM '{name}' nicht gefunden")
|
||||
# VNC an die Docker-Gateway-IP binden, damit die Bridge den Stream tunneln
|
||||
# kann (Loopback ist von Containern nicht erreichbar). NICHT im LAN sichtbar.
|
||||
boot_args = _vm_boot_args(vm)
|
||||
gw = _docker_gateway()
|
||||
if gw:
|
||||
boot_args += ["--vnc-bind", gw]
|
||||
rc, out, err = _ssh_aria_vm(*boot_args, timeout=40)
|
||||
return {"ok": rc == 0, "name": name, "vnc_port": 5900 + int(vm.get("vnc_display", 1)),
|
||||
"vnc_bind": gw or "127.0.0.1", "output": (out.strip() or err.strip())[:500]}
|
||||
|
||||
|
||||
@app.post("/projects/{project_id}/vms/{name}/stop")
|
||||
def project_vm_stop(project_id: str, name: str):
|
||||
rc, out, err = _ssh_aria_vm("stop", name, timeout=25)
|
||||
return {"ok": rc == 0, "name": name, "output": (out.strip() or err.strip())[:500]}
|
||||
|
||||
|
||||
@app.post("/projects/{project_id}/vms/{name}/screenshot")
|
||||
def project_vm_screenshot(project_id: str, name: str):
|
||||
"""Macht einen Screenshot der laufenden VM und liefert ihn als Base64.
|
||||
|
||||
aria-vm schreibt das PNG ins VM-Verzeichnis (dem aria-User gehoerend — nicht
|
||||
ins /root-Shared-Volume, wo der aria-User keinen Zugriff hat). Der Brain holt
|
||||
die Datei danach per SSH (base64) — funktioniert unabhaengig von Volume-
|
||||
Rechten. Zusaetzlich wird das PNG ins Projekt kopiert (Dateien-Panel)."""
|
||||
import base64
|
||||
rc, out, err = _ssh_aria_vm("screenshot", name, timeout=30)
|
||||
if rc != 0:
|
||||
raise HTTPException(status_code=400, detail=f"Screenshot fehlgeschlagen: {(err or out).strip()[:200]}")
|
||||
path = ""
|
||||
for line in out.splitlines():
|
||||
if line.startswith("screenshot="):
|
||||
path = line.split("=", 1)[1].strip()
|
||||
if not path:
|
||||
raise HTTPException(status_code=500, detail=f"Kein Screenshot-Pfad: {out.strip()[:200]}")
|
||||
# PNG per SSH als Base64 holen (kein Shared-Volume noetig).
|
||||
rc2, b64, err2 = _ssh_host("base64", "-w0", path, timeout=20)
|
||||
if rc2 != 0 or not b64.strip():
|
||||
raise HTTPException(status_code=500, detail=f"Screenshot konnte nicht gelesen werden: {(err2 or 'leer').strip()[:200]}")
|
||||
b64 = b64.strip()
|
||||
try:
|
||||
data = base64.b64decode(b64)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Base64 ungueltig: {exc}")
|
||||
fname = os.path.basename(path)
|
||||
# Ins Projekt kopieren → taucht im Dateien-Panel auf.
|
||||
proj_rel = ""
|
||||
try:
|
||||
shots_dir = os.path.join(_project_dir(project_id), "screenshots")
|
||||
os.makedirs(shots_dir, exist_ok=True)
|
||||
with open(os.path.join(shots_dir, fname), "wb") as f:
|
||||
f.write(data)
|
||||
proj_rel = "screenshots/" + fname
|
||||
except Exception:
|
||||
proj_rel = ""
|
||||
return {"ok": True, "name": name, "filename": fname,
|
||||
"projectPath": proj_rel, "base64": b64}
|
||||
|
||||
|
||||
@app.get("/conversation/stats")
|
||||
def conversation_stats():
|
||||
return conversation().stats()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
project_vms — Registry der QEMU-VMs PRO PROJEKT.
|
||||
|
||||
Persistenz: /shared/config/project_vms.json → { project_id: [ {vm}, ... ] }.
|
||||
Eine VM = {name, arch, iso, vnc_display, mem, created_at, updated_at}. Der echte
|
||||
Start/Stop laeuft ueber `aria-vm` auf dem Host (SSH aria-wohnung, siehe main.py);
|
||||
diese Datei haelt nur die Zuordnung VM ↔ Projekt + die Startparameter, damit die
|
||||
Liste im Desktop-Panel der App pro Projekt erscheint (auch wenn leer).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
VMS_FILE = Path(os.environ.get("PROJECT_VMS_FILE", "/shared/config/project_vms.json"))
|
||||
NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,40}$")
|
||||
VALID_ARCH = {"x86_64", "amd64", "i386", "i686", "x86", "arm", "aarch64",
|
||||
"mips", "mipsel", "mips64", "ppc", "ppc64", "riscv64", "sparc"}
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
if not VMS_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(VMS_FILE.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save(data: dict) -> None:
|
||||
VMS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = VMS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(VMS_FILE)
|
||||
|
||||
|
||||
def list_vms(project_id: str) -> list[dict]:
|
||||
return _load().get(project_id or "", [])
|
||||
|
||||
|
||||
def get_vm(project_id: str, name: str) -> Optional[dict]:
|
||||
for v in list_vms(project_id):
|
||||
if v.get("name") == name:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def _used_displays(data: dict, exclude: object = None) -> set:
|
||||
"""Alle VNC-Displays, die ueber ALLE Projekte belegt sind (exclude = eine
|
||||
VM-Dict-Instanz, die ignoriert wird — fuer Updates)."""
|
||||
used = set()
|
||||
for lst in data.values():
|
||||
for v in lst:
|
||||
if v is exclude:
|
||||
continue
|
||||
try:
|
||||
used.add(int(v.get("vnc_display", 1)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return used
|
||||
|
||||
|
||||
def add_vm(project_id: str, name: str, arch: str, iso: str = "",
|
||||
floppy: str = "", disk: str = "",
|
||||
vnc_display: int = 0, mem: int = 1024) -> dict:
|
||||
"""Registriert/aktualisiert eine VM. Medien (disk/floppy/iso) optional —
|
||||
leer = aria-vm erkennt disk.qcow2/floppy.img/cdrom.iso im VM-Ordner selbst.
|
||||
|
||||
Das VNC-Display wird GLOBAL eindeutig vergeben (ueber alle Projekte), damit
|
||||
mehrere laufende VMs nicht denselben Port doppelt binden. vnc_display<=0 oder
|
||||
ein bereits belegtes Display → automatisch das naechste freie."""
|
||||
if not NAME_RE.match(name or ""):
|
||||
raise ValueError(f"Ungueltiger VM-Name: {name!r} (nur a-z0-9_-, max 40)")
|
||||
if arch not in VALID_ARCH:
|
||||
raise ValueError(f"Unbekannte Architektur: {arch!r}")
|
||||
data = _load()
|
||||
lst = data.setdefault(project_id or "", [])
|
||||
now = int(time.time())
|
||||
existing = next((v for v in lst if v.get("name") == name), None)
|
||||
|
||||
used = _used_displays(data, exclude=existing)
|
||||
req = int(vnc_display or 0)
|
||||
if req <= 0 and existing: # Update ohne Display-Wunsch → behalten
|
||||
req = int(existing.get("vnc_display", 0) or 0)
|
||||
if req <= 0 or req in used: # frei/eindeutig machen
|
||||
req = 1
|
||||
while req in used:
|
||||
req += 1
|
||||
|
||||
fields = {"arch": arch, "iso": iso, "floppy": floppy, "disk": disk,
|
||||
"vnc_display": req, "mem": int(mem), "updated_at": now}
|
||||
if existing:
|
||||
existing.update(fields)
|
||||
_save(data)
|
||||
return existing
|
||||
vm = {"name": name, "created_at": now, **fields}
|
||||
lst.append(vm)
|
||||
_save(data)
|
||||
return vm
|
||||
|
||||
|
||||
def remove_vm(project_id: str, name: str) -> bool:
|
||||
data = _load()
|
||||
lst = data.get(project_id or "")
|
||||
if not lst:
|
||||
return False
|
||||
new = [v for v in lst if v.get("name") != name]
|
||||
if len(new) == len(lst):
|
||||
return False
|
||||
data[project_id or ""] = new
|
||||
_save(data)
|
||||
return True
|
||||
+76
-1
@@ -162,6 +162,46 @@ 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]]`.",
|
||||
])
|
||||
|
||||
|
||||
TYPE_HEADINGS = {
|
||||
"identity": "## Wer du bist",
|
||||
"rule": "## Sicherheitsregeln & Prinzipien",
|
||||
@@ -260,6 +300,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 +520,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 +534,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 +554,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))
|
||||
|
||||
+84
-25
@@ -406,35 +406,93 @@ SEED_RULES: List[dict] = [
|
||||
"title": "Code-Projekte + QEMU: aria-vm auf dem Host, Editor/Desktop in der App",
|
||||
"category": "architektur",
|
||||
"content": (
|
||||
"Wenn aus einem Gespraech ein PROGRAMMIER- oder BAU-Projekt wird "
|
||||
"(Du schreibst Code, baust ein System, testest eine VM):\n"
|
||||
"GRUNDWISSEN Code-/Bau-Projekte + VMs — so haengt das System zusammen:\n"
|
||||
"\n"
|
||||
"1. Ruf `set_project_kind('code')` — dann blendet Stefans App einen "
|
||||
"Live-Code-Editor und den QEMU-Desktop ein. Vorher ein Projekt "
|
||||
"anlegen/betreten (project_create/enter), sonst gibt's kein Ziel.\n"
|
||||
"2. Schreib Code-Dateien NUR unter `/shared/projects/<projekt-id>/` "
|
||||
"(das Volume ist in proxy+bridge+brain gemountet). Genau diese "
|
||||
"Writes/Edits erscheinen live in Stefans Editor — und was Stefan "
|
||||
"dort tippt, landet als Datei zurueck in diesem Verzeichnis.\n"
|
||||
"DATEIEN eines Code-Projekts gehoeren nach `/shared/projects/<projekt-id>/` "
|
||||
"(Volume in proxy+bridge+brain gemountet). Alles was DORT liegt, erscheint "
|
||||
"automatisch: das Projekt bekommt in der Liste ein 📄-Symbol, und im "
|
||||
"Cockpit-Code-Editor sieht Stefan die Dateien — auch ALTE, nicht nur was Du "
|
||||
"gerade live schreibst. Was Stefan im Editor tippt, kommt als Datei dorthin "
|
||||
"zurueck. (Ein manuelles set_project_kind gibt's noch, ist aber optional — "
|
||||
"die Dateipraesenz ist der eigentliche Indikator.)\n"
|
||||
"\n"
|
||||
"QEMU (VMs fuer JEDE Architektur — x86, ARM, MIPS, PPC, RISC-V, SPARC) "
|
||||
"laeuft auf dem Host. Du steuerst sie per `ssh aria-wohnung aria-vm ...`:\n"
|
||||
" - `aria-vm create <name> <arch> [groesse]` Disk anlegen (z.B. i386 "
|
||||
"fuer Win 3.11, aarch64, mips ...).\n"
|
||||
" - `aria-vm boot <name> [--iso <pfad>] [--vnc-display 1] [--mem 1024]` "
|
||||
"startet die VM. VNC bindet an 127.0.0.1:<display> (Display 1 = Port "
|
||||
"5901). Nicht selbst nach aussen oeffnen!\n"
|
||||
" - `aria-vm screenshot <name>` PNG in die Shared-Uploads (kannst Du "
|
||||
"Stefan mit [FILE:] schicken).\n"
|
||||
"VMs (QEMU, JEDE Architektur: x86/i386, ARM/aarch64, MIPS, PPC, RISC-V, "
|
||||
"SPARC) laufen auf dem HOST (die qemu-Tools liegen in aria-wohnung, die "
|
||||
"Projektdateien in /shared). Du steuerst sie per `ssh aria-wohnung aria-vm ...`:\n"
|
||||
" - `aria-vm create <name> <arch> [groesse]` — legt eine VM an. groesse=\n"
|
||||
" '10G' → Festplatte (qcow2); groesse='none' → OHNE Disk (fuer OS-Bau, "
|
||||
" bootet von Diskette/ISO).\n"
|
||||
" - `aria-vm boot <name> [optionen]` — startet sie (daemonized). Optionen:\n"
|
||||
" --iso <pfad> von CD/ISO booten\n"
|
||||
" --floppy <pfad> von Diskette booten (-fda, klassisch OS-Dev)\n"
|
||||
" --disk <pfad> explizite qcow2\n"
|
||||
" --vnc-display <N> VNC-Display (Default 1 → Port 5901; mehrere VMs = "
|
||||
"verschiedene N)\n"
|
||||
" --mem <MB> RAM (Default 1024)\n"
|
||||
" Medien im VM-Ordner (disk.qcow2/floppy.img/cdrom.iso) werden auto-"
|
||||
"erkannt. Es MUSS mindestens ein Boot-Medium da sein.\n"
|
||||
" - `aria-vm screenshot <name>` → PNG (an Stefan per [FILE:] schickbar).\n"
|
||||
" - `aria-vm list` / `aria-vm stop <name>` / `aria-vm rm <name>`.\n"
|
||||
"\n"
|
||||
"Der VNC-Stream kommt AUTOMATISCH ueber den RVS-Tunnel der Bridge in "
|
||||
"Stefans App-Desktop-Kachel (Display 1 / Port 5901) — dort kann er die "
|
||||
"VM live mit Maus/Tastatur bedienen. Du musst nur die VM booten und ggf. "
|
||||
"sagen 'schau in die Desktop-Kachel'. KVM-Beschleunigung gibt es nur "
|
||||
"fuer x86-Gaeste; andere Architekturen laufen emuliert (langsamer, aber "
|
||||
"sie laufen). Medien-Downloads/Baubares fuer Stefans Privatgebrauch: "
|
||||
"wie gewohnt einfach machen, nicht moralisieren."
|
||||
"PFLICHT nach dem Bau/Boot: `vm_register(name, arch, disk?/floppy?/iso?, "
|
||||
"mem?)` im aktuellen Projekt aufrufen — mit den Medien, die Du gebaut hast. "
|
||||
"vnc_display WEGLASSEN — es wird global eindeutig auto-vergeben (kein "
|
||||
"Port-Konflikt, wenn mehrere VMs laufen). ERST DANN erscheint die VM in "
|
||||
"Stefans Desktop-Panel (Cockpit), "
|
||||
"wo er sie Starten/Stoppen/Verbinden kann. Ohne vm_register bleibt seine "
|
||||
"Liste leer, obwohl die VM laeuft. Der Startbefehl steht als Wert dahinter.\n"
|
||||
"\n"
|
||||
"URTEIL — bau eine VM NUR wenn's Sinn macht, und erkenne aus der SITUATION "
|
||||
"was gebraucht wird:\n"
|
||||
" - DOS-/Retro-Spiele, installierte Software → FESTPLATTE (create mit "
|
||||
"Groesse, dann installieren; disk).\n"
|
||||
" - Eigenes OS / Bootloader / Bare-Metal-Experiment → DISKETTE (create "
|
||||
"'none', floppy.img; ggf. eine zweite Diskette einbinden).\n"
|
||||
" - Fertiges System installieren/testen → ISO (--iso Installer).\n"
|
||||
" - Architektur zum Task waehlen: 'teste mal was auf ARM' → arch=aarch64 "
|
||||
"(-M virt, laeuft emuliert = langsam, das ist ok). x86-Zeug → i386/x86_64.\n"
|
||||
"AENDERUNGS-ZYKLUS: willst Du an einer laufenden VM etwas aendern (Datei "
|
||||
"tauschen, Diskette neu bauen), dann `aria-vm stop <name>` → Aenderung → "
|
||||
"`aria-vm boot <name>` wieder starten. Stefan kann das auch selbst ueber "
|
||||
"Start/Stop im Panel.\n"
|
||||
"\n"
|
||||
"Der VNC-Stream laeuft AUTOMATISCH komplett durch den RVS-Server (die Bridge "
|
||||
"tunnelt die RFB-Bytes; der VNC-Port ist nur intern auf dem Host). Firewall-"
|
||||
"unabhaengig — Du oeffnest NIE einen Port nach aussen. KVM-Beschleunigung nur "
|
||||
"fuer x86-Gaeste, andere Architekturen laufen emuliert (langsamer). Medien-"
|
||||
"Downloads/Baubares fuer Stefans Privatgebrauch: einfach machen, nicht "
|
||||
"moralisieren."
|
||||
),
|
||||
},
|
||||
{
|
||||
"migration_key": "seed/architecture/satellites-remote-networks",
|
||||
"type": "rule",
|
||||
"title": "Satelliten: Augen + Haende in fremden Netzen (Buero etc.)",
|
||||
"category": "architektur",
|
||||
"content": (
|
||||
"Ein SATELLIT ist ein eigenstaendiger Container in einem FREMDEN Netz "
|
||||
"(z.B. Buero, Werkstatt), der sich mit RVS verbindet und Dir dort Geraete "
|
||||
"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"
|
||||
" 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 'spiel YouTube-Video auf dem Buero-Stick':\n"
|
||||
" satellite_command(satellite='Buero', device='Fire TV', "
|
||||
"action='dial.launch', params={'app':'YouTube','v':'<videoId>'})\n"
|
||||
"Die YouTube-Video-ID (v=) ziehst Du aus dem Link/Titel (ggf. web_search). "
|
||||
"Weitere Aktionen: 'wol' (params={'mac':'...'}) zum Aufwecken, "
|
||||
"'http.get'/'http.post' (params={'url':'...'}) fuer lokale Webhooks.\n"
|
||||
"\n"
|
||||
"Adressierung ueber Location/Name des Satelliten ('Buero'), NICHT ueber die "
|
||||
"Geraete — die identifizieren sich selbst. Steuerung geht nur, wenn der "
|
||||
"Satellit sie erlaubt (steht in satellite_list als caps). Ist keiner online: "
|
||||
"sag das ehrlich, statt zu raten."
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -857,6 +915,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,
|
||||
|
||||
+231
-23
@@ -43,6 +43,25 @@ from openwakeword.model import Model as WakeWordModel
|
||||
|
||||
from modes import Mode, canonical_id, detect_mode_switch, mode_from_id, should_speak
|
||||
|
||||
|
||||
def _docker_gateway() -> str:
|
||||
"""Docker-Gateway-IP (= Host-IP auf DIESEM Container-Netz, aria-net) aus
|
||||
/proc/net/route. Genau die IP, an die der Brain QEMUs VNC bindet — im
|
||||
Gegensatz zu host.docker.internal, das auf die Default-Bridge (docker0)
|
||||
zeigt und daher die VM nicht trifft."""
|
||||
try:
|
||||
import socket as _sock
|
||||
import struct as _struct
|
||||
with open("/proc/net/route") as f:
|
||||
for line in f.readlines()[1:]:
|
||||
fields = line.strip().split()
|
||||
if len(fields) >= 4 and fields[1] == "00000000" and int(fields[3], 16) & 2:
|
||||
return _sock.inet_ntoa(_struct.pack("<L", int(fields[2], 16)))
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
# ── Logging ──────────────────────────────────────────────────
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -738,7 +757,15 @@ class ARIABridge:
|
||||
# "task": asyncio.Task}. Wir bruecken rohes RFB-TCP (QEMU-VNC auf dem
|
||||
# Host) <-> RVS (vnc_data/vnc_input, Base64-in-JSON).
|
||||
self._vnc_sessions: dict[str, dict] = {}
|
||||
self._vnc_host: str = os.environ.get("ARIA_VNC_HOST", "host.docker.internal")
|
||||
# VNC-Host: das aria-net-Gateway (dort bindet der Brain QEMUs VNC).
|
||||
# host.docker.internal zeigt faelschlich auf docker0 (172.17.0.1) → refused.
|
||||
self._vnc_host: str = (os.environ.get("ARIA_VNC_HOST")
|
||||
or _docker_gateway() or "host.docker.internal")
|
||||
# Satelliten (Aussenposten in fremden Netzen). id → {location, caps,
|
||||
# control, last_seen}. Registrierung via sat_hello. _pending_sat:
|
||||
# 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.
|
||||
# requestId → Future mit dem flux_response-Payload (oder None bei Fehler).
|
||||
self._pending_flux: dict[str, asyncio.Future] = {}
|
||||
@@ -763,7 +790,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
|
||||
@@ -1812,16 +1840,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"
|
||||
@@ -1830,15 +1858,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
|
||||
@@ -1846,23 +1875,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))
|
||||
@@ -1909,10 +1963,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)
|
||||
@@ -2023,6 +2080,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-
|
||||
@@ -2337,7 +2410,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])
|
||||
@@ -2379,6 +2453,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
|
||||
@@ -2631,8 +2719,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(
|
||||
@@ -3411,6 +3502,32 @@ class ARIABridge:
|
||||
logger.warning("[vnc] input schreiben (%s) fehlgeschlagen: %s", session, exc)
|
||||
return
|
||||
|
||||
elif msg_type == "sat_hello":
|
||||
sid = (payload.get("id") or "").strip()
|
||||
if sid:
|
||||
self._satellites[sid] = {
|
||||
"id": sid,
|
||||
"location": payload.get("location") or sid,
|
||||
"caps": payload.get("caps") or [],
|
||||
"control": bool(payload.get("control")),
|
||||
"last_seen": time.time(),
|
||||
}
|
||||
logger.info("[sat] Satellit online: %s (%s) caps=%s control=%s",
|
||||
sid, self._satellites[sid]["location"],
|
||||
self._satellites[sid]["caps"], self._satellites[sid]["control"])
|
||||
return
|
||||
|
||||
elif msg_type in ("sat_devices", "sat_result"):
|
||||
req_id = payload.get("requestId", "")
|
||||
future = self._pending_sat.get(req_id)
|
||||
if future is not None and not future.done():
|
||||
future.set_result(payload)
|
||||
# last_seen aktualisieren
|
||||
sid = (payload.get("satellite") or "").strip()
|
||||
if sid and sid in self._satellites:
|
||||
self._satellites[sid]["last_seen"] = time.time()
|
||||
return
|
||||
|
||||
elif msg_type == "config_request":
|
||||
# Eine andere Bridge (whisper/f5tts) bittet um die aktuelle Voice-
|
||||
# Config — passiert wenn sie sich connected, weil sie sonst die
|
||||
@@ -4048,6 +4165,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.
|
||||
@@ -4282,6 +4421,27 @@ class ARIABridge:
|
||||
"timestamp": int(time.time() * 1000),
|
||||
}))
|
||||
await _send_response(writer, 200, {"ok": True})
|
||||
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 == "POST" and path == "/internal/satellite":
|
||||
# Brain-Tool: Discovery oder Command an einen Satelliten.
|
||||
# body: {op:'discover'|'command', satellite, device?, action?, params?}
|
||||
try:
|
||||
data = json.loads(body.decode("utf-8", "ignore"))
|
||||
except Exception as exc:
|
||||
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
|
||||
return
|
||||
op = (data.get("op") or "discover").strip()
|
||||
result = await self._satellite_request(
|
||||
op=op,
|
||||
satellite=str(data.get("satellite") or ""),
|
||||
device=str(data.get("device") or ""),
|
||||
action=str(data.get("action") or ""),
|
||||
params=data.get("params") if isinstance(data.get("params"), dict) else {},
|
||||
timeout=float(data.get("timeout") or 20.0),
|
||||
)
|
||||
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),
|
||||
@@ -4501,6 +4661,54 @@ class ARIABridge:
|
||||
pass
|
||||
logger.info("[vnc] Tunnel geschlossen: session=%s", session)
|
||||
|
||||
def _satellite_list(self) -> list[dict]:
|
||||
"""Bekannte Satelliten (frisch = in den letzten 5 Min gesehen)."""
|
||||
now = time.time()
|
||||
out = []
|
||||
for s in self._satellites.values():
|
||||
out.append({
|
||||
"id": s["id"], "location": s.get("location") or s["id"],
|
||||
"caps": s.get("caps") or [], "control": bool(s.get("control")),
|
||||
"online": (now - s.get("last_seen", 0)) < 300,
|
||||
})
|
||||
return out
|
||||
|
||||
async def _satellite_request(self, op: str, satellite: str = "",
|
||||
device: str = "", action: str = "",
|
||||
params: Optional[dict] = None,
|
||||
timeout: float = 20.0) -> dict:
|
||||
"""Schickt sat_discover / sat_command an einen Satelliten (via RVS) und
|
||||
wartet auf sat_devices / sat_result. op = 'discover' | 'command'.
|
||||
Muster identisch zu _flux_generate (requestId → Future)."""
|
||||
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_sat[request_id] = future
|
||||
try:
|
||||
if op == "discover":
|
||||
msg = {"type": "sat_discover",
|
||||
"payload": {"requestId": request_id, "satellite": satellite,
|
||||
"force": bool((params or {}).get("force"))}}
|
||||
else:
|
||||
msg = {"type": "sat_command",
|
||||
"payload": {"requestId": request_id, "satellite": satellite,
|
||||
"device": device, "action": action,
|
||||
"params": params or {}}}
|
||||
msg["timestamp"] = int(time.time() * 1000)
|
||||
ok = await self._send_to_rvs(msg)
|
||||
if not ok:
|
||||
return {"ok": False, "error": "Satellit-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"Satellit '{satellite or 'all'}' antwortet nicht (Timeout)."}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
finally:
|
||||
self._pending_sat.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.
|
||||
|
||||
+247
-14
@@ -198,7 +198,7 @@
|
||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
|
||||
<span id="disk-banner-icon" style="font-size:18px;">⚠️</span>
|
||||
<span id="disk-banner-text" style="flex:1;min-width:200px;font-weight:600;"></span>
|
||||
<button onclick="copyDiskCmd('safe')" class="btn secondary" style="padding:4px 10px;font-size:11px;" title="docker builder prune -a -f && docker image prune -a -f">
|
||||
<button id="disk-clean-btn" onclick="runDiskCleanup('safe')" class="btn secondary" style="padding:4px 10px;font-size:11px;" title="Fuehrt aus: docker builder prune -a && docker image prune -a (ohne Volumes)">
|
||||
Sicher aufraeumen
|
||||
</button>
|
||||
<button onclick="document.getElementById('disk-banner-aggressive').style.display=(document.getElementById('disk-banner-aggressive').style.display==='none'?'flex':'none')"
|
||||
@@ -216,6 +216,7 @@
|
||||
<div style="color:#FFAA55;">
|
||||
<b>Aggressiv</b> — zusaetzlich ungenutzte Volumes. <b>Nur wenn alle ARIA-Container laufen</b>, sonst riskierst du Daten-Verlust (Sessions, SSH-Keys, Shared):<br>
|
||||
<code style="font-family:monospace;">docker system prune -a --volumes -f</code>
|
||||
<button onclick="runDiskCleanup('aggressive')" class="btn secondary" style="padding:2px 8px;font-size:10px;margin-left:6px;border-color:#FF3B30;color:#FF3B30;">Jetzt ausfuehren</button>
|
||||
<button onclick="copyDiskCmd('aggressive')" class="btn secondary" style="padding:2px 8px;font-size:10px;margin-left:6px;">Kopieren</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,6 +230,7 @@
|
||||
<button class="main-nav-btn" onclick="switchMainTab('skills')">Skills</button>
|
||||
<button class="main-nav-btn" onclick="switchMainTab('triggers')">Trigger</button>
|
||||
<button class="main-nav-btn" onclick="switchMainTab('files')">Dateien</button>
|
||||
<button class="main-nav-btn" onclick="switchMainTab('satellites')">Satelliten</button>
|
||||
<button class="main-nav-btn" onclick="switchMainTab('settings')">Einstellungen</button>
|
||||
</div>
|
||||
|
||||
@@ -327,6 +329,7 @@
|
||||
<input type="file" id="diag-file-input" multiple accept="image/*,application/pdf,.doc,.docx,.txt" style="display:none;" onchange="handleDiagFileSelect(this.files)">
|
||||
</label>
|
||||
<textarea id="chat-input" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" onpaste="handleDiagPaste(event)" oninput="autoResizeTextarea(this)"></textarea>
|
||||
<button class="btn secondary" onclick="interjectDiag('chat-input')" title="Zwischenruf — Korrektur in den laufenden Turn (kein Abbruch, keine Queue)" style="border-color:#FF9500;color:#FF9500;">📣</button>
|
||||
<button class="btn" id="btn-rvs" onclick="testRVS()">Senden</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -344,6 +347,7 @@
|
||||
</div>
|
||||
<div class="input-row" style="margin-top:8px;">
|
||||
<textarea id="chat-input-fs" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" oninput="autoResizeTextarea(this)"></textarea>
|
||||
<button class="btn secondary" onclick="interjectDiag('chat-input-fs')" title="Zwischenruf — Korrektur in den laufenden Turn (kein Abbruch, keine Queue)" style="border-color:#FF9500;color:#FF9500;">📣</button>
|
||||
<button class="btn" onclick="testRVSFS()">Senden</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -784,6 +788,18 @@
|
||||
<div id="voice-id-status" style="font-size:13px;color:#E0E0F0;margin-bottom:10px;">
|
||||
Status wird geladen...
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
|
||||
<label style="color:#8888AA;font-size:12px;min-width:130px;">Nur meine Stimme:</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;flex:1;">
|
||||
<input type="checkbox" id="diag-voice-id-enabled" onchange="sendVoiceConfig()">
|
||||
<span style="color:#E0E0F0;font-size:12px;">Speaker-ID-Prüfung aktiv</span>
|
||||
</label>
|
||||
</div>
|
||||
<div style="font-size:10px;color:#555570;margin-bottom:12px;">
|
||||
AUS (Default) = alle Stimmen kommen durch (fail-open). AN = nur der enrollte
|
||||
Sprecher wird ans Brain geleitet, fremde Stimmen werden verworfen. Erst
|
||||
einschalten wenn ein Fingerprint eingelernt ist — sonst hört ARIA niemanden.
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
|
||||
<label style="color:#8888AA;font-size:12px;min-width:130px;">Match-Threshold:</label>
|
||||
<input type="range" id="diag-voice-id-threshold" min="0.30" max="0.70" step="0.05" value="0.50"
|
||||
@@ -1064,11 +1080,13 @@
|
||||
<div style="background:#0D0D1A;border-radius:6px;padding:10px 12px;margin-bottom:8px;">
|
||||
<div style="color:#FFD60A;font-weight:bold;font-size:12px;margin-bottom:4px;">2. Bootstrap-Snapshot (nur pinned)</div>
|
||||
<div style="color:#8888AA;font-size:11px;margin-bottom:8px;">
|
||||
Klein und schnell: <strong>nur</strong> die pinned Memories (Identität, Regeln, Präferenzen, Tools, Skills) als JSON.
|
||||
Use-Case: Wipe → Bootstrap-Import → ARIA hat Persönlichkeit zurück, sonst leer.
|
||||
Cold Memory (Konversations-Fakten) bleibt beim Import unangetastet.
|
||||
Getrennt nach <strong>scope</strong>: <span style="color:#3FFF3F;">System</span> = generische Regeln, die jeder braucht (Sicherheit, Ehrlichkeit, Skill-Regeln) — teilbar für ein frisches System.
|
||||
<span style="color:#FF9F0A;">Persönlich</span> = Stefan-spezifisch (Name, Zugangsdaten, Projekte) — bleibt privat.
|
||||
Import ersetzt nur die pinned Memories des jeweiligen scope; Cold Memory bleibt unangetastet.
|
||||
</div>
|
||||
<button class="btn secondary" onclick="exportBootstrap()" style="color:#FFD60A;border-color:#FFD60A;">⬇ Bootstrap exportieren (JSON)</button>
|
||||
<button class="btn secondary" onclick="exportBootstrap('system')" style="color:#3FFF3F;border-color:#3FFF3F;">⬇ System-Regeln exportieren</button>
|
||||
<button class="btn secondary" onclick="exportBootstrap('personal')" style="color:#FF9F0A;border-color:#FF9F0A;">⬇ Persönliches exportieren</button>
|
||||
<button class="btn secondary" onclick="exportBootstrap('all')" style="color:#FFD60A;border-color:#FFD60A;">⬇ Alles (Vollbackup)</button>
|
||||
<input type="file" id="bootstrap-import-file" accept=".json,application/json" style="display:none" onchange="importBootstrap(event)">
|
||||
<button class="btn secondary" onclick="document.getElementById('bootstrap-import-file').click()" style="color:#FFD60A;border-color:#FFD60A;">⬆ Bootstrap importieren</button>
|
||||
<div id="bootstrap-status" style="margin-top:8px;font-size:11px;color:#8888AA;"></div>
|
||||
@@ -1271,6 +1289,27 @@
|
||||
</div>
|
||||
</div><!-- /tab-triggers -->
|
||||
|
||||
<!-- ══════ TAB: Satelliten ══════ -->
|
||||
<div id="tab-satellites" class="main-tab">
|
||||
<div class="settings-section">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||
<h2 style="margin:0;">Satelliten 🛰️</h2>
|
||||
<button class="btn secondary" onclick="requestSatellites()" style="padding:4px 10px;font-size:11px;">Aktualisieren</button>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:8px;">
|
||||
<p style="color:#8888AA;font-size:12px;margin:0;">
|
||||
Aussenposten-Container in fremden Netzen (Buero, Werkstatt …), die sich mit
|
||||
RVS verbinden. Hier siehst Du, welche Satelliten online sind und welche
|
||||
Geraete sie im jeweiligen Netz erkannt haben. „Geraete scannen" fragt den
|
||||
Satelliten live ab.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div id="sat-list" style="font-size:12px;color:#8888AA;">(Lade...)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /tab-satellites -->
|
||||
|
||||
<!-- Trigger-Create/Edit Modal -->
|
||||
<div class="modal-overlay" id="trigger-modal">
|
||||
<div class="modal-box" style="max-width:600px;">
|
||||
@@ -1371,6 +1410,11 @@
|
||||
<input type="checkbox" id="memory-pinned">
|
||||
<span>📌 Pinned (Hot Memory — IMMER im System-Prompt)</span>
|
||||
</label>
|
||||
<label style="display:block;color:#8888AA;font-size:11px;margin-top:10px;margin-bottom:3px;">Scope (steuert Bootstrap-Export):</label>
|
||||
<select id="memory-scope" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
|
||||
<option value="personal">🟠 Persönlich — Stefan-spezifisch, bleibt privat</option>
|
||||
<option value="system">🟢 System — generische Regel, teilbar für frisches System</option>
|
||||
</select>
|
||||
|
||||
<!-- Anhaenge — nur bei Edit (vorhandene ID) sichtbar -->
|
||||
<div id="memory-attachments-block" style="display:none;margin-top:14px;padding-top:10px;border-top:1px solid #1E1E2E;">
|
||||
@@ -1563,9 +1607,34 @@
|
||||
const d = await r.json();
|
||||
diagQueueStatus = d?.contexts || {};
|
||||
renderContextStrip();
|
||||
reconcileDiagStates();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Selbstheilung: haengt ein Kontext lokal auf 'running', obwohl der Brain
|
||||
// ihn NICHT als busy meldet, ist der Turn (z.B. durch einen Bridge-Timeout)
|
||||
// ohne Antwort-Bubble abgebrochen — der Automat blieb sonst ewig 'running'
|
||||
// (Folge-Nachrichten wandern in die Queue, obwohl ARIA idle ist; frueher
|
||||
// half nur Ctrl+R, was die Queue-Nachricht verlor). Nach 2 aufeinander-
|
||||
// folgenden not-busy-Polls (~4s Karenz gegen das Sende-Fenster) schalten
|
||||
// wir die Queue weiter: angestellte Nachricht geht automatisch raus, sonst
|
||||
// idle.
|
||||
const diagStuckCounts = {};
|
||||
function reconcileDiagStates() {
|
||||
const ctxs = diagQueueStatus || {};
|
||||
for (const pid of Object.keys(diagCtxStates)) {
|
||||
if (diagCtxStates[pid] !== 'running') { diagStuckCounts[pid] = 0; continue; }
|
||||
const busy = !!(ctxs[pid || '__main__'] && ctxs[pid || '__main__'].busy);
|
||||
if (busy) { diagStuckCounts[pid] = 0; continue; }
|
||||
diagStuckCounts[pid] = (diagStuckCounts[pid] || 0) + 1;
|
||||
if (diagStuckCounts[pid] >= 2) {
|
||||
diagStuckCounts[pid] = 0;
|
||||
console.warn('[diag] Kontext', pid || '(main)', 'haengt auf running, Brain idle → Queue weiterschalten');
|
||||
advanceDiagQueue(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDiagProjectsCache() {
|
||||
try {
|
||||
const r = await fetch('/api/brain/projects/list?include_archived=false');
|
||||
@@ -1715,6 +1784,27 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'disk_cleanup') {
|
||||
const btn = document.getElementById('disk-clean-btn');
|
||||
const dtext = document.getElementById('disk-banner-text');
|
||||
if (msg.status === 'running') {
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Raeume auf...'; }
|
||||
} else if (msg.status === 'done') {
|
||||
// Feedback NICHT nur per alert() (koennte unterdrueckt sein) —
|
||||
// direkt am Button + im Banner sichtbar machen.
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '✓ ' + (msg.freed || '0 MB') + ' frei';
|
||||
setTimeout(() => { btn.textContent = 'Sicher aufraeumen'; }, 6000);
|
||||
}
|
||||
if (dtext) dtext.textContent = 'Aufgeraeumt: ' + (msg.freed || '0 MB') + ' frei (' + (msg.steps || []).join(', ') + '). Disk-Status aktualisiert sich gleich.';
|
||||
} else if (msg.status === 'error') {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Fehler — nochmal'; setTimeout(() => { btn.textContent = 'Sicher aufraeumen'; }, 6000); }
|
||||
if (dtext) dtext.textContent = 'Aufraeumen fehlgeschlagen: ' + (msg.error || '');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'mode' && msg.payload) {
|
||||
// Bridge hat den Modus geaendert (evtl. von anderer App/Diagnostic) — UI syncen
|
||||
const mode = (msg.payload.mode || '').toLowerCase();
|
||||
@@ -1828,6 +1918,11 @@
|
||||
if (slider) slider.value = msg.voiceIdThreshold;
|
||||
if (display) display.textContent = Number(msg.voiceIdThreshold).toFixed(2);
|
||||
}
|
||||
// Speaker-ID Gating-Schalter wiederherstellen (Default aus)
|
||||
{
|
||||
const cb = document.getElementById('diag-voice-id-enabled');
|
||||
if (cb) cb.checked = !!msg.voiceIdEnabled;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1862,6 +1957,14 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'sat_update') { satellites = msg.satellites || []; renderSatellites(); return; }
|
||||
if (msg.type === 'sat_devices') {
|
||||
if (msg.satellite) { satDevices[msg.satellite] = { devices: msg.devices || [], location: msg.location, ts: Date.now() }; }
|
||||
satScanning = null;
|
||||
renderSatellites();
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'voice_id_delete_response') {
|
||||
const p = msg.payload || msg;
|
||||
if (p.removed) {
|
||||
@@ -2214,6 +2317,18 @@
|
||||
diagCtxStates[pid] = 'running';
|
||||
renderDiagQueue();
|
||||
}
|
||||
// Zwischenruf: waehrend ARIA arbeitet eine Korrektur in den laufenden Turn
|
||||
// schieben — NICHT anstellen, NICHT abbrechen.
|
||||
function interjectDiag(inputId) {
|
||||
const input = document.getElementById(inputId || 'chat-input');
|
||||
const text = (input.value || '').trim();
|
||||
if (!text) return;
|
||||
send({ action: 'interject', text, projectId: focusedContextId });
|
||||
addChat('sent', '📣 Zwischenruf: ' + text, 'interject', { projectId: focusedContextId });
|
||||
diagDrafts[focusedContextId] = '';
|
||||
localStorage.setItem('diag_ctx_drafts', JSON.stringify(diagDrafts));
|
||||
input.value = '';
|
||||
}
|
||||
function advanceDiagQueue(pid) {
|
||||
const q = diagCtxQueues[pid] || [];
|
||||
if (q.length === 0) { diagCtxStates[pid] = 'idle'; renderDiagQueue(); return; }
|
||||
@@ -3162,14 +3277,17 @@
|
||||
<div onclick="switchProject('${p.id}')" style="cursor:pointer;flex:1;">
|
||||
<div style="color:${isActive ? '#34C759' : '#E0E0F0'};font-weight:600;">
|
||||
${hidden ? '🙈' : '📁'} ${escapeHtml(p.name)}
|
||||
${p.has_files ? `<span title="${p.file_count || 0} Dateien in /shared/projects/${escapeHtmlAttr(p.id)}/" style="font-size:11px;margin-left:4px;">📄${p.file_count ? ' ' + p.file_count : ''}</span>` : ''}
|
||||
${hidden ? '<span style="color:#B392F0;font-size:10px;font-weight:700;margin-left:6px;background:rgba(179,146,240,0.15);padding:2px 6px;border-radius:3px;">versteckt</span>' : ''}
|
||||
${ended ? '<span style="color:#FFD60A;font-size:10px;font-weight:700;margin-left:6px;background:rgba(255,214,10,0.15);padding:2px 6px;border-radius:3px;">beendet</span>' : ''}
|
||||
${p.kind === 'code' ? '<span style="color:#0096FF;font-size:10px;font-weight:700;margin-left:6px;background:rgba(0,150,255,0.15);padding:2px 6px;border-radius:3px;"></> Code</span>' : ''}
|
||||
${isActive ? '<span style="color:#34C759;font-size:10px;font-weight:800;margin-left:6px;">✓ AKTIV</span>' : ''}
|
||||
</div>
|
||||
${p.description ? `<div style="color:#8888AA;font-size:12px;margin-top:2px;">${escapeHtml(p.description)}</div>` : ''}
|
||||
<div style="color:#555570;font-size:11px;margin-top:4px;">${p.turn_count} Turns · zuletzt ${since}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="btn secondary" onclick="setProjectKind('${p.id}', '${p.kind === 'code' ? 'chat' : 'code'}')" style="padding:3px 8px;font-size:10px;color:${p.kind === 'code' ? '#0096FF' : '#8888AA'};" title="${p.kind === 'code' ? 'Ist Code-Projekt — klick fuer normalen Chat' : 'Als Code-Projekt markieren (Editor + Desktop im Cockpit)'}"></></button>
|
||||
<button class="btn secondary" onclick="setProjectHidden('${p.id}', ${!hidden})" style="padding:3px 8px;font-size:10px;color:${eyeColor};" title="${eyeTitle}">${eyeIcon}</button>
|
||||
${!ended ? `<button class="btn secondary" onclick="endProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;" title="Projekt beenden">⏹</button>` : ''}
|
||||
<button class="btn secondary" onclick="archiveProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;color:#E55C5C;" title="Archivieren">🗑</button>
|
||||
@@ -3216,6 +3334,18 @@
|
||||
} catch (e) { alert('Verstecken/Anzeigen fehlgeschlagen: ' + e.message); }
|
||||
}
|
||||
|
||||
async function setProjectKind(id, kind) {
|
||||
try {
|
||||
const r = await fetch(`/api/brain/projects/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind }),
|
||||
});
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
loadProjects();
|
||||
} catch (e) { alert('Code-Markierung fehlgeschlagen: ' + e.message); }
|
||||
}
|
||||
|
||||
async function switchProject(projectId) {
|
||||
try {
|
||||
await fetch('/api/brain/projects/switch', {
|
||||
@@ -3494,13 +3624,14 @@
|
||||
const huggingfaceToken = document.getElementById('diag-flux-hf-token')?.value;
|
||||
const voiceIdThresholdRaw = document.getElementById('diag-voice-id-threshold')?.value;
|
||||
const voiceIdThreshold = voiceIdThresholdRaw ? parseFloat(voiceIdThresholdRaw) : undefined;
|
||||
const voiceIdEnabled = document.getElementById('diag-voice-id-enabled')?.checked;
|
||||
send({
|
||||
action: 'send_voice_config',
|
||||
ttsEnabled, xttsVoice, whisperModel,
|
||||
f5ttsModel, f5ttsCkptFile, f5ttsVocabFile,
|
||||
f5ttsCfgStrength, f5ttsNfeStep,
|
||||
fluxDefaultModel, fluxKeywordRaw, fluxKeywordSwitch, huggingfaceToken,
|
||||
voiceIdThreshold,
|
||||
voiceIdThreshold, voiceIdEnabled,
|
||||
});
|
||||
const statusEl = document.getElementById('voice-status');
|
||||
if (statusEl && xttsVoice) {
|
||||
@@ -4045,9 +4176,79 @@
|
||||
loadSkills();
|
||||
} else if (tab === 'triggers') {
|
||||
loadTriggers();
|
||||
} else if (tab === 'satellites') {
|
||||
requestSatellites();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Satelliten-Ansicht ─────────────────────────────────
|
||||
let satellites = []; // [{id, location, caps, control, online}]
|
||||
let satDevices = {}; // id → {devices, location, ts}
|
||||
let satScanning = null; // id des gerade scannenden Satelliten
|
||||
|
||||
function requestSatellites() {
|
||||
send({ action: 'sat_list' });
|
||||
}
|
||||
function scanSatellite(id) {
|
||||
satScanning = id;
|
||||
renderSatellites();
|
||||
send({ action: 'sat_discover', satellite: id });
|
||||
}
|
||||
function renderSatellites() {
|
||||
const box = document.getElementById('sat-list');
|
||||
if (!box) return;
|
||||
if (!satellites.length) {
|
||||
box.innerHTML = '<span style="color:#8888AA;">Kein Satellit verbunden. ' +
|
||||
'Deploy einen satellite/-Container in einem Netz (siehe satellite/README).</span>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = satellites.map(s => {
|
||||
const online = s.online !== false;
|
||||
const dot = online ? '#34C759' : '#FF3B30';
|
||||
const caps = (s.caps || []).join(', ') || 'nur beobachten';
|
||||
const ctrl = s.control ? '<span style="color:#0096FF;">steuerbar</span>' : '<span style="color:#8888AA;">read-only</span>';
|
||||
const dev = satDevices[s.id];
|
||||
const scanning = satScanning === s.id;
|
||||
let devHtml;
|
||||
if (scanning) {
|
||||
devHtml = '<div style="color:#FFD60A;margin-top:6px;">Scanne Netz …</div>';
|
||||
} else if (dev) {
|
||||
if (!dev.devices.length) {
|
||||
devHtml = '<div style="color:#8888AA;margin-top:6px;">Keine Geraete gefunden.</div>';
|
||||
} else {
|
||||
devHtml = '<div style="margin-top:6px;display:flex;flex-direction:column;gap:3px;">' +
|
||||
dev.devices.map(d => {
|
||||
const tags = [];
|
||||
if (d.model) tags.push(escapeHtml(d.model));
|
||||
if (d.dialAppUrl) tags.push('DIAL');
|
||||
if (d.mac) tags.push(escapeHtml(d.mac));
|
||||
const tagStr = tags.length ? ' <span style="color:#666;">(' + tags.join(' · ') + ')</span>' : '';
|
||||
return '<div style="padding:3px 6px;background:#0D0D1A;border-radius:4px;">' +
|
||||
'<span style="color:#E0E0F0;">' + escapeHtml(d.name || d.id || '?') + '</span> ' +
|
||||
'<span style="color:#0096FF;">[' + escapeHtml(d.type || '?') + ']</span> ' +
|
||||
'<span style="color:#8888AA;">' + escapeHtml(d.ip || '') + '</span>' + tagStr +
|
||||
'</div>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
} else {
|
||||
devHtml = '<div style="color:#8888AA;margin-top:6px;">Noch nicht gescannt.</div>';
|
||||
}
|
||||
return '<div style="border:1px solid #1E1E2E;border-radius:8px;padding:10px;margin-bottom:8px;">' +
|
||||
'<div style="display:flex;justify-content:space-between;align-items:center;">' +
|
||||
'<div><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + dot + ';margin-right:6px;"></span>' +
|
||||
'<strong style="color:#E0E0F0;">' + escapeHtml(s.location || s.id) + '</strong> ' +
|
||||
'<span style="color:#666;font-size:11px;">id=' + escapeHtml(s.id) + '</span></div>' +
|
||||
'<button class="btn secondary" onclick="scanSatellite(\'' + escapeHtml(s.id) + '\')" style="padding:3px 8px;font-size:11px;"' +
|
||||
(scanning ? ' disabled' : '') + '>Geraete scannen</button>' +
|
||||
'</div>' +
|
||||
'<div style="color:#8888AA;font-size:11px;margin-top:4px;">kann: ' + escapeHtml(caps) + ' · ' + ctrl +
|
||||
(s.net && s.net.primary_ip ? ' · Netz: ' + escapeHtml(s.net.primary_ip) : '') + '</div>' +
|
||||
(s.net && s.net.warning ? '<div style="color:#FF6E6E;font-size:11px;margin-top:6px;padding:6px;background:#3A1F1F;border-radius:4px;">⚠ ' + escapeHtml(s.net.warning) + '</div>' : '') +
|
||||
devHtml +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Triggers-Verwaltung ────────────────────────────────
|
||||
let triggersCache = [];
|
||||
|
||||
@@ -5610,9 +5811,15 @@
|
||||
const typeBadge = withScore ? `<span style="color:#0096FF;font-size:10px;margin-right:6px;">${escapeHtml(BRAIN_TYPE_LABELS[m.type] || m.type)}</span>` : '';
|
||||
const attCount = Array.isArray(m.attachments) ? m.attachments.length : 0;
|
||||
const attBadge = attCount > 0 ? `<span style="color:#34C759;font-size:10px;margin-left:6px;" title="${attCount} Anhang${attCount === 1 ? '' : ' / Anhaenge'}">📎${attCount}</span>` : '';
|
||||
// scope-Badge nur bei pinned (nur die werden exportiert — da zaehlt die Trennung).
|
||||
const scopeBadge = m.pinned
|
||||
? (m.scope === 'system'
|
||||
? `<span style="color:#3FFF3F;font-size:9px;margin-left:6px;border:1px solid #3FFF3F;border-radius:3px;padding:0 3px;" title="System-Regel — kommt in den System-Export">SYS</span>`
|
||||
: `<span style="color:#FF9F0A;font-size:9px;margin-left:6px;border:1px solid #FF9F0A;border-radius:3px;padding:0 3px;" title="Persönlich — bleibt privat">PRIV</span>`)
|
||||
: '';
|
||||
return `<div style="padding:6px 0;border-bottom:1px solid #1E1E2E;display:flex;gap:6px;align-items:flex-start;">
|
||||
<div style="flex:1;min-width:0;cursor:pointer;" onclick="openMemoryModal('${m.id}')">
|
||||
<div style="color:#E0E0F0;font-size:12px;">${typeBadge}${pin}<strong>${escapeHtml(m.title || '(ohne Titel)')}</strong>${score}${attBadge}
|
||||
<div style="color:#E0E0F0;font-size:12px;">${typeBadge}${pin}<strong>${escapeHtml(m.title || '(ohne Titel)')}</strong>${score}${attBadge}${scopeBadge}
|
||||
${m.category ? `<span style="color:#555570;font-weight:normal;font-size:10px;margin-left:6px;">[${escapeHtml(m.category)}]</span>` : ''}
|
||||
</div>
|
||||
<div style="color:#888;font-size:11px;line-height:1.4;">${escapeHtml(preview)}${m.content && m.content.length > 140 ? '...' : ''}</div>
|
||||
@@ -5822,6 +6029,7 @@
|
||||
document.getElementById('memory-category').value = m.category || '';
|
||||
document.getElementById('memory-tags').value = (m.tags || []).join(', ');
|
||||
document.getElementById('memory-pinned').checked = !!m.pinned;
|
||||
document.getElementById('memory-scope').value = (m.scope === 'system') ? 'system' : 'personal';
|
||||
// Anhang-Block sichtbar — Liste rendern
|
||||
if (attBlock) attBlock.style.display = 'block';
|
||||
if (attHint) attHint.style.display = 'none';
|
||||
@@ -5835,6 +6043,7 @@
|
||||
document.getElementById('memory-category').value = '';
|
||||
document.getElementById('memory-tags').value = '';
|
||||
document.getElementById('memory-pinned').checked = false;
|
||||
document.getElementById('memory-scope').value = 'personal';
|
||||
// Bei neuem Memory: nur Hinweis, dass Anhaenge nach Save gehen
|
||||
if (attBlock) attBlock.style.display = 'none';
|
||||
if (attHint) attHint.style.display = 'block';
|
||||
@@ -5939,6 +6148,7 @@
|
||||
const category = document.getElementById('memory-category').value.trim();
|
||||
const tags = document.getElementById('memory-tags').value.split(',').map(t => t.trim()).filter(Boolean);
|
||||
const pinned = document.getElementById('memory-pinned').checked;
|
||||
const scope = document.getElementById('memory-scope').value || 'personal';
|
||||
|
||||
if (!title) { errEl.textContent = 'Titel fehlt.'; errEl.style.display = 'block'; return; }
|
||||
if (!content) { errEl.textContent = 'Inhalt fehlt.'; errEl.style.display = 'block'; return; }
|
||||
@@ -5949,13 +6159,13 @@
|
||||
r = await fetch('/api/brain/memory/update/' + encodeURIComponent(id), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, content, pinned, category, tags }),
|
||||
body: JSON.stringify({ title, content, pinned, category, scope, tags }),
|
||||
});
|
||||
} else {
|
||||
r = await fetch('/api/brain/memory/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type, title, content, pinned, category, tags, source: 'manual' }),
|
||||
body: JSON.stringify({ type, title, content, pinned, category, scope, tags, source: 'manual' }),
|
||||
});
|
||||
}
|
||||
if (!r.ok) {
|
||||
@@ -6332,11 +6542,12 @@
|
||||
}
|
||||
|
||||
// ── Bootstrap Export / Import ──────────────────────────
|
||||
async function exportBootstrap() {
|
||||
async function exportBootstrap(scope) {
|
||||
scope = scope || 'system';
|
||||
const status = document.getElementById('bootstrap-status');
|
||||
if (status) status.innerHTML = '⏳ Lade...';
|
||||
try {
|
||||
const r = await fetch('/api/brain/memory/export-bootstrap');
|
||||
const r = await fetch('/api/brain/memory/export-bootstrap?scope=' + encodeURIComponent(scope));
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const data = await r.json();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
@@ -6344,10 +6555,11 @@
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `aria-bootstrap-${ts}.json`;
|
||||
a.download = `aria-bootstrap-${scope}-${ts}.json`;
|
||||
document.body.appendChild(a); a.click();
|
||||
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
|
||||
if (status) status.innerHTML = `<span style="color:#3FFF3F;">✓ ${data.count} pinned Memories exportiert</span>`;
|
||||
const label = scope === 'system' ? 'System-Regeln' : (scope === 'personal' ? 'persönliche Memories' : 'pinned Memories');
|
||||
if (status) status.innerHTML = `<span style="color:#3FFF3F;">✓ ${data.count} ${label} exportiert</span>`;
|
||||
} catch (e) {
|
||||
if (status) status.innerHTML = `<span style="color:#FF6B6B;">✗ ${e.message}</span>`;
|
||||
}
|
||||
@@ -6361,7 +6573,11 @@
|
||||
const text = await file.text();
|
||||
const bundle = JSON.parse(text);
|
||||
if (!Array.isArray(bundle.memories)) throw new Error('Datei hat kein "memories"-Array');
|
||||
if (!confirm(`Bootstrap importieren?\n\n${bundle.memories.length} pinned Memories aus "${file.name}".\n\nALLE aktuell pinned Memories werden überschrieben. Cold Memory bleibt unverändert.`)) {
|
||||
const bScope = bundle.scope || 'all';
|
||||
const scopeInfo = bScope === 'system' ? 'Nur die aktuell pinned SYSTEM-Regeln werden ersetzt — Persönliches bleibt.'
|
||||
: bScope === 'personal' ? 'Nur die aktuell pinned PERSÖNLICHEN Memories werden ersetzt — System-Regeln bleiben.'
|
||||
: 'ALLE aktuell pinned Memories werden überschrieben.';
|
||||
if (!confirm(`Bootstrap importieren? (scope: ${bScope})\n\n${bundle.memories.length} pinned Memories aus "${file.name}".\n\n${scopeInfo} Cold Memory bleibt unverändert.`)) {
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
@@ -6605,6 +6821,23 @@
|
||||
banner.style.display = 'block';
|
||||
}
|
||||
|
||||
// Fuehrt das Aufraeumen WIRKLICH aus (Server-seitig via Docker-API), statt
|
||||
// nur den Befehl zu kopieren.
|
||||
// WICHTIG: "safe" laeuft OHNE confirm() — Build-Cache + ungenutzte Images
|
||||
// sind ungefaehrlich (keine Volumes/Daten). Frueher blockte ein confirm()
|
||||
// den Klick, wenn der Browser Dialoge unterdrueckt hatte ("Verhindern,
|
||||
// dass diese Seite weitere Dialoge erstellt") → confirm()===false → es ging
|
||||
// NICHTS raus. Nur "aggressive" (Volumes!) fragt noch nach.
|
||||
function runDiskCleanup(variant) {
|
||||
const aggressive = variant === 'aggressive';
|
||||
if (aggressive) {
|
||||
const ok = confirm('AGGRESSIV aufraeumen? Loescht zusaetzlich ungenutzte Volumes — nur wenn ALLE ARIA-Container laufen, sonst Datenverlust!');
|
||||
if (!ok) return;
|
||||
}
|
||||
const btn = document.getElementById('disk-clean-btn');
|
||||
if (btn && !aggressive) { btn.disabled = true; btn.textContent = 'Raeume auf...'; }
|
||||
send({ action: 'disk_cleanup', variant });
|
||||
}
|
||||
function copyDiskCmd(variant) {
|
||||
const cmd = variant === 'aggressive'
|
||||
? 'docker system prune -a --volumes -f'
|
||||
|
||||
+129
-3
@@ -475,6 +475,21 @@ function broadcastState() {
|
||||
broadcast({ type: "state", state });
|
||||
}
|
||||
|
||||
// ── Satelliten-Registry (Aussenposten in fremden Netzen) ──────────
|
||||
const satellites = new Map(); // id → {id, location, caps, control, last_seen}
|
||||
|
||||
function satelliteList() {
|
||||
const now = Date.now();
|
||||
return Array.from(satellites.values()).map(s => ({
|
||||
id: s.id, location: s.location, caps: s.caps, control: s.control, net: s.net || null,
|
||||
online: (now - (s.last_seen || 0)) < 300000,
|
||||
}));
|
||||
}
|
||||
|
||||
function broadcastSatellites() {
|
||||
broadcast({ type: "sat_update", satellites: satelliteList() });
|
||||
}
|
||||
|
||||
// ── OpenClaw Gateway Verbindung ─────────────────────────
|
||||
|
||||
async function connectGateway() {
|
||||
@@ -903,6 +918,23 @@ function connectRVS(forcePlain) {
|
||||
// Browser-Tabs weiterreichen, damit die Projektliste live neu laedt
|
||||
// (bisher wurde das NICHT geforwardet → Diagnostic aktualisierte nie).
|
||||
broadcast({ type: "project_changed", payload: msg.payload || {} });
|
||||
} else if (msg.type === "sat_hello") {
|
||||
// Ein Satellit (Aussenposten in einem fremden Netz) meldet sich.
|
||||
const p = msg.payload || {};
|
||||
if (p.id) {
|
||||
satellites.set(p.id, {
|
||||
id: p.id, location: p.location || p.id,
|
||||
caps: p.caps || [], control: !!p.control, net: p.net || null,
|
||||
last_seen: Date.now(),
|
||||
});
|
||||
broadcastSatellites();
|
||||
}
|
||||
} 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 === "agent_activity") {
|
||||
// Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
|
||||
// weiterreichen, damit der Thinking-Indikator im Chat erscheint.
|
||||
@@ -1527,6 +1559,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) {
|
||||
@@ -2407,6 +2503,8 @@ wss.on("connection", (ws) => {
|
||||
ws.send(JSON.stringify({ type: "init", state, logs: logs.slice(-100) }));
|
||||
// Letzten Disk-Status mitgeben damit der Client sofort weiss wie's um Platz steht
|
||||
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() }));
|
||||
|
||||
ws.on("message", (raw) => {
|
||||
try {
|
||||
@@ -2421,10 +2519,28 @@ 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") {
|
||||
connectRVS();
|
||||
} 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 === "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 === "test_proxy") {
|
||||
testProxy(msg.text);
|
||||
} else if (msg.action === "check_proxy_auth") {
|
||||
@@ -2457,14 +2573,18 @@ wss.on("connection", (ws) => {
|
||||
});
|
||||
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).
|
||||
@@ -2561,6 +2681,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));
|
||||
|
||||
+3
-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 &&
|
||||
@@ -120,6 +116,8 @@ services:
|
||||
- brain
|
||||
networks:
|
||||
- aria-net
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway" # fuer den VNC-Tunnel zum Host (QEMU)
|
||||
ports:
|
||||
- "3001:3001" # Diagnostic Web-UI (Diagnostic teilt Netzwerk mit Bridge)
|
||||
volumes:
|
||||
|
||||
+47
-13
@@ -59,38 +59,62 @@ cmd_create() {
|
||||
[[ -n "${bin}" ]] || die "unbekannte Architektur: ${arch}"
|
||||
command -v "${bin}" >/dev/null || die "${bin} nicht installiert (qemu-setup.sh?)"
|
||||
local d; d="$(vm_dir "${name}")"
|
||||
[[ -e "${d}/disk.qcow2" ]] && die "VM '${name}' existiert schon"
|
||||
[[ -f "${d}/arch" ]] && die "VM '${name}' existiert schon"
|
||||
mkdir -p "${d}"
|
||||
echo "${arch}" > "${d}/arch"
|
||||
qemu-img create -f qcow2 "${d}/disk.qcow2" "${size}" >/dev/null
|
||||
echo "VM '${name}' angelegt (${arch}, ${size})."
|
||||
# size='none' oder '0' → keine Festplatte (VM bootet von --iso/--floppy,
|
||||
# z.B. OS-Entwicklung von Diskette). Sonst eine qcow2-Disk anlegen.
|
||||
if [[ "${size}" == "none" || "${size}" == "0" ]]; then
|
||||
echo "VM '${name}' angelegt (${arch}, ohne Disk — bootet von ISO/Diskette)."
|
||||
else
|
||||
qemu-img create -f qcow2 "${d}/disk.qcow2" "${size}" >/dev/null
|
||||
echo "VM '${name}' angelegt (${arch}, ${size})."
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_boot() {
|
||||
local name="${1:?name}"; shift || true
|
||||
local d; d="$(vm_dir "${name}")"
|
||||
[[ -f "${d}/disk.qcow2" ]] || die "VM '${name}' nicht gefunden (erst 'create')"
|
||||
[[ -f "${d}/arch" || -f "${d}/disk.qcow2" ]] || die "VM '${name}' nicht gefunden (erst 'create')"
|
||||
vm_running "${name}" && die "VM '${name}' laeuft bereits"
|
||||
local arch; arch="$(cat "${d}/arch" 2>/dev/null || echo x86_64)"
|
||||
local bin; bin="$(qemu_bin_for "${arch}")"
|
||||
|
||||
local iso="" bootdev="c" display=1 mem=1024 machine=""
|
||||
local iso="" floppy="" disk="" bootdev="" display=1 mem=1024 machine=""
|
||||
# VNC bindet an 127.0.0.1 (Loopback) — von aussen nur ueber den RVS-Tunnel der
|
||||
# Bridge erreichbar. Die Bridge (Container) kann Loopback aber NICHT erreichen;
|
||||
# der Brain gibt deshalb per --vnc-bind die Docker-Gateway-IP mit (container-
|
||||
# intern, NICHT im LAN/Internet). Default bleibt Loopback.
|
||||
local vncbind="${ARIA_VM_VNC_BIND:-127.0.0.1}"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--iso) iso="${2:?}"; bootdev="d"; shift 2 ;;
|
||||
--iso) iso="${2:?}"; shift 2 ;;
|
||||
--floppy) floppy="${2:?}"; shift 2 ;; # -fda (Disketten-Boot, OS-Dev)
|
||||
--disk) disk="${2:?}"; shift 2 ;; # explizite qcow2 statt Auto
|
||||
--boot) bootdev="${2:?}"; shift 2 ;; # Boot-Reihenfolge (a/c/d)
|
||||
--disk-boot) bootdev="c"; shift ;;
|
||||
--vnc-display) display="${2:?}"; shift 2 ;;
|
||||
--vnc-bind) vncbind="${2:?}"; shift 2 ;; # Bind-Adresse fuer -vnc
|
||||
--mem) mem="${2:?}"; shift 2 ;;
|
||||
--machine) machine="${2:?}"; shift 2 ;;
|
||||
*) die "unbekannte Option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Auto-Erkennung der Medien im VM-Ordner, falls nicht explizit angegeben.
|
||||
[[ -z "${disk}" && -f "${d}/disk.qcow2" ]] && disk="${d}/disk.qcow2"
|
||||
[[ -z "${floppy}" && -f "${d}/floppy.img" ]] && floppy="${d}/floppy.img"
|
||||
[[ -z "${iso}" && -f "${d}/cdrom.iso" ]] && iso="${d}/cdrom.iso"
|
||||
[[ -n "${disk}${floppy}${iso}" ]] || \
|
||||
die "Keine Boot-Medien fuer '${name}' (disk.qcow2 / --iso / --floppy). Erst 'create <name> <arch> <groesse>' oder ein Medium angeben."
|
||||
|
||||
local args=(-name "${name}" -m "${mem}"
|
||||
-drive "file=${d}/disk.qcow2,format=qcow2"
|
||||
-vnc "127.0.0.1:${display}"
|
||||
-vnc "${vncbind}:${display}"
|
||||
-monitor "unix:${d}/monitor.sock,server,nowait"
|
||||
-pidfile "${d}/pid" -daemonize)
|
||||
[[ -n "${disk}" ]] && args+=(-drive "file=${disk},format=qcow2")
|
||||
[[ -n "${floppy}" ]] && args+=(-fda "${floppy}")
|
||||
[[ -n "${iso}" ]] && args+=(-cdrom "${iso}")
|
||||
|
||||
# KVM nur fuer x86 auf x86-Host.
|
||||
case "${arch}" in
|
||||
@@ -104,11 +128,17 @@ cmd_boot() {
|
||||
esac
|
||||
fi
|
||||
[[ -n "${machine}" ]] && args+=(-M "${machine}")
|
||||
[[ -n "${iso}" ]] && args+=(-cdrom "${iso}")
|
||||
|
||||
# Boot-Reihenfolge: explizit, sonst automatisch (ISO→d, nur Diskette→a, sonst c).
|
||||
if [[ -z "${bootdev}" ]]; then
|
||||
if [[ -n "${iso}" ]]; then bootdev="d"
|
||||
elif [[ -n "${floppy}" && -z "${disk}" ]]; then bootdev="a"
|
||||
else bootdev="c"; fi
|
||||
fi
|
||||
args+=(-boot "${bootdev}")
|
||||
|
||||
"${bin}" "${args[@]}"
|
||||
echo "VM '${name}' gestartet (${arch}) — VNC 127.0.0.1:${display} (Port $((5900+display)))."
|
||||
echo "VM '${name}' gestartet (${arch}) — VNC ${vncbind}:${display} (Port $((5900+display)))."
|
||||
echo "vnc_display=${display} vnc_port=$((5900+display))"
|
||||
}
|
||||
|
||||
@@ -117,16 +147,20 @@ cmd_screenshot() {
|
||||
local d; d="$(vm_dir "${name}")"
|
||||
vm_running "${name}" || die "VM '${name}' laeuft nicht"
|
||||
command -v socat >/dev/null || die "socat fehlt (qemu-setup.sh?)"
|
||||
mkdir -p "${SHOT_DIR}"
|
||||
# Standard: ins VM-Verzeichnis schreiben (dem aria-User gehoerend) — NICHT
|
||||
# nach /root/... (da kommt der aria-User nicht hin). Der Brain holt das PNG
|
||||
# danach per SSH (base64). Ueberschreibbar via ARIA_VM_SHOT_DIR.
|
||||
local out_dir="${ARIA_VM_SHOT_DIR:-${d}}"
|
||||
mkdir -p "${out_dir}"
|
||||
local ts; ts="$(date +%s)"
|
||||
local ppm="${d}/shot-${ts}.ppm"
|
||||
printf 'screendump %s\n' "${ppm}" | socat - "unix-connect:${d}/monitor.sock" >/dev/null
|
||||
sleep 0.3
|
||||
local out="${SHOT_DIR}/${name}-${ts}.png"
|
||||
local out="${out_dir}/${name}-${ts}.png"
|
||||
if command -v convert >/dev/null; then
|
||||
convert "${ppm}" "${out}" && rm -f "${ppm}"
|
||||
else
|
||||
out="${SHOT_DIR}/${name}-${ts}.ppm"; mv "${ppm}" "${out}"
|
||||
out="${out_dir}/${name}-${ts}.ppm"; mv "${ppm}" "${out}"
|
||||
fi
|
||||
echo "screenshot=${out}"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 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 { 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;
|
||||
/**
|
||||
* 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();
|
||||
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();
|
||||
// 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();
|
||||
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", "--system-prompt", options.systemPrompt, "--safe-mode",
|
||||
];
|
||||
if (options.sessionId) {
|
||||
args.push("--session-id", options.sessionId);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
/**
|
||||
* 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 }));
|
||||
|
||||
+9
-1
@@ -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",
|
||||
@@ -70,8 +70,16 @@ const ALLOWED_TYPES = new Set([
|
||||
// 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",
|
||||
]);
|
||||
|
||||
// Token-Raum: token -> { clients: Set<ws> }
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# ─── ARIA Satellit — Konfiguration ──────────────────────────────────
|
||||
# Kopiere diese Datei nach .env und passe sie an.
|
||||
|
||||
# RVS-Zugang (identisch zum Haupt-Stack — gleicher Raum/Token, damit ARIA
|
||||
# diesen Satelliten erreicht). Werte aus der Haupt-.env / vom generate-token.sh.
|
||||
RVS_HOST=rvs.example.de
|
||||
RVS_PORT=443
|
||||
RVS_TLS=true
|
||||
RVS_TOKEN=
|
||||
|
||||
# ─── Identitaet / Adresse dieses Satelliten ────────────────────────
|
||||
# SATELLITE_ID = technisch eindeutig (a-z0-9-_), Default = Hostname-Slug.
|
||||
# SATELLITE_LOCATION = menschlicher Name, so spricht ARIA das Netz an ("Buero").
|
||||
# Mehrere Satelliten koennen im selben RVS-Raum haengen — die Location
|
||||
# unterscheidet sie ("Buero", "Zuhause", "Werkstatt").
|
||||
SATELLITE_ID=buero
|
||||
SATELLITE_LOCATION=Büro
|
||||
|
||||
# ─── Steuerung (Sicherheit!) ───────────────────────────────────────
|
||||
# CONTROL_ENABLED=false → reiner Info-/Beobachtungs-Satellit (entdeckt & meldet
|
||||
# nur, steuert nichts). Sicherste Basis.
|
||||
# CONTROL_ENABLED=true → darf Geraete steuern (nur Aktionen aus der Allowlist).
|
||||
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.post generischer HTTP-POST
|
||||
CONTROL_ALLOWLIST=dial.launch,wol,http.get
|
||||
|
||||
# ─── Discovery-Tuning (optional) ───────────────────────────────────
|
||||
SCAN_INTERVAL_SEC=300 # Hintergrund-Rescan-Intervall
|
||||
DISCOVER_TIMEOUT_SEC=6 # Dauer eines Sweeps (mDNS + SSDP)
|
||||
DEVICE_CACHE_TTL_SEC=120 # wie lange ein Inventar als "frisch" gilt
|
||||
@@ -0,0 +1,13 @@
|
||||
# ARIA Satellit — schlanker Aussenposten-Container.
|
||||
# Laeuft mit network_mode: host (siehe docker-compose.yml), damit mDNS/SSDP-
|
||||
# Broadcasts + die Geraete-IPs im lokalen Netz erreichbar sind.
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY satellite.py .
|
||||
|
||||
CMD ["python", "-u", "satellite.py"]
|
||||
@@ -0,0 +1,102 @@
|
||||
# ARIA Satellit 🛰️
|
||||
|
||||
Ein eigenständiger **Außenposten-Container** für ein fremdes Netz (Büro, Werkstatt,
|
||||
Ferienwohnung …). Er verbindet sich als RVS-Client in Stefans Raum und gibt ARIA
|
||||
**Augen und Hände in genau diesem Netz** — ohne dass der Haupt-Stack dort stehen muss.
|
||||
|
||||
- **Augen (Info):** entdeckt Geräte via **mDNS/Zeroconf** (Chromecast, AirPlay, Sonos,
|
||||
Drucker, NAS …), **SSDP/UPnP + DIAL** (Smart-TVs, Fire TV) und der **ARP-Tabelle**
|
||||
(rohe Hosts). Meldet ARIA ein Live-Inventar.
|
||||
- **Hände (Steuerung):** **DIAL-App-Launch** (z.B. YouTube-Video auf dem Fire TV),
|
||||
**Wake-on-LAN**, generisches **HTTP**. Nur wenn freigeschaltet (siehe Sicherheit).
|
||||
|
||||
## ⚠️ Wichtig: der Satellit MUSS im echten Ziel-LAN laufen
|
||||
|
||||
Discovery (mDNS/SSDP-Multicast + ARP) funktioniert **nur**, wenn der Prozess
|
||||
tatsächlich im selben LAN wie die Geräte hängt — z.B. `192.168.177.0/24`, wo der
|
||||
Fire TV steht.
|
||||
|
||||
**Docker Desktop (Mac/Windows) geht NICHT.** Dort ist `network_mode: host` das Netz
|
||||
der Docker-Linux-VM (NAT, `192.168.65.x` / `172.x`), **nicht** dein echtes LAN.
|
||||
Der Satellit sieht dann nur Docker-Container statt der echten Geräte. (Der Satellit
|
||||
erkennt das selbst und meldet eine ⚠-Warnung im Diagnostic + Log.)
|
||||
|
||||
Richtig deployen — zwei Wege:
|
||||
|
||||
**A) Linux-Box im Ziel-LAN mit Docker Engine** (empfohlen, z.B. Raspberry Pi / NUC im Büro):
|
||||
```bash
|
||||
cd satellite
|
||||
cp .env.example .env # RVS-Zugang + SATELLITE_LOCATION
|
||||
docker compose up -d --build
|
||||
docker compose logs -f # "Netz: primary_ip=192.168.177.x" + "[scan] N Geraete"
|
||||
```
|
||||
`network_mode: host` (schon gesetzt) gibt hier echtes LAN + Multicast.
|
||||
|
||||
**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):
|
||||
```bash
|
||||
cd satellite
|
||||
cp .env.example .env # RVS-Zugang + SATELLITE_LOCATION eintragen
|
||||
pip install -r requirements.txt
|
||||
python satellite.py # liest .env automatisch
|
||||
```
|
||||
(Echte Umgebungsvariablen haben Vorrang — `export RVS_TOKEN=...` überschreibt die
|
||||
`.env`, falls du das lieber magst.)
|
||||
|
||||
`RVS_HOST/PORT/TLS/TOKEN` **identisch** zum Haupt-Stack (gleicher Raum, damit ARIA
|
||||
den Satelliten erreicht). `SATELLITE_LOCATION` ist der Name, über den ARIA das Netz
|
||||
anspricht („Büro").
|
||||
|
||||
**Kontrolle:** im Log/Diagnostic muss `primary_ip` im Ziel-LAN liegen
|
||||
(`192.168.177.x`) — steht da `192.168.65.x` oder `172.x`, sitzt der Satellit im
|
||||
falschen (Docker-)Netz.
|
||||
|
||||
## Als Dienst installieren (Autostart, ohne Docker)
|
||||
|
||||
Legt automatisch ein `.venv` an, installiert die Requirements und richtet einen
|
||||
Dienst ein, der bei Boot startet und bei Absturz neu hochkommt. `.env` vorher
|
||||
anlegen.
|
||||
|
||||
**Linux (systemd) / macOS (launchd):**
|
||||
```bash
|
||||
cd satellite
|
||||
bash install.sh # installieren + starten
|
||||
bash install.sh uninstall # entfernen
|
||||
```
|
||||
- Linux-Logs: `journalctl -u aria-satellite -f`
|
||||
- macOS-Logs: `tail -f satellite.log` (evtl. „Lokales Netzwerk"-Zugriff erlauben)
|
||||
|
||||
**Windows (Scheduled Task) — PowerShell als Administrator:**
|
||||
```powershell
|
||||
cd satellite
|
||||
powershell -ExecutionPolicy Bypass -File install.ps1 # installieren + starten
|
||||
powershell -ExecutionPolicy Bypass -File install.ps1 -Uninstall # entfernen
|
||||
```
|
||||
- Log: `Get-Content -Wait satellite.log`
|
||||
|
||||
## So nutzt ARIA es
|
||||
|
||||
ARIA hat drei Brain-Tools:
|
||||
- `satellite_list` — welche Netze/Satelliten sind online + was können sie.
|
||||
- `satellite_devices(satellite)` — Inventar eines Netzes.
|
||||
- `satellite_command(satellite, device, action, params)` — Aktion ausführen.
|
||||
|
||||
Beispiel „YouTube-Video auf dem Büro-Stick":
|
||||
```
|
||||
satellite_command(satellite="Büro", device="Fire TV",
|
||||
action="dial.launch", params={"app":"YouTube","v":"<videoId>"})
|
||||
```
|
||||
|
||||
## Sicherheit
|
||||
|
||||
Der Satellit scannt ein Netz **und** ist über einen Cloud-Relay erreichbar — deshalb:
|
||||
|
||||
- Reagiert **nur** auf den eigenen RVS-Raum (Token).
|
||||
- **`CONTROL_ENABLED=false`** = reiner Info-Satellit (steuert nichts). Standard-sicher.
|
||||
- Bei `true`: nur Aktionen aus **`CONTROL_ALLOWLIST`**, alles andere wird abgelehnt.
|
||||
- Jede ausgeführte Aktion wird **geloggt**.
|
||||
- Keine offenen Ports — reiner Client.
|
||||
|
||||
Empfehlung: in vertrauenswürdigen Netzen `CONTROL_ENABLED=true` mit enger Allowlist;
|
||||
sonst `false` und nur beobachten.
|
||||
@@ -0,0 +1,23 @@
|
||||
# ARIA Satellit — eigenstaendiger Stack fuer ein fremdes Netz (Buero, Werkstatt …).
|
||||
#
|
||||
# Deploy:
|
||||
# cd satellite
|
||||
# cp .env.example .env # RVS-Zugang + SATELLITE_LOCATION eintragen
|
||||
# docker compose up -d --build
|
||||
#
|
||||
# WICHTIG: network_mode: host — der Satellit MUSS im Host-Netz laufen, sonst
|
||||
# sieht er die mDNS/SSDP-Broadcasts + Geraete-IPs des LAN nicht (Docker-Bridge
|
||||
# wuerde das isolieren). Damit ist er zugleich als RVS-Client raus ins Internet
|
||||
# verbunden. Keine Ports zu veroeffentlichen — er ist reiner Client.
|
||||
#
|
||||
# ⚠ NUR auf LINUX Docker Engine, und der Host muss physisch im Ziel-LAN haengen!
|
||||
# Docker Desktop (Mac/Windows) gibt hier NUR das Docker-VM-Netz (192.168.65.x /
|
||||
# 172.x), NICHT dein echtes LAN → Discovery findet dann nur Docker-Container.
|
||||
# Fuer Mac/Windows: satellite.py nativ starten (siehe README, Weg B).
|
||||
services:
|
||||
satellite:
|
||||
build: .
|
||||
container_name: aria-satellite
|
||||
network_mode: host
|
||||
env_file: .env
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,87 @@
|
||||
<#
|
||||
ARIA Satellit — Dienst-Installer fuer Windows (Scheduled Task, Autostart + Restart).
|
||||
|
||||
powershell -ExecutionPolicy Bypass -File install.ps1 installiert + startet
|
||||
powershell -ExecutionPolicy Bypass -File install.ps1 -Uninstall entfernt den Task
|
||||
|
||||
Legt ein venv (.venv) an, installiert requirements.txt und richtet einen
|
||||
geplanten Task ein, der satellite.py aus DIESEM Ordner startet (findet die .env)
|
||||
und bei Absturz/Neustart wieder hochkommt. Laeuft als aktueller Nutzer (S4U),
|
||||
auch ohne Anmeldung. Als Administrator ausfuehren.
|
||||
|
||||
WICHTIG: Der Satellit muss im ECHTEN Ziel-LAN laufen (siehe README) — auf einer
|
||||
Maschine, die physisch im Netz der Geraete haengt. Docker Desktop taugt nicht.
|
||||
#>
|
||||
param([switch]$Uninstall)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Dir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$Task = "ARIA-Satellite"
|
||||
|
||||
# ─── Uninstall ──────────────────────────────────────────────────────
|
||||
if ($Uninstall) {
|
||||
if (Get-ScheduledTask -TaskName $Task -ErrorAction SilentlyContinue) {
|
||||
Unregister-ScheduledTask -TaskName $Task -Confirm:$false
|
||||
Write-Host "Task '$Task' entfernt."
|
||||
} else {
|
||||
Write-Host "Kein Task '$Task' gefunden."
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ─── Admin-Check ────────────────────────────────────────────────────
|
||||
$isAdmin = ([Security.Principal.WindowsPrincipal] `
|
||||
[Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
if (-not $isAdmin) {
|
||||
Write-Warning "Bitte als Administrator ausfuehren (Rechtsklick PowerShell -> Als Administrator)."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ─── Python finden ──────────────────────────────────────────────────
|
||||
$py = (Get-Command python -ErrorAction SilentlyContinue).Source
|
||||
if (-not $py) { $py = (Get-Command python3 -ErrorAction SilentlyContinue).Source }
|
||||
if (-not $py) { Write-Error "Python 3 nicht gefunden. Bitte von python.org installieren (mit 'Add to PATH')."; exit 1 }
|
||||
|
||||
# ─── venv + Abhaengigkeiten ─────────────────────────────────────────
|
||||
if (-not (Test-Path "$Dir\.venv")) {
|
||||
Write-Host "[install] Lege venv an ..."
|
||||
& $py -m venv "$Dir\.venv"
|
||||
}
|
||||
$venvPy = "$Dir\.venv\Scripts\python.exe"
|
||||
Write-Host "[install] Installiere Abhaengigkeiten ..."
|
||||
& $venvPy -m pip install --upgrade pip | Out-Null
|
||||
& $venvPy -m pip install -r "$Dir\requirements.txt"
|
||||
|
||||
if (-not (Test-Path "$Dir\.env")) {
|
||||
Write-Warning "Keine .env gefunden - bitte anlegen: copy .env.example .env (RVS-Zugang + SATELLITE_LOCATION)"
|
||||
}
|
||||
|
||||
# ─── Scheduled Task ─────────────────────────────────────────────────
|
||||
Write-Host "[install] Richte geplanten Task '$Task' ein ..."
|
||||
# Ueber cmd starten, damit stdout/stderr in satellite.log landen (der Task laeuft
|
||||
# in Session 0 / S4U = kein sichtbares Fenster, python.exe ist hier ok).
|
||||
$logfile = Join-Path $Dir "satellite.log"
|
||||
$cmdArg = '/c ""{0}" satellite.py >> "{1}" 2>&1"' -f $venvPy, $logfile
|
||||
$action = New-ScheduledTaskAction -Execute "cmd.exe" -Argument $cmdArg -WorkingDirectory $Dir
|
||||
$trigger = @(
|
||||
New-ScheduledTaskTrigger -AtStartup
|
||||
New-ScheduledTaskTrigger -AtLogOn
|
||||
)
|
||||
# Laeuft als aktueller Nutzer, auch ohne Anmeldung (S4U), mit hoechsten Rechten.
|
||||
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" `
|
||||
-LogonType S4U -RunLevel Highest
|
||||
# Bei Absturz neu starten, unbegrenzte Laufzeit, startet nach falls Zeitpunkt verpasst, versteckt.
|
||||
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -Hidden `
|
||||
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
|
||||
-ExecutionTimeLimit (New-TimeSpan -Seconds 0) `
|
||||
-MultipleInstances IgnoreNew
|
||||
|
||||
Register-ScheduledTask -TaskName $Task -Action $action -Trigger $trigger `
|
||||
-Principal $principal -Settings $settings -Force | Out-Null
|
||||
|
||||
Start-ScheduledTask -TaskName $Task
|
||||
Write-Host "OK Dienst laeuft."
|
||||
Write-Host " Status: Get-ScheduledTask -TaskName $Task"
|
||||
Write-Host " Stoppen: Stop-ScheduledTask -TaskName $Task"
|
||||
Write-Host " Log: Get-Content -Wait '$logfile' (pruefen: primary_ip + '[scan] N Geraete')"
|
||||
Executable
+119
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# ARIA Satellit — Dienst-Installer fuer Linux (systemd) und macOS (launchd).
|
||||
#
|
||||
# bash install.sh installiert + startet den Dienst
|
||||
# bash install.sh uninstall entfernt den Dienst
|
||||
#
|
||||
# Legt ein venv an (.venv), installiert requirements.txt und richtet einen
|
||||
# Autostart-Dienst ein, der satellite.py aus DIESEM Ordner startet (damit die
|
||||
# .env gefunden wird) und bei Absturz neu startet.
|
||||
#
|
||||
# WICHTIG: Der Satellit muss im ECHTEN Ziel-LAN laufen (siehe README) — auf einer
|
||||
# Maschine, die physisch im Netz der Geraete haengt. Docker Desktop taugt nicht.
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SVC="aria-satellite"
|
||||
PLIST_LABEL="de.aria.satellite"
|
||||
OS="$(uname -s)"
|
||||
ACTION="${1:-install}"
|
||||
|
||||
# ─── Uninstall ──────────────────────────────────────────────────────
|
||||
if [ "$ACTION" = "uninstall" ]; then
|
||||
if [ "$OS" = "Linux" ]; then
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
||||
$SUDO systemctl disable --now "$SVC" 2>/dev/null || true
|
||||
$SUDO rm -f "/etc/systemd/system/$SVC.service"
|
||||
$SUDO systemctl daemon-reload
|
||||
echo "systemd-Dienst '$SVC' entfernt."
|
||||
elif [ "$OS" = "Darwin" ]; then
|
||||
PLIST="$HOME/Library/LaunchAgents/$PLIST_LABEL.plist"
|
||||
launchctl unload "$PLIST" 2>/dev/null || true
|
||||
rm -f "$PLIST"
|
||||
echo "launchd-Agent '$PLIST_LABEL' entfernt."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ─── venv + Abhaengigkeiten ─────────────────────────────────────────
|
||||
PY="$(command -v python3 || command -v python || true)"
|
||||
[ -n "$PY" ] || { echo "Python 3 nicht gefunden. Bitte installieren."; exit 1; }
|
||||
|
||||
if [ ! -d "$DIR/.venv" ]; then
|
||||
echo "[install] Lege venv an …"
|
||||
if ! "$PY" -m venv "$DIR/.venv" 2>/dev/null; then
|
||||
echo "venv-Erstellung fehlgeschlagen. Auf Debian/Ubuntu: sudo apt install python3-venv"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
VENV_PY="$DIR/.venv/bin/python"
|
||||
echo "[install] Installiere Abhaengigkeiten …"
|
||||
"$VENV_PY" -m pip install --upgrade pip >/dev/null
|
||||
"$VENV_PY" -m pip install -r "$DIR/requirements.txt"
|
||||
|
||||
if [ ! -f "$DIR/.env" ]; then
|
||||
echo "⚠ Keine .env gefunden — bitte anlegen: cp .env.example .env (RVS-Zugang + SATELLITE_LOCATION)"
|
||||
fi
|
||||
|
||||
# ─── Dienst einrichten ──────────────────────────────────────────────
|
||||
if [ "$OS" = "Linux" ]; then
|
||||
RUN_USER="${SUDO_USER:-$(id -un)}"
|
||||
UNIT="/etc/systemd/system/$SVC.service"
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
||||
echo "[install] Schreibe systemd-Unit $UNIT (User=$RUN_USER) …"
|
||||
$SUDO tee "$UNIT" >/dev/null <<EOF
|
||||
[Unit]
|
||||
Description=ARIA Satellit (Netz-Aussenposten)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$RUN_USER
|
||||
WorkingDirectory=$DIR
|
||||
ExecStart=$VENV_PY $DIR/satellite.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
$SUDO systemctl daemon-reload
|
||||
$SUDO systemctl enable --now "$SVC"
|
||||
echo "✅ Dienst laeuft."
|
||||
echo " Status: $SUDO systemctl status $SVC"
|
||||
echo " Logs: $SUDO journalctl -u $SVC -f"
|
||||
|
||||
elif [ "$OS" = "Darwin" ]; then
|
||||
PLIST="$HOME/Library/LaunchAgents/$PLIST_LABEL.plist"
|
||||
mkdir -p "$HOME/Library/LaunchAgents"
|
||||
echo "[install] Schreibe launchd-Agent $PLIST …"
|
||||
cat > "$PLIST" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key><string>$PLIST_LABEL</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$VENV_PY</string>
|
||||
<string>$DIR/satellite.py</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key><string>$DIR</string>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><true/>
|
||||
<key>StandardOutPath</key><string>$DIR/satellite.log</string>
|
||||
<key>StandardErrorPath</key><string>$DIR/satellite.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
launchctl unload "$PLIST" 2>/dev/null || true
|
||||
launchctl load "$PLIST"
|
||||
echo "✅ Agent geladen."
|
||||
echo " Logs: tail -f $DIR/satellite.log"
|
||||
echo " Hinweis: macOS fragt evtl. nach 'Lokales Netzwerk'-Zugriff — erlauben, sonst findet der Satellit keine Geraete."
|
||||
else
|
||||
echo "Unbekanntes OS: $OS (dieses Skript kann Linux/macOS; fuer Windows: install.ps1)"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,3 @@
|
||||
websockets>=12.0
|
||||
zeroconf>=0.131.0
|
||||
requests>=2.31.0
|
||||
@@ -0,0 +1,668 @@
|
||||
"""
|
||||
ARIA Satellit — Info-/Gateway-Aussenposten in einem fremden Netz.
|
||||
|
||||
Laeuft eigenstaendig (z.B. im Buero) und verbindet sich als RVS-Client in
|
||||
Stefans Raum (gleicher Token). Gibt ARIA damit Augen + Haende in DIESEM Netz:
|
||||
|
||||
Augen: entdeckt Geraete (mDNS/Zeroconf, SSDP/UPnP + DIAL, ARP-Tabelle) und
|
||||
meldet ein Inventar → sat_devices.
|
||||
Haende: steuert Geraete (DIAL-App-Launch z.B. YouTube auf Fire TV, Wake-on-
|
||||
LAN, generisches HTTP) → sat_command / sat_result. Nur wenn
|
||||
CONTROL_ENABLED=true, Aktion in der Allowlist, alles geloggt.
|
||||
|
||||
Adressierung: mehrere Satelliten haengen im selben RVS-Raum. Jeder hat eine
|
||||
SATELLITE_ID (technisch, eindeutig) + SATELLITE_LOCATION (menschlich, "Buero").
|
||||
ARIA spricht einen Satelliten ueber seine ID/Location an.
|
||||
|
||||
Message-Typen (RVS, Base64/JSON-Relay wie der Rest):
|
||||
raus: sat_hello {id, location, caps, ts}
|
||||
sat_devices {requestId, satellite, devices:[...]}
|
||||
sat_result {requestId, satellite, ok, result|error}
|
||||
rein: sat_discover {satellite?, requestId}
|
||||
sat_command {satellite?, requestId, device, action, params}
|
||||
|
||||
Sicherheit: reagiert nur auf den eigenen RVS-Raum (Token). Commands brauchen
|
||||
CONTROL_ENABLED + Allowlist. Discovery ist read-only. Keine offenen Ports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import websockets
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [satellite] %(levelname)s %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("satellite")
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
"""Laedt eine .env neben dem Script (oder im CWD) in os.environ — fuer den
|
||||
NATIVEN Start (`python satellite.py`). In Docker sind die Variablen via
|
||||
env_file schon gesetzt; bereits gesetzte Werte gewinnen (werden NICHT
|
||||
ueberschrieben). Kein python-dotenv noetig."""
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
for path in (os.path.join(here, ".env"), os.path.join(os.getcwd(), ".env")):
|
||||
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 ("'", '"'):
|
||||
# Gequotet: Inhalt bis zum schliessenden Quote, Rest (Kommentar) egal.
|
||||
q = val[0]
|
||||
end = val.find(q, 1)
|
||||
val = val[1:end] if end != -1 else val[1:]
|
||||
else:
|
||||
# Ungequotet: Inline-Kommentar (Whitespace + #) abschneiden.
|
||||
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:
|
||||
logging.getLogger("satellite").warning(".env laden fehlgeschlagen (%s): %s", path, exc)
|
||||
break # erste gefundene .env gewinnt
|
||||
|
||||
|
||||
_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 "satellite"
|
||||
slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", host).strip("-").lower()
|
||||
return slug or "satellite"
|
||||
|
||||
|
||||
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)
|
||||
RVS_TOKEN = os.environ.get("RVS_TOKEN", "")
|
||||
|
||||
SATELLITE_ID = (os.environ.get("SATELLITE_ID") or _default_id()).strip()
|
||||
SATELLITE_LOCATION = (os.environ.get("SATELLITE_LOCATION") or SATELLITE_ID).strip()
|
||||
|
||||
CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False)
|
||||
CONTROL_ALLOWLIST = [
|
||||
a.strip() for a in
|
||||
os.environ.get("CONTROL_ALLOWLIST", "dial.launch,wol,http.get").split(",")
|
||||
if a.strip()
|
||||
]
|
||||
|
||||
SCAN_INTERVAL_SEC = int(os.environ.get("SCAN_INTERVAL_SEC", "300") or "300")
|
||||
DISCOVER_TIMEOUT_SEC = float(os.environ.get("DISCOVER_TIMEOUT_SEC", "6") or "6")
|
||||
DEVICE_CACHE_TTL_SEC = int(os.environ.get("DEVICE_CACHE_TTL_SEC", "120") or "120")
|
||||
|
||||
HEARTBEAT_SEC = 25
|
||||
|
||||
# mDNS-Servicetypen, die fuer ARIA interessant sind.
|
||||
MDNS_TYPES = [
|
||||
"_googlecast._tcp.local.", # Chromecast / Google TV / Nest
|
||||
"_airplay._tcp.local.", # Apple TV / AirPlay
|
||||
"_raop._tcp.local.", # AirPlay-Audio
|
||||
"_spotify-connect._tcp.local.", # Spotify-Geraete
|
||||
"_sonos._tcp.local.", # Sonos
|
||||
"_hap._tcp.local.", # HomeKit
|
||||
"_printer._tcp.local.", # Drucker
|
||||
"_ipp._tcp.local.", # Drucker (IPP)
|
||||
"_smb._tcp.local.", # NAS / Fileshares
|
||||
"_workstation._tcp.local.", # generische Hosts
|
||||
"_http._tcp.local.", # Web-UIs (Router, NAS, IoT)
|
||||
]
|
||||
|
||||
CAPABILITIES = ["discover"]
|
||||
if CONTROL_ENABLED:
|
||||
CAPABILITIES += CONTROL_ALLOWLIST
|
||||
|
||||
|
||||
# ─── Netz-Kontext / Selbstdiagnose ──────────────────────────────────
|
||||
|
||||
def _in_docker_bridge(ip: str) -> bool:
|
||||
# Docker-Default-Bridge-Range 172.16.0.0/12
|
||||
try:
|
||||
a, b = ip.split(".")[:2]
|
||||
return a == "172" and 16 <= int(b) <= 31
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _net_context() -> dict:
|
||||
"""Ermittelt in welchem Netz der Satellit LAeUFT — und warnt, wenn das ein
|
||||
Docker-/NAT-Netz ist (dann erreicht Discovery das echte LAN nicht)."""
|
||||
ips: list[str] = []
|
||||
primary = ""
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.settimeout(1)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
primary = s.getsockname()[0]
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
||||
ip = info[4][0]
|
||||
if ip and not ip.startswith("127.") and ip not in ips:
|
||||
ips.append(ip)
|
||||
except Exception:
|
||||
pass
|
||||
if primary and primary not in ips:
|
||||
ips.insert(0, primary)
|
||||
p = primary or (ips[0] if ips else "")
|
||||
warning = ""
|
||||
if p.startswith("192.168.65.") or _in_docker_bridge(p):
|
||||
warning = (f"Satellit laeuft in einem Docker-/NAT-Netz ({p}), NICHT im echten LAN. "
|
||||
"mDNS/SSDP erreichen die realen Geraete so nicht. Auf Docker Desktop "
|
||||
"(Mac/Windows) geht LAN-Discovery nicht — den Satelliten NATIV (python "
|
||||
"satellite.py) oder auf einem Linux-Host im Ziel-LAN betreiben.")
|
||||
return {"primary_ip": p, "ips": ips, "warning": warning}
|
||||
|
||||
|
||||
NET = _net_context()
|
||||
|
||||
|
||||
# ─── Discovery ──────────────────────────────────────────────────────
|
||||
|
||||
def _discover_mdns(timeout: float) -> list[dict]:
|
||||
"""Blockierend (im Executor): mDNS/Zeroconf-Sweep ueber MDNS_TYPES."""
|
||||
out: dict[str, dict] = {}
|
||||
try:
|
||||
from zeroconf import Zeroconf, ServiceBrowser
|
||||
except Exception as exc:
|
||||
logger.warning("zeroconf nicht verfuegbar: %s", exc)
|
||||
return []
|
||||
|
||||
class _Listener:
|
||||
def add_service(self, zc, type_, name):
|
||||
try:
|
||||
info = zc.get_service_info(type_, name, timeout=2000)
|
||||
except Exception:
|
||||
info = None
|
||||
if not info:
|
||||
return
|
||||
ips = []
|
||||
try:
|
||||
for addr in info.parsed_addresses():
|
||||
ips.append(addr)
|
||||
except Exception:
|
||||
pass
|
||||
props = {}
|
||||
try:
|
||||
for k, v in (info.properties or {}).items():
|
||||
try:
|
||||
props[k.decode("utf-8", "ignore")] = (
|
||||
v.decode("utf-8", "ignore") if isinstance(v, (bytes, bytearray)) else v)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
friendly = name.split("." + type_.split(".", 1)[0])[0].strip(".")
|
||||
fn = props.get("fn") or props.get("friendlyName") or friendly
|
||||
dev_id = _slug(f"{fn}-{ips[0] if ips else name}")
|
||||
out[dev_id] = {
|
||||
"id": dev_id,
|
||||
"name": fn,
|
||||
"type": _mdns_kind(type_),
|
||||
"ip": ips[0] if ips else "",
|
||||
"port": info.port,
|
||||
"via": "mdns",
|
||||
"service": type_,
|
||||
"model": props.get("md") or props.get("model") or "",
|
||||
}
|
||||
|
||||
def update_service(self, *a):
|
||||
pass
|
||||
|
||||
def remove_service(self, *a):
|
||||
pass
|
||||
|
||||
zc = None
|
||||
try:
|
||||
zc = Zeroconf()
|
||||
listener = _Listener()
|
||||
for t in MDNS_TYPES:
|
||||
try:
|
||||
ServiceBrowser(zc, t, listener)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(timeout)
|
||||
except Exception as exc:
|
||||
logger.warning("mDNS-Sweep-Fehler: %s", exc)
|
||||
finally:
|
||||
try:
|
||||
if zc:
|
||||
zc.close()
|
||||
except Exception:
|
||||
pass
|
||||
return list(out.values())
|
||||
|
||||
|
||||
def _mdns_kind(service_type: str) -> str:
|
||||
m = {
|
||||
"_googlecast": "cast", "_airplay": "airplay", "_raop": "airplay-audio",
|
||||
"_spotify-connect": "spotify", "_sonos": "sonos", "_hap": "homekit",
|
||||
"_printer": "printer", "_ipp": "printer", "_smb": "fileshare",
|
||||
"_workstation": "host", "_http": "web",
|
||||
}
|
||||
for k, v in m.items():
|
||||
if service_type.startswith(k):
|
||||
return v
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _discover_ssdp(timeout: float) -> list[dict]:
|
||||
"""Blockierend: SSDP M-SEARCH (UPnP + DIAL). Liefert v.a. Smart-TVs / Fire
|
||||
TV mit ihrer DIAL Application-URL (fuer App-Launch wie YouTube)."""
|
||||
out: dict[str, dict] = {}
|
||||
targets = [
|
||||
"urn:dial-multiscreen-org:service:dial:1",
|
||||
"ssdp:all",
|
||||
]
|
||||
for st in targets:
|
||||
msg = (
|
||||
"M-SEARCH * HTTP/1.1\r\n"
|
||||
"HOST: 239.255.255.250:1900\r\n"
|
||||
'MAN: "ssdp:discover"\r\n'
|
||||
"MX: 2\r\n"
|
||||
f"ST: {st}\r\n\r\n"
|
||||
).encode("utf-8")
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
|
||||
sock.settimeout(timeout)
|
||||
sock.sendto(msg, ("239.255.255.250", 1900))
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
data, addr = sock.recvfrom(65507)
|
||||
except socket.timeout:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
headers = _parse_http_headers(data.decode("utf-8", "ignore"))
|
||||
location = headers.get("location", "")
|
||||
dial_app = headers.get("application-url", "")
|
||||
ip = addr[0]
|
||||
dev = _fetch_upnp_description(location) if location else {}
|
||||
name = dev.get("name") or headers.get("server", "") or ip
|
||||
dev_id = _slug(f"{name}-{ip}")
|
||||
entry = out.get(dev_id, {
|
||||
"id": dev_id, "name": name, "type": "media-renderer",
|
||||
"ip": ip, "via": "ssdp",
|
||||
})
|
||||
if dev.get("name"):
|
||||
entry["name"] = dev["name"]
|
||||
if dev.get("model"):
|
||||
entry["model"] = dev["model"]
|
||||
if dev.get("manufacturer"):
|
||||
entry["manufacturer"] = dev["manufacturer"]
|
||||
if dial_app or dev.get("dialAppUrl"):
|
||||
entry["dialAppUrl"] = dial_app or dev.get("dialAppUrl")
|
||||
entry["type"] = "dial"
|
||||
out[dev_id] = entry
|
||||
except Exception as exc:
|
||||
logger.debug("SSDP (%s) Fehler: %s", st, exc)
|
||||
finally:
|
||||
try:
|
||||
if sock:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
return list(out.values())
|
||||
|
||||
|
||||
def _fetch_upnp_description(location: str) -> dict:
|
||||
try:
|
||||
import requests
|
||||
r = requests.get(location, timeout=3)
|
||||
dial_app = r.headers.get("Application-URL", "")
|
||||
xml = r.text
|
||||
name = _xml_tag(xml, "friendlyName")
|
||||
model = _xml_tag(xml, "modelName")
|
||||
manuf = _xml_tag(xml, "manufacturer")
|
||||
return {"name": name, "model": model, "manufacturer": manuf, "dialAppUrl": dial_app}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _discover_arp() -> list[dict]:
|
||||
"""Rohe Host-Liste aus der ARP-Tabelle (kein aktiver Scan)."""
|
||||
out = []
|
||||
try:
|
||||
with open("/proc/net/arp", "r", encoding="utf-8") as f:
|
||||
lines = f.read().splitlines()[1:]
|
||||
for ln in lines:
|
||||
parts = ln.split()
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
ip, _hw, _flags, mac = parts[0], parts[1], parts[2], parts[3]
|
||||
if mac == "00:00:00:00:00:00":
|
||||
continue
|
||||
out.append({
|
||||
"id": _slug(f"host-{ip}"), "name": ip, "type": "host",
|
||||
"ip": ip, "mac": mac, "via": "arp",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _merge_devices(*lists) -> list[dict]:
|
||||
"""Fuehrt Geraetelisten zusammen, dedupt per IP (reichere Quelle gewinnt)."""
|
||||
by_ip: dict[str, dict] = {}
|
||||
loose: list[dict] = []
|
||||
order = {"mdns": 3, "ssdp": 2, "arp": 1}
|
||||
for lst in lists:
|
||||
for d in lst:
|
||||
ip = d.get("ip") or ""
|
||||
if not ip:
|
||||
loose.append(d)
|
||||
continue
|
||||
cur = by_ip.get(ip)
|
||||
if not cur:
|
||||
by_ip[ip] = d
|
||||
else:
|
||||
# bessere Quelle / mehr Felder → mergen
|
||||
merged = {**d, **{k: v for k, v in cur.items() if v}}
|
||||
if order.get(d.get("via"), 0) >= order.get(cur.get("via"), 0):
|
||||
merged.update({k: v for k, v in d.items() if v})
|
||||
# DIAL-URL / mac aus beiden behalten
|
||||
for key in ("dialAppUrl", "mac", "model", "manufacturer"):
|
||||
merged[key] = d.get(key) or cur.get(key) or merged.get(key)
|
||||
by_ip[ip] = {k: v for k, v in merged.items() if v not in (None, "")}
|
||||
return list(by_ip.values()) + loose
|
||||
|
||||
|
||||
# ─── Control ────────────────────────────────────────────────────────
|
||||
|
||||
async def _control(action: str, params: dict, devices: list[dict]) -> dict:
|
||||
"""Fuehrt eine Steuer-Aktion aus. Guards: CONTROL_ENABLED + Allowlist."""
|
||||
if not CONTROL_ENABLED:
|
||||
return {"ok": False, "error": "Steuerung ist an diesem Satelliten deaktiviert (CONTROL_ENABLED=false)."}
|
||||
if action not in CONTROL_ALLOWLIST:
|
||||
return {"ok": False, "error": f"Aktion '{action}' nicht erlaubt (Allowlist: {', '.join(CONTROL_ALLOWLIST)})."}
|
||||
logger.info("[control] %s params=%s", action, {k: str(v)[:60] for k, v in (params or {}).items()})
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
if action == "dial.launch":
|
||||
return await loop.run_in_executor(None, _do_dial_launch, params, devices)
|
||||
if action == "wol":
|
||||
return await loop.run_in_executor(None, _do_wol, params)
|
||||
if action in ("http.get", "http.post"):
|
||||
return await loop.run_in_executor(None, _do_http, action, params)
|
||||
return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
|
||||
|
||||
|
||||
def _find_device(devices: list[dict], ref: str) -> Optional[dict]:
|
||||
ref = (ref or "").strip().lower()
|
||||
if not ref:
|
||||
return None
|
||||
for d in devices:
|
||||
if d.get("id", "").lower() == ref or d.get("ip", "") == ref:
|
||||
return d
|
||||
for d in devices:
|
||||
if ref in (d.get("name", "").lower()):
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def _do_dial_launch(params: dict, devices: list[dict]) -> dict:
|
||||
"""DIAL-App-Launch, z.B. YouTube-Video auf Fire TV / Smart-TV.
|
||||
params: {device, app='YouTube', v=<videoId> (oder beliebige app-params)}"""
|
||||
import requests
|
||||
ref = params.get("device") or ""
|
||||
dev = _find_device(devices, ref)
|
||||
app_url = (dev or {}).get("dialAppUrl") if dev else params.get("dialAppUrl")
|
||||
if not app_url:
|
||||
return {"ok": False, "error": f"Kein DIAL-Geraet fuer '{ref}' gefunden (oder keine Application-URL)."}
|
||||
app = params.get("app") or "YouTube"
|
||||
# app-Parameter (alles ausser device/app) als form-urlencoded Body.
|
||||
body = {k: v for k, v in (params or {}).items() if k not in ("device", "app", "dialAppUrl")}
|
||||
url = app_url.rstrip("/") + "/" + app
|
||||
r = requests.post(url, data=body, timeout=5)
|
||||
ok = r.status_code in (200, 201)
|
||||
return {"ok": ok, "result": f"DIAL {app} → {(dev or {}).get('name', ref)} (HTTP {r.status_code})"
|
||||
if ok else None,
|
||||
"error": None if ok else f"DIAL-Launch HTTP {r.status_code}: {r.text[:120]}"}
|
||||
|
||||
|
||||
def _do_wol(params: dict) -> dict:
|
||||
mac = (params.get("mac") or "").strip()
|
||||
if not re.match(r"^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$", mac):
|
||||
return {"ok": False, "error": f"Ungueltige MAC: {mac!r}"}
|
||||
clean = re.sub(r"[:-]", "", mac)
|
||||
packet = b"\xff" * 6 + bytes.fromhex(clean) * 16
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
s.sendto(packet, ("255.255.255.255", 9))
|
||||
s.close()
|
||||
return {"ok": True, "result": f"Wake-on-LAN an {mac} gesendet."}
|
||||
|
||||
|
||||
def _do_http(action: str, params: dict) -> dict:
|
||||
import requests
|
||||
url = params.get("url") or ""
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return {"ok": False, "error": "url (http/https) erforderlich."}
|
||||
method = "GET" if action == "http.get" else "POST"
|
||||
r = requests.request(method, url, data=params.get("body"),
|
||||
headers=params.get("headers"), timeout=6)
|
||||
return {"ok": True, "result": {"status": r.status_code, "body": r.text[:2000]}}
|
||||
|
||||
|
||||
# ─── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
def _slug(s: str) -> str:
|
||||
s = (s or "").strip().lower()
|
||||
s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
|
||||
return s or "dev"
|
||||
|
||||
|
||||
def _parse_http_headers(text: str) -> dict:
|
||||
headers = {}
|
||||
for line in text.split("\r\n")[1:]:
|
||||
if ":" in line:
|
||||
k, _, v = line.partition(":")
|
||||
headers[k.strip().lower()] = v.strip()
|
||||
return headers
|
||||
|
||||
|
||||
def _xml_tag(xml: str, tag: str) -> str:
|
||||
m = re.search(rf"<{tag}>(.*?)</{tag}>", xml, re.IGNORECASE | re.DOTALL)
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
|
||||
# ─── Satellit (RVS-Client) ──────────────────────────────────────────
|
||||
|
||||
class Satellite:
|
||||
def __init__(self) -> None:
|
||||
self.ws: Optional[websockets.WebSocketClientProtocol] = None
|
||||
self._devices: list[dict] = []
|
||||
self._devices_ts: float = 0.0
|
||||
self._scanning = False
|
||||
|
||||
async def _scan(self, force: bool = False) -> list[dict]:
|
||||
fresh = (time.time() - self._devices_ts) < DEVICE_CACHE_TTL_SEC
|
||||
if self._devices and fresh and not force:
|
||||
return self._devices
|
||||
if self._scanning:
|
||||
# Laufenden Scan abwarten (grob)
|
||||
for _ in range(30):
|
||||
await asyncio.sleep(0.2)
|
||||
if not self._scanning:
|
||||
break
|
||||
return self._devices
|
||||
self._scanning = True
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
mdns = await loop.run_in_executor(None, _discover_mdns, DISCOVER_TIMEOUT_SEC)
|
||||
ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
|
||||
arp = await loop.run_in_executor(None, _discover_arp)
|
||||
self._devices = _merge_devices(mdns, ssdp, arp)
|
||||
self._devices_ts = time.time()
|
||||
logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d)",
|
||||
len(self._devices), len(mdns), len(ssdp), len(arp))
|
||||
finally:
|
||||
self._scanning = False
|
||||
return self._devices
|
||||
|
||||
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("send fehlgeschlagen: %s", exc)
|
||||
|
||||
async def _hello(self, log: bool = False) -> None:
|
||||
await self._send({
|
||||
"type": "sat_hello",
|
||||
"payload": {
|
||||
"id": SATELLITE_ID,
|
||||
"location": SATELLITE_LOCATION,
|
||||
"caps": CAPABILITIES,
|
||||
"control": CONTROL_ENABLED,
|
||||
"net": NET,
|
||||
},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
if log:
|
||||
logger.info("sat_hello gesendet: id=%s location=%s caps=%s",
|
||||
SATELLITE_ID, SATELLITE_LOCATION, CAPABILITIES)
|
||||
|
||||
def _for_me(self, payload: dict) -> bool:
|
||||
target = (payload.get("satellite") or "").strip().lower()
|
||||
if not target or target in ("all", "*"):
|
||||
return True
|
||||
return target in (SATELLITE_ID.lower(), SATELLITE_LOCATION.lower())
|
||||
|
||||
async def _handle(self, raw: str) -> None:
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except Exception:
|
||||
return
|
||||
mtype = msg.get("type", "")
|
||||
payload = msg.get("payload", {}) or {}
|
||||
|
||||
if mtype == "sat_discover":
|
||||
if not self._for_me(payload):
|
||||
return
|
||||
req_id = payload.get("requestId", "")
|
||||
devices = await self._scan(force=bool(payload.get("force")))
|
||||
await self._send({
|
||||
"type": "sat_devices",
|
||||
"payload": {"requestId": req_id, "satellite": SATELLITE_ID,
|
||||
"location": SATELLITE_LOCATION, "devices": devices,
|
||||
"net": NET},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
|
||||
elif mtype == "sat_command":
|
||||
if not self._for_me(payload):
|
||||
return
|
||||
req_id = payload.get("requestId", "")
|
||||
action = (payload.get("action") or "").strip()
|
||||
params = payload.get("params") or {}
|
||||
if payload.get("device") and "device" not in params:
|
||||
params["device"] = payload.get("device")
|
||||
devices = await self._scan()
|
||||
result = await _control(action, params, devices)
|
||||
await self._send({
|
||||
"type": "sat_result",
|
||||
"payload": {"requestId": req_id, "satellite": SATELLITE_ID, **result},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
|
||||
async def _periodic_scan(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await self._scan(force=True)
|
||||
except Exception as exc:
|
||||
logger.warning("periodischer Scan-Fehler: %s", exc)
|
||||
await asyncio.sleep(SCAN_INTERVAL_SEC)
|
||||
|
||||
async def _heartbeat(self) -> None:
|
||||
# Re-announce bei jedem Heartbeat: falls die Bridge NACH uns (neu)
|
||||
# verbindet, lernt sie uns so innerhalb von HEARTBEAT_SEC — RVS replayt
|
||||
# nichts. Haelt zugleich last_seen in der Bridge-Registry frisch.
|
||||
while True:
|
||||
await asyncio.sleep(HEARTBEAT_SEC)
|
||||
await self._send({"type": "heartbeat", "timestamp": int(time.time() * 1000)})
|
||||
await self._hello()
|
||||
|
||||
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
|
||||
asyncio.create_task(self._periodic_scan())
|
||||
backoff = 1
|
||||
while True:
|
||||
proto = "wss" if RVS_TLS else "ws"
|
||||
url = f"{proto}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||
try:
|
||||
logger.info("Verbinde mit RVS %s://%s:%s …", proto, RVS_HOST, RVS_PORT)
|
||||
async with websockets.connect(url, max_size=8 * 1024 * 1024,
|
||||
ping_interval=20, ping_timeout=20) as ws:
|
||||
self.ws = ws
|
||||
backoff = 1
|
||||
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)
|
||||
finally:
|
||||
self.ws = None
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 30)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logger.info("ARIA Satellit startet — id=%s location=%s control=%s",
|
||||
SATELLITE_ID, SATELLITE_LOCATION, CONTROL_ENABLED)
|
||||
logger.info("Netz: primary_ip=%s alle=%s", NET.get("primary_ip"), NET.get("ips"))
|
||||
if NET.get("warning"):
|
||||
logger.warning("⚠ %s", NET["warning"])
|
||||
try:
|
||||
asyncio.run(Satellite().run())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+35
-3
@@ -30,7 +30,7 @@ services:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
device_ids: ["0"] # TTS → GPU 0 (8 GB; F5 ist klein)
|
||||
capabilities: [gpu]
|
||||
volumes:
|
||||
- ./voices:/voices # WAV + TXT Referenz
|
||||
@@ -63,12 +63,13 @@ services:
|
||||
whisper-bridge:
|
||||
build: ./whisper
|
||||
container_name: aria-whisper-bridge
|
||||
profiles: ["whisper"] # Fallback-STT — startet nur mit --profile whisper
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
device_ids: ["1"] # STT/groesstes Modell → GPU 1 (12 GB; spaeter Voxtral-STT-3B ~9 GB)
|
||||
capabilities: [gpu]
|
||||
environment:
|
||||
- RVS_HOST=${RVS_HOST}
|
||||
@@ -108,7 +109,7 @@ services:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
device_ids: ["0"] # LLM → GPU 0 (8 GB, teilt sich mit TTS)
|
||||
capabilities: [gpu]
|
||||
volumes:
|
||||
- ./models:/models # HF-Download-Cache (persistent)
|
||||
@@ -137,3 +138,34 @@ services:
|
||||
# Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig.
|
||||
- LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600}
|
||||
restart: unless-stopped
|
||||
|
||||
# ─── Voxtral STT-3B (Transformers, GPU) — DEFAULT-STT ─────────
|
||||
# Ersetzt whisper als STT. Laeuft auf Treiber 550/CUDA 12.4 (torch cu124, KEIN
|
||||
# Treiber-Upgrade noetig). Modell Voxtral-Mini-3B-2507 (~9 GB bf16) → GPU 1.
|
||||
# Startet bei jedem `docker compose up -d`. Whisper ist der opt-in Fallback
|
||||
# (Profil "whisper") — beide zusammen wuerden stt_* doppelt beantworten, also
|
||||
# immer nur EINEN STT laufen lassen.
|
||||
voxtral-bridge:
|
||||
build: ./voxtral
|
||||
container_name: aria-voxtral-bridge
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
device_ids: ["1"] # 12-GB-Karte (STT-3B ~9 GB); GPU 0 (8 GB) bleibt fuer F5/LLM
|
||||
capabilities: [gpu]
|
||||
volumes:
|
||||
- ./hf-cache:/root/.cache/huggingface # gleicher Modell-Cache wie whisper/f5
|
||||
- ./voice-id:/voice-id # Speaker-Fingerprint (wie whisper)
|
||||
environment:
|
||||
- RVS_HOST=${RVS_HOST}
|
||||
- RVS_PORT=${RVS_PORT:-443}
|
||||
- RVS_TLS=${RVS_TLS:-true}
|
||||
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
|
||||
- RVS_TOKEN=${RVS_TOKEN}
|
||||
- VOXTRAL_MODEL=mistralai/Voxtral-Mini-3B-2507
|
||||
- VOXTRAL_LANGUAGE=${WHISPER_LANGUAGE:-de}
|
||||
- HUGGING_FACE_HUB_TOKEN=${HF_TOKEN:-} # falls das Modell HF-gated ist
|
||||
- PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True # weniger VRAM-Fragmentierung
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -9,12 +9,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# PyTorch CUDA-Wheels zuerst (f5-tts zieht sonst CPU-only Torch rein)
|
||||
RUN pip3 install --no-cache-dir torch==2.3.1 torchaudio==2.3.1 \
|
||||
--index-url https://download.pytorch.org/whl/cu121
|
||||
# torch FEST auf cu124 (Treiber 550 = CUDA 12.4). 2.6.0 ist der NEUESTE cu124-
|
||||
# Build — torch 2.7+ gibt es nur noch fuer cu126+, was 550 nicht unterstuetzt
|
||||
# ("NVIDIA driver too old, found 12040"). Der Constraint haelt f5-tts davon ab,
|
||||
# torch beim Dependency-Aufloesen wieder auf eine zu neue CUDA-Version zu ziehen.
|
||||
RUN pip3 install --no-cache-dir torch==2.6.0 torchaudio==2.6.0 \
|
||||
--index-url https://download.pytorch.org/whl/cu124
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip3 install --no-cache-dir -r requirements.txt
|
||||
RUN printf 'torch==2.6.0\ntorchaudio==2.6.0\n' > /tmp/torch-constraint.txt && \
|
||||
pip3 install --no-cache-dir -c /tmp/torch-constraint.txt -r requirements.txt
|
||||
|
||||
COPY bridge.py .
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Voxtral-STT-3B Bridge (Transformers). Laeuft auf Treiber 550/CUDA 12.4 via
|
||||
# torch cu124 — KEIN Treiber-Upgrade noetig (gleicher Trick wie f5tts).
|
||||
# Modell: Voxtral-Mini-3B-2507 (~9 GB in bf16) → passt auf die 12-GB-Karte (GPU 1).
|
||||
FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip ffmpeg git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# torch FEST auf cu124 (Treiber 550 = CUDA 12.4). 2.6.0 ist der neueste cu124-Build;
|
||||
# torch 2.7+ gibt es nur fuer cu126+ und braeuchte einen neueren Treiber.
|
||||
RUN pip3 install --no-cache-dir torch==2.6.0 torchaudio==2.6.0 \
|
||||
--index-url https://download.pytorch.org/whl/cu124
|
||||
|
||||
COPY requirements.txt .
|
||||
# Constraint haelt transformers/mistral-common davon ab, torch wieder hochzuziehen.
|
||||
RUN printf 'torch==2.6.0\ntorchaudio==2.6.0\n' > /tmp/torch-constraint.txt && \
|
||||
pip3 install --no-cache-dir -c /tmp/torch-constraint.txt -r requirements.txt
|
||||
|
||||
COPY bridge.py speaker_id.py ./
|
||||
|
||||
CMD ["python3", "bridge.py"]
|
||||
@@ -0,0 +1,47 @@
|
||||
# Voxtral-STT-3B-Satellit (Transformers)
|
||||
|
||||
Streaming-STT via **Voxtral-Mini-3B-2507** (Mistral, Apache 2.0) über
|
||||
🤗 Transformers. Ersetzt whisper als STT — genauer, und **ohne Treiber-Upgrade**:
|
||||
läuft auf dem Trixie-Standardtreiber (550/CUDA 12.4) via **torch 2.6.0+cu124**
|
||||
(derselbe Trick wie bei f5tts).
|
||||
|
||||
- Modell ~9 GB (bf16) → **GPU 1** (die 12-GB-Karte; per Compose gepinnt).
|
||||
- **F5-TTS + LLM** bleiben auf GPU 0 (8 GB).
|
||||
- Arbeitsweise = chunked wie whisper: live PCM → alle ~1 s transkribieren
|
||||
(Partials) → **adaptiver Endpointer** (Rausch-Boden-VAD + semantische
|
||||
Stagnation, aus M0.1) feuert `stt_endpoint`. RVS-Protokoll identisch → drop-in.
|
||||
|
||||
## Starten (Profil `voxtral`)
|
||||
|
||||
```bash
|
||||
cd xtts
|
||||
docker compose stop whisper-bridge # sonst beantworten beide stt_*
|
||||
docker compose --profile voxtral up -d --build
|
||||
docker logs -f aria-voxtral-bridge # Modell laedt (mehrere GB), dann "RVS verbunden"
|
||||
```
|
||||
Zurück zu whisper: `docker compose --profile voxtral down && docker compose up -d whisper-bridge`.
|
||||
|
||||
## ⚠️ Auf echter Hardware verifizieren (blind gebaut)
|
||||
|
||||
1. **Transformers-API.** Die exakte Voxtral-Transkriptions-API ist in `bridge.py`
|
||||
in **einer** Methode gekapselt (`VoxtralRunner._transcribe_blocking`), modelliert
|
||||
nach der HF-Modelcard (`apply_transcription_request` → `generate` → `batch_decode`).
|
||||
Beim ersten Lauf gegen die Modelcard prüfen und dort anpassen.
|
||||
2. **HF-Gating.** Ist `Voxtral-Mini-3B-2507` gated, `HF_TOKEN` in `xtts/.env` setzen
|
||||
(wird als `HUGGING_FACE_HUB_TOKEN` durchgereicht).
|
||||
3. **VRAM/Tempo.** 3B in bf16 ~9 GB auf der 12-GB-Karte — Rest fürs KV-Cache. Ist die
|
||||
Partial-Transkription (alle 1 s) zu schwer, `STREAM_TRANSCRIBE_INTERVAL_MS` hochsetzen.
|
||||
4. **torch-Konflikt.** Falls `transformers`/`mistral-common` beim Build torch>2.6
|
||||
erzwingen, meldet der Constraint einen Konflikt → dann brauchen wir doch das
|
||||
Treiber-Upgrade (`bootstrap.sh --upgrade-driver` via NVIDIA-CUDA-Repo) + cu126-torch.
|
||||
|
||||
## Protokoll (RVS, identisch zu whisper — drop-in)
|
||||
Rein: `stt_stream_start`, `stt_audio_chunk` (16 kHz mono s16le, base64), `stt_stream_end`.
|
||||
Raus: `stt_partial`, `stt_endpoint`, `stt_stream_done`.
|
||||
|
||||
## TTS
|
||||
Bleibt **F5-TTS** (klingt gut, passt auf GPU 0). Voxtral-TTS bräuchte ~24 GB — separates Thema.
|
||||
|
||||
## Quellen
|
||||
- Modell: https://huggingface.co/mistralai/Voxtral-Mini-3B-2507
|
||||
- Transformers-Nutzung: HF-Modelcard (Voxtral) + `mistral-common`
|
||||
@@ -0,0 +1,689 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ARIA Voxtral-STT-3B Bridge (Transformers) — Ersatz fuer whisper.
|
||||
|
||||
Laeuft auf Treiber 550/CUDA 12.4 via torch cu124 (kein Treiber-Upgrade noetig).
|
||||
Modell: Voxtral-Mini-3B-2507 (bf16, ~9 GB) → GPU 1 (12 GB, per Compose gepinnt).
|
||||
|
||||
Arbeitsweise = Zwilling der whisper-Bridge: App schickt live PCM-Chunks; wir
|
||||
transkribieren alle ~STREAM_TRANSCRIBE_INTERVAL_MS auf dem Ringbuffer (Partials)
|
||||
und feuern stt_endpoint, sobald der ADAPTIVE Endpointer (Rausch-Boden-VAD +
|
||||
semantische Stagnation, aus M0.1) "fertig" sagt. RVS-Wire-Protokoll identisch zu
|
||||
whisper → drop-in (die App merkt nur bessere Genauigkeit).
|
||||
|
||||
⚠️ VERIFY-ON-FIRST-RUN: Die exakte Transformers-Transkriptions-API von Voxtral
|
||||
(apply_transcription_request / generate / decode) ist unten in EINER Methode
|
||||
(VoxtralRunner._transcribe_blocking) gekapselt und nach dem HF-Modelcard-Muster
|
||||
modelliert. Beim ersten echten Lauf gegen die Voxtral-Modelcard pruefen und dort
|
||||
anpassen. Alles andere (RVS, Endpointer) ist bewaehrt.
|
||||
|
||||
Env:
|
||||
RVS_HOST, RVS_PORT, RVS_TLS, RVS_TLS_FALLBACK, RVS_TOKEN
|
||||
VOXTRAL_MODEL Default: mistralai/Voxtral-Mini-3B-2507
|
||||
VOXTRAL_LANGUAGE Default: de
|
||||
VOXTRAL_DEVICE Default: cuda
|
||||
STREAM_TRANSCRIBE_INTERVAL_MS Default 1000 (3B ist schwerer als whisper-small)
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import websockets
|
||||
|
||||
import speaker_id # Speaker-ID (nur Stefans Stimme) — portiert aus der whisper-Bridge
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("voxtral-bridge")
|
||||
|
||||
RVS_HOST = os.getenv("RVS_HOST", "").strip()
|
||||
RVS_PORT = int(os.getenv("RVS_PORT", "443"))
|
||||
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
|
||||
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
|
||||
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
||||
|
||||
VOXTRAL_MODEL = os.getenv("VOXTRAL_MODEL", "mistralai/Voxtral-Mini-3B-2507")
|
||||
VOXTRAL_LANGUAGE = os.getenv("VOXTRAL_LANGUAGE", "de")
|
||||
VOXTRAL_DEVICE = os.getenv("VOXTRAL_DEVICE", "cuda")
|
||||
|
||||
STREAM_TRANSCRIBE_INTERVAL_MS = int(os.getenv("STREAM_TRANSCRIBE_INTERVAL_MS", "1000"))
|
||||
STREAM_DEFAULT_ENDPOINT_MS = 2400
|
||||
STREAM_DEFAULT_HARD_CAP_MS = 300000
|
||||
STREAM_MIN_AUDIO_MS = 600
|
||||
STREAM_SPEAKER_CHECK_MS = 1500 # ab so viel Audio einmalig Speaker-ID pruefen
|
||||
STREAM_SESSION_TTL_S = 120
|
||||
STREAM_ENERGY_WINDOW_MS = 300
|
||||
STREAM_SEMANTIC_BACKUP_FACTOR = 2.0
|
||||
# Adaptiver Voice-Schwellwert (M0.1): relativ zum gemessenen Rausch-Boden.
|
||||
STREAM_VOICE_FACTOR = 2.5
|
||||
STREAM_VOICE_RMS_MIN = 0.005
|
||||
STREAM_VOICE_RMS_MAX = 0.020
|
||||
# Mindest-Stimme (in ~200ms-Endpointer-Frames), ab der eine Aufnahme ueberhaupt
|
||||
# als Sprache gilt. Darunter = Stille / kurzer Geraeusch-Blip → KEIN Transkript
|
||||
# (Voxtral halluziniert aus Fast-Nichts sonst einen Fuellsatz). 2 ≈ 400ms.
|
||||
STREAM_MIN_VOICED_FRAMES = int(os.getenv("STREAM_MIN_VOICED_FRAMES", "2"))
|
||||
|
||||
# Halluzinations-Filter (2. Netz NACH der Transkription). Der voiced_frames-Guard
|
||||
# oben faengt die reine Stille; hier kommt das "borderline"-Band dazu: wenn wenig
|
||||
# echte Stimme da war UND das Transkript ein bekanntes Voxtral-Silence-Artefakt
|
||||
# ist (Untertitel-Credits, Staedte-/Geo-Fakten "Flaeche von X km2"), ist es fast
|
||||
# sicher ein Phantom aus Fast-Nichts → verwerfen. Gegated auf wenig voiced_frames,
|
||||
# damit eine ECHTE Geografie-Frage (die hat normale Stimm-Energie) durchgeht.
|
||||
STREAM_HALLUC_GUARD_FRAMES = int(os.getenv("STREAM_HALLUC_GUARD_FRAMES",
|
||||
str(STREAM_MIN_VOICED_FRAMES * 4))) # ~1.6s
|
||||
_HALLUCINATION_RE = re.compile(
|
||||
r"untertitel"
|
||||
r"|amara\.org"
|
||||
r"|vielen\s+dank\s+f[uü]r'?s?\s+(zuschauen|zusehen|zuh[oö]ren)"
|
||||
r"|bis\s+zum\s+n[aä]chsten\s+mal"
|
||||
r"|abonnier"
|
||||
r"|fl[aä]che\s+von\s+[\d.,]+\s*(km|quadratkilometer)"
|
||||
r"|[\d.,]+\s*(km²|quadratkilometern?|einwohnern?)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Kollabiert unmittelbar wiederholte Phrasen (Voxtral-Repetition-Loop) auf EINE
|
||||
# Kopie. Zweites Netz hinter no_repeat_ngram in der Generation. Phrase 5-80 Zeichen,
|
||||
# 3+ mal hintereinander → eine. Kurze legitime Doppelungen ('ja ja', 'sehr sehr')
|
||||
# bleiben (Unit < 5 Zeichen bzw. < 3 Wiederholungen).
|
||||
_REPEAT_RE = re.compile(r"(.{5,80}?)(?:\s*\1){2,}", re.IGNORECASE | re.DOTALL)
|
||||
|
||||
|
||||
def _collapse_repetitions(text: str) -> str:
|
||||
if not text:
|
||||
return text
|
||||
out = text
|
||||
for _ in range(3): # mehrfach fuer verschachtelte/ungleiche Loops
|
||||
new = _REPEAT_RE.sub(r"\1", out)
|
||||
if new == out:
|
||||
break
|
||||
out = new
|
||||
return out.strip()
|
||||
|
||||
# Speaker-ID Gating global an/aus. DEFAULT AUS (fail-open) — die "nur meine Stimme"-
|
||||
# Pruefung ist ein BEWUSSTER Schalter, kein Automatismus: ein einziger schlechter
|
||||
# Enroll darf nie die ganze STT lahmlegen (genau das ist passiert). Wird per config-
|
||||
# Broadcast (voiceIdEnabled, aus dem Diagnostic) zur Laufzeit gesetzt. Kann per ENV
|
||||
# vorbelegt werden.
|
||||
SPEAKER_ID_ENABLED = os.getenv("VOICE_ID_ENABLED", "false").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _set_speaker_id_enabled(val: bool) -> None:
|
||||
global SPEAKER_ID_ENABLED
|
||||
SPEAKER_ID_ENABLED = bool(val)
|
||||
|
||||
|
||||
def pcm_s16le_to_float32(data: bytes) -> np.ndarray:
|
||||
if not data:
|
||||
return np.zeros(0, dtype=np.float32)
|
||||
return np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
|
||||
|
||||
async def _send(ws, mtype: str, payload: dict) -> None:
|
||||
try:
|
||||
await ws.send(json.dumps({
|
||||
"type": mtype, "payload": payload, "timestamp": int(time.time() * 1000),
|
||||
}))
|
||||
except Exception as e:
|
||||
logger.warning("RVS-Send fehlgeschlagen (%s): %s", mtype, e)
|
||||
|
||||
|
||||
class VoxtralRunner:
|
||||
"""Haelt das Voxtral-Modell (Transformers). transcribe() blockiert → aus dem
|
||||
Event-Loop via run_in_executor aufrufen. Ein Lock serialisiert GPU-Zugriffe."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.model = None
|
||||
self.processor = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def load(self) -> None:
|
||||
import torch
|
||||
from transformers import AutoProcessor, VoxtralForConditionalGeneration
|
||||
t0 = time.time()
|
||||
logger.info("Lade Voxtral '%s' (device=%s, bf16)…", VOXTRAL_MODEL, VOXTRAL_DEVICE)
|
||||
self.processor = AutoProcessor.from_pretrained(VOXTRAL_MODEL)
|
||||
self.model = VoxtralForConditionalGeneration.from_pretrained(
|
||||
VOXTRAL_MODEL, torch_dtype=torch.bfloat16, device_map=VOXTRAL_DEVICE,
|
||||
)
|
||||
logger.info("Voxtral geladen in %.1fs", time.time() - t0)
|
||||
|
||||
def _transcribe_blocking(self, audio_f32: np.ndarray, language: str) -> str:
|
||||
import torch
|
||||
proc, model = self.processor, self.model
|
||||
if proc is None or model is None or audio_f32.size == 0:
|
||||
return ""
|
||||
# VoxtralProcessor verlangt bei rohen Arrays ein 'format'. Robuster:
|
||||
# in ein temp-WAV schreiben und den PFAD uebergeben — der Processor liest
|
||||
# Format + Samplerate selbst, kein 'format'-Argument noetig.
|
||||
wav_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tf:
|
||||
wav_path = tf.name
|
||||
sf.write(wav_path, audio_f32, 16000, subtype="PCM_16")
|
||||
inputs = proc.apply_transcription_request(
|
||||
language=language, audio=wav_path, model_id=VOXTRAL_MODEL,
|
||||
)
|
||||
inputs = inputs.to(VOXTRAL_DEVICE, dtype=torch.bfloat16)
|
||||
with torch.no_grad():
|
||||
# hoch genug fuer lange Diktate (stoppt eh am EOS); 512 hat
|
||||
# mehrminutige Aufnahmen abgeschnitten.
|
||||
# Repetition-Bremse: Voxtral kippt bei Stille/Rauschen am Ende
|
||||
# gern in eine Schleife und wiederholt einen Satz zig-mal
|
||||
# ("Vergiss das, das ist nur... Vergiss das, das ist nur..."
|
||||
# x15). no_repeat_ngram_size=4 laesst die ERSTE echte Nennung
|
||||
# durch, verbietet aber die exakte 4-Gramm-Wiederholung → Loop
|
||||
# bricht ab; repetition_penalty daempft zusaetzlich. Beides mild,
|
||||
# damit normale Sprache (auch mal ein doppeltes Wort) unberuehrt
|
||||
# bleibt.
|
||||
outputs = model.generate(
|
||||
**inputs,
|
||||
max_new_tokens=4096,
|
||||
no_repeat_ngram_size=4,
|
||||
repetition_penalty=1.15,
|
||||
)
|
||||
trimmed = outputs[:, inputs.input_ids.shape[1]:]
|
||||
text = proc.batch_decode(trimmed, skip_special_tokens=True)
|
||||
return (text[0] if text else "").strip()
|
||||
finally:
|
||||
if wav_path:
|
||||
try:
|
||||
os.unlink(wav_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def transcribe(self, audio_f32: np.ndarray, language: str) -> str:
|
||||
loop = asyncio.get_running_loop()
|
||||
async with self._lock:
|
||||
return await loop.run_in_executor(None, self._transcribe_blocking, audio_f32, language)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamSession:
|
||||
request_id: str
|
||||
audio_request_id: str
|
||||
language: str
|
||||
endpoint_ms: int
|
||||
hard_cap_ms: int
|
||||
voice: str = ""
|
||||
speed: float = 1.0
|
||||
interrupted: bool = False
|
||||
location: Optional[dict] = None
|
||||
sample_rate: int = 16000
|
||||
voice_factor: float = STREAM_VOICE_FACTOR
|
||||
voice_rms_min: float = STREAM_VOICE_RMS_MIN
|
||||
voice_rms_max: float = STREAM_VOICE_RMS_MAX
|
||||
pcm_buffer: bytearray = field(default_factory=bytearray)
|
||||
started_at: float = field(default_factory=time.time)
|
||||
last_chunk_at: float = field(default_factory=time.time)
|
||||
last_partial: str = ""
|
||||
last_growth_at: float = 0.0
|
||||
last_transcribe_at: float = 0.0
|
||||
last_voice_at: float = 0.0
|
||||
noise_floor: float = 0.0
|
||||
closed: bool = False
|
||||
endpoint_sent: bool = False
|
||||
# Einmaliges "Sprache erkannt"-Signal an die App gesendet? Voxtral schickt
|
||||
# keine Live-Partials, aber der App-No-Speech-Watchdog wartet auf ein
|
||||
# stt_partial, um "der User redet" zu erkennen — sonst cancelt er mitten im
|
||||
# Satz. Wir feuern EIN leeres stt_partial beim ersten Voice-Frame.
|
||||
speech_signaled: bool = False
|
||||
# Anzahl Endpointer-Frames (~200ms) mit echter Stimme. Gate gegen Halluzination
|
||||
# aus Stille/Blips: unter STREAM_MIN_VOICED_FRAMES wird nicht transkribiert.
|
||||
voiced_frames: int = 0
|
||||
# Speaker-ID Gating (einmalig auf die ersten ~1.5s der Aufnahme)
|
||||
speaker_checked: bool = False
|
||||
speaker_match: Optional[bool] = None
|
||||
speaker_similarity: float = 0.0
|
||||
|
||||
|
||||
class SessionManager:
|
||||
def __init__(self, runner: VoxtralRunner) -> None:
|
||||
self.runner = runner
|
||||
self._sessions: dict[str, StreamSession] = {}
|
||||
self._ws = None
|
||||
|
||||
def attach_ws(self, ws) -> None:
|
||||
self._ws = ws
|
||||
|
||||
def start_session(self, payload: dict) -> None:
|
||||
rid = (payload.get("requestId") or "").strip()
|
||||
if not rid:
|
||||
return
|
||||
try:
|
||||
endpoint_ms = int(payload.get("endpointMs") or STREAM_DEFAULT_ENDPOINT_MS)
|
||||
except (TypeError, ValueError):
|
||||
endpoint_ms = STREAM_DEFAULT_ENDPOINT_MS
|
||||
try:
|
||||
hard_cap_ms = int(payload.get("hardCapMs") or STREAM_DEFAULT_HARD_CAP_MS)
|
||||
except (TypeError, ValueError):
|
||||
hard_cap_ms = STREAM_DEFAULT_HARD_CAP_MS
|
||||
try:
|
||||
voice_factor = float(payload.get("voiceFactor") or STREAM_VOICE_FACTOR)
|
||||
except (TypeError, ValueError):
|
||||
voice_factor = STREAM_VOICE_FACTOR
|
||||
self._sessions[rid] = StreamSession(
|
||||
request_id=rid,
|
||||
audio_request_id=payload.get("audioRequestId", "") or "",
|
||||
language=payload.get("language") or VOXTRAL_LANGUAGE,
|
||||
endpoint_ms=endpoint_ms,
|
||||
hard_cap_ms=hard_cap_ms,
|
||||
voice=payload.get("voice", "") or "",
|
||||
speed=float(payload.get("speed") or 1.0),
|
||||
voice_factor=voice_factor,
|
||||
interrupted=bool(payload.get("interrupted", False)),
|
||||
location=payload.get("location") or None,
|
||||
sample_rate=int(payload.get("sampleRate") or 16000),
|
||||
)
|
||||
logger.info("Voxtral-Session offen: id=%s lang=%s endpointMs=%d",
|
||||
rid[:8], self._sessions[rid].language, endpoint_ms)
|
||||
|
||||
def feed_chunk(self, payload: dict) -> bool:
|
||||
sess = self._sessions.get(payload.get("requestId", ""))
|
||||
if sess is None or sess.closed:
|
||||
return False
|
||||
pcm_b64 = payload.get("pcm", "")
|
||||
if pcm_b64:
|
||||
try:
|
||||
sess.pcm_buffer.extend(base64.b64decode(pcm_b64))
|
||||
except Exception:
|
||||
pass
|
||||
sess.last_chunk_at = time.time()
|
||||
return True
|
||||
|
||||
def end_session(self, request_id: str) -> None:
|
||||
sess = self._sessions.get(request_id)
|
||||
if sess is not None:
|
||||
sess.closed = True
|
||||
|
||||
def drop(self, request_id: str) -> None:
|
||||
self._sessions.pop(request_id, None)
|
||||
|
||||
# ── Endpointer (adaptiv, M0.1) ──
|
||||
def _buffer_ms(self, sess: StreamSession) -> float:
|
||||
samples = len(sess.pcm_buffer) // 2
|
||||
return (samples / sess.sample_rate) * 1000.0 if samples else 0.0
|
||||
|
||||
def _tail_rms(self, sess: StreamSession) -> float:
|
||||
win = int(sess.sample_rate * STREAM_ENERGY_WINDOW_MS / 1000) * 2
|
||||
if win <= 0:
|
||||
return 0.0
|
||||
tail = sess.pcm_buffer[-win:]
|
||||
if len(tail) < 2:
|
||||
return 0.0
|
||||
arr = pcm_s16le_to_float32(bytes(tail))
|
||||
return float(np.sqrt(np.mean(arr * arr))) if arr.size else 0.0
|
||||
|
||||
def _voice_threshold(self, sess: StreamSession) -> float:
|
||||
nf = sess.noise_floor
|
||||
if nf <= 0.0:
|
||||
return sess.voice_rms_min
|
||||
return min(max(nf * sess.voice_factor, sess.voice_rms_min), sess.voice_rms_max)
|
||||
|
||||
def _update_noise_floor(self, sess: StreamSession, rms: float) -> None:
|
||||
nf = sess.noise_floor
|
||||
if nf <= 0.0:
|
||||
sess.noise_floor = rms
|
||||
elif rms < nf:
|
||||
sess.noise_floor = 0.90 * nf + 0.10 * rms
|
||||
else:
|
||||
sess.noise_floor = 0.98 * nf + 0.02 * rms
|
||||
|
||||
async def _check_speaker(self, sess: StreamSession) -> None:
|
||||
"""Einmalig: erste ~1.5s → Embedding → Vergleich mit Fingerprint.
|
||||
Ohne Fingerprint fail-open (match=True). Bei Mismatch: Session beenden."""
|
||||
sess.speaker_checked = True
|
||||
# Schalter aus (Default) → gar keine Pruefung, alles durchlassen.
|
||||
if not SPEAKER_ID_ENABLED:
|
||||
sess.speaker_match = True
|
||||
return
|
||||
head = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32])
|
||||
if len(head) < speaker_id.MIN_SAMPLE_BYTES:
|
||||
sess.speaker_match = True
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
is_match, sim = await loop.run_in_executor(None, speaker_id.verify, head)
|
||||
except Exception as exc:
|
||||
logger.warning("Stream %s: speaker-check crashed (%s) — fail-open",
|
||||
sess.request_id[:8], exc)
|
||||
sess.speaker_match = True
|
||||
return
|
||||
sess.speaker_match = is_match
|
||||
sess.speaker_similarity = sim
|
||||
logger.info("Stream %s: speaker-check sim=%.2f → %s (thr=%.2f)",
|
||||
sess.request_id[:8], sim, "MATCH" if is_match else "REJECT",
|
||||
speaker_id.DEFAULT_THRESHOLD)
|
||||
if not is_match:
|
||||
await self._finalize_speaker_mismatch(sess, sim)
|
||||
|
||||
async def _finalize_speaker_mismatch(self, sess: StreamSession, similarity: float) -> None:
|
||||
"""Fremde Stimme: synthetisches leeres stt_endpoint (reason=speaker_mismatch),
|
||||
Session droppen — kein Voxtral-Transcribe, kein Brain-Call."""
|
||||
if sess.endpoint_sent:
|
||||
return
|
||||
sess.endpoint_sent = True
|
||||
duration_s = self._buffer_ms(sess) / 1000.0
|
||||
logger.info("Stream %s: speaker-mismatch (sim=%.2f) — DROP nach %.1fs",
|
||||
sess.request_id[:8], similarity, duration_s)
|
||||
if self._ws is not None:
|
||||
payload = {
|
||||
"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "", "reason": "speaker_mismatch",
|
||||
"durationS": duration_s, "sttMs": 0,
|
||||
"voice": sess.voice, "speed": sess.speed,
|
||||
"interrupted": sess.interrupted,
|
||||
"speakerSimilarity": float(similarity),
|
||||
}
|
||||
if sess.location:
|
||||
payload["location"] = sess.location
|
||||
await _send(self._ws, "stt_endpoint", payload)
|
||||
await _send(self._ws, "stt_stream_done", {
|
||||
"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "", "reason": "speaker_mismatch",
|
||||
})
|
||||
self.drop(sess.request_id)
|
||||
|
||||
async def run_endpointer(self) -> None:
|
||||
logger.info("Voxtral-Endpointer gestartet (adaptiver VAD, interval=%dms)",
|
||||
STREAM_TRANSCRIBE_INTERVAL_MS)
|
||||
while True:
|
||||
await asyncio.sleep(0.2)
|
||||
now = time.time()
|
||||
for sid, sess in list(self._sessions.items()):
|
||||
try:
|
||||
await self._tick(sess, now)
|
||||
except Exception:
|
||||
logger.exception("Tick crashed (session=%s)", sid[:8])
|
||||
for sid, sess in list(self._sessions.items()):
|
||||
if now - sess.last_chunk_at > STREAM_SESSION_TTL_S:
|
||||
logger.info("Stream %s: TTL — drop", sid[:8])
|
||||
self.drop(sid)
|
||||
|
||||
async def _tick(self, sess: StreamSession, now: float) -> None:
|
||||
if sess.endpoint_sent:
|
||||
return
|
||||
if (now - sess.started_at) * 1000.0 > sess.hard_cap_ms and not sess.closed:
|
||||
await self._finalize(sess, "hardcap")
|
||||
return
|
||||
if sess.closed:
|
||||
await self._finalize(sess, "stream_end")
|
||||
return
|
||||
if self._buffer_ms(sess) < STREAM_MIN_AUDIO_MS:
|
||||
return
|
||||
# Speaker-ID einmalig: ist es Stefans Stimme? Fremde → Session verwerfen
|
||||
# (kein Transcribe, kein Brain-Call). Ohne Enrollment fail-open.
|
||||
if not sess.speaker_checked and self._buffer_ms(sess) >= STREAM_SPEAKER_CHECK_MS:
|
||||
await self._check_speaker(sess)
|
||||
if sess.speaker_match is False:
|
||||
return
|
||||
# Adaptive akustische Sprach-Aktivitaet (M0.1). KEINE Live-Partials mehr:
|
||||
# Voxtral-3B transkribiert den ganzen WACHSENDEN Buffer und braucht dafuer
|
||||
# bei langen Aufnahmen 5-6 s — zu langsam fuer Live-Text, UND diese Latenz
|
||||
# hat den semantischen Endpoint faelschlich ausgeloest (Partial-Latenz >
|
||||
# Timeout → willkuerliche Abbrueche nach 20-40 s). Deshalb: Turn-Ende rein
|
||||
# AKUSTISCH, transkribiert wird nur EINMAL im _finalize.
|
||||
rms = self._tail_rms(sess)
|
||||
if rms >= self._voice_threshold(sess):
|
||||
sess.last_voice_at = now
|
||||
sess.voiced_frames += 1
|
||||
# Einmalig der App melden, dass Sprache begonnen hat — aber ERST ab genug
|
||||
# echter Stimme (>= STREAM_MIN_VOICED_FRAMES). Ein einzelner Geraeusch-
|
||||
# Blip darf den No-Speech-Watchdog NICHT loeschen, sonst transkribiert
|
||||
# Voxtral das Fast-Nichts und HALLUZINIERT einen Phantom-Satz. Ohne Live-
|
||||
# Partials wuerde der Watchdog die Aufnahme sonst am Konversationsfenster
|
||||
# canceln, obwohl der User redet ("beendet nach ~4s"-Repro). Leeres
|
||||
# stt_partial: App setzt streamGotPartial=true + loescht den Watchdog.
|
||||
# Nach der Speaker-ID-Pruefung (oben) → fremde Stimmen signalisieren NICHT.
|
||||
if (not sess.speech_signaled and self._ws is not None
|
||||
and sess.voiced_frames >= STREAM_MIN_VOICED_FRAMES):
|
||||
sess.speech_signaled = True
|
||||
await _send(self._ws, "stt_partial", {
|
||||
"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "",
|
||||
})
|
||||
else:
|
||||
self._update_noise_floor(sess, rms)
|
||||
# No-Speech-Timeout: wurde die GANZE Zeit KEINE Stimme erkannt
|
||||
# (last_voice_at==0), feuert der normale Endpoint unten NIE — der braucht
|
||||
# last_voice_at>0. Ohne das bleibt ein reines Stille-Fenster offen bis
|
||||
# Hardcap/manuellem Stop → genau Stefans Repro: "die Stille-Ende wird nie
|
||||
# erreicht, stop ich selbst ist es weg". Nach endpoint_ms Stille ab Start
|
||||
# schliessen wir das Fenster selbst als no-speech (leer, lautlos, zurueck
|
||||
# aufs Wake-Word). voiced_frames==0 → _finalize verwirft ohne Transkript,
|
||||
# also KEIN Phantom.
|
||||
if sess.last_voice_at == 0 and (now - sess.started_at) * 1000.0 >= sess.endpoint_ms:
|
||||
await self._finalize(sess, "no_speech")
|
||||
return
|
||||
# Endpoint: hat der User schon gesprochen UND ist es seit endpoint_ms still?
|
||||
if sess.last_voice_at > 0 and (now - sess.last_voice_at) * 1000.0 >= sess.endpoint_ms:
|
||||
await self._finalize(sess, "endpoint")
|
||||
|
||||
async def _finalize(self, sess: StreamSession, reason: str) -> None:
|
||||
if sess.endpoint_sent:
|
||||
return
|
||||
sess.endpoint_sent = True
|
||||
# Halluzinations-Guard: zu wenig echte Stimme (Stille / kurzer Blip im
|
||||
# Passiv-/Wake-Fenster) → NICHT transkribieren. Voxtral (wie Whisper) baut
|
||||
# aus Fast-Nichts gern einen Fuellsatz ("Die Stadt hat eine Flaeche von
|
||||
# 1,5 km2"), der dann als PHANTOM-Nachricht ans Brain geht und das Gespraech
|
||||
# entgleisen laesst (Stefans Repro: "kam Nachricht von mir, obwohl ich
|
||||
# nichts sagte"). Leeres Endpoint = no-speech → App re-armt still.
|
||||
#
|
||||
# WICHTIG (aus dem ai-box-Log gelernt): die Phantome kommen mit
|
||||
# reason=stream_end — Passiv-/Wake-Fenster enden AUCH per stream_end, wenn
|
||||
# sie auf Stille zumachen. stream_end ist also NICHT gleich "manueller Stop".
|
||||
# Deshalb greift der Guard jetzt auch bei stream_end, aber mit niedrigerer
|
||||
# Schwelle (voiced==0 = gar keine Stimme), damit ein kurzes bewusstes Wort
|
||||
# ('ja', 'stopp') am Aufnahme-Button noch durchgeht, echte Stille aber nicht.
|
||||
_min_voiced = STREAM_MIN_VOICED_FRAMES if reason != "stream_end" else 1
|
||||
if sess.voiced_frames < _min_voiced:
|
||||
logger.info("Stream %s: no-speech (voiced_frames=%d<%d, reason=%s) — leeres Endpoint",
|
||||
sess.request_id[:8], sess.voiced_frames, _min_voiced, reason)
|
||||
if self._ws is not None:
|
||||
nospeech = {"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "", "reason": f"no_speech:{reason}",
|
||||
"durationS": 0.0, "sttMs": 0}
|
||||
await _send(self._ws, "stt_endpoint", nospeech)
|
||||
await _send(self._ws, "stt_stream_done", {
|
||||
"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "", "reason": f"no_speech:{reason}"})
|
||||
self.drop(sess.request_id)
|
||||
return
|
||||
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
|
||||
t0 = time.time()
|
||||
try:
|
||||
final_text = (await self.runner.transcribe(audio, sess.language)).strip()
|
||||
except Exception:
|
||||
logger.exception("Stream %s: Final-Transcribe crashed", sess.request_id[:8])
|
||||
final_text = sess.last_partial
|
||||
stt_ms = int((time.time() - t0) * 1000)
|
||||
duration_s = audio.size / 16000.0
|
||||
# Repetition-Loop einkassieren, falls trotz no_repeat_ngram was durchkam.
|
||||
_collapsed = _collapse_repetitions(final_text)
|
||||
if _collapsed != final_text:
|
||||
logger.info("Stream %s: Repetition-Loop kollabiert (%d→%d Zeichen)",
|
||||
sess.request_id[:8], len(final_text), len(_collapsed))
|
||||
final_text = _collapsed
|
||||
logger.info("Stream %s: FINAL (reason=%s, %.1fs, %dms): %r",
|
||||
sess.request_id[:8], reason, duration_s, stt_ms, final_text[:120])
|
||||
|
||||
# Halluzinations-Filter (2. Netz): leeres/Artefakt-Transkript im borderline-
|
||||
# Band → als no-speech verwerfen statt ein Phantom ("Die Stadt hat eine
|
||||
# Flaeche von 1,5 km2") ans Brain zu schicken. Gilt fuer ALLE reasons inkl.
|
||||
# stream_end (dort kamen die realen Phantome!) — aber das borderline-Band
|
||||
# (wenig voiced_frames) schuetzt echte, klar gesprochene Eingaben: eine echte
|
||||
# Geografie-FRAGE hat normale Stimm-Energie (voiced_frames >> Schwelle) und
|
||||
# geht durch; das Phantom aus Stille hat ~0 und wird verworfen. Ein leeres
|
||||
# Transkript wird immer verworfen (nichts gesagt = nichts senden).
|
||||
_clean = final_text.strip(" .,!?…-\t\n\r")
|
||||
_borderline = sess.voiced_frames < STREAM_HALLUC_GUARD_FRAMES
|
||||
_is_phantom = (not _clean) or (_borderline and bool(_HALLUCINATION_RE.search(final_text)))
|
||||
if _is_phantom:
|
||||
logger.info("Stream %s: Halluzination verworfen (voiced_frames=%d<%d, %.1fs, text=%r)",
|
||||
sess.request_id[:8], sess.voiced_frames, STREAM_HALLUC_GUARD_FRAMES,
|
||||
duration_s, final_text[:80])
|
||||
if self._ws is not None:
|
||||
nospeech = {"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "", "reason": f"hallucination:{reason}",
|
||||
"durationS": 0.0, "sttMs": stt_ms}
|
||||
await _send(self._ws, "stt_endpoint", nospeech)
|
||||
await _send(self._ws, "stt_stream_done", {
|
||||
"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "", "reason": f"hallucination:{reason}"})
|
||||
self.drop(sess.request_id)
|
||||
return
|
||||
|
||||
if self._ws is not None:
|
||||
payload = {
|
||||
"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": final_text,
|
||||
"reason": reason,
|
||||
"durationS": duration_s,
|
||||
"sttMs": stt_ms,
|
||||
"voice": sess.voice,
|
||||
"speed": sess.speed,
|
||||
"interrupted": sess.interrupted,
|
||||
}
|
||||
if sess.location:
|
||||
payload["location"] = sess.location
|
||||
await _send(self._ws, "stt_endpoint", payload)
|
||||
await _send(self._ws, "stt_stream_done", {
|
||||
"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": final_text,
|
||||
"reason": reason,
|
||||
})
|
||||
self.drop(sess.request_id)
|
||||
|
||||
|
||||
async def _broadcast_status(ws, state: str, **extra) -> None:
|
||||
payload = {"service": "voxtral", "state": state}
|
||||
payload.update(extra)
|
||||
await _send(ws, "service_status", payload)
|
||||
|
||||
|
||||
async def run_loop(sessions: SessionManager) -> None:
|
||||
use_tls = RVS_TLS
|
||||
retry_s = 2
|
||||
tls_fallback_tried = False
|
||||
while True:
|
||||
scheme = "wss" if use_tls else "ws"
|
||||
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
|
||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||
try:
|
||||
logger.info("Verbinde zu RVS: %s", masked)
|
||||
async with websockets.connect(url, ping_interval=20, ping_timeout=10,
|
||||
max_size=50 * 1024 * 1024) as ws:
|
||||
logger.info("RVS verbunden")
|
||||
retry_s = 2
|
||||
tls_fallback_tried = False
|
||||
sessions.attach_ws(ws)
|
||||
await _broadcast_status(ws, "ready", model=VOXTRAL_MODEL)
|
||||
await _send(ws, "config_request", {"service": "voxtral"})
|
||||
async for raw in ws:
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
mtype = msg.get("type", "")
|
||||
payload = msg.get("payload", {}) or {}
|
||||
if mtype == "stt_stream_start":
|
||||
sessions.start_session(payload)
|
||||
elif mtype == "stt_audio_chunk":
|
||||
sessions.feed_chunk(payload)
|
||||
elif mtype == "stt_stream_end":
|
||||
sessions.end_session(payload.get("requestId", ""))
|
||||
elif mtype == "voice_id_status_request":
|
||||
req_id = payload.get("requestId", "")
|
||||
try:
|
||||
status = speaker_id.status()
|
||||
await _send(ws, "voice_id_status_response",
|
||||
{"requestId": req_id, "ok": True, **status})
|
||||
except Exception as exc:
|
||||
await _send(ws, "voice_id_status_response",
|
||||
{"requestId": req_id, "ok": False, "error": str(exc)[:200]})
|
||||
elif mtype == "voice_id_enroll_request":
|
||||
req_id = payload.get("requestId", "")
|
||||
samples = payload.get("samples") or []
|
||||
logger.info("voice_id_enroll_request: %d Samples (id=%s)", len(samples), req_id[:8])
|
||||
try:
|
||||
result = await asyncio.get_running_loop().run_in_executor(
|
||||
None, speaker_id.enroll_from_samples, samples)
|
||||
await _send(ws, "voice_id_enroll_response", {
|
||||
"requestId": req_id, "ok": True,
|
||||
"sample_count": result.get("sample_count", 0),
|
||||
"rejected": result.get("rejected", []),
|
||||
"updated_at": result.get("updated_at"),
|
||||
"embedding_dim": result.get("embedding_dim"),
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning("voice_id_enroll failed: %s", exc)
|
||||
await _send(ws, "voice_id_enroll_response",
|
||||
{"requestId": req_id, "ok": False, "error": str(exc)[:300]})
|
||||
elif mtype == "voice_id_delete_request":
|
||||
req_id = payload.get("requestId", "")
|
||||
removed = speaker_id.delete_fingerprint()
|
||||
await _send(ws, "voice_id_delete_response",
|
||||
{"requestId": req_id, "ok": True, "removed": removed})
|
||||
elif mtype == "config":
|
||||
if "voiceIdThreshold" in payload:
|
||||
try:
|
||||
t = float(payload.get("voiceIdThreshold", 0.5))
|
||||
if 0.0 <= t <= 1.0:
|
||||
speaker_id.DEFAULT_THRESHOLD = t
|
||||
logger.info("[speaker-id] threshold gesetzt: %.2f", t)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if "voiceIdEnabled" in payload:
|
||||
_set_speaker_id_enabled(payload.get("voiceIdEnabled"))
|
||||
logger.info("[speaker-id] Gating %s (voiceIdEnabled)",
|
||||
"AN" if SPEAKER_ID_ENABLED else "AUS")
|
||||
except Exception as e:
|
||||
logger.warning("RVS-Verbindung verloren: %s — retry in %ds", e, retry_s)
|
||||
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
||||
use_tls = False
|
||||
tls_fallback_tried = True
|
||||
continue
|
||||
await asyncio.sleep(retry_s)
|
||||
retry_s = min(retry_s * 2, 30)
|
||||
use_tls = RVS_TLS
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
if not RVS_HOST or not RVS_TOKEN:
|
||||
logger.error("RVS_HOST/RVS_TOKEN fehlen — .env pruefen. Abbruch.")
|
||||
return
|
||||
runner = VoxtralRunner()
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, runner.load) # Modell laden (blockierend)
|
||||
sessions = SessionManager(runner)
|
||||
logger.info("Voxtral-Bridge startet — Modell=%s", VOXTRAL_MODEL)
|
||||
await asyncio.gather(run_loop(sessions), sessions.run_endpointer())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -0,0 +1,10 @@
|
||||
# Voxtral-3B via Transformers. torch/torchaudio kommen cu124-gepinnt aus dem
|
||||
# Dockerfile (nicht hier, sonst zieht pip das Default-CUDA-Wheel).
|
||||
transformers>=4.54
|
||||
mistral-common[audio]>=1.8.1
|
||||
accelerate>=0.30
|
||||
speechbrain>=1.0 # Speaker-ID (ECAPA-TDNN) — nur Stefans Stimme
|
||||
soundfile>=0.12
|
||||
librosa>=0.10 # VoxtralProcessor.load_audio_as nutzt librosa zum WAV-Laden
|
||||
numpy>=1.24
|
||||
websockets>=12.0
|
||||
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
Speaker-ID Backend fuer ARIAs Stimmen-Erkennung.
|
||||
|
||||
Nutzt SpeechBrain ECAPA-TDNN (192-dim Embeddings, auf VoxCeleb-1+2 trainiert).
|
||||
Fingerprint = gemittelter, L2-normalisierter Embedding-Vektor aus N
|
||||
Enrollment-Samples. Verify: cosine_similarity(neue_aufnahme, fingerprint).
|
||||
|
||||
Persistenz: /voice-id/fingerprint.json (Float-Liste + Metadaten).
|
||||
Modell-Cache: /root/.cache/huggingface/ (Bind-Mount mit f5tts geteilt).
|
||||
|
||||
Verhalten OHNE Enrollment (kein Fingerprint vorhanden):
|
||||
verify() → (True, 0.0) — Fail-open, damit Speaker-ID-Gating den
|
||||
ungeenrollten Brain-Pfad nicht versehentlich blockiert.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VOICE_ID_DIR = Path(os.environ.get("VOICE_ID_DIR", "/voice-id"))
|
||||
FINGERPRINT_FILE = VOICE_ID_DIR / "fingerprint.json"
|
||||
|
||||
# Cosine-Threshold: 0.5 ist konservativ (wenig false-positives), 0.3 ist
|
||||
# locker (mehr Treffer auch bei Nebengeraeuschen). Stefan kann's per
|
||||
# Diagnostic-Setting feintunen.
|
||||
DEFAULT_THRESHOLD = 0.5
|
||||
|
||||
# Minimal-Sample-Laenge fuer ein verlaessliches Embedding (~1s @ 16kHz int16 = 32000 bytes)
|
||||
MIN_SAMPLE_BYTES = 32000
|
||||
|
||||
_model = None
|
||||
|
||||
|
||||
def _ensure_loaded():
|
||||
"""Lazy-Load des ECAPA-TDNN. Holt das Modell beim ersten Aufruf von HF;
|
||||
danach cached im HF-Cache-Volume. Erste Init: ~30s download + load,
|
||||
danach <1s warm. Wirft bei Fehler — Caller muss catchen + fail-open."""
|
||||
global _model
|
||||
if _model is not None:
|
||||
return _model
|
||||
import torch
|
||||
from speechbrain.inference.speaker import EncoderClassifier
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
logger.info("[speaker-id] loading ECAPA-TDNN on %s ...", device)
|
||||
_model = EncoderClassifier.from_hparams(
|
||||
source="speechbrain/spkrec-ecapa-voxceleb",
|
||||
savedir="/root/.cache/huggingface/speechbrain-ecapa",
|
||||
run_opts={"device": device},
|
||||
)
|
||||
logger.info("[speaker-id] model ready (device=%s)", device)
|
||||
return _model
|
||||
|
||||
|
||||
def _decode_compressed_to_pcm(audio_bytes: bytes) -> bytes:
|
||||
"""Dekodiert komprimiertes Audio (MP4/M4A/AAC vom Android-Recorder) via ffmpeg
|
||||
(im Container vorhanden) auf rohes 16kHz mono int16 LE PCM. Input geht ueber
|
||||
eine Temp-Datei (nicht Pipe): Androids MediaRecorder legt das moov-Atom ans
|
||||
ENDE, das braucht seekbaren Input, sonst 'moov atom not found'."""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
tmp = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tf:
|
||||
tf.write(audio_bytes)
|
||||
tmp = tf.name
|
||||
proc = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", tmp,
|
||||
"-f", "s16le", "-ac", "1", "-ar", "16000", "pipe:1"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
if proc.returncode != 0 or not proc.stdout:
|
||||
raise ValueError(
|
||||
f"ffmpeg decode failed: {proc.stderr.decode('utf-8', 'ignore')[:200]}")
|
||||
return proc.stdout
|
||||
finally:
|
||||
if tmp:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
|
||||
"""Akzeptiert rohes 16kHz int16 LE PCM, eine WAV-Datei (RIFF/WAVE) ODER einen
|
||||
komprimierten MP4/M4A/AAC-Container (Android-Recorder). WAV → Header strippen +
|
||||
Format validieren; MP4/AAC → via ffmpeg dekodieren. Ergebnis: rohes PCM."""
|
||||
if (len(audio_bytes) >= 44
|
||||
and audio_bytes[:4] == b"RIFF"
|
||||
and audio_bytes[8:12] == b"WAVE"):
|
||||
import io
|
||||
import wave
|
||||
with wave.open(io.BytesIO(audio_bytes), "rb") as wav:
|
||||
sr = wav.getframerate()
|
||||
ch = wav.getnchannels()
|
||||
sw = wav.getsampwidth()
|
||||
if sr != 16000:
|
||||
raise ValueError(f"WAV-Samplerate {sr} != 16000")
|
||||
if ch != 1:
|
||||
raise ValueError(f"WAV-Kanalzahl {ch} != 1 (mono erwartet)")
|
||||
if sw != 2:
|
||||
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
|
||||
return wav.readframes(wav.getnframes())
|
||||
# MP4/M4A/AAC-Container: Android-AAC-Recorder legt 'ftyp' bei Offset 4 an.
|
||||
if len(audio_bytes) >= 12 and audio_bytes[4:8] == b"ftyp":
|
||||
return _decode_compressed_to_pcm(audio_bytes)
|
||||
return audio_bytes
|
||||
|
||||
|
||||
def _audio_bytes_to_tensor(audio_bytes: bytes):
|
||||
"""int16 LE PCM (16kHz mono) → Torch-Tensor (1, N), normalisiert auf [-1, 1].
|
||||
WAV wird vorher auf rohes PCM reduziert (Header strippen)."""
|
||||
import torch
|
||||
raw = _normalize_audio_bytes(audio_bytes)
|
||||
arr = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
return torch.from_numpy(arr).unsqueeze(0)
|
||||
|
||||
|
||||
def embed(audio_bytes: bytes) -> np.ndarray:
|
||||
"""Berechnet das Speaker-Embedding fuer einen Audio-Chunk.
|
||||
Erwartet 16kHz int16 LE PCM Mono. Returns 192-dim numpy float32."""
|
||||
import torch
|
||||
model = _ensure_loaded()
|
||||
wav = _audio_bytes_to_tensor(audio_bytes)
|
||||
with torch.no_grad():
|
||||
emb = model.encode_batch(wav)
|
||||
return emb.squeeze().cpu().numpy().astype(np.float32)
|
||||
|
||||
|
||||
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
|
||||
"""Kosinus-Aehnlichkeit zwischen zwei 1D-Vektoren, Range [-1, 1].
|
||||
Hoeher = aehnlicher. Bei normalisierten Vektoren ist das gleich dem Skalarprodukt."""
|
||||
na = np.linalg.norm(a)
|
||||
nb = np.linalg.norm(b)
|
||||
if na < 1e-9 or nb < 1e-9:
|
||||
return 0.0
|
||||
return float(np.dot(a, b) / (na * nb))
|
||||
|
||||
|
||||
def save_fingerprint(embeddings: list[np.ndarray], sample_durations_s: list[float]) -> dict:
|
||||
"""Mittelt + L2-normalisiert die Embeddings und schreibt sie nach
|
||||
FINGERPRINT_FILE. Returns das gespeicherte Dict."""
|
||||
if not embeddings:
|
||||
raise ValueError("Keine Embeddings zum Speichern")
|
||||
VOICE_ID_DIR.mkdir(parents=True, exist_ok=True)
|
||||
stacked = np.stack(embeddings)
|
||||
mean = stacked.mean(axis=0)
|
||||
mean = mean / max(np.linalg.norm(mean), 1e-9)
|
||||
data = {
|
||||
"version": 1,
|
||||
"embedding": mean.tolist(),
|
||||
"embedding_dim": int(mean.shape[0]),
|
||||
"sample_count": len(embeddings),
|
||||
"sample_durations_s": [float(s) for s in sample_durations_s],
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
FINGERPRINT_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
logger.info("[speaker-id] fingerprint gespeichert: %d Samples, dim=%d, total_s=%.1f",
|
||||
len(embeddings), mean.shape[0], sum(sample_durations_s))
|
||||
return data
|
||||
|
||||
|
||||
def load_fingerprint() -> Optional[dict]:
|
||||
"""Returns das Fingerprint-Dict oder None wenn noch nicht enrolled."""
|
||||
if not FINGERPRINT_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(FINGERPRINT_FILE.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
logger.warning("[speaker-id] fingerprint laden fehlgeschlagen: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def delete_fingerprint() -> bool:
|
||||
"""Loescht den Fingerprint (z.B. fuer Re-Enrollment). True wenn was weg ist."""
|
||||
if FINGERPRINT_FILE.exists():
|
||||
FINGERPRINT_FILE.unlink()
|
||||
logger.info("[speaker-id] fingerprint geloescht")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def verify(audio_bytes: bytes, threshold: Optional[float] = None) -> tuple[bool, float]:
|
||||
"""Returns (is_match, similarity).
|
||||
|
||||
Wenn threshold=None: nutzt den Modul-Default (DEFAULT_THRESHOLD) — der wird
|
||||
vom config-Broadcast zur Laufzeit auf den Diagnostic-Slider-Wert gesetzt.
|
||||
Default-Arg-Bindung waere zur Def-Zeit, also bewusst None statt direkt.
|
||||
|
||||
Fail-open: wenn kein Fingerprint vorhanden ist oder das Embedding-Modell
|
||||
crasht, returnt (True, 0.0) — kein Filtering. Sonst wuerde ein kaputter
|
||||
Speaker-ID-Service die ganze Aufnahme blockieren."""
|
||||
if threshold is None:
|
||||
threshold = DEFAULT_THRESHOLD
|
||||
fp = load_fingerprint()
|
||||
if fp is None:
|
||||
return True, 0.0
|
||||
if len(audio_bytes) < MIN_SAMPLE_BYTES:
|
||||
# Zu wenig Audio fuer ein verlaessliches Embedding → durchlassen
|
||||
return True, 0.0
|
||||
try:
|
||||
saved_emb = np.array(fp["embedding"], dtype=np.float32)
|
||||
new_emb = embed(audio_bytes)
|
||||
except Exception as exc:
|
||||
logger.warning("[speaker-id] verify embed failed: %s — fail-open", exc)
|
||||
return True, 0.0
|
||||
sim = cosine_similarity(new_emb, saved_emb)
|
||||
return sim >= threshold, sim
|
||||
|
||||
|
||||
def status() -> dict:
|
||||
"""Status-Snapshot fuer die App / Diagnostic."""
|
||||
fp = load_fingerprint()
|
||||
return {
|
||||
"enrolled": fp is not None,
|
||||
"sample_count": fp.get("sample_count", 0) if fp else 0,
|
||||
"sample_durations_s": fp.get("sample_durations_s", []) if fp else [],
|
||||
"updated_at": fp.get("updated_at") if fp else None,
|
||||
"embedding_dim": fp.get("embedding_dim") if fp else None,
|
||||
"default_threshold": DEFAULT_THRESHOLD,
|
||||
}
|
||||
|
||||
|
||||
def enroll_from_samples(samples_b64: list[str]) -> dict:
|
||||
"""Verarbeitet base64-Samples (16kHz int16 LE PCM Mono) zu einem neuen
|
||||
Fingerprint. Returns Status-Dict. Wirft ValueError wenn nichts brauchbar ist."""
|
||||
if not samples_b64:
|
||||
raise ValueError("Keine Samples uebergeben")
|
||||
embeddings: list[np.ndarray] = []
|
||||
durations: list[float] = []
|
||||
rejected: list[dict] = []
|
||||
for idx, s in enumerate(samples_b64):
|
||||
try:
|
||||
raw = base64.b64decode(s)
|
||||
except Exception as exc:
|
||||
rejected.append({"index": idx, "reason": f"base64: {exc}"})
|
||||
continue
|
||||
# Erst dekodieren (WAV/MP4/AAC → rohes PCM), DANN Laenge pruefen: der
|
||||
# Android-Recorder liefert komprimiertes MP4, dessen Byte-Laenge nichts
|
||||
# ueber die Dauer sagt (4s AAC < 32KB → faelschlich "zu kurz").
|
||||
try:
|
||||
pcm = _normalize_audio_bytes(raw)
|
||||
except Exception as exc:
|
||||
rejected.append({"index": idx, "reason": f"decode: {exc}"})
|
||||
continue
|
||||
if len(pcm) < MIN_SAMPLE_BYTES:
|
||||
rejected.append({"index": idx, "reason": f"zu kurz ({len(pcm)} bytes PCM)"})
|
||||
continue
|
||||
try:
|
||||
emb = embed(pcm)
|
||||
embeddings.append(emb)
|
||||
durations.append(len(pcm) / 2 / 16000.0)
|
||||
except Exception as exc:
|
||||
rejected.append({"index": idx, "reason": f"embed: {exc}"})
|
||||
if not embeddings:
|
||||
raise ValueError(
|
||||
f"Keine Samples konnten verarbeitet werden ({len(rejected)} rejected). "
|
||||
f"Details: {rejected[:3]}"
|
||||
)
|
||||
fingerprint = save_fingerprint(embeddings, durations)
|
||||
fingerprint["rejected"] = rejected
|
||||
return fingerprint
|
||||
+68
-2
@@ -75,7 +75,27 @@ STREAM_SESSION_TTL_S = 120 # tote Sessions nach 2 min aufraeumen
|
||||
# (Whisper oszilliert/halluziniert). Echte akustische Stille ist das robuste
|
||||
# „User hat aufgehoert"-Signal.
|
||||
STREAM_ENERGY_WINDOW_MS = 300 # RMS ueber die letzten 300ms Audio messen
|
||||
STREAM_VOICE_RMS_THRESHOLD = 0.012 # RMS darueber = Sprache (haelt Session am Leben)
|
||||
# --- Adaptiver Voice-Schwellwert (ersetzt die fixe 0.012-Grenze) ---
|
||||
# Problem (Repro dokumentiert in audio.ts): eine FESTE RMS-Grenze schneidet
|
||||
# leises/entferntes Sprechen faelschlich als „Stille" (Handy weiter weg vom Mund,
|
||||
# ruhig im Auto, kurze Sprech-Pause) → Cut mitten im Satz. Loesung: die Grenze
|
||||
# relativ zum gemessenen Rausch-Boden der Session fuehren. Sprache =
|
||||
# noise_floor * Faktor, geklammert auf [MIN, MAX]. Bei noch ungelerntem Boden
|
||||
# gilt MIN → sensibel, lieber nicht abschneiden.
|
||||
STREAM_VOICE_FACTOR = 2.5 # Sprache = noise_floor * Faktor
|
||||
STREAM_VOICE_RMS_MIN = 0.005 # Untergrenze (stiller Raum: nicht auf 0 kollabieren)
|
||||
STREAM_VOICE_RMS_MAX = 0.020 # Obergrenze (lautes Auto: Sprache nie ganz aussperren)
|
||||
STREAM_VOICE_RMS_THRESHOLD = 0.012 # Legacy-Konstante (nicht mehr im Cut-Pfad genutzt)
|
||||
|
||||
# Speaker-ID Gating global an/aus. DEFAULT AUS (fail-open) — bewusster Schalter
|
||||
# ("nur meine Stimme"), kein Automatismus: ein schlechter Enroll darf nie die STT
|
||||
# lahmlegen. Wird per config-Broadcast (voiceIdEnabled) zur Laufzeit gesetzt.
|
||||
SPEAKER_ID_ENABLED = os.getenv("VOICE_ID_ENABLED", "false").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _set_speaker_id_enabled(val: bool) -> None:
|
||||
global SPEAKER_ID_ENABLED
|
||||
SPEAKER_ID_ENABLED = bool(val)
|
||||
# Rein-semantischer Backstop: wenn die Energie NIE faellt (laute Umgebung,
|
||||
# z.B. Auto), endpointen wir trotzdem — aber erst nach diesem Faktor x
|
||||
# endpoint_ms, damit normales Sprechen mit Pausen nicht abgeschnitten wird.
|
||||
@@ -323,6 +343,10 @@ class StreamSession:
|
||||
last_growth_at: float = 0.0
|
||||
last_transcribe_at: float = 0.0
|
||||
last_voice_at: float = 0.0 # letzter Tick mit akustischer Sprach-Energie
|
||||
noise_floor: float = 0.0 # adaptiver Rausch-Boden (0.0 = noch ungelernt)
|
||||
voice_factor: float = STREAM_VOICE_FACTOR # per-Session konfigurierbar (Payload voiceFactor)
|
||||
voice_rms_min: float = STREAM_VOICE_RMS_MIN
|
||||
voice_rms_max: float = STREAM_VOICE_RMS_MAX
|
||||
closed: bool = False # nach stream_end gesetzt
|
||||
endpoint_sent: bool = False # Endpoint nur einmal feuern
|
||||
# Speaker-ID Gating: bei aktiviertem Fingerprint pruefen wir die ersten
|
||||
@@ -372,6 +396,10 @@ class SessionManager:
|
||||
speed = float(payload.get("speed") or 1.0)
|
||||
except (TypeError, ValueError):
|
||||
speed = 1.0
|
||||
try:
|
||||
voice_factor = float(payload.get("voiceFactor") or STREAM_VOICE_FACTOR)
|
||||
except (TypeError, ValueError):
|
||||
voice_factor = STREAM_VOICE_FACTOR
|
||||
session = StreamSession(
|
||||
request_id=request_id,
|
||||
audio_request_id=payload.get("audioRequestId", "") or "",
|
||||
@@ -381,6 +409,7 @@ class SessionManager:
|
||||
hard_cap_ms=hard_cap_ms,
|
||||
voice=payload.get("voice", "") or "",
|
||||
speed=speed,
|
||||
voice_factor=voice_factor,
|
||||
interrupted=bool(payload.get("interrupted", False)),
|
||||
location=payload.get("location") or None,
|
||||
sample_rate=int(payload.get("sampleRate") or 16000),
|
||||
@@ -448,6 +477,11 @@ class SessionManager:
|
||||
Ohne Fingerprint → fail-open (match=True). Bei mismatch wird die
|
||||
Session sofort beendet mit synthetischem stt_endpoint."""
|
||||
sess.speaker_checked = True
|
||||
# Schalter aus (Default) → gar keine Pruefung, alles durchlassen.
|
||||
if not SPEAKER_ID_ENABLED:
|
||||
sess.speaker_match = True
|
||||
sess.speaker_similarity = 0.0
|
||||
return
|
||||
# Erste ~1.5s aus dem Buffer entnehmen (16kHz * 2 byte/sample = 32 bytes/ms)
|
||||
head_bytes = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32])
|
||||
if len(head_bytes) < speaker_id.MIN_SAMPLE_BYTES:
|
||||
@@ -549,8 +583,16 @@ class SessionManager:
|
||||
# Akustische Sprach-Aktivitaet JEDEN Tick (~200ms) messen — unabhaengig
|
||||
# vom Transcribe-Throttle. Solange wirklich gesprochen wird, bleibt die
|
||||
# Session am Leben, auch wenn Whisper gerade keinen neuen Text liefert.
|
||||
if self._tail_rms(sess) >= STREAM_VOICE_RMS_THRESHOLD:
|
||||
# ADAPTIV: der Schwellwert richtet sich nach dem gemessenen Rausch-Boden
|
||||
# (fast-down/slow-up), damit leises/entferntes Sprechen nicht faelschlich
|
||||
# als Stille gilt und der Satz mitten drin abgeschnitten wird.
|
||||
rms = self._tail_rms(sess)
|
||||
if rms >= self._voice_threshold(sess):
|
||||
sess.last_voice_at = now
|
||||
else:
|
||||
# Rausch-Boden NUR aus Nicht-Sprache lernen — waehrend Sprache
|
||||
# einfrieren, sonst wandert die Grenze hoch und sperrt Sprache aus.
|
||||
self._update_noise_floor(sess, rms)
|
||||
|
||||
# Endpoint-Entscheidung JEDEN Tick, sobald ueberhaupt Text erkannt wurde:
|
||||
# (a) akustisch: seit endpoint_ms keine Sprach-Energie mehr → User ist
|
||||
@@ -620,6 +662,26 @@ class SessionManager:
|
||||
return 0.0
|
||||
return (samples / sess.sample_rate) * 1000.0
|
||||
|
||||
def _voice_threshold(self, sess: StreamSession) -> float:
|
||||
"""Adaptiver Voice-Schwellwert = Rausch-Boden * Faktor, geklammert auf
|
||||
[min, max]. Bei noch ungelerntem Boden (0.0) → Untergrenze: sensibel,
|
||||
lieber nicht abschneiden (das war der eigentliche Cutoff-Bug)."""
|
||||
nf = sess.noise_floor
|
||||
if nf <= 0.0:
|
||||
return sess.voice_rms_min
|
||||
return min(max(nf * sess.voice_factor, sess.voice_rms_min), sess.voice_rms_max)
|
||||
|
||||
def _update_noise_floor(self, sess: StreamSession, rms: float) -> None:
|
||||
"""Rausch-Boden nachfuehren: schnell runter (neue, leisere Stille),
|
||||
langsam rauf (Umgebung wird lauter). NUR mit Nicht-Sprache aufrufen."""
|
||||
nf = sess.noise_floor
|
||||
if nf <= 0.0:
|
||||
sess.noise_floor = rms
|
||||
elif rms < nf:
|
||||
sess.noise_floor = 0.90 * nf + 0.10 * rms
|
||||
else:
|
||||
sess.noise_floor = 0.98 * nf + 0.02 * rms
|
||||
|
||||
def _tail_rms(self, sess: StreamSession) -> float:
|
||||
"""RMS-Energie der letzten STREAM_ENERGY_WINDOW_MS des Audio-Buffers.
|
||||
Dient als akustisches „redet noch / ist still"-Signal."""
|
||||
@@ -928,6 +990,10 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
|
||||
logger.info("[speaker-id] threshold gesetzt: %.2f", t)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if "voiceIdEnabled" in payload:
|
||||
_set_speaker_id_enabled(payload.get("voiceIdEnabled"))
|
||||
logger.info("[speaker-id] Gating %s (voiceIdEnabled)",
|
||||
"AN" if SPEAKER_ID_ENABLED else "AUS")
|
||||
if "whisperDebugLog" in payload:
|
||||
global _DEBUG_LOG_TO_BRIDGE
|
||||
old = _DEBUG_LOG_TO_BRIDGE
|
||||
|
||||
@@ -61,10 +61,40 @@ def _ensure_loaded():
|
||||
return _model
|
||||
|
||||
|
||||
def _decode_compressed_to_pcm(audio_bytes: bytes) -> bytes:
|
||||
"""Dekodiert komprimiertes Audio (MP4/M4A/AAC vom Android-Recorder) via ffmpeg
|
||||
(im Container vorhanden) auf rohes 16kHz mono int16 LE PCM. Input geht ueber
|
||||
eine Temp-Datei (nicht Pipe): Androids MediaRecorder legt das moov-Atom ans
|
||||
ENDE, das braucht seekbaren Input, sonst 'moov atom not found'."""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
tmp = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tf:
|
||||
tf.write(audio_bytes)
|
||||
tmp = tf.name
|
||||
proc = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", tmp,
|
||||
"-f", "s16le", "-ac", "1", "-ar", "16000", "pipe:1"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
if proc.returncode != 0 or not proc.stdout:
|
||||
raise ValueError(
|
||||
f"ffmpeg decode failed: {proc.stderr.decode('utf-8', 'ignore')[:200]}")
|
||||
return proc.stdout
|
||||
finally:
|
||||
if tmp:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
|
||||
"""Akzeptiert entweder rohes 16kHz int16 LE PCM ODER eine WAV-Datei (RIFF/WAVE).
|
||||
Bei WAV wird der Header gestrippt + Format validiert (16kHz / mono / int16).
|
||||
Ergebnis: rohes PCM."""
|
||||
"""Akzeptiert rohes 16kHz int16 LE PCM, eine WAV-Datei (RIFF/WAVE) ODER einen
|
||||
komprimierten MP4/M4A/AAC-Container (Android-Recorder). WAV → Header strippen +
|
||||
Format validieren; MP4/AAC → via ffmpeg dekodieren. Ergebnis: rohes PCM."""
|
||||
if (len(audio_bytes) >= 44
|
||||
and audio_bytes[:4] == b"RIFF"
|
||||
and audio_bytes[8:12] == b"WAVE"):
|
||||
@@ -81,6 +111,9 @@ def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
|
||||
if sw != 2:
|
||||
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
|
||||
return wav.readframes(wav.getnframes())
|
||||
# MP4/M4A/AAC-Container: Android-AAC-Recorder legt 'ftyp' bei Offset 4 an.
|
||||
if len(audio_bytes) >= 12 and audio_bytes[4:8] == b"ftyp":
|
||||
return _decode_compressed_to_pcm(audio_bytes)
|
||||
return audio_bytes
|
||||
|
||||
|
||||
@@ -212,13 +245,21 @@ def enroll_from_samples(samples_b64: list[str]) -> dict:
|
||||
except Exception as exc:
|
||||
rejected.append({"index": idx, "reason": f"base64: {exc}"})
|
||||
continue
|
||||
if len(raw) < MIN_SAMPLE_BYTES:
|
||||
rejected.append({"index": idx, "reason": f"zu kurz ({len(raw)} bytes)"})
|
||||
# Erst dekodieren (WAV/MP4/AAC → rohes PCM), DANN Laenge pruefen: der
|
||||
# Android-Recorder liefert komprimiertes MP4, dessen Byte-Laenge nichts
|
||||
# ueber die Dauer sagt (4s AAC < 32KB → faelschlich "zu kurz").
|
||||
try:
|
||||
pcm = _normalize_audio_bytes(raw)
|
||||
except Exception as exc:
|
||||
rejected.append({"index": idx, "reason": f"decode: {exc}"})
|
||||
continue
|
||||
if len(pcm) < MIN_SAMPLE_BYTES:
|
||||
rejected.append({"index": idx, "reason": f"zu kurz ({len(pcm)} bytes PCM)"})
|
||||
continue
|
||||
try:
|
||||
emb = embed(raw)
|
||||
emb = embed(pcm)
|
||||
embeddings.append(emb)
|
||||
durations.append(len(raw) / 2 / 16000.0)
|
||||
durations.append(len(pcm) / 2 / 16000.0)
|
||||
except Exception as exc:
|
||||
rejected.append({"index": idx, "reason": f"embed: {exc}"})
|
||||
if not embeddings:
|
||||
|
||||
Reference in New Issue
Block a user