Compare commits

..
192 Commits
Author SHA1 Message Date
duffyduck baf9e93c2a release: bump version to 0.2.5.8 2026-10-25 12:55:10 +01:00
duffyduckandClaude Opus 5.5 db2055ff3b fix: alte App-Versionen in der Raum-Liste erkennen + HF-Token aus zwei weiteren Log-Zeilen
RVS: die ARIA-App wird auch ohne client_hello erkannt, sobald sie eine
Nachricht schickt, die nur sie sendet (app_log mit platform android/ios,
location_update, stt_lease_request, update_download, vnc_open).

Bridge: "Voice-Config geladen" und "Voice-Config gespeichert" schrieben
den huggingfaceToken noch im Klartext ins Log — jetzt ***.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:52:36 +01:00
duffyduckandClaude Opus 5.5 5a59ea3930 fix(xtts): Flotte zeigt die echte Karte statt "GPU void"
Das NVIDIA-Container-Toolkit ersetzt NVIDIA_VISIBLE_DEVICES im Prozess durch
"void" — whisper/f5tts/voxtral meldeten das als GPU. Die Compose gibt die
Karte jetzt zusaetzlich als ARIA_GPU mit (nur Anzeige).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:51:41 +01:00
duffyduckandClaude Opus 5.5 7b4a1f4429 feat(diagnostic): Liste, wer gerade mit dem RVS-Raum verbunden ist
Einstellungen, zwischen Runtime-Konfiguration und App-Onboarding: alle
Verbindungen im Raum des konfigurierten Tokens — Art, Name, Details,
Adresse, verbunden seit, zuletzt aktiv.

RVS merkt sich pro Verbindung, wer sie ist: aus Nachrichten, die ohnehin
fliessen (sat_hello, host_hello, worker_hello, update_check der App,
llm_cluster_*), oder aus dem neuen client_hello (Bridge, Diagnostic, App
mit Geraetename). Adresse aus X-Forwarded-For hinter einem Reverse-Proxy.
room_clients_query liefert NUR den eigenen Raum; room_clients von Clients
wird nicht weitergereicht. Typen in der Allow-List ergaenzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:34:14 +01:00
duffyduckandClaude Opus 5.5 2267f0a726 docs(rvs): WebSocket-Weiterleitung beim Reverse-Proxy deutlich markieren + Testbefehl
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:29:30 +01:00
duffyduckandClaude Opus 5.5 8cc85c1fe1 feat(rvs): interner Port optional verschluesselt (RVS_HTTP_TLS)
Mit RVS_HTTP_TLS=true spricht der RVS auf RVS_HTTP_PORT HTTPS/WSS statt
Klartext — damit ist auch die Strecke externer Proxy → RVS verschluesselt.
Das oeffentliche Zertifikat hat der Proxy; hier reicht ein selbstsigniertes,
das beim ersten Start in data/rvs-tls/ erzeugt wird (eigenes darf dort
liegen). Image bekommt dafuer openssl. Nur ohne den Caddy im Stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:23:24 +01:00
duffyduckandClaude Opus 5.5 88036bcad3 feat(rvs): Caddy optional per COMPOSE_PROFILES — oder eigener Reverse-Proxy
Neu in rvs/.env: COMPOSE_PROFILES=caddy (wie bisher, Caddy macht TLS) oder
leer (ohne Caddy). Der RVS bietet dafuer immer einen unverschluesselten Port
an (RVS_HTTP_BIND:RVS_HTTP_PORT, Default 127.0.0.1:3000), auf den ein
vorhandener nginx/Traefik zeigt — statt nginx → Caddy → RVS mit doppeltem TLS.
Caddys HTTPS-Port ist jetzt CADDY_HTTPS_PORT (Default 444 wie bisher).

README: beide Modi, nginx-Vorlage mit WebSocket-Headern und IPv6, Hinweis
dass Caddy fuer die Zertifikats-Erneuerung Port 80 von aussen braucht.
Bestehende Installationen mit Caddy muessen COMPOSE_PROFILES=caddy eintragen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:22:11 +01:00
duffyduck 35436bff3c release: bump version to 0.2.5.7 2026-10-25 12:01:35 +01:00
duffyduck b82df7dfca release: bump version to 0.2.5.6 2026-10-25 10:40:09 +01:00
duffyduckandClaude Opus 5.5 3b63831c86 feat(app): Schloss-Symbol fuer die Verbindung + abgelaufenes Zertifikat erlaubbar
Neben dem Zahnrad zeigt ein Schloss, wie sicher die Verbindung zu ARIA ist:
Schloss = verschluesselt und in Ordnung, Schloss mit ! = Zertifikat-Problem
(abgelaufen oder abgelehnt), durchgestrichen = unverschluesselt (TLS-Fallback
oder so eingestellt). Antippen erklaert es in einfachen Worten; einmal pro
App-Start kommt ein Hinweis, wenn das Zertifikat abgelaufen ist.

Einstellungen → Verbindung: "Abgelaufenes Zertifikat akzeptieren". Toleriert
GENAU das Ablaufdatum: die Kette wird zum Zeitpunkt kurz vor dem Ablauf gegen
die System-CAs geprueft, der Hostname weiter von OkHttp. Selbstsignierte oder
fremde Zertifikate kommen auch mit Schalter nicht durch (gegen das echte,
abgelaufene RVS-Zertifikat und badssl.com getestet). Gilt fuer WebSocket und
fetch (z.B. App-Update vom RVS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:31:51 +02:00
duffyduckandClaude Opus 5.5 7da552f249 fix(log): HuggingFace-Token und andere Geheimnisse nicht mehr im Klartext im Log
Die Bridge schrieb beim Config-Broadcast den kompletten Payload ins Log,
inklusive huggingfaceToken. Jetzt werden Werte von Schluesseln wie token,
secret, passw, apikey, credential als *** ausgegeben (Zaehler wie maxTokens
bleiben sichtbar). Dasselbe fuer das Debug-Log der RVS-Nachrichten in der
Diagnostic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:18:05 +02:00
duffyduckandClaude Opus 5.5 79a901089b feat(llm): Kontextfenster pro Karte einstellbar + Fehlerart context_exceeded
Anlass: die Software-Schmiede bekam "request (8274 tokens) exceeds the
available context size (8192 tokens)". Sie holt ctx vom Cluster zwar ab,
kuerzt aber nach einem eigenen, von Hand eingetragenen Wert (32768).

ARIA-Seite:
- Neue Fehlerart context_exceeded (statt model_error) mit promptTokens und ctx
  — Programme koennen automatisch kuerzen und nochmal senden, statt Text
  auszuwerten. llm-adapter liest es aus llama.cpps exceed_context_size_error,
  der Cluster-Router reicht es durch und wiederholt es nicht anderswo.
- Diagnostic → LLM-Karten: Kontextfenster pro Karte (4k … 128k). Laedt das
  Modell mit neuem -c; Programme sehen den neuen Wert ueber ctx. Passt es
  nicht in den Grafikspeicher, setzt die Box zurueck — jetzt inkl. Registry
  (vorher blieb bei einem Fehlschlag der zu grosse ctx in aria_models.json
  stehen und waere beim naechsten Start wieder gescheitert).
- Doku: context_exceeded, ctx kann sich aendern, "danach kuerzen, nicht nur
  anzeigen".

Getestet gegen llama-swap: Kontext 512 → 1024 (gleiche Karte), Fehlschlag
bei 65536 → Registry + Config wieder 1024, zu langer Verlauf →
context_exceeded mit promptTokens=6030, ctx=1024.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:32:37 +02:00
duffyduckandClaude Opus 5.5 f8ae2f4613 fix(llm): kaputte Werkzeug-Argumente im Verlauf lassen nicht mehr die ganze Anfrage scheitern
Zwischendurch kam bei Programmen (Software-Schmiede) "model_error: Server
error '500 …'" — nach "nochmal versuchen" lief es weiter. Ursache, auf der
Box nachgestellt: steht im Verlauf ein frueherer Werkzeugaufruf, dessen
arguments kein gueltiges JSON sind (typisch bei Code: Dateiinhalt mit
Anfuehrungszeichen/Umbruechen), lehnt llama.cpp (Stand der Box, Aug. 2026)
die GANZE Anfrage mit 500 ab — und jede weitere mit demselben Verlauf.
Neuere llama.cpp-Versionen nehmen es an.

llm-adapter:
- _sanitize_messages: solche arguments werden als {"_raw": "…"} verpackt,
  dict-arguments zu JSON, fehlende zu "{}". Nur Kopien; gueltige bleiben
  unangetastet. Auf der Box: kaputt → 500, eingepackt → 200.
- _llama_error_text: bei HTTP-Fehlern kommt der GRUND von llama.cpp mit
  (z.B. "request (9017 tokens) exceeds the available context size (8192)")
  statt nur "Server error '500'". Volle Antwort ins Box-Log — falls es noch
  einen anderen Ausloeser gibt, steht er dort.

Hinweis: ob das der einzige Ausloeser ist, ist offen — die urspruengliche
Fehlerantwort war 4,6 KB gross, diese Ablehnung nur 265 Byte. Mit dem
durchgereichten Grund ist der naechste Fall sofort zu sehen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:56:42 +02:00
duffyduckandClaude Opus 5.5 32d34f39cd fix(xtts): LLM auf der GPU + Container finden nach dem Reboot den RVS
Zwei Ursachen, beide auf der KI-Box nachgewiesen:

1. llama.cpp fand auch mit Treiber 615 keine Karte ("unsupported display
   driver / cuda driver combination"). Das llama-swap-Image traegt in seiner
   ld.so.conf die eigene CUDA-Kompatibilitaets-libcuda (575) VOR dem vom
   Container-Toolkit eingehaengten Host-Treiber (615) ein. Die Kompatibilitaets-
   bibliothek hilft nur Rechenzentrumskarten; bei GeForce scheitert llama.cpp
   damit — mit aelterem Treiber ("forward compatibility …") wie mit neuerem —
   und rechnet still auf der CPU. Jetzt LD_LIBRARY_PATH mit dem Host-Treiber
   zuerst. Auf der Box getestet: llama.cpp sieht CUDA0 + CUDA1. Whisper und
   F5-TTS (andere Images) sehen ihre Karte mit 615 ohnehin.

2. Nach dem Reboot erreichte kein Container den RVS ("Temporary failure in name
   resolution"). Debian traegt die Netzwerkkarte als allow-hotplug ein; darauf
   wartet network-online.target nicht. Docker startete die Container 2 s nach
   "online", bevor DHCP den DNS-Server lieferte ("DNS resolver has no external
   nameservers"), und die Container behalten diesen leeren Stand. Passiert bei
   jedem Reboot. bootstrap.sh legt jetzt ein systemd-Drop-in an: Docker wartet
   vor dem Start bis zu 90 s auf einen Nameserver in /etc/resolv.conf, dann
   startet es trotzdem. Netzkonfiguration bleibt unangetastet. Escaping und
   beide Pfade (DNS da / nicht da) in einer echten systemd-Unit getestet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:42:06 +02:00
duffyduckandClaude Opus 5.5 f0d6ae63a0 fix(xtts): Treiber-Upgrade liess die Box ohne GPU zurueck — Nachbereitung
Nach "--upgrade-driver" + Reboot lud der freie nouveau-Treiber statt nvidia,
nvidia-smi fehlte, und Docker startete keinen GPU-Container mehr ("failed to
initialize NVML: Driver Not Loaded"). Zwei Stolperfallen, die in der
apt-Simulation vorher nicht sichtbar waren:

1. NVIDIAs Paket "nvidia-smi" ist nur noch Doku. Das Programm — und
   Bibliotheken, die das Container-Toolkit braucht — steckt bei NVIDIA in
   "nvidia-driver-cuda". Jetzt in der Paketliste.
2. /etc/modprobe.d/nvidia.conf (sperrt nouveau) war ein Verweis ueber das
   Alternativen-System. Die entfernten Altpakete nehmen ihn mit, dpkg legt die
   neue Datei nur als nvidia.conf.dpkg-new ab. Jetzt: tote Verweise weg,
   .dpkg-new aktivieren (oder eigene Sperre schreiben), initramfs fuer alle
   Kernel neu bauen.

nv_postfix laeuft nach der Installation UND, wenn der Treiber schon neu ist —
ein erneuter --upgrade-driver-Lauf repariert also eine Box, die schon in
diesem Zustand steckt. Idempotent; meldet nur Erfolg, wenn das Schreiben
geklappt hat. Getestet gegen den nachgestellten Zustand der Box (erst
Reparatur + Reboot-Hinweis, zweiter Lauf: nichts zu tun).

Dazu: die DKMS-Pruefung las den Kernelnamen als Regex — "6.12.101+deb13"
passte darum nie, die Warnung "nicht gebaut" war falsch. Jetzt -F, und fuer
jeden installierten Kernel (apt hatte nebenbei 6.12.113 mitgebracht, der dann
startete).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:34:02 +02:00
duffyduckandClaude Opus 5.5 9be14eef5e fix(diagnostic): Karten kommen im Browser an, lokales Modell springt nicht zurueck
1. "Die LLM-Box meldet noch keine Karten": die Box schickte die Karten-
   Belegung, aber workerList() im Diagnostic-Server gibt nur eine feste
   Feldliste an den Browser — slots stand nicht drauf. Meine UI-Tests haben
   die Flotte direkt im Browser gesetzt und sind an genau dieser Stelle
   vorbeigelaufen. Jetzt Ende-zu-Ende getestet: echter Diagnostic-Server +
   RVS + Box-Meldung → Browser (per DevTools-Protokoll gelesen).

2. Lokales Modell sprang auf "Qwen3 8B" zurueck: die Auswahl wurde gespeichert,
   aber die Seite behielt intern das alte Modell und stellte das Dropdown beim
   naechsten Lebenszeichen der Box (10 s) darauf zurueck. Dazu wie gewuenscht
   umgebaut: nur Modelle, die im Cluster geladen sind, jedes einmal, ohne
   Karte. Ist das gewaehlte gerade nirgends geladen: "(offline)" — geht
   automatisch weiter, sobald eine Box es hat. Nichts da: "kein Modell
   verfuegbar". Waehrend die Auswahl offen ist, wird nicht umgebaut.

3. "Bereit", aber Karte leer: der llm-adapter meldet pro Karte den belegten
   VRAM. Laeuft ein Modell "ready" und die Karte bleibt unter 400 MB, rechnet
   llama.cpp auf der CPU (Treiber zu alt fuer das CUDA im Image) — das sagt
   llama.cpp nicht nach aussen. Diagnostic zeigt es jetzt in Flotte,
   LLM-Karten und Modell-Auswahl ("laeuft auf der CPU").

4. bootstrap.sh: der normale Lauf meldete "✓ Treiber aktiv", auch wenn er fuer
   das LLM zu alt ist. Jetzt Warnung mit Version und dem Befehl
   (--upgrade-driver), wenn das LLM-Profil aktiv ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:11:02 +02:00
duffyduckandClaude Opus 5.5 901cd1c16b fix(xtts): --upgrade-driver holt den Treiber von NVIDIA statt aus den Backports
Das lokale LLM rechnete auf der KI-Box die ganze Zeit auf der CPU: Treiber
550 (Debian, auch trixie-backports — mehr gibt es dort nicht) kann CUDA 12.4,
die llama.cpp-/llama-swap-Images brauchen 12.8/12.9. llama.cpp meldet dann
"failed to initialize CUDA … forward compatibility …", findet keine Karte
und laeuft still auf der CPU weiter — "ready" meldet es trotzdem. Whisper und
F5-TTS (CUDA 12.2) waren nicht betroffen.

--upgrade-driver installierte bisher "nvidia-driver" aus den Backports und
meldete Erfolg — es kam aber derselbe 550er. Jetzt: NVIDIAs Paketquelle fuer
Debian 13 (Treiber 615), Upgrade an Ort und Stelle.

Vorher auf der Box simuliert (apt -s in einem Temp-Verzeichnis, nichts
veraendert): ALLE installierten NVIDIA-Pakete mit hochzuziehen scheitert an
Konflikten — NVIDIA hat Debians Teilpakete (nvidia-driver-bin,
nvidia-alternative, …) zusammengefasst. Nur nvidia-driver, nvidia-kernel-dkms,
nvidia-smi, libcuda1 (+ persistenced/modprobe, wenn installiert) anfordern →
apt loest es sauber: 30 aktualisiert, 8 neu, 10 Altpakete entfernt,
Container-Toolkit unberuehrt.

Sicherungen: spielt erst durch (apt -s); bricht OHNE Aenderung ab und nimmt
die Paketquelle wieder raus, wenn apt keinen Weg findet oder das
Container-Toolkit anfassen wuerde. Zeigt den Plan und fragt nach (--yes zum
Ueberspringen). Prueft danach Version und DKMS-Build. Ablauf in einem
frischen Debian-13-Container getestet (Installation abgefangen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:43:09 +02:00
duffyduckandClaude Opus 5.5 c26b27f09f feat(llm): Cluster — Programme fragen ein Modell, der Cluster waehlt die Karte
Programme sollen nicht Box und Karte aussuchen muessen. Die ARIA-Bridge kennt
alle Boxen samt Karten-Belegung und verteilt (bridge/llm_cluster.py):

- Auswahl: unter allen online Boxen mit dem Modell die mit den wenigsten
  laufenden Anfragen, bei Gleichstand reihum. Eine Karte / alles frei →
  alles dorthin ("verteilen, wenn noetig").
- Ausfall: geht eine Box waehrend der Anfrage offline (kein Ping mehr), wird
  sie EINMAL an eine andere Box mit demselben Modell gegeben. Modell-Fehler
  (z.B. Kontext zu lang) werden nicht wiederholt — waeren ueberall gleich.
- Fehler kommen als Fehler (ok=false + errorCode), nie als Antwort-Text: ein
  Programm wuerde ihn sonst als Aussage der KI in seinen Verlauf schreiben —
  so ist bei ARIA schon mal ein ganzer Chat kaputtgegangen.

RVS: llm_cluster_models_request → llm_cluster_models (jedes Modell EINMAL,
ctx = kleinstes Kontextfenster unter seinen Karten), llm_cluster_request →
llm_cluster_response. In der RVS-Allow-List freigegeben.

ARIA nutzt denselben Router (mit ihrer 30-s-Frist fuer den lokalen
Schnellweg) — vorher Round-Robin ohne Ausfall-Ersatz, und errorCode ging
verloren.

xtts/openai_rvs_wrapper: fuer Programme, die nur "OpenAI-kompatibel" koennen
(OpenHands & Co.). Kleiner Container NEBEN dem Programm, fuer das der Cluster
dann http://127.0.0.1:8099/v1 ist; braucht nur eine ausgehende Verbindung zum
RVS. /v1/models (jedes Modell einmal, context_length), /v1/chat/completions
(auch stream und tools), Fehler als HTTP mit OpenAI-Fehlerobjekt. Hilfsseite
auf / zeigt Status, Base URL und Modelle. Fuer Programme in Docker: Netz
aria-llm, Name openai-rvs-wrapper. .env.example mit RVS_HOST/PORT/TOKEN.
(Ein OpenAI-Endpunkt direkt auf der VM war kurz drin und ist wieder raus —
der Wrapper braucht keinen offenen Port auf der VM.)

docs/LLM-SCHNITTSTELLE.md neu gegliedert: A) Cluster ueber RVS (empfohlen),
B) Wrapper, C) direkt an eine Box (Sonderfall), plus "Umstellen" fuer
bestehende Clients (Software-Schmiede).

Tests: mit echtem RVS und zwei echten llm-adaptern als zwei Boxen —
Modell einmal gelistet, 6 parallele Anfragen 3/3 verteilt, Box friert
mitten in der Anfrage ein → nach 15 s von der anderen beantwortet
(attempts=2), danach direkt die gesunde, nicht geladenes Modell →
model_unavailable ohne content. Wrapper als Docker-Container gegen das
offizielle OpenAI-SDK: models.list, create mit/ohne stream, NotFoundError,
AuthenticationError. Das Doku-Beispiel lief unveraendert (nur Adresse).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:43:09 +02:00
duffyduckandClaude Opus 5.5 60f2d2e087 feat(diagnostic): pro LLM-Karte ein Dropdown — und Kontextfenster an Clients melden
Einstellungen → Modelle & KI → "LLM-Karten": fuer jede Karte, die auf einer
Box fuers LLM vorgesehen ist, ein Dropdown mit den Katalog-Modellen und
"— leer —" (entlaedt die Karte). Nur echte LLM-Karten erscheinen. Modelle,
die auf derselben Box schon auf einer anderen Karte liegen, werden nicht
angeboten; zu grosse (Katalog-Groesse > VRAM der Karte) sind als "passt
nicht" markiert. Waehrend eines Ladevorgangs ist das Dropdown gesperrt und
zeigt den Fortschritt.

Box meldet dafuer pro Karte den VRAM (nvidia-smi memory.total) und das
Kontextfenster, mit dem llama-server das Modell startet (-c).

Kontext an Clients: llm_models enthaelt jetzt "ctx". Anlass: die Software-
Schmiede rechnete mit 32768 Tokens, die Box startet die Modelle mit 8192 —
lange Auftraege waeren gescheitert oder falsch gekuerzt worden, und der Client
konnte es nicht wissen. Doku entsprechend ergaenzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:22:06 +02:00
duffyduckandClaude Opus 5.5 a1aa90177d fix(diagnostic): Status-Meldung unten rechts immer wegklickbar
Das X war absichtlich versteckt, sobald ein Dienst einen Fehler meldete — es
erschien nur, wenn alles gruen war. Gerade eine Fehlermeldung blieb also
stehen. Jetzt ist das X immer da; Wegklicken vergisst Fehler- und
Fertig-Meldungen (sonst kaemen sie mit der naechsten Statusmeldung eines
anderen Dienstes wieder hoch), ein laufender Ladevorgang darf wiederkommen.

Dazu: httpx haengt an jeden Fehler "For more information check: <Mozilla-
Link>" — raus, in der Anzeige und im llm-adapter an der Quelle. Bei nur einer
Meldung stand das ❌ doppelt (grosses Symbol + Zeile) — jetzt einmal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:46:25 +02:00
duffyduckandClaude Opus 5.5 ae79d9e8f6 fix(llm): Modelle mit anderer Quantisierung laden (Gemma 4 von unsloth)
Laden von unsloth/gemma-4-12B-it-qat-GGUF scheiterte mit "500": der Katalog
trug pauschal Q4_K_M ein, das Repo hat aber nur UD-Q4_K_XL. llama-server fand
die Datei nicht und beendete sich sofort; llama-swap reicht den Grund nicht
weiter ("upstream command exited prematurely"), es kam nur "500" an.

llm-adapter: fragt vor dem Laden die Dateiliste des Repos ab. Gibt es die
gewuenschte Quantisierung nicht, nimmt er die naechstbeste aus einer festen
Reihenfolge (Q4_K_M, UD-Q4_K_XL, Q4_K_XL, Q4_K_S, IQ4_XS, …) und sagt das im
Download-Fenster an. Kein ladbares GGUF im Repo → sofortige Absage, die Karte
bleibt unberuehrt. Bild-Projektor (mmproj), MTP-Zusatzdateien und imatrix
zaehlen nicht als Modell. Endet llama-server trotzdem sofort, nennt die
Meldung die haeufigsten Gruende statt nur "500".

Diagnostic: "Von HuggingFace aktualisieren" traegt pro HF-Repo die tatsaechlich
vorhandene Quantisierung und die echte Groesse ein (bisher 0 GB) — auch bei
schon vorhandenen HF-Eintraegen. Repos ohne ladbares Modell kommen nicht mehr
in den Katalog bzw. werden als "nicht ladbar" markiert. Katalog zeigt die
Quantisierung pro Modell.

Gemma 4 selbst: llama.cpp kann es seit Juni (#24077), der Stand auf der Box ist
vom 14.08. — es lag nur an der Datei.

Getestet gegen echte HF-Repos (Gemma → UD-Q4_K_XL 6,7 GB, Qwen/Mistral bleiben
Q4_K_M) in Adapter und Diagnostic identisch, und gegen llama-swap: Ausweichen
angesagt + geladen; Repo ohne GGUF → Absage ohne Download.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:43:55 +02:00
duffyduckandClaude Opus 5.5 23feb3ba2d feat(llm): ein Modell pro Karte, Clients waehlen nur aus — und Doku fuer Drittprogramme
Bisher konnte jeder Client per Modellname llama-swap zum Wechseln bringen —
ein Fremdprogramm haette ARIA das Modell unter den Fuessen weggetauscht.
Jetzt:

- Jede LLM-Karte einer Box (LLM_GPUS=1,2, alt LLM_GPU=1) haelt GENAU ein
  Modell, dauerhaft. Welches, legt nur die Diagnostic fest. Karten, die nicht
  fuers LLM vorgesehen sind, tauchen beim LLM nicht auf und werden als Ziel
  abgelehnt.
- Clients (ARIA, Fremdprogramme ueber RVS, OpenHands ueber den OpenAI-Port)
  sehen nur die geladenen Modelle und laden nie: Anfrage fuer ein anderes →
  model_not_loaded mit Liste des Geladenen (RVS) bzw. HTTP 404 (OpenAI-Port).
  Nichts wird verdraengt.
- Mehrere Clients koennen dasselbe Modell gleichzeitig nutzen.

llm-adapter: Belegung in /models/aria_slots.json. Die llama-swap-Config
enthaelt nur belegte Karten: pro Modell CUDA_VISIBLE_DEVICES=<Karte>, ttl 0,
groups.swap=false, hooks.on_startup.preload. Alles gegen llama-swap
ausprobiert (zwei Modelle gleichzeitig ready, keins verdraengt, Unbekanntes
404, env kommt beim jeweiligen llama-server an). llama-swap laeuft mit
--watch-config; die frueher aufgerufenen Reload-Endpunkte (/reload,
/api/config/reload) gibt es nicht (404). Neu: llm_unload_model (Karte leeren),
llm_models_request → llm_models (Erkundung, jede Box antwortet einzeln).
Fehlgeschlagenes Laden setzt die Karte auf ihr vorheriges Modell zurueck,
sonst risse jedes Neuladen die anderen Karten mit. Uebernahme: ohne
Belegungsdatei landet LLM_MODEL auf der ersten LLM-Karte.

Compose: llama-swap sieht alle Karten (NVIDIA_VISIBLE_DEVICES=all) und
zaehlt sie wie nvidia-smi (CUDA_DEVICE_ORDER=PCI_BUS_ID); das Pinnen pro
Modell macht der Adapter.

Bridge: uebernimmt models/slots auch aus worker_ping (nach "Entladen" sonst
30 s veraltet) und sagt sofort ab, wenn das Modell auf keiner Box liegt,
statt per Broadcast in den Timeout zu laufen.

Diagnostic: Flotte zeigt pro LLM-Karte Modell + Zustand + "Entladen";
Katalog waehlt eine Ziel-KARTE (leere vorausgewaehlt, Ersetzen mit
Rueckfrage); Download-Fenster nennt Karte und was ersetzt wird.

RVS: llm_unload_model, llm_models_request, llm_models freigegeben.

docs/LLM-SCHNITTSTELLE.md: Schnittstelle fuer Drittprogramme (RVS + OpenAI-
Port), Fehlercodes, Gleichzeitigkeit, Python-Beispiel.

Tests: 23 gegen llama-swap (Adapter direkt) + Ende-zu-Ende ueber den echten
RVS mit dem Adapter als Prozess: Laden per RVS mit Fortschritt, das
Doku-Beispiel unveraendert (nur Adresse) findet beide Karten und bekommt eine
Antwort, Absage/Schweigen wie dokumentiert, zwei Clients parallel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:04:24 +02:00
duffyduckandClaude Opus 5.5 89cb3a765b feat(llm): Diagnostic zeigt, welches Modell gerade in der Karte liegt
Die Flotte listete "qwen3-4b, qwen3-8b" — das sind die Modelle, die die Box
fahren KANN (llama-swap /v1/models), nicht die geladenen. Ohne groups-Config
liegt immer nur eins in der Karte; ein anderes anzufragen entlaedt es
(gegen llama-swap ausprobiert: A laden, B anfragen → A unloaded).

Der Adapter fragt jetzt llama-swap /running und schickt das Ergebnis in jedem
worker_ping (10 s) und im worker_hello mit; nach einem Modellwechsel sofort.
None = llama-swap nicht erreichbar, [] = nichts geladen — die Anzeige
unterscheidet das ("geladen: ?" vs. "keins geladen").

Diagnostic: das geladene Modell gruen mit "● geladen", die anderen grau mit
Hinweis, dass sie beim ersten Aufruf laden und das geladene verdraengen.

"GPU void": die LLM-Zeile zeigt jetzt LLM_GPU (die Karte von llama-swap) statt
der NVIDIA_VISIBLE_DEVICES des Adapters, die mit der Modell-Karte nichts zu
tun hat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:40:17 +02:00
duffyduckandClaude Opus 5.5 864237ff2c feat(llm): Download-Fortschritt beim Modell-Laden — und "Laden" kommt endlich an
Eigentlicher Fehler zuerst: llm_provision_model/_result und llm_remove_model
standen nie in der RVS-Allow-List. Der RVS verwirft unbekannte Typen mit einem
stillen return — jeder Klick auf "Laden" im Modell-Katalog ist seit Stage D
(19.09.) an dieser Liste gestorben, die Box hat nie davon erfahren. Laden
liessen sich nur die Modelle aus der Basis-Config der Box.

Gefunden, weil grep rvs/server.js fuer eine Binaerdatei hielt und still
uebersprang: in Zeile 164 stand seit a7998bcd ein ECHTES Nullbyte im
Template-String statt eines Escapes. Node stoert das nicht, aber git zeigte
fuer jede Aenderung am RVS nur noch "Binary files differ". Jetzt \u0000 —
zur Laufzeit derselbe String.

Fortschritt: llama.cpp zieht das GGUF per -hf selbst und meldet nichts. Der
llm-adapter fragt vorab bei Hugging Face, welche Dateien (Groesse, sha256)
kommen, und misst genau diese im HF-Cache-Layout, das llama.cpp verwendet:
  /models/models--org--repo/blobs/<sha256>.downloadInProgress  (waechst)
  /models/models--org--repo/blobs/<sha256>                     (fertig)
Layout gegen den echten llama.cpp-Server ausprobiert, nicht angenommen. Geht
auch bei gesplitteten Modellen und zwei Downloads gleichzeitig; Fallback fuer
aeltere Layouts ueber alle *.downloadInProgress. Phasen: queued → start →
download (%, MB/s, Restzeit) → load ("laedt in die Karte") → ready ("in Karte
N geladen", Name + VRAM aus nvidia-smi) bzw. error.

Mitgefixt: der Warmup wartete LLM_TIMEOUT_SEC (Default 60 s). Ein Modell mit
mehreren GB meldete deshalb "Fehler", waehrend llama-swap weiterlud. Jetzt
LLM_WARMUP_TIMEOUT_S (1900 s, ueber llama-swaps healthCheckTimeout).

Diagnostic: Knopf "⬇ Downloads" neben "Von HuggingFace aktualisieren" mit
Zaehler fuer laufende; "Laden" oeffnet das Fenster direkt. Der Server merkt
sich den Stand pro (Box, Modell), ein spaeter geoeffneter Tab sieht laufende
Downloads. Antwortet die Box nicht, steht nach 30 s eine Warnung da statt
nichts.

Compose (KI-Box): llm-adapter bekommt LLM_GPU (Karte fuer die Meldung) und
HF_TOKEN (Dateiliste gesperrter Repos).

Test: Fortschritt gegen einen echten llama.cpp-Download (Qwen2.5-0.5B Q8,
676 MB): Prozent steigend 0 → 91.6, dann load; zweiter Lauf aus dem Cache
direkt load mit cached=true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:33:54 +02:00
duffyduckandClaude Opus 5.5 d684bbb5f0 feat: ARIA kann E-Mails lesen und senden
IMAP/SMTP mit SSL/TLS, STARTTLS oder unverschluesselt. Senden mit Text, HTML,
Anhaengen und eingebetteten Bildern (cid:), Antworten mit In-Reply-To. Lesen,
Suchen, Ordner anlegen (auch Unterordner), Mails verschieben.
Tools: email_status/send/list/read/folders/folder_create/move.

Whitelist als harte Grenze im Sendepfad, nicht im Prompt: alles, was nicht
drinsteht, ist gesperrt, leer = nirgendwohin. Jeder Empfaenger zaehlt (An, Cc,
Bcc), ein fremder blockiert die ganze Mail. Genau eine Schreibweise:
name@domain, '*' nur als *@domain, *@*.domain oder *@*. '*hacker-net.de' liest
sich, als passte es auf 'evilhacker-net.de' — solche Kurzformen lehnt das
Speichern mit Korrekturvorschlag ab. Verglichen wird die Domain als Ganzes.

Zugangsdaten sieht ARIA nie:
- liegen im Brain unter /data. NICHT in /shared — das ist in ARIAs Container
  eingehaengt, /data nicht.
- keine Schnittstelle gibt das Passwort zurueck, nur password_set.
- Brain-Export laesst email_secret.json + email_admin.key aus (GNU- und
  busybox-tar geprueft, der Diagnostic-Container ist Alpine).
- Anhangspfade nach realpath geprueft: ein Symlink in /shared/uploads auf die
  Geheimnis-Datei wuerde sonst im Brain aufgeloest und verschickt.
- Konfiguration nur mit Admin-Schluessel (X-Aria-Admin, liegt in /data). Die
  Diagnostic haengt ihn an, weist aber Anfragen aus ARIAs Container ab —
  sonst koennte eine eingehende Mail sie per curl die Whitelist oeffnen lassen.
Grenze: ARIAs Container hat SSH auf die VM. Gegen einen gezielten Einbruch
schuetzt das nicht, nur dagegen, dass Zugangsdaten von selbst auftauchen.

Eingehende Mails sind fuer ARIA als Daten markiert, nicht als Anweisungen.

Diagnostic: Einstellungen → Integrationen → ARIA E-Mail, plus
"📬 Postfach oeffnen" — ARIAs ganzes Postfach in eigenem Fenster, Unterordner
eingerueckt, Posteingang + Gesendet oben. Nur lesend, markiert nichts als
gelesen. Mail-HTML in einem Rahmen ohne Skripte und ohne Nachladen von aussen
(kein Tracking-Pixel); eingebettete Bilder kommen als data: mit. Jede
gesendete Mail landet als Kopie in "Gesendet" — fehlt der Ordner, wird er
angelegt, statt die Kopie still zu verlieren.

Tests: 65 Einheitstests (Whitelist inkl. evilhacker-net.de, MIME-Aufbau,
Header-Injection, Symlink-Ausbruch, Geheimnis nie in Ausgaben) + 50 gegen einen
echten Mailserver (GreenMail): TLS-Login, Senden mit Anhang + Bild, Flags,
Suche mit Umlaut, Gesendet-Kopie, In-Reply-To, Ordner mit Umlaut, MOVE und
der Fallback ohne MOVE (der keine fremden geloeschten Mails mitreisst).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:14:31 +02:00
duffyduckandClaude Opus 5.5 6d978d72b3 feat: Modell pro Chat waehlen — und sehen, welches gerade laeuft
Nach einem Sicherheitsfilter-Treffer fuhr ein Projekt still auf dem
Ausweich-Modell weiter, und nirgends war zu sehen, auf welchem. Zurueck ging
nur ueber "Liste leeren" im Filter-Panel, und das gleich fuer alle Chats.

Jetzt drei Ebenen (aria-brain/context_model.py):
  Einstellung (brainModel) < pro Chat < Ausweich (Sicherheitsfilter)

- Pro Chat: Hauptchat und jedes Projekt koennen ein eigenes Modell haben, in
  /shared/config/context_models.json. Bewusst kein Feld im Projekt-Manifest —
  der Hauptchat ist kein Projekt und waere sonst aussen vor.
- "↩ zurueck" pro Chat: loescht nur die Filter-Vermerke DIESES Chats. Blockt
  der Filter erneut, weicht das Brain eben wieder aus.
- Angezeigt wird, was wirklich laeuft: chat() gibt das Modell aus modelUsage
  der CLI zurueck (aufgeloeste Version statt Alias) samt Grund; Brain → Bridge
  → chat_backup + Live-Payload → App und Diagnostic. Als Grundeinstellung
  zaehlt das Modell, mit dem das Brain laeuft, nicht der Dateiwert — so faellt
  ein ausstehender Neustart nach brainModel-Aenderung auf.

Diagnostic: 🧠-Auswahl neben "+ Projekt" fuer den fokussierten Chat, Modell im
Untertitel jeder Kontext-Karte, Modell statt "Claude" am Bubble-Badge (orange
mit ↪ bei Ausweich).

App: hinter der Quell-Anzeige (Einstellungen, Default aus — Kopfzeile bleibt
fuer Mama unveraendert) ein Modell-Chip in der Kopfzeile; Tippen oeffnet die
Auswahl fuer diesen Chat inkl. "Zurueck". Laeuft ueber den vorhandenen
brain_request-Kanal, also keine neuen RVS-Typen.

Tests: 47/47 (12 neue fuer die Aufloesungsreihenfolge gegen agent.chat()),
tsc ohne Fehler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:39:30 +02:00
duffyduckandClaude Opus 5 d221c1a320 feat(brain): Sicherheitsfilter blockiert nicht mehr das ganze Projekt
Anthropics Filter ("safeguards flagged this message", Details: [cyber]) trifft
genau unsere Themen — Ports, Tunnel, Zugangsdaten. Bisher war danach der
Projekt-Chat tot, und zwar aus einem Grund, den man der Meldung nicht ansieht:

Der Filter meldet sich NICHT als HTTP-Fehler. Die CLI liefert ein voellig
normales Ergebnis mit is_error=true und dem Fehlertext im content — also dort,
wo sonst ARIAs Antwort steht. proxy_client.py warf das Flag weg, der Agent
konnte Fehler und Antwort nicht unterscheiden und schrieb den Text per
conversation.add("assistant", ...) in den Verlauf. Weil der Verlauf bei jedem
Turn mitgeht, scheiterte ab da JEDE weitere Frage an derselben Stelle.

Zwei Stufen dagegen (aria-brain/safeguard.py):

1. Modellwechsel. Der Filter haengt am Modell ("Opus 5's safeguards ..."),
   Anthropic raet selbst zu "change your model". Derselbe Turn wird automatisch
   mit dem Ausweich-Modell neu gefragt; laeuft der Rest des Turns (Tool-Schritte)
   gleich mit darauf weiter. Der ausloesende Turn wird vermerkt, damit die
   naechsten Turns nicht erst wieder gegen die Wand laufen. Der Vermerk
   verfaellt von selbst, sobald der Turn aus dem Rolling Window gerollt ist —
   nichts muss aufgeraeumt werden, und das Projekt faellt von allein aufs
   Hauptmodell zurueck.

2. Quarantaene. Blockt auch das Ausweich-Modell, liegt es am Verlauf selbst.
   Dann faellt der ausloesende Turn aus dem Prompt-Fenster. NICHT geloescht: er
   bleibt in conversation.jsonl, steht mit Text in der Diagnostic und ist per
   Klick zurueckholbar. Der rohe Fehlertext wird nie persistiert — ARIA sagt in
   eigenen Worten, was passiert ist.

Der Zustand liegt in /data/safeguard.json, bewusst nicht in conversation.jsonl:
ein Markieren dort erzwaenge ein Rewrite, und das schreibt nur das In-Memory-
Window zurueck — alles schon Destillierte waere weg.

Erkennung bewusst zweistufig: mit is_error genuegt ein Marker, ohne muss der
Text mit "API Error" beginnen. Sonst wuerde eine Antwort, in der ARIA ueber den
Filter *spricht*, einen Modellwechsel ausloesen.

Mitgenommen:
- Das Memory-Destillat schickt denselben Verlauf nochmal durch und wurde
  genauso blockiert — nur fiel es dort als "unparsbares JSON" auf und
  wiederholte sich bei jedem Lauf. Weicht jetzt ebenfalls aus.
- clean_poisoned_turns.py raeumt die Altlasten: Fehlertexte, die vor diesem Fix
  schon im Verlauf stehen. Neu auch in Projekten (dort sterben sie ja), mit dem
  ausloesenden User-Turn desselben Kontexts, Dry-Run per Default.
- Diagnostic: Ausweich-Modell einstellbar (runtime.json, ohne Neustart, weil
  safeguard.py erst im Ernstfall liest) und ein Panel, das zeigt was blockiert
  wurde und ob es NOCH wirkt.

Tests: 35/35 fuer die Rettungskette gegen den echten agent.chat(), 16/16 fuer
das Aufraeum-Script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 10:16:24 +02:00
duffyduckandClaude Opus 5 d53dd83df4 feat(diagnostic): "+ Projekt" direkt ueber dem Chat
Im Kontext-Streifen gab es keinen Weg, ein Projekt anzulegen — das ging nur
unten in den Einstellungen. Der Knopf steht bewusst AUSSERHALB des scrollenden
Streifens: bei mehreren Projekten waere er dort nach rechts rausgerutscht.

Anlegen zieht jetzt beide Projekt-Oberflaechen nach (Einstellungs-Liste UND
Streifen) und setzt den Fokus aufs neue Projekt. Vorher aktualisierte nur die
Liste, das neue Projekt blieb im Streifen bis zum 15s-Poll unsichtbar — und der
Fokus blieb im Hauptchat, obwohl create_project() serverseitig umschaltet. Man
legte also ein Projekt an und schrieb weiter in den Hauptchat.

Dazu ein Fehlerpfad: ein fehlgeschlagenes Anlegen (z.B. leerer Name, 400 vom
Brain) wurde stillschweigend verschluckt — das Modal schloss, als waere alles
gut. Jetzt kommt die Begruendung des Brains als Meldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 10:16:01 +02:00
duffyduckandClaude Opus 5 58d60adbaf feat(diagnostic): Satelliten und Host-Agenten umbenennen
Beide hiessen bisher so, wie es in IHRER .env steht. Umbenennen hiess also: auf
den Rechner, Datei aendern, Dienst neu starten — beim Fire TV sogar neu
provisionieren. In der Oberflaeche gab es dafuer gar keinen Weg.

Jetzt ein ✎ pro Eintrag. Der Alias liegt zentral in
/shared/config/display_names.json ({"host": {...}, "satellite": {...}}) und
wirkt sofort fuer JEDEN schon ausgerollten Agenten, ohne ihn anzufassen.

Der entscheidende Teil ist die Rueckrichtung: die Bridge loest den Alias beim
ADRESSIEREN wieder auf (_resolve_host / _resolve_satellite). Ohne das haette man
etwas umbenennen koennen und es danach nicht mehr ansprechen — der Agent
vergleicht die Adresse gegen seine EIGENE Kennung und kennt den Alias nicht.
Angesprochen werden kann weiterhin alles: Alias, Eigenname oder technische ID.

Der Eigenname bleibt in der Liste klein daneben sichtbar, damit man den Bezug
zum Geraet nicht verliert. Leerer Name entfernt den Alias.

Diagnostic und Bridge teilen sich die Datei ueber /shared — kein neuer
Nachrichtentyp, keine Allow-List-Aenderung, eine Wahrheit.

Nebenbei behoben: _display_name_set legte fest "/shared/config" an, obwohl der
Dateipfad konfigurierbar ist. Damit scheiterte das Schreiben stillschweigend,
sobald der Pfad abwich. Verzeichnis wird jetzt aus dem Pfad abgeleitet.

17 Pruefungen: Aliasvergabe, Liste, Aufloesung in beide Richtungen,
Gross/Kleinschreibung, alter Name bleibt gueltig, anderer Agent unberuehrt,
beide Bereiche in einer Datei ohne sich zu stoeren, Entfernen, unbekanntes Ziel
wird nicht still umgebogen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 18:54:36 +02:00
duffyduckandClaude Opus 5 6404b99510 feat(satellite): der Satelliten-Rechner steht selbst mit in der Geraeteliste
Der Satellit laeuft auf einem ganz normalen Rechner — bei Stefan auf
duffy-desktop. Dort laufen typischerweise noch andere Dienste (FreeCAD-MCP,
Web-App, Dateiserver). Bisher war ausgerechnet dieser Rechner unsichtbar: man
findet sich nicht per mDNS/ARP selbst.

Er erscheint jetzt als normales Geraet — ansprechbar wie jedes andere, nur
zusaetzlich mit 🛰️ markiert und blau abgesetzt, damit klar ist, dass dort der
Satellit traegt. Abschaltbar ueber LIST_SELF=false, falls man den Traeger
bewusst heraushalten will.

Nebeneffekt, der ein echtes Problem loest: der eigene Rechner gilt damit als
"gefunden" und ist ohne Eintrag in TUNNEL_ALLOW tunnelbar. Genau der Fall, um
den es ging — der FreeCAD-MCP laeuft auf dem Satelliten-Host selbst.

Beim Zusammenfuehren bleibt die Markierung erhalten: findet mDNS denselben
Rechner nochmal, haette der Merge isSatellite sonst verschluckt. hostname kommt
aus demselben Grund mit in die erhaltenen Felder.

Dazu ein ✎-Knopf pro Geraet in der Diagnostic: Namen vergeben, damit ARIA
"duffy-desktop" ansprechen kann statt einer IP. Der Name haengt an der MAC und
ueberlebt einen DHCP-Wechsel.

8 Pruefungen: Selbst-Eintrag vollstaendig, Markierung ueberlebt den Merge,
mDNS-Infos bleiben erhalten, eigener Rechner ohne TUNNEL_ALLOW erlaubt, fremde
IP weiterhin blockiert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 18:49:49 +02:00
duffyduckandClaude Opus 5 cfd17b200a feat(satellite): Port-Tunnel ins fremde Netz + Geraete-Namen
ARIA sitzt woanders und kommt nicht ins Buero-Netz. Der Satellit kann beides:
er sieht das LAN und haelt eine ausgehende Verbindung zum RVS. Also reicht er
eine TCP-Verbindung durch — wie der VNC-Tunnel, nur fuer beliebige Ports.
Damit erreicht ARIA einen FreeCAD-MCP, eine Web-App, ein NAS-Interface.

Entscheidend fuer die Brauchbarkeit: die Bridge macht daraus einen GANZ
NORMALEN lokalen Port (aria-bridge:19000...). Dadurch funktioniert alles, was
sowieso TCP spricht — MCP-Client, curl, Browser — ohne Sonder-API. Jede
eingehende Verbindung bekommt einen eigenen Tunnel, parallele Verbindungen
sind also kein Problem (ein Browser oeffnet gern sechs).

Geraete-Namen: "duffy-desktop" ist ansprechbar, "192.168.1.47" nicht. Stefan
kann jedem gefundenen Geraet einen Namen geben, ARIA adressiert danach.
Schluessel ist die MAC, nicht die IP — per DHCP wandert die IP, die MAC bleibt.

Absicherung, weil das ein Weg in ein fremdes Netz ist:
- TUNNEL_ENABLED default AUS, pro Satellit.
- Ohne TUNNEL_ALLOW nur Ziele, die bei der Suche GEFUNDEN wurden. Der Tunnel
  kann damit nichts ansteuern, was nicht ohnehin im LAN sichtbar ist — und
  nichts im Internet.
- Obergrenze gleichzeitiger Tunnel + Reaper fuer ungenutzte (sonst bleibt eine
  vergessene Verbindung offen, bis der Satellit neu startet).
- Faehigkeit 'tunnel' wird im sat_hello gemeldet, damit Diagnostic und ARIA
  sehen, ob der Satellit das ueberhaupt erlaubt.

Neue RVS-Typen eingetragen (sat_tunnel_*, sat_name_*) — ohne das verwirft die
Allow-List sie still, und nichts funktioniert ohne Fehlermeldung.

16 Pruefungen gegen BEIDE echten Implementierungen (nur der RVS dazwischen ist
kurzgeschlossen): Namensaufloesung, Freigabe-Regeln, fremder Satellit
ignoriert, abgeschaltet blockt, echter Byte-Durchlauf (PONG:HALLO), unbekanntes
Ziel schliesst sauber statt zu haengen, Liste/Schliessen/Doppel-Schliessen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 18:35:40 +02:00
duffyduckandClaude Opus 5 ae3a112868 docs: README holt den Nachmittag nach — Modelle, Datenschutz, Flugwetter, Overlay
Die README war vom Vormittag, danach kamen neun Commits. Keiner davon stand
drin.

Neu dokumentiert:
- Sprachmodell: Liste aus Anthropics Modell-Katalog, Tier vs. feste Version,
  Test-Knopf samt seiner vier Ergebnisse (inkl. dem gelben Fall "nicht
  erkannt", bei dem der Proxy still opus eingesetzt haette)
- Datenschutz-Schalter: die drei Sendekanaele, warum kein Neustart noetig ist,
  und warum eine per docker-compose gesetzte Variable als gesperrt erscheint
- Warnung bei veralteten Proxy-Patches (die Falle hat zweimal Zeit gekostet)
- Anhang-Limit, Zeitstempel/Sortierung/Zeitraum bei den Gedanken
- Overlay: wie es ohne feste Dauer wieder weggeht, und warum der
  Accessibility-Weg genommen wurde statt des naheliegenden
- flugwetter als Beispiel-Skill: Quellen mit Kosten, pc_met als dokumentierte
  Kundenschnittstelle, Seitenwind gegen Pistenrichtung, Herkunft pro Zahl

Neuer Abschnitt "Secrets und was bei Anthropic landet" unter den
Sicherheitsprinzipien. Der gehoert dorthin und nicht in eine Fussnote: alles
was ARIA sieht, wird uebertragen und gespeichert, und ob daraus Trainingsdaten
werden, haengt an einer Konto-Einstellung. Dazu die drei Regeln (nie in den
Chat, Zugangsdaten in die Konfiguration, oauth_call statt Token-Herausgabe) und
ehrlich auch die Grenze: ARIA laeuft mit Root im Proxy-Container und kann
/shared/config lesen, wenn sie hinschaut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 12:42:57 +02:00
duffyduck 517a8ea942 release(agent): bump to 0.0.1.5 2026-10-03 12:40:58 +02:00
duffyduckandClaude Opus 5 4589f4abc3 feat(android-agent): Einblendung geht weg, wenn Stefan etwas tut — statt nach fester Zeit
Eine feste Anzeigedauer ist immer geraten: mal zu kurz zum Lesen, mal steht sie
laengst im Weg. `seconds=0` liess die Einblendung bisher einfach stehen.

Jetzt heisst seconds=0: bleibt, bis der Nutzer etwas tut.

- Fernbedienung: der Accessibility-Dienst bekommt onKeyEvent und raeumt die
  Einblendung weg. Er gibt die Taste mit `return false` WEITER an den Film —
  es geht also kein Tastendruck verloren und der Player bleibt bedienbar.
  Dafuer brauchte der Dienst canRequestFilterKeyEvents + flagRequestFilterKeyEvents.
- Touch: FLAG_WATCH_OUTSIDE_TOUCH meldet eine Beruehrung ausserhalb. Die geht
  dank FLAG_NOT_TOUCHABLE gleichzeitig an die App darunter durch.
- Notbremse: nach 10 Minuten verschwindet sie in jedem Fall.

Bewusst NICHT der naheliegende Weg (Overlay faengt Tasten selbst ab): dafuer
muessten FLAG_NOT_FOCUSABLE und FLAG_NOT_TOUCHABLE weg, und dann nimmt die
Einblendung dem laufenden Film JEDE Eingabe, solange sie steht. Genau das soll
sie nicht.

host_notify beschreibt seconds=0 jetzt als den richtigen Wert fuer alles zum
Lesen (Wetter, Listen, Zahlen) und die feste Dauer fuer kurze Hinweise.

APK baut durch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 12:29:14 +02:00
duffyduckandClaude Opus 5 e67659b78a feat(diagnostic): warnt, wenn der Proxy mit veralteten Patches laeuft
Der Proxy kopiert proxy-patches/*.js beim START ueber den npm-Code. Wer nach
einem git pull nur `restart` macht — oder neu startet, BEVOR der Pull durch ist
— laeuft still mit der alten Fassung weiter. Nichts deutet darauf hin: der
Container ist "Up", die Oberflaeche sieht normal aus, die Aenderung wirkt
einfach nicht.

Das hat heute zweimal Zeit gekostet: einmal wirkte ein Proxy-Fix nicht, einmal
blieb die Modell-Liste alt, obwohl der Code laengst gepullt war (Proxy
gestartet 08:51, Patches gepullt 09:28).

Die Einstellungen vergleichen jetzt die Zeitstempel der Quelldateien mit den
eingespielten und zeigen einen Hinweis samt fertigem Befehl — inklusive der
Angabe, welche Datei wann geaendert und wann eingespielt wurde. Toleranz 5s,
weil Quelle und Kopie beim Start kurz nacheinander entstehen. Ein Fehler beim
Pruefen loest bewusst KEINEN Alarm aus, sonst stuende dort eine Warnung, die
nichts ueber die Patches aussagt.

Dazu: der Datenschutz-Block sagt jetzt ausdruecklich, dass KEIN Neustart noetig
ist — die CLI liest ~/.claude/settings.json bei jedem Aufruf neu. Nur ein
gerade laufender Turn nutzt noch die alte Einstellung.

4 Pruefungen gegen den echten Container, in beide Richtungen: schweigt nach
frischem Start, erkennt eine veraltete Kopie samt Zeitstempeln, schweigt nach
Wiederherstellung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 11:38:04 +02:00
duffyduck 988b074478 release(agent): bump to 0.0.1.4 2026-10-03 11:27:12 +02:00
duffyduckandClaude Opus 5 d5eea920a6 feat(diagnostic): Datenschutz-Schalter von Claude Code pruefen und setzen
Drei Kanaele, ueber die Claude Code zusaetzlich zur eigentlichen Anfrage Daten
verschickt — bisher nur per Hand in der docker-compose.yml abschaltbar, und
niemand konnte sehen, ob sie an sind:

  DISABLE_ERROR_REPORTING   Stacktraces an einen Fehler-Dienst. Bei Pro/Max ab
                            CLI 2.1.198 standardmaessig AN.
  DISABLE_TELEMETRY         Nutzungs-Metriken.
  DISABLE_FEEDBACK_COMMAND  /feedback sendet sonst die ganze Unterhaltung
                            inkl. Code — 5 Jahre Aufbewahrung.

Gesetzt wird in ~/.claude/settings.json unter "env". Die Datei ist in den
Proxy-Container gemountet, die Diagnostic erreicht sie ueber den vorhandenen
dockerExec — also kein Anfassen der docker-compose.yml und KEIN Neustart: die
CLI liest die Datei pro Aufruf.

Geprueft wird beides: was in settings.json steht UND was im Prozess wirklich
ankommt. Eine echte Container-Umgebungsvariable gewinnt gegen settings.json —
so ein Eintrag wird darum als "per docker-compose gesetzt" angezeigt und der
Haken gesperrt, statt eine Einstellung anzubieten, die nichts tut.

Geschrieben wird atomar (temp + mv) und nur der env-Block angefasst; bestehende
Einstellungen (tui, theme) bleiben. Sind alle drei abgewaehlt, verschwindet der
env-Block wieder ganz.

Der Hinweis im Panel sagt ausdruecklich, dass das NICHT die Trainings-
Einstellung ist — die steht im Anthropic-Konto und laesst sich hier nicht
umstellen. Link dorthin ist daneben.

12 Pruefungen gegen den echten Proxy-Container: lesen, setzen, Teilmengen,
JSON-Integritaet, Erhalt bestehender Werte, CLI laeuft damit, Abwaehlen raeumt
auf, Ausgangszustand wiederhergestellt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 11:21:47 +02:00
duffyduckandClaude Opus 5 2c60a19ed6 feat(oauth): ARIA bekommt Zugangstokens nicht mehr zu sehen
oauth_get_token hat den access_token im Klartext als Tool-Ergebnis
zurueckgegeben. Tool-Ergebnisse sind Teil der Unterhaltung — der Token ging
damit an Anthropic und lag je nach Konto-Einstellung 30 Tage oder 5 Jahre dort.

Neu: das Brain fuehrt den API-Aufruf SELBST aus.

- `oauth_call(service, url, method, body)` haengt die Authorization an,
  schickt den Request und gibt nur die Antwort zurueck. Der Token verlaesst
  den Brain-Prozess nicht.
- `oauth_get_token` sagt nur noch, OB authentifiziert ist und wie lange noch.
  Refresh laeuft weiter automatisch.
- Skills bleiben unveraendert: die holen den Token serverseitig ueber
  {BRAIN_INTERNAL_URL}/oauth/<service>/token — der laeuft nie durch ein
  Gespraech.

Host-Sperre als Sicherheitskern: die URL kommt vom Modell, also koennte ein
Aufruf den Token sonst an einen beliebigen Server schicken. Erlaubt sind nur
Hosts, die zum Provider gehoeren (aus auth_url/token_url, plus optionale
api_hosts). Verglichen wird auf den letzten zwei Labels, NICHT per endswith —
"boesespotify.com" endet sonst auch auf "spotify.com". Dazu: nur https (sonst
ginge der Token im Klartext ueber die Leitung) und nur die ueblichen Methoden.

19 Pruefungen, darunter die Angriffsvarianten: boesespotify.com,
spotify.com.angreifer.de, accounts.spotify.com.evil.io, spotify.co, leerer
Host — alle blockiert; api.spotify.com und abschliessender Punkt erlaubt.

System-Prompt und seed_rules nachgezogen, sonst erklaert ARIA sich das alte
Verhalten selbst. Beide sagen jetzt zusaetzlich ausdruecklich: Secrets gehoeren
nie in den Chat, und ein Secret nie in einer Antwort wiederholen. Genau so ist
hier naemlich ein client_secret abgeflossen — nicht ueber ein Tool, sondern
weil es eingetippt und dann zurueckzitiert wurde.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 11:16:41 +02:00
duffyduckandClaude Opus 5 752e2abc5f feat(models): Test-Knopf — zeigt, welches Modell wirklich geantwortet hat
Bisher liess sich nicht nachsehen, ob eine Modell-Einstellung ueberhaupt
greift. Der Proxy hat die Information sogar aktiv weggeworfen:
normalizeModelName() nahm den echten Namen aus modelUsage (z.B.
"claude-opus-5-5") und schrieb ihn auf "claude-opus-4" um. Das `model`-Feld
der Antwort log also — und damit war jede Ueberpruefung unmoeglich.

- normalizeModelName gibt den echten Namen durch. Im OpenAI-Protokoll soll
  `model` das Modell nennen, das geantwortet HAT; ein erfundener Name ist
  schlechter als keiner.
- Zusatzfelder aria_model / aria_error / aria_context_window / aria_max_output
  aus modelUsage.canonicalModel. Noetig, weil die CLI bei einem unbekannten
  Modell gar nicht bis zum Call kommt und ihre Begruendung einfach in den
  Antworttext legt — ohne Flag waere ein Fehlschlag nicht von einer Antwort
  zu unterscheiden.
- Knopf "🔬 Testen" neben Dropdown und Freitextfeld. Er schickt eine echte
  Mini-Anfrage durch den GANZEN Weg (Diagnostic → Proxy → extractModel →
  claude CLI → Anthropic).

Ausdruecklich NICHT gemacht: das Modell nach seiner eigenen Version fragen.
Darauf ist kein Verlass — Modelle kennen ihren Versionsstring oft nicht und
raten. Und den Dropdown-Wert zurueckzugeben waere sinnlos. Die Wahrheit kommt
aus modelUsage der CLI.

Vier unterscheidbare Ergebnisse:
  Tier-Alias      gruen  — zeigt die aufgeloeste Version ("opus" → Opus 5.5)
  exakte Version  gruen  — angefragt == geantwortet
  nicht erkannt   GELB   — der Proxy hat still "opus" eingesetzt; warnt
                           ausdruecklich davor, die ID so zu setzen
  ungueltig       ROT    — CLI-Begruendung im Klartext

Der gelbe Fall war wichtig: ein Name, der nicht wie ein Claude-Modell aussieht,
faellt auf "opus" zurueck und haette sonst als Erfolg gegolten — man setzt dann
eine ID, die nie benutzt wird, und merkt es nie.

Geprueft gegen ECHTE CLI-Ausgaben aus dem Proxy-Container (9 Faelle: Alias,
exakte Version, Tippfehler) plus 7 Faelle fuer die Anzeige-Logik. Nebenbefund:
`--model opus` loest derzeit auf claude-opus-5-5 auf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 10:54:22 +02:00
duffyduckandClaude Opus 5 09d06011d5 fix(models): Modell-Liste kommt jetzt von Anthropic — und feste Versionen greifen wirklich
Zwei Fehler. Der zweite war der schlimmere.

1) Die Liste war handgepflegt und damit garantiert veraltet.
   Sie kannte "Opus (aktuell: Opus 5)" — waehrend es laengst Opus 5.5,
   Sonnet 5.5 und Fable 5.1 gab. Der Knopf "↻ Aktualisieren" las nur eine
   lokale Datei neu und aktualisierte damit gar nichts.

   Jetzt kommt die Liste aus Anthropics eigenem Modell-Katalog
   (downloads.claude.ai/model-catalog/v1/catalog.json, Surface 'cc') —
   dieselbe Quelle, aus der sich Claude Code selbst bedient. Oeffentlich,
   ohne API-Key (die Models-API braucht einen; mit dem Max-Abo gibt es keinen).
   6h-Cache plus Datei-Cache, der auch offline und bei abgelaufenem TTL
   einspringt: eine etwas alte echte Liste ist besser als keine.

   Die Tier-Aliase (opus/sonnet/haiku/fable) bleiben vorne und bleiben die
   Empfehlung — sie folgen automatisch der neuesten Version. Darunter die
   exakten Versionen, aeltere ausdruecklich als solche markiert.

2) Eine feste Version liess sich faktisch nicht waehlen.
   extractModel() hatte eine MODEL_MAP und fiel fuer alles Unbekannte STILL
   auf "opus" zurueck. Wer "claude-opus-5-5" setzte, bekam ohne jede Meldung
   das Tier-Default. Schlimmer noch: claude-opus-5 und claude-fable-5 standen
   selbst in der Map und wurden auf ihr Tier eingeschmolzen — eine bewusst
   gewaehlte aeltere Version wurde also durch die neueste ersetzt.

   Volle Modellnamen gehen jetzt unveraendert an die CLI. Die versteht sie
   ("--model: an alias for the latest model or a model's full name") und
   meldet sich selbst klar, wenn sie einen Namen nicht kennt — ehrlicher als
   ein stilles Downgrade. Nur echter Muell faellt noch auf "opus", und das
   wird geloggt.

Nachgewiesen gegen den LIVE-Katalog und die echte CLI im Proxy-Container:
20 Pruefungen gruen, u.a. dass alle 12 gelieferten exakten Versionen den Proxy
unveraendert passieren. `claude --print --model claude-opus-5-5` antwortet.
Stefans aktive Einstellung ist der Alias "opus" — am laufenden Betrieb aendert
sich dadurch nichts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 10:47:34 +02:00
duffyduckandClaude Opus 5 4c0d8ce3fe feat(agent): Bild mit Text einblenden — host_notify kann jetzt image_path
Der Android-Agent konnte schon immer ein Bild MIT Text einblenden
(OverlayView.show nimmt ein imageB64), aber ARIA kam nicht dran: in der
Parameter-Liste von host_notify fehlte das Feld einfach. Die Faehigkeit war
gebaut und unerreichbar.

ARIA gibt jetzt image_path an (ein Pfad unter /shared/uploads/, damit arbeitet
sie sowieso) und das Brain wandelt die Datei nach base64 — statt ARIA
megabyteweise base64 durch den Tool-Call schieben zu lassen. Pfad-Praefix wird
geprueft, und ueber 4 MB wird abgelehnt: fuer eine Einblendung auf einem
Fernseher ist mehr sinnlos und blaeht die WS-Nachricht auf.

Nur Android-Agenten koennen das; die Beschreibung sagt das ausdruecklich.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 10:34:04 +02:00
duffyduckandClaude Opus 5 734868efab feat(diagnostic): Anhang-Limit einstellbar + Datum/Uhrzeit bei Gedanken
Zwei Dinge, die bisher nur im Code bzw. nur in der DB standen.

Anhang-Limit (Einstellungen → System & Wartung):
- Das Limit war eine Env-Variable mit 20 MB Default — zum Anheben musste man
  die .env editieren und das Brain neu starten. Jetzt steht es in runtime.json
  und das Brain liest es bei JEDEM Upload neu: Aenderung greift sofort.
- Reihenfolge: runtime.json > ENV > Default. Anders als beim OpenAI-Endpunkt
  gewinnt hier NICHT die .env — dort erzwingt ein Docker-Port-Mapping das,
  hier gibt es keinen solchen Zwang, und eine Einstellung die nichts tut waere
  schlimmer als keine.
- Default von 20 auf 64 MB. Genau so viel holt die App auch auf ausdruecklichen
  Tipp noch herunter (TAP_DL_MAX_BYTES); darueber warnt die UI, verbietet es
  aber nicht — der Anhang liegt dann sauber auf dem Server, ist mobil nur nicht
  mehr ladbar.
- Neuer Endpunkt GET /attachments/limit. Das fest eingetippte "max 20 MB pro
  Datei" im HTML ist weg — es stimmte nach jeder Aenderung nicht mehr.
- Die Fehlermeldung nennt jetzt MB statt Bytes und sagt, wo die Einstellung ist.

Datum/Uhrzeit bei Gedanken:
- created_at/updated_at lagen schon immer in der DB und wurden ausgeliefert —
  nur nie angezeigt. Jede Zeile zeigt jetzt "angelegt <Zeit>" und, falls
  abweichend, "geaendert <Zeit>" plus ein relatives "(vor 3 Std)". Damit sieht
  man auf den ersten Blick, was der letzte Eintrag war.
- Sortier-Dropdown: nach Typ gruppiert (wie bisher) oder nach Anlage/Aenderung
  auf- und absteigend. Bei Zeit-Sortierung wird die Liste flach dargestellt —
  man will sehen was zuletzt passiert ist, nicht in welche Schublade es gehoert.
- Zeitraum-Filter (🕒) mit von/bis, umschaltbar zwischen angelegt und geaendert,
  plus Schnellwahl Heute / 7 / 30 Tage. Gilt auch fuer Such-Treffer, damit
  "suche X, aber nur letzte Woche" geht. Zuklappen schaltet ihn aus, und das
  ✕-Zuruecksetzen raeumt ihn mit auf (sonst filtert er unsichtbar weiter).

Beides gegen den echten Code getestet: 7 Faelle fuers Limit (inkl. kaputter
runtime.json und einem 50-MB-PDF an beiden Grenzen), 10 fuer Sortierung und
Zeitraum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 10:33:54 +02:00
duffyduckandClaude Opus 5 7b46881677 docs: README auf den aktuellen Stand — Android-Agent, Jellyfin, OpenAI-Endpunkt, Phase C
Die README war auf dem Stand vom 24.09. Seitdem sind ~60 Commits dazugekommen,
von denen ganze Baustellen gar nicht auftauchten.

Neu dokumentiert:
- Android-Agent 📱📺 (eigener Abschnitt): eine APK fuer Handy und Fire TV —
  sehen, steuern, ausgeben (Overlay + ARIAs echte Stimme), Audio-Fokus,
  Sprachdialog, Turn-Ownership im Broadcast-Relay, fester Signaturschluessel,
  Ausrollen per Satellit via ADB inkl. Fern-Provisionierung
- GUI-Steuerung der Desktop-Host-Agenten (capability-gated)
- Jellyfin-Integration (mehrere Server, API statt Klick-Weg)
- OpenAI-Endpunkt fuers lokale LLM + openhands-Profil, inkl. der Regel
  ".env gewinnt immer -> Diagnostic-Felder nur lesend"
- Agent-Historie und Auslastungs-Monitor in der Diagnostic
- App: Teilen-Ziel, Sprachnachricht zu Anhaengen, Zwischenruf per Sprache
- Trigger: Projekt-Kontext + ehrlicher Zustand in trigger_list
- Roadmap "Phase C" samt der fuenfteiligen Ursachenkette hinter den verlorenen
  Sprachnachrichten — jede Ursache sah fuer sich plausibel aus, das ist
  dokumentationswuerdig

Korrigiert (waren schlicht falsch):
- "Bildanalyse eingeschraenkt, ARIA sieht den Dateipfad, nicht das Bild" —
  widersprach der eigenen Roadmap; Vision laeuft
- "Grosse Dateien >5MB koennen WebSocket-Limits ueberschreiten" — die echten
  Grenzen sind jetzt 1500 MB Transport, 25 MB Auto-Download, 64 MB auf Tipp
- Audio-Format: Live-PCM-Streaming ist der Normalweg, AAC/MP4 nur noch Fallback

Neu als Limitierung benannt, weil heute gefragt: die Sprachaufnahme ist NICHT
wiederaufsetzbar — ein Verbindungsabbruch mitten in der Aufnahme verliert das
Stueck, es wird nicht gepuffert. Gleiches gilt fuer die TTS-Richtung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 10:21:50 +02:00
duffyduckandClaude Opus 5 cb89ebcd10 fix(stt): lange Sprachnachrichten brachen mitten im Satz ab
Der Endpointer hat seine EIGENE Rechenzeit als Stille gezaehlt.

Der Tick-Loop laeuft alle 200ms und ist die einzige Stelle, die akustische
Sprach-Energie misst (last_voice_at). Er hat aber auf die Zwischen-
Transkription gewartet — und die laeuft jedes Mal ueber den KOMPLETTEN Buffer.
Bei 10s Audio harmlos, bei zwei Minuten dauert sie mehrere Sekunden. In dieser
Zeit tickte nichts, danach war `now - last_voice_at` um die ganze Rechenzeit
groesser als endpoint_ms (5s) — Endpoint mitten im Satz, obwohl durchgehend
gesprochen wurde. Deshalb traf es genau die LANGEN Nachrichten, und zwar immer
spaeter werdend je laenger die Aufnahme.

Nachgewiesen: die Nachricht vom 02.10. 21:28 (1231 Zeichen) war die einzige mit
reason=endpoint statt stream_end und endet mitten im Satz ("...weil ich
weiss..."). Alle vollstaendigen Nachrichten im Thread haben reason=stream_end,
also den Stop-Knopf.

Fix:
- Zwischen-Transkription laeuft als eigener Task. Der Tick bleibt frei und
  misst weiter alle 200ms — die Rechenzeit kann nicht mehr als Stille gelten.
  Laeuft noch eine, wird keine zweite gestartet.
- Zwischen-Transkriptionen sehen nur noch die letzten 30s an. Damit bleibt ihre
  Kosten konstant statt mit der Aufnahmelaenge zu wachsen. Der FINALE Text
  entsteht unveraendert aus dem gesamten Buffer mit beam_size=5 — es geht kein
  Wort verloren. stt_partial bekommt dafuer ein Feld 'windowed'.
- _finalize und drop brechen eine laufende Zwischen-Transkription ab, damit der
  finale Text nicht hinter ihr auf den GPU-Lock warten muss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 10:18:02 +02:00
duffyduckandClaude Opus 5 443b36bb9d fix(android-agent): Sprachausgabe meldet erst Erfolg, wenn wirklich Ton laeuft
Der Agent meldete "gesprochen", sobald die TTS-Anfrage im Socket lag. Ob sie
ueberhaupt rausging, ob der Server antwortete oder ob ein einziger Ton kam,
hat nie jemand geprueft. Auf dem Fire TV blieb es damit still, waehrend ARIA
"erledigt" sagte — ein Fehler, der sich nicht melden kann.

Drei Ursachen:
- OkHttps send() WIRFT NICHT, sondern gibt false zurueck, wenn die Verbindung
  zu ist. Der Rueckgabewert wurde verworfen: Senden ins Leere sah aus wie
  Erfolg. send() gibt das jetzt durch, requestTts wirft bei abgelehntem Senden.
- Niemand wartete auf Audio. Neu: awaitSpeechStart() wartet, bis fuer diese
  messageId wirklich Ton laeuft, sonst Fehler mit Grund.
- Warten darf NICHT im WS-Lese-Thread passieren: handle() laeuft dort, und das
  erwartete audio_pcm kommt ueber genau diese Verbindung — wir haetten uns
  selbst ausgehungert. speak/notify laufen jetzt in einem eigenen Thread und
  schicken ihr host_result von dort.

Brain-seitig haben speak/notify 40s statt 20s Zeit, damit die ehrliche Antwort
(TTS-Render + Uebertragung) nicht in den Timeout laeuft.

APK baut durch (Gradle, nur die zwei bekannten Deprecation-Warnungen).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 10:17:48 +02:00
duffyduckandClaude Opus 5 12b8362d26 fix(trigger): Antwort landet im Projekt des Timers — und der Zustand luegt nicht mehr
Zwei Fehler, die zusammen wie "der Timer hat nicht gefeuert" aussahen.

1) Trigger hatten KEIN Projekt. Beim Feuern ging die Antwort ohne projectId
   durch _process_core_response — also immer in den Hauptchat, auch wenn der
   Timer mitten in einem Projekt-Thread gestellt wurde. Im Projekt blieb es
   still, und es sah aus wie "nicht gefeuert". Jetzt wird das Projekt des
   Requests mit angelegt, der getriggerte Turn laeuft darin, und die Antwort
   kommt dort zurueck.

2) trigger_list zeigte nur "aktiv" / "DEAKTIVIERT" und verschwieg fire_count
   und last_fired_at. Ein One-Shot-Timer wird NACH dem Feuern automatisch
   deaktiviert — "hat sauber gefeuert" und "war nie an" sahen damit identisch
   aus. ARIA hat genau das falsch gelesen und berichtet, sie habe vergessen den
   Timer zu aktivieren, obwohl er puenktlich gefeuert hatte. Die Ausgabe nennt
   jetzt ausdruecklich "SCHON GEFEUERT (Nx, zuletzt <Zeit>)" plus den Kontext,
   in dem die Antwort landet.

Nachgewiesen am Fall flugwetter-whv-freitag-0900: gefeuert 07:00:05 UTC
(= 09:00 lokal), Antwort 46s spaeter, fire_count=1 — aber active=false im
Manifest und die Antwort im Hauptchat statt im Projekt-Thread.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 10:17:35 +02:00
duffyduck e628170760 release(agent): bump to 0.0.1.3 2026-10-03 10:16:20 +02:00
duffyduck 7af0b76ad3 release: bump version to 0.2.5.5 2026-10-02 07:35:59 +02:00
duffyduckandClaude Opus 5 614ba48531 feat(app): Sprachnachricht zu Anhaengen + Zwischenruf per Sprache
Bisher war die Eingabeleiste ein Entweder-oder: sobald Text im Feld stand oder
ein Anhang dranhing, verschwand das Mikro. Damit ging genau das nicht, was man
eigentlich will — ein Bild anhaengen und den Auftrag dazu SPRECHEN. Und ein
Zwischenruf ging nur getippt.

Das Mikro ist jetzt immer da; Senden-Pfeil und Mund-/Ohr-Knoepfe weichen je
nach Lage, damit die Zeile nicht ueberlaeuft.

Anhang + Sprachnachricht:
- Mikro antippen mit wartenden Anhaengen laedt sie SOFORT hoch (parallel zum
  Reden) und markiert sie mit awaitVoice + clientMsgId.
- Die Bridge schickt solche Anhaenge nicht nach 0.8s alleine ans Brain, sondern
  wartet auf den stt_endpoint und fuehrt Anhang + gesprochenen Satz zu EINEM
  Turn zusammen. Ohne das sah ARIA ein Bild ohne Auftrag und danach einen Satz
  ohne Bild.
- Der Merge ist strikt an die clientMsgId gebunden (require_match): eine
  Sprachnachricht, die zufaellig waehrend eines fremden Uploads fertig wird,
  reisst dessen Anhaenge nicht an sich. Der Text-Pfad behaelt seinen Fallback.
- Bild und gesprochener Text landen in EINER Bubble (gemeinsame audioRequestId).
- Klappt das Mikro nicht, bleibt die Bubble mit den Bildern stehen statt zu
  verschwinden — die Dateien sind ja schon auf dem Server.
- Das Notfall-Fenster (420s) liegt ueber dem Aufnahme-Hard-Cap von 5 Minuten,
  sonst waeren die Anhaenge bei einer langen Aufnahme vorher alleine raus.

Zwischenruf per Sprache:
- Der 📣-Knopf erscheint jetzt immer, wenn ARIA im Kontext arbeitet. Mit Text
  schickt er sofort (wie bisher), ohne Text schaltet er die naechste Aufnahme
  scharf (sichtbar hervorgehoben, gilt fuer genau eine Aufnahme).
- Eine so markierte Aufnahme geht per stt_stream_start als interject raus; die
  Bridge schiebt sie in den laufenden Turn statt sie anzustellen.
- Dabei wird der laufende Turn NICHT abgebrochen (das war der Sinn) — nur die
  Wiedergabe stoppt, damit ARIA nicht ins offene Mikro redet.
- Die Bubble zeigt 📣 Zwischenruf statt 🎤, damit man nicht auf eine eigene
  Antwort wartet.

Merge-Logik gegen den echten Bridge-Code getestet (6 Faelle), tsc sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-02 02:11:06 +02:00
duffyduck 5b5591aa72 release: bump version to 0.2.5.4 2026-10-02 01:51:14 +02:00
duffyduckandClaude Opus 5 b70623bb78 fix(app): Absturzschleife — Auto-Re-Download holte ungefragt ein 150-MB-Video
Die Absturzschleife (alle ~70s, stundenlang) war selbsttragend:

  App startet → Auto-Re-Download fordert 2s spaeter ALLE fehlenden Anhaenge an,
  ohne Groessen-Grenze → Bridge schickt das 150-MB-Video als ~200 MB base64 in
  EINER WebSocket-Nachricht → die App muss das puffern und durch JSON.parse
  schicken → nativer OOM (kein JS-Fehler, darum nie ein Stacktrace) → Android
  startet den Vordergrund-Dienst neu → App startet → von vorn.

Dieselbe Nachricht hat parallel whisper/f5tts/voxtral mit Code 1009 umgebracht
— darum lag der Absturz immer direkt hinter dem Audio-Abbau, und darum kam
jede gerade laufende Sprachaufnahme nie an.

Drei Bremsen:
- Auto-Re-Download ueberspringt alles ueber 25 MB. Bleibt als "150 MB —
  tippen zum Laden" stehen; der User entscheidet.
- Ausdrueckliches Antippen bremst bei 64 MB mit einer ehrlichen Meldung samt
  Server-Pfad, statt die App zu killen.
- Der Brotkrumen wird von der Crash-Meldung nicht mehr ueberschrieben. Bisher
  merkte sich der zweite Absturz nur noch "letzte Aktivitaet: Crash-Meldung" —
  in einer Schleife war nach dem ersten Durchlauf jede Spur weg.

Diagnose kam aus /shared/logs/app.log (app_log via RVS), kein ADB noetig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-02 01:48:27 +02:00
duffyduckandClaude Opus 5 73b6827f6b fix(bridge): Log-Fluten durch wiederholte hello-Meldungen abstellen
sat_hello und worker_hello kommen im Takt von ~25-30s erneut und wurden jedes
Mal mit "online" geloggt. Dadurch war das Bridge-Log praktisch unlesbar: die
interessanten Zeilen (Fehler, STT, Turns) gingen zwischen hunderten
identischen Meldungen unter.

Jetzt wird nur geloggt, wenn eine Box/ein Satellit NEU ist oder sich etwas
Echtes geaendert hat (Modell, GPUs, Standort, caps, control) — so wie es
host_hello schon vorher gemacht hat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-02 01:43:52 +02:00
duffyduckandClaude Opus 5 a7998bcd8b fix(rvs): Datei-Antworten gezielt an den Fragenden statt an den ganzen Raum
Der RVS ist ein Broadcast-Relay — bisher bekam jeder Client im Raum jede
Nachricht. Bei einem Datei-Re-Download war das fatal: ein 150-MB-Video wird
als base64 ~200 MB gross und ging an ALLE Clients, auch an die STT/TTS-Worker.
Die sind daran mit Code 1009 (message too big) gestorben und haben die gerade
laufende Sprachaufnahme mitgerissen — die Ursache von "erste Sprachnachricht
kommt nicht durch".

Wer eine Anfrage mit requestId stellt, wird jetzt gemerkt; die Antwort geht
nur an ihn zurueck (file_response, file_zip_response,
file_version_download_response, brain_response). Unbekannte requestId wird
wie bisher gebroadcastet, ein weggefallener Fragender verworfen statt an alle
geschickt.

Verifiziert mit drei WS-Clients gegen einen lokalen RVS: der Fragende bekommt
seine Antwort, der Unbeteiligte nicht, chat bleibt Broadcast.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-02 01:43:04 +02:00
duffyduck 07df538763 release: bump version to 0.2.5.3 2026-10-02 01:37:49 +02:00
duffyduckandClaude Opus 4.8 5761c5f6b9 fix(xtts): Worker-Neustartschleife — max_size an den RVS angleichen (50MB -> 1500MB)
Ursache der staendigen whisper/f5tts-Neustarts, direkt aus dem Box-Log:
  sent 1009 (message too big) frame with 209221780 bytes
  exceeds limit of 52428800 bytes

Der RVS ist ein BROADCAST-Relay: eine grosse Nachricht (Datei-Transfer,
Voice-Upload, APK-Update) geht an JEDEN Client im Raum. Die aria-bridge nutzt
deshalb max_size=1500MB — die Worker standen auf 50MB (llm-adapter sogar 16MB).
Ein 209-MB-Frame liess sie die Verbindung mit Code 1009 schliessen; danach
TLS-Fallback auf ws:// (scheitert an Caddy), Reconnect, naechster Frame, von
vorn. Ergebnis: Neustart im Minutentakt, und jede laufende Aufnahme war weg —
das ist die eigentliche Ursache fuer 'erste Sprachnachricht kommt nicht durch'.

Alle vier Worker nutzen jetzt RVS_MAX_SIZE_MB (Default 1500), passend zu
RVS/Bridge. Die GPU war NICHT das Problem (4,3 von 8 GB belegt, zweite Karte leer).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 01:33:22 +02:00
duffyduckandClaude Opus 4.8 87755b9c58 fix(app): Aufnahme-Sperre fragt den Socket statt den gemerkten Zustand
Beweis aus app.log 02.10 01:22: beim Aufnehmen fehlte 'bg.start slot=rec' —
die Aufnahme wurde abgelehnt, BEVOR sie begann. Ursache ist die in 10e7faa
eingefuehrte Sperre 'ohne Verbindung nicht aufnehmen': sie prueft den gemerkten
Zustand, und der steht wegen des Zombie-Bugs faelschlich auf 'disconnected',
obwohl die Verbindung laeuft (TTS kam ja an). Ergebnis: Stefan konnte gar nicht
mehr sprechen — die Sperre hat das Problem verschlimmert statt es zu verhindern.

rvs.isSocketOpen() fragt jetzt ws.readyState direkt; audio.ts nutzt das. Damit
ist die Sperre immun gegen falsche Zustandsmeldungen und blockt nur, wenn der
Socket wirklich zu ist. tsc: 0 Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 01:24:57 +02:00
duffyduckandClaude Opus 4.8 b6195d3a53 feat(app): Teilen-Ziel — Bilder/Videos/Dateien aus anderen Apps an ARIA
'Teilen -> ARIA' aus WhatsApp, Galerie, Browser & Co. Ablauf wie gewuenscht:
sehen WAS geteilt wurde -> WOHIN (Projekt oder Hauptchat) -> optional Text dazu.
Ohne Text geht genauso.

- Manifest: intent-filter fuer ACTION_SEND (text/image/video/audio/application)
  und ACTION_SEND_MULTIPLE. launchMode=singleTask war bereits gesetzt.
- ShareIntentModule (Kotlin): liest den Intent und kopiert die Inhalte SOFORT in
  den App-Cache — die Leseerlaubnis fuer content://-URIs gilt nur fuer diesen
  Intent und waere nach der Projektauswahl womoeglich erloschen. Liefert echte
  Dateipfade, dadurch laeuft Teilen durch exakt dieselbe Upload-Strecke wie die
  Bueroklammer (RNFS -> Base64 -> Anhang im Chat).
- MainActivity.onNewIntent: setIntent + Event an JS (best effort). Zweitweg in
  JS ueber AppState 'active' -> getSharedData(), falls das Event je nach
  RN-Architektur nicht durchkommt; getSharedData markiert den Intent als
  verbraucht, also kein doppeltes Aufpoppen.
- ShareModal: Vorschau der geteilten Inhalte, Projektliste (inkl. Hauptchat),
  optionales Textfeld. Senden erst nach Zielwahl moeglich.
- sendPendingAttachments nimmt die Anhaenge jetzt optional als Parameter — beim
  Teilen waere der State zum Aufrufzeitpunkt noch leer.
tsc --noEmit: 0 Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 01:19:55 +02:00
duffyduck b9354283af release: bump version to 0.2.5.2 2026-10-02 01:14:25 +02:00
duffyduckandClaude Opus 4.8 bf1db6cbf9 fix(app): Diagnostic-Nachrichten verschwanden in Projekten (projectId fehlte)
Stefan: 'die antwort kam auf der app, aber meine frage ist nicht in der app'.
Die App zeigt Diagnostic-Nachrichten durchaus an (sender=diagnostic -> als
User-Bubble), legte sie aber OHNE projectId ab. Die Chat-Ansicht filtert nach
Projekt (ChatScreen.tsx:2078) — damit war die Nachricht nur im Hauptchat
sichtbar und in jedem Projekt unsichtbar. ARIAs Antwort trug die projectId und
erschien korrekt; nur die eigene Frage fehlte. Jetzt wird die mitgeschickte
projectId uebernommen. tsc: 0 Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 01:11:18 +02:00
duffyduckandClaude Opus 4.8 ea159d4ed7 fix(app): Zombie-Verbindung — App zeigt 'getrennt', empfaengt aber weiter
Symptom (live beobachtet): die App steht auf 'getrennt' und nichts, was Stefan
schreibt oder spricht, erreicht den Server — ARIAs Antwort und TTS kommen aber
trotzdem in der App an.

Ursache: Beim Verwerfen einer Verbindung wurden nur onclose und onerror
entfernt, NICHT onmessage. Die aufgegebene Verbindung lieferte deshalb weiter
Nachrichten an die App-Listener (daher die ankommende Antwort), waehrend this.ws
auf die neue/tote zeigte — send() lehnte ab und der Zustand blieb
'disconnected'. Betraf beide Abriss-Pfade (Neu-Connect und TLS-Fallback).

Fix: detachSocket() klemmt eine Verbindung vollstaendig ab (onopen/onmessage/
onclose/onerror + close) und wird in beiden Pfaden genutzt. Zusaetzlich ignoriert
der onmessage-Handler Nachrichten, die nicht von der AKTUELLEN Verbindung
stammen — sonst taeuscht ein Zombie weiter Betrieb vor. tsc: 0 Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 01:08:37 +02:00
duffyduckandClaude Opus 4.8 dd1e1ac458 fix(app): verlorene Aufnahme sichtbar machen statt still zu loeschen
Beweis aus den Logs: die Whisper-Bridge startete MITTEN in der ersten Aufnahme
neu (stream.start 16:35:43 -> boot 16:36:02), damit war die Sitzung weg. Die App
fiel in den 3s-Fallback (reason=...no-response) und entfernte die Bubble
kommentarlos — fuer den Nutzer sah es aus, als waere nie etwas gesendet worden.

Jetzt wird bei 'no-response' die Platzhalter-Bubble durch einen Hinweis ersetzt
('Aufnahme ging verloren — Spracherkennung war nicht erreichbar'). Der stille
no-speech-Fall (Du hast wirklich nichts gesagt) bleibt wie bisher. tsc: 0 Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-01 17:08:42 +02:00
duffyduckandClaude Opus 4.8 ddd77c7ad4 docs(xtts): openhands-Profil in .env.example und README sichtbar machen
Das Profil existierte bereits in der compose-Datei, tauchte aber nicht in der
Profil-Liste der .env.example auf — man konnte also nicht sehen, dass es waehlbar
(und per Default AUS) ist. Jetzt dort gelistet, mit dem Hinweis, dass ohne
Eintrag gar kein Container angelegt wird, plus Warnung, dass Profile compose v2
brauchen (das alte docker-compose 1.x behandelt sie unzuverlaessig).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 22:40:14 +02:00
duffyduckandClaude Opus 4.8 7c3c932054 docs(xtts): OpenHands — SSH-Tunnel statt LAN-Port als Absicherung
Der Host-Socket bleibt (bewusste Entscheidung fuer den Heimbetrieb). Dafuer der
wirksamste kostenlose Schutz explizit dokumentiert: OPENHANDS_BIND bleibt auf
127.0.0.1, Zugriff von anderen Rechnern per 'ssh -L 3000:127.0.0.1:3000'. Dazu
der Hinweis, was echte Isolation waere (eigene VM / rootless Daemon).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 22:34:14 +02:00
duffyduckandClaude Opus 4.8 d6c7502810 feat(xtts): OpenAI-Endpunkt fuers lokale LLM + OpenHands-Profil
llama-swap ist bereits OpenAI-kompatibel, war aber nur im Compose-Netz erreichbar
(kein ports-Mapping). Jetzt nutzbar fuer OpenHands, Continue, Open WebUI & Co.

- compose: llama-swap veroeffentlicht 8080 als
  ${LLM_OPENAI_BIND:-127.0.0.1}:${LLM_OPENAI_PORT:-8081} — sicher per Default
  (nur localhost), fuer LAN bewusst BIND=0.0.0.0 setzen.
- llm-adapter: API-Key wird in JEDEN llama-server-Befehl der generierten
  llama-swap-Config eingehaengt (--api-key, shlex-gequotet). Quelle: .env
  (Vorrang) sonst /models/openai_key.json (0600). Neuer Handler
  llm_openai_config setzt ihn zur Laufzeit und lehnt bei .env-Vorrang ab.
  worker_hello meldet Port/Bind/keySet/envLocked — den Key NIE (Broadcast!).
- Diagnostic: pro LLM-Box Endpunkt-Zeile mit Base-URL, Key-Status und Eingabe.
  Bei .env-Vorrang ist das Feld schreibgeschuetzt (🔒). Warnung, wenn im LAN
  offen ohne Key. Der PORT wird nur ANGEZEIGT: er ist ein Docker-Port-Mapping
  und steht beim Container-Start fest — dafuer ist die .env zustaendig.
- rvs: llm_openai_config(_result) in ALLOWED_TYPES (sonst still verworfen).
- Neues Profil 'openhands' (COMPOSE_PROFILES=llm,openhands) mit Port/Bind/Modell
  aus der .env, lokales LLM vorbelegt. Ohne Profil aendert sich nichts.
- .env.example + README: alle Variablen, Sicherheitshinweise (offener Endpunkt,
  Docker-Socket bei OpenHands), curl-Test und wann der Key greift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 22:30:37 +02:00
duffyduckandClaude Opus 4.8 dc77933014 fix(jellyfin): Erfolgsmeldung sagt ausdruecklich, dass kein Neustart noetig war
Nachgeprueft: der Selbstheilungs-Fix lief korrekt — die Wiedergabe startete auf
Anhieb (Nachkontrolle positiv), es gab KEINEN App-Neustart (kein host_app_launch
in der Agent-Historie). ARIA behauptete trotzdem 'Session war eingeschlafen, ich
hab Jellyfin neu gestartet' — nacherzaehlt aus dem vorherigen Turn, wo das
tatsaechlich noetig war.

Die Erfolgsmeldung stellt jetzt klar, dass nichts repariert werden musste, damit
das Modell die alte Panne nicht weitererzaehlt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 22:01:54 +02:00
duffyduckandClaude Opus 4.8 6a6b320f37 fix(jellyfin): eingeschlafene Sitzung erkennen und selbst neu aufbauen
Beim zweiten Film ging nichts mehr ('Session abgelaufen') — ARIA musste Jellyfin
von Hand neu starten. Ursache: Jellyfin listet Clients noch, wenn die App laengst
weg ist, und quittiert den Play-Befehl auch dann mit Erfolg. Es lief nur nichts.

- /Sessions wird jetzt mit activeWithinSeconds gefiltert (Play + devices) —
  Karteileichen tauchen gar nicht erst als Ziel auf.
- _jellyfin_playing(): nach dem Play-Befehl wird nachgesehen, ob NowPlayingItem
  wirklich gesetzt ist (bis ~6s). Ohne das meldet man faelschlich Erfolg.
- Laeuft nichts und ist 'host' gesetzt: Jellyfin wird ueber den Host-Agenten neu
  gestartet (host_app_launch), frische Sitzung geholt, einmal nachgelegt — also
  genau der Handgriff, den ARIA vorher manuell machen musste. Ohne 'host' gibt es
  eine klare Handlungsanweisung statt eines stillen Fehlschlags.
- Tool-Beschreibung draengt auf 'host', damit die Selbstheilung greift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 21:50:43 +02:00
duffyduck cc91497923 release: bump version to 0.2.5.1 2026-09-30 21:18:09 +02:00
duffyduckandClaude Opus 4.8 10e7faa92b fix(app): verlorene erste Sprachaufnahme — Watchdog, Reihenfolge, Startzustand
Drei App-seitige Ursachen fuer 'erst beim zweiten Aufnehmen' (tsc --noEmit: 0 Fehler):

1) No-Speech-Watchdog: Der Aufrufer uebergibt die Sprechpausen-Toleranz
   (sttEndpointMs, Default 2400ms, per Setting bis 1000ms). Die misst ab dem
   ENDE des Sprechens, der Watchdog lief aber ab dem START der Aufnahme. Bis ein
   Zwischenergebnis zurueck sein KANN, vergehen realistisch 1,3-2,2s (600ms
   Mindestpuffer + 700ms Transkriptions-Takt + Netzweg). Schon kurzes Ueberlegen
   nach dem Gong brach die Aufnahme ab. Jetzt +2500ms Zuschlag.

2) stt_stream_start ging ERST NACH PcmStreamRecorder.start() raus — die ersten
   Chunks (200ms-Takt) trafen auf eine Bridge ohne Sitzung und wurden verworfen
   (Wortanfang weg; bei kurzen Kommandos blieb nichts uebrig). Jetzt davor.

3) recordingState wurde erst nach mehreren awaits auf 'recording' gesetzt. In
   diesem Fenster war isStreamingRecording() true, der Zustand aber 'idle': ein
   Abbruch dort liess den Start weiterlaufen, der Recorder blieb haengen und die
   NAECHSTE Aufnahme wurde abgelehnt. Zustand wird jetzt sofort gesetzt, plus
   Session-Guard nach den awaits.

Zusaetzlich: ohne RVS-Verbindung wird gar nicht erst aufgenommen (send()
verwirft still, kein Puffer) — nach App-Resume fiel sonst die erste Aufnahme
komplett aus. Nachtraeglich gepuffertes Audio waere sinnlos, die STT-Sitzung
laeuft in Echtzeit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 13:02:55 +02:00
duffyduckandClaude Opus 4.8 d030cd7bc6 fix(voice): verlorene Sprachnachrichten — Allow-List, Kaltstart, Dedup, Voice-Router
Vier unabhaengige Ursachen fuer 'erst beim zweiten Aufnehmen' bzw. 'wird gar
nicht verarbeitet' (server-/box-seitig, ohne App-Update wirksam):

1) rvs/server.js: stt_lease, stt_lease_request, stt_transcribe_blob,
   stt_transcribe_result und chat_ack fehlten in ALLOWED_TYPES und wurden STILL
   verworfen (dieselbe Falle wie bei worker_*/host_*). Folgen: das Lease lief
   immer in den 250ms-Timeout -> jede Aufnahme startete verspaetet und OHNE
   targetInstance nahmen alle STT-Worker dieselbe Session an (doppelte
   stt_endpoint); chat_ack kam nie an -> getippte Nachrichten galten in der App
   nach 3 Versuchen als fehlgeschlagen, obwohl sie verarbeitet waren.

2) xtts/whisper/bridge.py: Beim Kaltstart wurde die Session erst NACH dem
   Modell-Load angelegt — alle Chunks der ersten Aufnahme liefen ins Leere und
   der Take war weg (die zweite klappte, weil das Modell dann warm war). Session
   wird jetzt sofort angelegt, der Endpointer wartet solange (runner.model None).

3) bridge/aria_bridge.py: Text-Dedup-Fenster 5s -> 1.5s. Es soll nur den
   Zwilling zweier Worker abfangen; mit 5s fielen echte Wiederholungen weg —
   also genau das Nachsprechen nach einem verschluckten Take, und kurze
   Kommandos ('lauter', 'nächstes').

4) bridge/aria_bridge.py Voice-Router: Komma als Prefix-Trenner entfernt (nur
   noch ':' und '-'), Fuzzy-Schwelle 0.6 -> 0.82, Sticky wird nicht mehr bei
   jeder Nachricht verlaengert. Vorher matchten normale Saetze wie 'In Ordnung,
   mach mal ...' als Projekt-Prefix: die Nachricht landete still in einem
   Projekt und war im Hauptchat unsichtbar — verifiziert mit Beispielsaetzen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 11:05:06 +02:00
duffyduckandClaude Opus 4.8 36641e6519 fix(proxy): 'spawn E2BIG' — System-Prompt ueber Datei statt Kommandozeile
Gefunden ueber die Brain-Logs: mehrere Nachrichten blieben unbeantwortet, weil
POST /chat mit 502 endete. Ursache:
  Proxy HTTP 500: {"error":{"message":"spawn E2BIG"}}
execve() begrenzt argv+envp ZUSAMMEN auf ARG_MAX — im Proxy-Container nur
131072 Bytes (getconf ARG_MAX). Der System-Prompt wurde als einzelnes Argument
uebergeben und war im Fehlerfall 76881 Zeichen gross (gepinnte Erinnerungen +
Skills + Projektkontext) -> Start scheitert, Turn verloren, App bekommt 502.
11 solcher Faelle im aktuellen Log.

Fix: --system-prompt-file <tmpfile> statt --system-prompt <text> (die CLI
unterstuetzt das). Damit faellt die Groessengrenze praktisch weg. Die Temp-Datei
wird bei close/error/catch wieder entfernt; schlaegt das Schreiben fehl, wird
wie bisher das Argument genutzt (kleine Prompts laufen weiter).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 10:58:45 +02:00
duffyduckandClaude Opus 4.8 9b11c16479 fix(android-agent): Sprachausgabe — ARIAs echte Stimme statt (fehlender) Geraete-TTS
Der Agent meldete 'gesprochen', es kam aber kein Ton: Fire TV Sticks haben in
der Regel keine (deutsche) TTS-Engine, und wir haben weder setLanguage noch den
Rueckgabewert von speak() geprueft -> stiller Fehlschlag.

- speak/notify fordern jetzt ARIAs ECHTE Stimme ueber den bestehenden Serverpfad
  an: tts_request -> Bridge -> F5/XTTS -> audio_pcm, das der Agent abspielt.
  Keine Server-Aenderung noetig (beide Typen stehen in der RVS-Allow-List).
  VoiceSession merkt sich die angeforderte messageId und spielt genau dieses
  Audio ab — die Turn-Ownership bleibt sonst unveraendert (Handy redet nicht mit).
- Geraete-TTS nur noch auf ausdrueckliche Anforderung (engine='device') und nun
  ehrlich: fehlende Engine, fehlende deutsche Sprachdaten und ein abgelehntes
  speak() werden als Fehler gemeldet statt als Erfolg.
Android-Version 0.0.1.6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 03:47:22 +02:00
duffyduckandClaude Opus 4.8 8f9e3e5c79 feat: Jellyfin-Integration — ARIA steuert die Wiedergabe ueber die API
Statt sich durch die Fire-TV-Oberflaeche zu klicken (fehleranfaellig: ARIA ist am
Titel vorbeigescrollt und auf Home gelandet) spricht ARIA den Jellyfin-Server
direkt an: suchen -> Sitzung des Abspielgeraets finden -> abspielen.

- Diagnostic (Einstellungen > Integrationen): Jellyfin-Abschnitt mit MEHREREN
  Servern (+-Button; eigener, vom Kumpel ...), einer davon Standard. Pro Server
  Name/URL/API-Key + Test-Knopf (prueft /System/Info, meldet Name und Version).
  Gespeichert in /shared/config/jellyfin.json mit Datei-Rechten 0600 (API-Keys).
- Brain: jellyfin_search / jellyfin_devices / jellyfin_play / jellyfin_control.
  Serverwahl per Name oder Standard. 'resume' ist echt implementiert (liest
  PlaybackPositionTicks und setzt startPositionTicks) statt nur zu existieren.
  Fehlertexte sind handlungsleitend: 401 -> API-Key pruefen; kein Geraet -> die
  Jellyfin-App muss offen sein, sonst erst host_app_launch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 03:23:30 +02:00
duffyduck f1ae18b658 release(agent): bump to 0.0.1.2 2026-09-30 02:51:29 +02:00
duffyduckandClaude Opus 4.8 27f9399eda feat(diagnostic): bereits vergebener Geraetename wird beim Ausrollen vorgeschlagen
Bisher stand im Namensfeld die technische Kennung aus der Netz-Erkennung
(F8FCE10B4E6C@AS-AFTKA[AirPlay]) — auch wenn das Geraet laengst 'Fire-TV
Schlafzimmer' heisst. Jetzt in dieser Reihenfolge:
  1. Name, den der Agent auf DIESEM Geraet traegt — Zuordnung ueber die IP, die
     der Agent neu in host_hello mitmeldet (localIp()); Server reicht sie durch.
  2. zuletzt hier eingegebener Name (localStorage je Satellit+IP).
  3. Name aus der Erkennung, aber nur wenn menschenlesbar — Seriennummern, IPs
     und Kennungen mit @/[]/: werden verworfen (Feld bleibt leer).
Android-Version 0.0.1.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:49:38 +02:00
duffyduckandClaude Opus 4.8 c131820be0 feat(android-agent): Audio-Fokus — ARIA redet nicht mehr ueber den laufenden Film
Bisher wurde KEIN Audio-Fokus angefordert: Android mischt dann beide Tonspuren,
d.h. Netflix/Jellyfin/Spotify liefen in voller Lautstaerke weiter und ARIA war
kaum zu verstehen — und beim Zuhoeren nahm das Mikrofon den Filmton mit auf.

AudioFocusHelper mit drei Modi:
- speak/notify: AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK ('duck', Standard) -> Medien
  werden leiser und danach automatisch wieder lauter. Per focus-Param auch
  'pause' (Medien anhalten) oder 'none'.
- listen: AUDIOFOCUS_GAIN_TRANSIENT ('pause') -> Medien halten an, solange das
  Mikrofon offen ist; danach laufen sie weiter.
- Fokus wird in allen Pfaden zurueckgegeben (Completion, Fehler, stop, shutdown),
  sonst blieben die Medien dauerhaft leise.
Brain: focus-Parameter in host_notify/host_speak. Android-Version 0.0.1.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:46:14 +02:00
duffyduckandClaude Opus 4.8 e43b44725b feat(android-agent): Bildschirm-Aufnahme nach Neustart automatisch scharf schalten
Die BERECHTIGUNGEN bleiben dauerhaft (pm grant / appops in appops.xml), die
MediaProjection-SITZUNG aber nicht — Android laesst sie nicht speichern. Nach
Reboot/App-Neustart war der Agent also verbunden, konnte aber keinen Screenshot
machen, bis jemand am Fernseher 'Bildschirm-Zugriff erlauben' drueckt.

- CaptureActivity: unsichtbarer Helfer (Theme.Translucent, noHistory,
  excludeFromRecents), fordert die Projektion an und reicht das Ergebnis an den
  Dienst. Mit gesetztem AppOp PROJECT_MEDIA laeuft das OHNE Dialog durch.
- AgentService startet sie beim Dienststart automatisch nach (2,5s Verzoegerung),
  aber nur wenn captureEnabled gesetzt ist — wer die Aufnahme nie wollte, bekommt
  auch keinen Dialog.
- AgentConfig.captureEnabled: wird gesetzt, sobald die Aufnahme einmal lief, und
  kann beim Fern-Einrichten mitgegeben werden (nur wenn der Satellit PROJECT_MEDIA
  wirklich erteilen konnte).
Android-Version 0.0.1.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:41:46 +02:00
duffyduckandClaude Opus 4.8 586a2bf134 feat: Brain-Tools fuer Overlay/Sprache + Rechte automatisch per ADB erteilen
1) ARIA hatte KEIN Werkzeug fuer die Einblendung — der Agent kann notify/speak/
   volume laengst, aber im Brain fehlten die Tools. Sie hat deshalb improvisiert
   ('cmd notification post', gibt es auf Android 9 nicht) und ist gescheitert.
   Neu: host_notify (Overlay/Sprache, mode ueberschreibbar), host_speak,
   host_volume — mit dem ausdruecklichen Hinweis, es NICHT ueber adb zu versuchen.
2) _adb_grant_permissions: appops PROJECT_MEDIA (Screenshots ohne Dialog) +
   SYSTEM_ALERT_WINDOW (Overlay) + pm grant RECORD_AUDIO (Mikro) werden beim
   Einrichten automatisch gesetzt — kein Zusammenklicken mit der Fernbedienung
   mehr. Best effort; was scheitert, wird als 'manuell noetig' gemeldet. Neue
   Aktion adb.permissions setzt sie einzeln nach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:37:20 +02:00
duffyduck ac40afc856 release(agent): bump to 0.0.1.1 2026-09-30 02:18:08 +02:00
duffyduckandClaude Opus 4.8 ff6d474f29 fix: Umbenennen greift nicht — Android lieferte den Intent nicht aus
Android vergleicht beim Activity-Start Komponente/Action/Daten, aber NICHT die
Extras. Ein zweites Provisioning galt damit als 'derselbe Intent': die Activity
wurde nur nach vorn geholt, onCreate/onNewIntent liefen nie — der alte Name blieb
stehen (und kam nach dem Loeschen aus der Liste sofort wieder).

- Satellit: eindeutige Daten-URI (aria://provision/<ms>) je Aufruf -> der Intent
  ist nie 'gleich'. Zusaetzlich wird die am-start-Ausgabe auf 'brought to the
  front'/'not started' geprueft und als Fehler gemeldet statt als Erfolg.
- Android: MainActivity launchMode=singleTop -> erneutes Provisioning landet
  zuverlaessig in onNewIntent (statt neuer Instanz); setIntent() ergaenzt.
Android-Version 0.0.1.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:16:21 +02:00
duffyduckandClaude Opus 4.8 44aef71813 fix: Leerzeichen im Geraetenamen + alte Agenten aus der Liste entfernbar
1) 'adb shell' haengt seine Argumente zu EINER Kommandozeile zusammen, die auf
   dem Geraet nochmal von der Shell zerlegt wird — '-e name Wohnzimmer Fire TV'
   kam als 'Wohnzimmer' an, der Rest fiel weg. Alle Werte werden jetzt mit
   shlex.quote gequotet.
2) Umbenennen erzeugt eine neue hostId, der alte Eintrag blieb fuer immer in der
   Diagnostic stehen (nach 5 min nur rot, nie weg). Neuer ✕-Button je Agent
   (host_forget) mit Rueckfrage; laeuft der Agent noch, meldet er sich von selbst
   wieder — das Entfernen stoppt ihn also nicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:07:27 +02:00
duffyduckandClaude Opus 4.8 27381e7038 feat(diagnostic): Geraetename beim Rollout am PC vergeben + 'Nur einrichten'
Fire TV meldet sich in der Netz-Erkennung oft nur mit der Seriennummer
(0d94400fa5b63301) — genau der Name, den man auf dem Fernseher nicht tippen will.

- Namensfeld im Rollout-Dialog (am PC getippt), wird beim Einrichten mitgegeben.
- Vorbelegung filtert technische Namen (Hex-Seriennummern, IPs) raus.
- Neuer Button 'Nur einrichten': setzt Zugang + Namen per adb.provision, OHNE die
  APK neu zu installieren — zum Umbenennen bereits ausgerollter Geraete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:01:55 +02:00
duffyduck 21d389cc26 release(agent): bump to 0.0.1.0 2026-09-30 01:56:49 +02:00
duffyduckandClaude Opus 4.8 eae036a9fc feat: Fern-Einrichtung des Agents per ADB (kein Abtippen am Fire TV)
Am Fire TV gibt es keine Kamera (kein QR) und Host/Token mit der Fernbedienung
einzugeben ist eine Qual. Der Satellit hat aber ohnehin schon eine ADB-Verbindung
— darueber bekommt der Agent seine Zugangsdaten direkt.

- Android: MainActivity.applyProvisioning() liest Intent-Extras (host/port/tls/
  token/name/control/reply_mode), speichert sie, startet den Dienst und zeigt
  'Von ARIA eingerichtet' (Transparenz). Auch via onNewIntent.
- Satellit: adb.provision (am start mit Extras) und adb.status (installiert?
  laeuft? welche Version?). adb.install richtet direkt mit ein, wenn rvs_host/
  rvs_token mitkommen -> ein Klick = installieren + einrichten + online.
- Diagnostic: Checkbox 'Gleich einrichten' (default an) + 'Status pruefen'-Button.
  Der RVS-Zugang wird SERVERSEITIG ergaenzt (params.provision) — das Token steht
  nie im Browser-Formular.
- Allowlists (Code + .env.example) und README ergaenzt, inkl. Sicherheitshinweis.
Android-Version 0.0.1.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 01:54:38 +02:00
duffyduckandClaude Opus 4.8 621b5525a8 feat(satellite): eigene MAC-Adresse melden + in der Diagnostic anzeigen
NET bekommt ein 'mac'-Feld (_own_mac): liest /sys/class/net des Interfaces, das
zur primaeren IP gehoert — wichtig, weil sonst die Docker-Bridge-MAC (02:42:...)
statt der echten Karte kaeme. Fallback uuid.getnode(). Wird ueber sat_hello.net
mitgeschickt (Server reicht net bereits durch) und in der Satelliten-Zeile hinter
der IP angezeigt; auch im Startup-Log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 01:41:48 +02:00
duffyduckandClaude Opus 4.8 6da567aee6 docs(satellite): Workaround fuer KeyError 'ContainerConfig' (docker-compose 1.29.2)
Bug der alten Python-Version beim Ersetzen eines Containers mit Volume:
get_container_data_volumes liest image_config['ContainerConfig'], das neuere
Docker-Versionen nicht mehr schreiben. Fix: 'docker compose' (v2) nutzen oder
den alten Container vorher mit 'rm -sf' entfernen (dann greift der create- statt
recreate-Pfad). Trat schon beim frueheren Satelliten-Build auf — jetzt dokumentiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 01:36:44 +02:00
duffyduck 7071614434 release(agent): bump to 0.0.0.9 2026-09-30 01:36:14 +02:00
duffyduckandClaude Opus 4.8 8301342728 feat(satellite): ADB-Rollout wartet auf die Geraete-Bestaetigung (ein Klick genuegt)
Bisher: 1. Klick -> 'unauthorized' (Fehler), am Fire TV bestaetigen, 2. Klick ->
klappt. Jetzt wartet der Satellit nach dem ersten connect bis zu ADB_AUTH_WAIT_SEC
(90s) auf die Autorisierung und installiert danach automatisch weiter — der
Dialog auf dem Fernseher erscheint ja erst DURCH das connect.

- _adb_wait_authorized(): pollt adb devices alle 2s und verbindet neu (nach dem
  Bestaetigen wechselt der Zustand oft erst mit frischem connect auf 'device').
- adb.install/adb.connect nutzen es; scheitert es doch, nennt die Meldung Wartezeit
  und Zustand statt nur 'unauthorized'.
- Brain-Timeout fuer adb.* auf 320s (Warten + Download + Installation).
- Diagnostic: Hinweis erscheint SOFORT beim Klick ('Jetzt auf den Bildschirm des
  Geraets schauen'), nicht erst als Fehler hinterher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 01:34:18 +02:00
duffyduck 6a8dd662fa release(agent): bump to 0.0.0.8 2026-09-30 01:25:49 +02:00
duffyduck a48d8442b1 docs(android-agent): Fire TV (Overlay/Sprache/D-Pad) + ADB-Rollout per Satellit 2026-09-29 19:18:19 +02:00
duffyduckandClaude Opus 4.8 47735642f2 feat(satellite): APK-Rollout per ADB — Fire TV & Android aus der Ferne bespielen
Der Satellit kann Android-Geraete im LAN erkennen und die Agent-APK ausrollen,
statt sie von Hand aufs Geraet zu kopieren.

- satellite.py: adb.devices/connect/install/shell. _apk_fetch laedt die APK per
  HTTP (gecached in /data/apk, sha256 optional) statt sie durchs RVS zu schicken —
  der RVS ist ein BROADCAST-Relay (jede base64-APK ginge an alle Clients, und das
  Satelliten-max_size liegt bei 8 MB). adb.install ist selbstheilend (connect ->
  install -r) und uebersetzt die typischen Fehler in klare Ansagen (unauthorized,
  Signatur-Mismatch, Speicher voll). Imports lokal in den Funktionen (Hausmuster).
- Discovery: mDNS-Typen _amzn-wplay/_adb-tls-connect/_androidtvremote2, _mdns_kind
  -> firetv/android-adb/androidtv, plus _enrich_adb (TCP-Probe 5555) -> adbCapable.
- Dockerfile: adb; compose: HOME=/data (ADB-Key persistent, sonst fragt das Geraet
  nach jedem Rebuild neu) + ANDROID_ADB_SERVER_PORT=5038 (Host-Kollision).
- .env.example: neue Aktionen + Warnung, dass die Allowlist den Code-Default
  ueberschreibt (sonst lehnt ein bestehender Satellit adb.* ab).
- Brain: adb.* in der satellite_command-Beschreibung; WICHTIG Timeout 200s fuer
  adb.*/ssh.exec — der Bridge-Default von 20s haette jede Installation abgewuergt.
- Diagnostic: sat_command (Browser->RVS) und sat_result (RVS->Browser) gab es
  bisher GAR NICHT; beide ergaenzt. 📦-Button je ADB-faehigem Geraet + Rollout-
  Modal mit Status/Log, ADB-Tag in der Geraeteliste.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-29 19:17:53 +02:00
duffyduckandClaude Opus 4.8 e15f7a5bc0 feat(android-agent): Fire TV als ARIA-Endpunkt — Sprache, Overlay, Ausgabe-Modus
Der Stick wird vom gesteuerten Geraet zum Gespraechspartner:

- VoiceSession: Mikro -> stt_stream_start/stt_audio_chunk(pcm,16k mono s16le,
  6400B)/stt_stream_end (exakt das App-Protokoll, keine Server-Aenderung noetig);
  Antwort via chat(sender=aria) + audio_pcm(base64, 24k) in AudioTrack. AEC/NS an.
  TURN-OWNERSHIP: der RVS broadcastet an alle — es wird nur ausgegeben, was aus der
  EIGENEN Frage entstand (audioRequestId -> messageId), sonst reden Handy und Stick
  gleichzeitig.
- OverlayView: TYPE_APPLICATION_OVERLAY, Einblendung ueber dem laufenden Bild
  (Text + optional Bild), nicht fokussierbar (Fernbedienung bleibt frei).
- Speaker: ARIAs echte Stimme (fertiges Audio) bevorzugt, Geraete-TTS als Fallback,
  Lautstaerke-Steuerung.
- Neue Aktionen: notify (Einblendung/Sprache, mode ueberschreibbar), speak, volume,
  listen/listen_stop.
- Einstellung replyMode (overlay|speech) in der App, pro Anfrage ueberschreibbar.
- Sprech-Taste in der UI (Fire TV: OK-Taste) + Mikro-/Overlay-Berechtigungen.
Version 0.0.1.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-29 19:12:17 +02:00
duffyduckandClaude Opus 4.8 39f908dd4a feat(android-agent): Fire TV / Android TV Support (eine APK fuer Handy + Stick)
Kein zweiter Agent: dieselbe APK laeuft auf Fire TV, Fähigkeiten capability-gated
(wie beim Desktop-Agent).

- Manifest: touchscreen/faketouch/leanback als required=false (sonst gilt die App
  als Touch-Pflicht bzw. laeuft nicht mehr aufs Handy), LEANBACK_LAUNCHER (ohne
  das fehlt die App im Fire-TV-Menue), TV-Banner (Launcher zeigt Banner statt Icon).
- RvsClient: isTv-Erkennung (leanback/television/UiModeManager) -> caps bekommen
  'tv', os meldet 'Fire TV / Android TV'.
- AriaAccessibilityService.globalKey: D-Pad (up/down/left/right/ok) via Fokus-
  Navigation, Medientasten via dispatchMediaKeyEvent, Lautstaerke/Mute via
  AudioManager — deutsche Aliase (hoch/runter/lauter/stumm).
- MainActivity/Layout: auf TV QR-Scan ausgeblendet (keine Kamera), erster Fokus
  gesetzt, Buttons fokussierbar (Fernbedienung).
Version 0.0.0.8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-29 18:26:09 +02:00
duffyduckandClaude Opus 4.8 176997a800 feat(host-agent): GUI-Steuerung fuer Desktop (capability-gated)
Desktop-Agenten (Win/Linux/Mac) koennen jetzt bedienen, nicht nur sehen — mit
DENSELBEN Actions/Brain-Tools wie Android (ui_tap/ui_text/ui_key/ui_swipe/
app_launch), ein Werkzeugset fuer Handy und Rechner.

- host_agent.py: _gui_input_method() erkennt X11(xdotool)/Wayland(ydotool)/
  macOS(osascript+cliclick)/Windows(PowerShell). CAPS werden dynamisch erweitert
  -> reiner Terminal-Server (kein DISPLAY) meldet KEINE ui_*-Caps. _do_ui_tap/
  text/key/swipe + _do_app_launch je Methode; info liefert 'gui'.
- Brain: Tool-Beschreibungen decken Desktop ab (Koordinaten aus dem Screenshot,
  kein ui_dump; button/double bei tap; command bei app_launch); _UI_ACTIONS
  reicht die neuen Params durch.
- README: Fähigkeiten/Plattform-Tabelle + Helfer je OS + HiDPI-Hinweis.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-25 14:30:38 +02:00
duffyduck 5b7e205ede release(agent): bump to 0.0.0.7 2026-09-25 13:52:39 +02:00
duffyduckandClaude Opus 4.8 47cc1b7f08 feat(android-agent): Sperre-Erkennung — Steuern nur entsperrt (v0.0.0.7)
Erkenntnis aus dem Live-Test: bei gesperrtem Bildschirm greifen Gesten/Tasten
nicht (Android-Sicherheit), der Agent meldete aber still 'ok' -> Verwirrung
(home,home,home, Screenshot zeigt trotzdem alte App). Fix: KeyguardManager.
isKeyguardLocked() gate fuer Steuer-Aktionen (ui_tap/text/swipe/key/app_launch)
-> klare Meldung 'Geraet ist gesperrt - bitte entsperren'. Sehen (screenshot/
ui_dump/info) bleibt auch gesperrt erlaubt. README-Hinweis ergaenzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-25 13:44:49 +02:00
duffyduckandClaude Opus 4.8 f4b7148cf9 feat(diagnostic): Agent-Historie pro Agent — Akkordeon je Nachricht
Jede Host-Aktion wird als Historie-Eintrag festgehalten und in der Diagnostic
pro User-Nachricht gruppiert aufklappbar dargestellt (mit Screenshot-Thumbnails).

- Brain: trace_id+msg pro chat(); _dispatch_host als Wrapper um _dispatch_host_inner
  emittiert je Aktion einen Eintrag an POST /internal/agent-history (Datei-Pfad aus
  Screenshot-Text erkannt).
- Bridge: /internal/agent-history persistiert nach /shared/config/agent_history/
  <hostId>.jsonl (Ringpuffer 200) + broadcastet agent_history; agent_history_query
  -> agent_history_list.
- RVS: agent_history/_query/_list in ALLOWED_TYPES (sonst still verworfen).
- Diagnostic-Server: relay browser<->RVS + /uploads/<file> Bild-Endpoint (nur
  /shared/uploads, kein Traversal).
- index.html: 'Historie'-Button je Host, Modal mit Akkordeon (Gruppe=Nachricht,
  aufklappbar, Screenshot-Thumbs), Live-Refresh bei agent_history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-25 13:32:40 +02:00
duffyduck 5941be0f21 release(agent): bump to 0.0.0.6 2026-09-24 21:57:38 +02:00
duffyduckandClaude Opus 4.8 2038b676e6 feat(android-agent): Meilenstein 3 — steuern (tap/text/swipe/key/app_launch)
Bedienungshilfe kann jetzt bedienen: canPerformGestures + dispatchGesture (tap/
swipe), ACTION_SET_TEXT (text), performGlobalAction (back/home/recents/notif).
app_launch via getLaunchIntentForPackage (+ Label-Suche, QUERY_ALL_PACKAGES).
caps erweitert. Brain-Tools host_ui_tap/_text/_swipe/_key/host_app_launch ->
generischer _UI_ACTIONS-Dispatch. Tap-Koordinaten = ui_dump-x/y (echte Pixel,
nicht der skalierte Screenshot). Damit voller Ablauf sehen->steuern. Version 0.0.0.6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 21:53:02 +02:00
duffyduck 8b84ee3b9c release(agent): bump to 0.0.0.5 2026-09-24 21:44:05 +02:00
duffyduckandClaude Opus 4.8 c86cd59b5c fix(android-agent): Screenshot — Vordergrund-Dienst-Typ mediaProjection
Geraet (Android 12) meldete beim Projizieren:
'Media projections require a foreground service ...
FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION'. Der Dienst lief als reiner dataSync.
Fix: foregroundServiceType=dataSync|mediaProjection + Permissions
FOREGROUND_SERVICE_MEDIA_PROJECTION/-DATA_SYNC; beim Projektions-Start
startForeground(..., MEDIA_PROJECTION|DATA_SYNC) VOR getMediaProjection().
Normale Starts nutzen explizit nur dataSync (Android-14-konform). Version 0.0.0.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 21:42:23 +02:00
duffyduck 96012dc986 release(agent): bump to 0.0.0.4 2026-09-24 21:16:56 +02:00
duffyduckandClaude Opus 4.8 80d7f62eaa fix(android-agent): Screenshot-Timeout — nie stumm scheitern + gekappte Aufnahme
Timeout hiess: kein host_result kam zurueck. Ursachen adressiert:
1) handle() umschliesst JEDE Aktion mit try/catch(Throwable) -> auch OOM/Exception
   liefert jetzt ein host_result mit Fehlertext statt Stille (kein Timeout mehr).
2) ScreenCapturer nimmt direkt in gekappter Aufloesung auf (max 1280 lange Seite,
   MediaProjection skaliert) statt Vollbild-Bitmap + Nachskalieren -> viel weniger
   Speicher/Zeit (kein 10-MB-ARGB-Bitmap -> kein OOM), Frame-Warten bis ~3s.
lastError fliesst in die Screenshot-Fehlermeldung. Version 0.0.0.4/code 4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 21:14:15 +02:00
duffyduck 5f5234ee62 release(agent): bump to 0.0.0.3 2026-09-24 21:01:05 +02:00
duffyduckandClaude Opus 4.8 28e58089aa fix(android-agent): fester Signaturschluessel -> Updates statt Paket-Konflikt
Jeder Build signierte bisher mit einem zufaelligen Auto-Debug-Key -> neue APK =
andere Signatur -> Android verweigert Update ('Konflikt mit bestehendem Paket').
Fester signingConfig (rootProject/aria-agent.keystore) fuer debug+release, per
Datei-Existenz-Guard. Keystore liegt nur lokal (gitignored, NICHT im public Repo
— Backup!). Verifiziert: Signer-DN CN=ARIA Host-Agent. Version 0.0.0.3/code 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:59:00 +02:00
duffyduck c1a90102f8 release(agent): bump to 0.0.0.2 2026-09-24 20:52:21 +02:00
duffyduckandClaude Opus 4.8 ba531adc74 fix(android-agent): Crash bei Bildschirm-Zugriff (startForeground-Pflicht)
Der Projection-Consent kommt per startForegroundService, obwohl der Dienst schon
laeuft. Android verlangt danach binnen ~5s ein startForeground() -> fehlte im
Projection-Zweig -> ForegroundServiceDidNotStartInTimeException -> Prozess-Crash
(Diagnostic zeigt den Host bis zum Ping-Timeout noch gruen). Fix: onStartCommand
ruft IMMER zuerst startForeground(). Zusaetzlich ScreenCapturer.start in try/catch
mit lastError, das in der Screenshot-Fehlermeldung und der Notification erscheint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:50:33 +02:00
duffyduck 04d29b256e fix(host-agent): Release-Notes mit echten Zeilenumbruechen (literal \n weg) 2026-09-24 20:43:08 +02:00
duffyduck e6e07f672a release(agent): bump to 0.0.0.1 2026-09-24 20:41:50 +02:00
duffyduckandClaude Opus 4.8 d32e7e59c3 feat(host-agent): Windows-Build aus Docker (Wine) + setup.exe (Dienst)
Dockerfile.win (tobix/pywine): baut die Windows-.exe via Wine+PyInstaller und
per NSIS ein setup.exe, das den Agent via nssm als Autostart-Windows-Dienst
einrichtet und die .env aus %ProgramData%\ARIA-Host-Agent liest (AppDirectory).
build-win.sh als Einstieg; release_agent.sh baut Windows jetzt mit (SKIP_WINDOWS=1
ueberspringt). _load_dotenv haertet: sucht .env auch neben sys.executable (onefile-
.exe-Ort), nicht nur __file__/CWD. README: Windows-Build + Dienst + Release.
macOS bleibt self-build (nicht aus Docker moeglich).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:39:08 +02:00
duffyduckandClaude Opus 4.8 4f8e65b76b feat(host-agent): release_agent.sh — Binaries als Gitea-Assets, nicht im Tree
release_agent.sh <version> (wie die App-release.sh): setzt Version
(host_agent.py AGENT_VERSION + Android versionName/Code), baut Linux-Binary +
Android-APK per Docker, taggt agent-v<version> (eigener Namespace, keine
Kollision mit App-Tags v<version>), legt Gitea-Release an und laedt Assets hoch.
mac/win optional falls in dist/ vorgebaut. Agent meldet AGENT_VERSION jetzt in
host_hello + info. README-Release-Sektion aktualisiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:32:11 +02:00
duffyduckandClaude Opus 4.8 a2605a2802 fix(host-agent): binutils im Build-Image (PyInstaller braucht objdump)
Das slim-bullseye-Image bringt kein objdump mit -> PyInstaller bricht mit
'On Linux, objdump is required' ab. binutils per apt nachinstalliert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:28:08 +02:00
duffyduckandClaude Opus 4.8 0588a8d9b3 docs(android-agent): README-Build-Abschnitt praezisiert; Version 0.2.0
Ehrlicher Bau-Weg (nur Docker noetig, dist/aria-android-agent.apk, Debug-Key,
Installieren, Erst-Build-Kosten/Stolperer). release.sh klar als M4 geplant
markiert statt als vorhandener Befehl. versionCode 2 / versionName 0.2.0 (M1+M2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:26:50 +02:00
duffyduckandClaude Opus 4.8 be202b1fa1 feat(android-agent): Meilenstein 2 — sehen (Screenshot + ui_dump)
Screenshot via MediaProjection (ScreenCapturer, gleicher {format,bytes,base64}-
Vertrag wie der Desktop-Agent -> host_screenshot, inkl. Vision). UI-Baum via
AriaAccessibilityService (nur lesend) -> neues Brain-Tool host_ui_dump. Freigabe
einmalig in der App: 'Bildschirm-Zugriff erlauben' + 'Bedienungshilfe oeffnen'.
caps = [info, screenshot, ui_dump]. targetSdk 33 -> keine mediaProjection-FGS-
Typpflicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:22:52 +02:00
duffyduckandClaude Opus 4.8 1ad85fb687 feat(android-agent): Meilenstein 1 — verbinden + in Diagnostic sichtbar
Nativer Kotlin-Agent (host-agent/android/): Gradle-Projekt, AndroidManifest,
RVS-WebSocket-Client (OkHttp) mit host_hello/host_ping/host_command->host_result,
Foreground-Service (Weg A, Auto-Reconnect, BootReceiver), Connect-UI (QR-Scan via
ZXing ODER manuell: host/port/token/name/TLS/Steuerung-Schalter). QR-Format =
{host,port,token,tls} wie die ARIA-App -> derselbe QR nutzbar.

M1-Aktion: info (Modell/Android/Akku). screenshot/ui_* liefern 'kommt in M2/M3'.
Gate: CONTROL_ENABLED-Schalter in der App. Build: Docker (Android-SDK+Gradle) ->
dist/aria-android-agent.apk (Debug, auto-signiert). Kein Google.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:08:54 +02:00
duffyduckandClaude Opus 4.8 558afee239 docs(android-agent): Push OHNE Google — self-hosted ntfy/UnifiedPush
Klargestellt: FCM (Google) ist NUR eine Option, keine Pflicht. Akkuschonender
Push geht self-hosted via UnifiedPush + ntfy auf dem ARIA-Server (laeuft auch auf
Custom-ROMs ohne Play Services). Fuer ein dediziertes Ziel-Handy ist der eigene
RVS-Socket (Weg A) oft die einfachste Dauerloesung. Empfehlung entsprechend
angepasst: A -> Push-Hybrid mit ntfy, FCM nur optional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 19:50:14 +02:00
duffyduckandClaude Opus 4.8 b2fd8d8953 docs(android-agent): Push-Hybrid-Modell (Leerlauf=Push, Session=Socket) ergaenzt
Akku-optimal wie WhatsApp: idle nur FCM-Push, bei Befehl kurz Socket + Wakelock
fuer die Interaktion, danach wieder schlafen. Requirements (Firebase/Play Services/
Server-Push-Trigger) + Latenz dokumentiert. Empfehlung: erst Foreground-Service
(M1-3, sofort lauffaehig), dann Push-Hybrid als Akku-Ausbau.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 19:47:33 +02:00
duffyduckandClaude Opus 4.8 737915e267 docs(android-agent): Design fuer nativen Android-Agenten (Pfad B)
Nativer Kotlin-Agent unter host-agent/android/ zur Handy-Fernsteuerung inkl.
Bedienen fremder App-UIs (E-Mail-Setup) via AccessibilityService. Verbindung wie
die ARIA-App (QR-Scan/manuell), erscheint in der Diagnostic (host_hello).

Festgehalten: Android-Action-Set (screenshot/ui_dump/ui_tap/ui_text/ui_swipe/
ui_key/app_launch/info/notify statt Shell), Sicherheit (CONTROL_ENABLED + explizite
Accessibility/MediaProjection-Freigabe), Dauerbetrieb (A: Foreground-Service —
einfach, reutzt host_command 1:1; B: FCM-Push wie WhatsApp — akkuschonend, spaeter),
4 Meilensteine, Build via Docker/Gradle + release.sh <version> -> Gitea-Asset.

Status: Design. Naechster Schritt: Meilenstein 1 (Verbinden + sichtbar).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 19:46:19 +02:00
duffyduckandClaude Opus 4.8 1140934e15 feat(host-agent): cross-platform — Linux, macOS UND Windows
Eine Codebasis fuer alle drei Desktop-OS. Der Agent waehlt je OS automatisch:
- exec: bash -lc (Linux/macOS) bzw. PowerShell (Windows)
- Root/Admin: sudo (Unix) bzw. 'als Administrator starten' (Windows, kein sudo)
- Screenshot: grim/scrot (Linux) · screencapture (macOS) · PowerShell/System.
  Drawing (Windows)
- Root-Check: _is_admin() (os.geteuid Unix / IsUserAnAdmin Windows) statt hartem
  os.geteuid (crashte auf Windows)
- info: user aus USER|USERNAME; getloadavg bleibt guarded

Build: build.sh (Linux/Docker), build-native.sh (Linux/macOS), build-native.bat
(Windows). PyInstaller cross-kompiliert nicht -> je OS bauen. README-Plattform-
Tabelle ergaenzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 19:40:17 +02:00
duffyduck 29bf3370b4 release: bump version to 0.2.5.0 2026-09-24 18:51:30 +02:00
duffyduckandClaude Opus 4.8 6b5d847615 fix(app+brain): leere Bubble bei Tool-Loop/Fehler beheben + Limit hoch
Symptom: ARIA laeuft ins Tool-Loop-Limit (host_agent-Exploration), postet die
Meldung, aber die App zeigt eine LEERE Bubble.

Ursache: stripSystemHints (App) entfernt fuehrende [..]-Bloecke (fuer Hinweise
wie '[Kontext:..] Hallo'). Die Tool-Loop-/Fehler-Meldung ist KOMPLETT ein
[..]-Block -> restlos gestrippt -> leer.

Fixes:
- App (ChatScreen): stripSystemHints zeigt das Original, wenn nach dem Strippen
  nichts uebrig bleibt -> Fehler-/Meta-Meldungen (auch '[Fehler: ...]') sind
  wieder sichtbar statt leer.
- Brain: MAX_TOOL_ITERATIONS 8 -> 20 (env-tunebar) — 8 war zu knapp fuer echte
  agentische Arbeit (Host-Exploration); ARIA brach mittendrin ab.
- Brain: Loop-Limit-Meldung ARIA-stimmig + handlungsleitend statt technischer
  Marker (liest sich natuerlich in der Bubble).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 18:47:58 +02:00
duffyduckandClaude Opus 4.8 145c64e67f feat(diagnostic): RVS-Raum-Monitor + Server/GPU-Icon fuer die Compute-Flotte
Raum-Monitor (haette uns die Nacht erspart): der RVS-Log kuerzt Tokens auf 8
Zeichen -> zwei verschiedene Tokens mit gleichem Prefix ergeben zwei Raeume,
die im Log IDENTISCH aussehen (Prefix-Kollision). Jetzt zeigt die Diagnostic
(Satelliten-Tab, Panel 'RVS-Raeume 🚪') alle Raeume mit token8 + laengerem
Fingerprint (sha256) + Token-Laenge + Client-Zahl, ★ = eigener Raum. >1 Raum =
Warnung 'Token-Mismatch'. Keine vollen Tokens (nur Fingerprint).

- rvs: rooms_query -> direkt rooms_info antworten (wie update_check); Typen in
  ALLOWED_TYPES; sha256-Fingerprint via crypto.
- diagnostic: rooms_query durchreichen, rooms_info an Browser; Panel + Tabelle.

Dazu: Compute-Flotte-Ueberschrift bekommt statt 🖥️ ein Inline-SVG (Server mit
GPU-Karte, currentColor -> theme-aware).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 18:29:37 +02:00
duffyduckandClaude Opus 4.8 6cb29a28ce feat(host-agent,satellite): RVS_TLS_FALLBACK wie in den Compute-Bridges
Host-Agent und Satellit hatten keinen TLS-Fallback (nur die vier Compute-Bridges).
Jetzt konsistent: bei TLS-Fehlschlag einmal auf ws:// zurueckfallen, danach wieder
mit RVS_TLS starten (kein Sticky-Fallback, wie bei den Bridges). uri_host/proto
werden pro Versuch aus use_tls berechnet, damit der RVS_SNI-Pfad nur bei wss gilt.

Hilft nur wo der RVS plaintext erreichbar ist; gegen Caddy-TLS bleibt wss. RVS_TLS_
FALLBACK in beiden .env.example dokumentiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:58:19 +02:00
duffyduckandClaude Opus 4.8 df5cd3be99 fix(rvs-clients): RVS_SNI via getaddrinfo-Override statt host=/port= (HTTP-200-Fix)
Symptom mit RVS_SNI: 'server rejected WebSocket connection: HTTP 200'. Ursache:
die IP stand in der URI -> HTTP-Host-Header = IP -> Caddy findet keinen RVS-Site-
Block (routet nach Host) und liefert eine Default-200-Seite statt WS-Upgrade. Der
vorige Fix korrigierte nur SNI/Cert (server_hostname), nicht den Host-Header.

Zudem: host=/port= als websockets.connect-kwargs kollidieren in der Legacy-API
mit dem aus der URI abgeleiteten Host (TypeError).

Loesung (host-agent, satellite, f5tts/whisper/voxtral/llm-adapter): die URI nutzt
den HOSTNAMEN (RVS_SNI) -> Host-Header + SNI + Cert stimmen; ein In-Process-
getaddrinfo-Override mappt RVS_SNI -> RVS_HOST (IP) fuer den TCP-Connect.
Versionsunabhaengig, nicht-blockierend, nur aktiv wenn RVS_SNI gesetzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:54:38 +02:00
duffyduckandClaude Opus 4.8 4125ab8160 chore(seed): Pentest-Vorfalls-Absatz anonymisiert (Kundendomains raus)
Nennt keine realen Kunden-Produktions-/Staging-Domains mehr; die Sicherheits-
regel (nie gegen Produktion testen) bleibt vollstaendig wirksam.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:44:16 +02:00
duffyduckandClaude Opus 4.8 edd0b4823f chore: .claude/settings.json aus Tracking nehmen (lokale Permission-Allowlist)
Enthaelt echte Server-IPs fuer den Dev-Zugriff -> gehoert nicht ins oeffentliche
Repo. In .gitignore aufgenommen; lokale Datei bleibt (mit echter IP) erhalten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:38:17 +02:00
duffyduckandClaude Opus 4.8 d4e3a73e89 feat(host-agent): systemd-Service-Installer + SNI-Doku ueberall
install-service.sh: installiert Binary (/usr/local/bin) + .env (/etc/aria-host-
agent/.env, 0600) + systemd-Unit und macht enable --now. .env-Pfad als Argument
ODER ohne Argument ein ncurses-Dateidialog (dialog --fselect, bietet Installation
von dialog an). Findet die Binary unter dist/ bzw. ./ oder als 2. Argument.

Doku aktualisiert (RVS_SNI fuer RZ-interne Clients + Installer):
- host-agent/README: Installer-Abschnitt + TLS/SNI-Abschnitt.
- README (zentral): Installer + SNI im Host-Agent-Abschnitt (Verweis auf
  Satellit/Compute-Nodes).
- satellite/README + xtts/README: RVS_SNI-Hinweis fuer Boxen/Satelliten im
  RVS-Netz.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:27:07 +02:00
duffyduckandClaude Opus 4.8 9d93c3fe3d feat(xtts): RVS_SNI in allen Compute-Bridges — AI-Box im RZ-Netz auf interne IP
Damit eine AI-Box (f5tts/whisper/voxtral/llm-adapter) auch im selben Netz wie
der RVS direkt auf dessen interne IP verbinden kann (kein NAT-Hairpin ueber den
externen Hostnamen), ohne am SNI/Cert zu scheitern: RVS_HOST=<ip> + RVS_SNI=<name>.
server_hostname im ssl-Context, gated auf use_tls (respektiert den TLS-Fallback).
Leer = Verhalten wie bisher. Szenario: zweite Box im RZ + eine zuhause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:22:27 +02:00
duffyduckandClaude Opus 4.8 39f7947da9 feat(satellite): RVS_SNI — wie beim Host-Agent (interne IP + Cert-Hostname)
Gleiche Faehigkeit wie beim host-agent: ein Satellit im selben Netz wie der RVS
kann direkt auf die interne IP verbinden und trotzdem den Cert-Namen im TLS-SNI
praesentieren (Caddy findet sonst kein Zertifikat -> tlsv1 alert internal error).
Nuetzlich z.B. fuer einen RZ-internen Satelliten, nur um Credentials zu hinterlegen.
RVS_HOST=<ip> + RVS_SNI=<name>; leer = Verhalten wie bisher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:19:59 +02:00
duffyduckandClaude Opus 4.8 855cc0333e feat(host-agent): RVS_SNI — auf interne IP verbinden, aber Cert-Hostname im TLS
Fuer Agenten im selben Netz wie der RVS: direkt auf die interne IP verbinden
(kein NAT-Hairpin ueber den externen Hostnamen), aber im TLS-Handshake den
Namen praesentieren, fuer den das Caddy-Zertifikat gilt. Ohne das schickt der
Client die IP als SNI -> Caddy findet kein Cert -> 'tlsv1 alert internal error'.

RVS_HOST=<interne-ip> + RVS_SNI=<zert-name>. server_hostname im ssl-Context.
Leeres RVS_SNI = Verhalten wie bisher. Robuster als /etc/hosts (ueberlebt
Reboots, wichtig auf Live-ISOs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:16:45 +02:00
duffyduckandClaude Opus 4.8 2cac1cad0a feat(host-agent): Docker-loser Native-Build + Live-ISO-Hinweis
Docker-Build scheitert auf Live-ISOs (overlayfs-Root -> overlay2 kann kein
Overlay-auf-Overlay stapeln: 'failed to mount ... invalid argument'). Ergaenzt:
- build-native.sh: PyInstaller in einem venv, ohne Docker (Warnung: linkt gegen
  lokales glibc).
- README: Live-ISO-Ursache erklaert + zwei Auswege (Binary woanders bauen und
  kopieren [empfohlen, portabel] oder nativ bauen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 16:42:39 +02:00
duffyduck f89e7ac36d release: bump version to 0.2.4.9 2026-09-24 16:38:20 +02:00
duffyduckandClaude Opus 4.8 cb1f571ad0 docs: Vision-Roadmap-Punkt geschlossen — ARIA sieht Bilder via Read-Tool
"Claude Vision direkt (ohne Dateipfad-Umweg)" abgehakt und umformuliert:
Bildanalyse funktioniert bereits (App-Uploads + Screenshots via Read-Tool,
/shared/uploads im Proxy-Container gemountet). Die "direkte" Variante waere
reine Politur (ein Read weniger) und ist bewusst nicht geplant — vermeidet,
dass jemand den heiklen Proxy-Umbau spaeter grundlos wieder aufmacht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 16:26:45 +02:00
duffyduckandClaude Opus 4.8 179fe53143 docs: Host-Agent-Deployment in der README (Tabelle + eigener Abschnitt)
Der Host-Agent stand nur in host-agent/README.md, nicht in der zentralen Doku.
Ergaenzt: Zeile in der Deploy-Tabelle + Abschnitt "Host-Agenten" (build.sh ->
Binary, Install, sudo-Faelle inkl. Live-ISO, Sicherheit, Vision via Read-Tool).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 16:22:20 +02:00
duffyduckandClaude Opus 4.8 b73e89e9bb feat(host-agent): ARIA SIEHT den Screenshot (via eigenes Read-Tool)
Der Proxy reduziert Multimodal-Content zu Text (openai-to-cli), ARIA laeuft auf
der Claude-Code-CLI. Aber: der Proxy-Container hat /shared gemountet und ARIA
laeuft mit vollen Tools (--dangerously-skip-permissions) -> sie kann die unter
/shared/uploads/ gespeicherte Screenshot-PNG mit ihrem eigenen Read-Tool
oeffnen, das Claude Code nativ als Bild rendert. Damit SIEHT sie den Bildschirm
und kann 'was ist auf meinem Bildschirm' beantworten — ganz ohne Proxy-/Vision-
Umbau. host_screenshot-Result + Tool-Beschreibung weisen sie an, den Pfad zu
Read'en (sehen) UND als [FILE:]-Marker zu setzen (im Chat zeigen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 16:20:08 +02:00
duffyduckandClaude Opus 4.8 2b4428d823 feat(host-agent): host_screenshot erscheint inline im Chat (FILE-Marker)
Statt den Screenshot nur nach /shared/host-screenshots zu legen und den Pfad
zu nennen, speichert host_screenshot das PNG jetzt nach /shared/uploads/ und
liefert ARIA die Anweisung, den Pfad als [FILE: ...]-Marker in die Antwort zu
schreiben — exakt das flux_generate-Muster. Die Bridge erkennt den Marker,
broadcastet file_from_aria, und App/Diagnostic zeigen das Bild inline im Chat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 16:16:15 +02:00
duffyduckandClaude Opus 4.8 74fcf7fd03 feat(diagnostic): Host-Agenten in der Satelliten-Flotte anzeigen (Phase E)
Diagnostic-Server trackt host_hello/host_ping (hosts-Map), broadcastet
host_update und beantwortet die host_list-Action. UI: neues Panel
"Host-Agenten" im Satelliten-Tab — zeigt Name/OS/Caps/online + ob
CONTROL_ENABLED. Reine Sichtbarkeit; Steuerung laeuft ueber ARIA.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 16:12:50 +02:00
duffyduckandClaude Opus 4.8 c7761284c7 feat(host-agent): Backend-Anbindung — RVS + Bridge-Registry + Brain-Tools (Phase B-D)
Damit kann ARIA Host-Agenten tatsaechlich nutzen (Ende-zu-Ende):

- RVS: host_hello/host_ping/host_command/host_result in ALLOWED_TYPES.
- Bridge: _hosts-Registry (host_hello/host_ping/host_result), _host_list,
  _host_request (requestId->Future wie Satelliten), HTTP-Endpoints
  /internal/host-list und /internal/host (op via action).
- Brain: Tools host_list/host_exec/host_read/host_write/host_info/
  host_screenshot + _dispatch_host (ruft /internal/host*). host_screenshot
  speichert das PNG unter /shared/host-screenshots. host_exec kann sudo=true.

Naechste (optionale) Phase E: Host-Agenten in der Diagnostic-Flotte anzeigen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 16:11:06 +02:00
duffyduckandClaude Opus 4.8 223692d4c7 feat(host-agent): Direktzugriffs-Agent fuer einen Rechner (Phase A: Agent + Build)
Schlanker Agent, der DIREKT auf einem Linux-Rechner laeuft und sich ausgehend
zum RVS verbindet -> ARIA steuert den Rechner auch hinter NAT/Firewall, wo er
sonst nicht erreichbar ist. Anders als der Satellit (LAN-Gateway) steuert der
Agent den Rechner, auf dem er laeuft.

Faehigkeiten (host_command -> host_result): exec (opt. sudo), read, write,
info (CPU/RAM/Disk/Uptime via psutil), screenshot (grim/scrot/maim/import).
sudo-Logik: root -> direkt; SUDO_PASSWORD -> sudo -S; SUDO_NOPASSWD (Live-ISO)
-> sudo -n; sonst klare Fehlermeldung. Gate: CONTROL_ENABLED + RVS-Token.
stdout wird wie beim Satelliten gefenstert (contains/offset/max_chars).

Als portable Onefile-Binary verteilbar (PyInstaller im bullseye-Container fuer
breite glibc-Kompatibilitaet): build.sh + Dockerfile.build. Plus .env.example,
systemd-Unit und README.

Naechste Phasen: RVS ALLOWED_TYPES (host_*), Bridge-Registry + /internal/host*,
Brain-Tools host_list/exec/read/write/info/screenshot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 16:07:27 +02:00
duffyduckandClaude Opus 4.8 a99ea32664 feat(satellite): ssh.exec — ARIA fuehrt Kommandos per SSH auf Geraeten aus
Neuer Credential-Typ 'ssh' (Benutzer + Passwort ODER privater Key, Port) und
Aktion ssh.exec (params: ip, cmd). paramiko-Client; grosse Ausgaben werden wie
bei http.get gefenstert (contains/offset/max_chars gemeinsam via _window_text),
Antwort mit exit_code + stdout + stderr. Auth kommt aus dem Credential-Store,
ARIA muss keine Passwoerter mitgeben.

- satellite: _do_ssh + _ssh_load_key (RSA/Ed25519/ECDSA/DSS aus String),
  ssh.exec in _control + Allowlist, 'ssh' in beide Cred-Typ-Listen; paramiko
  in requirements; .env.example ergaenzt.
- diagnostic: SSH-Sektion im Credentials-Modal (User/Port/Passwort/Key) +
  Save-Logik (User + Passwort|Key).
- brain: satellite_command-Tool um ssh.exec erweitert.

Laeuft ueber den bestehenden sat_command/sat_result-Pfad — keine RVS-Aenderung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 16:00:54 +02:00
duffyduckandClaude Opus 4.8 4e1ab95684 feat(app): Datei-Upload erlaubt alle Dateitypen (Bueroklammer)
Der DocumentPicker war auf images/pdf/docx/plainText beschraenkt -> vom
Smartphone liessen sich nur Bilder/Dokumente hochladen. Auf allFiles
umgestellt; die Komponente verarbeitet beliebige Typen ohnehin (Base64 +
octet-stream-Fallback), und die Server-Seite hat kein MIME-Gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 15:57:56 +02:00
duffyduckandClaude Opus 4.8 bb7a19f818 fix(creds): HTTP-Basic-Benutzer optional (Password-only-Geraete)
Wie bei der FritzBox: manche Geraete nutzen HTTP-Basic-Auth nur mit Passwort.
Modal speichert HTTP-Creds jetzt bei User ODER Passwort; _do_http wendet die
Auth auch bei leerem Benutzer an. SNMP v1/v2c hat ohnehin nur die Community
(kein User); v3 braucht den securityName zwingend und bleibt Pflicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 17:45:39 +02:00
duffyduckandClaude Opus 4.8 6ffbc77387 fix(diagnostic): FritzBox-Benutzer optional — Password-only-Boxen speicherbar
Das Credentials-Modal speicherte FritzBox-Zugangsdaten nur, wenn das Benutzer-
Feld ausgefuellt war (if fu) — Username war damit faktisch Pflicht. FritzBoxen
ohne benannten Benutzer haben aber nur ein Passwort. Jetzt: gespeichert sobald
User ODER Passwort gesetzt ist; Placeholder kennzeichnet den User als optional.
Backend (_do_fritzbox) verlangte ohnehin nur das Passwort.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 17:44:34 +02:00
duffyduckandClaude Opus 4.8 3893c89d5f fix(satellite): fehlender Path-Import — Crash-Loop beim Start (_creds_load)
Der Credential-Store nutzt pathlib.Path, das Modul importierte es aber nicht
-> NameError in _creds_load(), Satellit crashte in Endlosschleife beim Start.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 17:40:30 +02:00
duffyduckandClaude Opus 4.8 9f741df10e feat(satellite): Credential-Store + snmp.ports/info + FritzBox-Reader
Baut SNMP zur generellen Geraete-Auskunft aus und macht Zugangsdaten pro Geraet
hinterlegbar, damit der Satellit tiefer lesen kann.

Satellit:
- Verschluesselter Credential-Store (Fernet) pro IP im Bind-Volume ./data:
  SNMP (community/v2c oder v3 user/auth/priv), HTTP-Basic, FritzBox-Login.
  Neue Messages sat_creds_set/list/delete + *_result; Secrets werden NIE
  zurueckgeliefert (list gibt nur Typen). snmp.*/http/fritzbox nutzen die
  Creds automatisch (params haben Vorrang).
- snmp.ports: ifTable -> aktive/freie Ports, Linkspeed ('sind noch Ports frei').
- snmp.info: sysName/Descr + Entity-MIB Modell/Serial/Firmware (installierte
  Version; Update-Check ist Hersteller-Sache, kein SNMP).
- fritzbox.info/hosts: TR-064 (SOAP+Digest) — Verbindung/Datenrate/externe IP
  bzw. verbundene Geraete. Bewusst als Reader, weil TR-064 fuer on-the-fly
  http.post zu fummelig ist.
- CONTROL_ALLOWLIST-Default + .env.example erweitert; data/ ge-gitignore-t.

RVS: sat_creds_* in ALLOWED_TYPES (sonst verworfen).

Diagnostic: pro entdecktem Geraet ein Schluessel-Button -> Modal (SNMP v2c/v3,
HTTP-Basic, FritzBox); Speichern/Loeschen via RVS an den Satelliten; gesetzte
Typen werden pro Geraet angezeigt (🔑 ✓). Server relayed die Cred-Messages.

Brain: satellite_command-Tool um snmp.ports/info + fritzbox.* erweitert; ARIA
muss keine Passwoerter mitgeben (Satellit nutzt den Store).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 17:16:06 +02:00
duffyduckandClaude Opus 4.8 22f5f5c954 feat(satellite): SNMP-Anreicherung bei Discovery — Switches/Router/APs/NAS geben Infos preis
SNMP soll nicht nur Drucker abfragen: jedes beim Scan entdeckte Geraet mit IP
wird jetzt kurz nach seiner SNMP-System-Group (RFC 1213) gefragt. Antwortet es,
haengt der Satellit ein 'snmp'-Feld an (sysName/sysDescr/sysContact/sysLocation/
sysUpTime) und leitet einen praeziseren 'type' aus sysDescr ab (switch/router/
access-point/nas/printer/ups). Aus einer nackten ARP-IP wird so ein benanntes,
klassifiziertes Geraet im Inventar, das ARIA via satellite_devices sieht.

- parallel (Semaphore, Default 16) mit kurzem Timeout (-t1 -r0, 2s) -> Nicht-
  SNMP-Hosts fallen sofort raus, der Scan bleibt flott.
- SNMP_DISCOVERY (Default true) schaltet es ab; Concurrency/Timeout per env.
- satellite_devices-Tool: ARIA weiss jetzt vom snmp-Feld + genaueren type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 17:03:50 +02:00
duffyduckandClaude Opus 4.8 e3f6b3626d feat(satellite): SNMP-Aktionen (snmp.get/walk/printer) — Drucker-Tinte zuverlaessig
HTML-Scrapen der Drucker-Statusseite ist fragil (grosse Seite, Werte teils in
JS/Grafik). SNMP/Printer-MIB liefert die Fuellstaende als saubere Zahlen.

- Dockerfile: net-snmp-CLI (Paket 'snmp') installiert.
- satellite.py: Aktionen snmp.get / snmp.walk (generisch, params ip+oid) und
  snmp.printer (Komfort: liest prtMarkerSupplies aus der Printer-MIB und rechnet
  je Patrone name+level+max+percent). net-snmp via subprocess, numerische OIDs
  (keine MIB-Files noetig), -t2 -r1 + Timeout = schnell scheitern statt haengen.
  RFC-Sonderwerte (-1/-2 unbekannt, -3 vorhanden) behandelt.
- CONTROL_ALLOWLIST-Default + .env.example um snmp.* erweitert, SNMP_*-Envs
  dokumentiert.
- agent.py + seed_rules.py: satellite_command-Tool + Drucker-Beispiel — snmp.
  printer ist jetzt der BEVORZUGTE Weg fuer Tinte, http.get nur Fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 17:01:08 +02:00
duffyduckandClaude Opus 4.8 67ea68514a chore(logging): Debug-Logs raus + app.log-Rotation gegen volllaufende Platte
- diagnostic/server.js: die beiden Debug-Logs der ai-box-Fehlersuche entfernt
  (roher RX-Dump jeder worker_*/sat_hello-Nachricht + 'worker_hello empfangen'
  bei jedem 30s-Resend). Die Flotte laeuft, der Firehose kann weg.
- bridge/aria_bridge.py: /shared/logs/app.log (JSONL, wuchs unbegrenzt bei
  jeder App-Log-Zeile) rotiert jetzt bei >5 MB auf die letzten 2000 Zeilen
  (Muster wie metrics._maybe_rotate).

RVS selbst loggt nur Lifecycle (Connect/Disconnect) — kein Message-Firehose,
unveraendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 16:47:50 +02:00
duffyduckandClaude Opus 4.8 d6cca88455 fix(satellite): http.get liefert ganze Statusseiten + contains-Filter, kein Scan-Overhead
ARIA erreichte den Drucker per Satellit, bekam aber nur die ersten 2000
Zeichen (r.text[:2000]) — die Tintenbalken stehen weiter unten und fielen
weg; ein Offset-Versuch wurde ignoriert (nicht unterstuetzt). Zudem lief vor
JEDEM sat_command ein voller LAN-Scan, auch fuer http.get, das die URL direkt
nutzt -> sehr lange Antwortzeiten.

_do_http:
- Body-Limit env-konfigurierbar (HTTP_MAX_CHARS Default 20000, harter Deckel
  HTTP_MAX_CHARS_HARD 200000) statt fixer 2000.
- params.offset + params.max_chars zum Paginieren.
- params.contains (String/Liste): nur Zeilen mit einem der Begriffe -> zieht
  aus einer grossen Statusseite gezielt die Tintenwerte, ohne Paging.
- Antwort meldet total_chars/returned_chars/offset/truncated/filtered.
- HTTP_TIMEOUT_SEC (Default 10s) statt fixer 6s.

sat_command: LAN-Scan nur noch bei dial.launch oder wenn ein device-Ref
mitkommt; http.get/http.post/wol nutzen die gecachte Liste -> deutlich schneller.

agent.py + seed_rules.py: satellite_command-Tool + Drucker-Beispiel um
contains/offset/max_chars ergaenzt, damit ARIA den Filter nutzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 12:09:52 +02:00
duffyduckandClaude Opus 4.8 d0d06defb1 ui(diagnostic): Flotten-Status 'frei' -> 'frei – idle – keine Auslastung'
Klarer lesbar in der Compute-Flotte: der idle-Zustand eines Workers heisst
jetzt ausgeschrieben. beschaeftigt/offline unveraendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 11:48:37 +02:00
duffyduckandClaude Opus 4.8 056637ab7a feat(diagnostic): Link von LLM-Einstellungen zur Compute-Flotte (Auslastung)
Unter der LLM-Boxen-Liste in Modelle & KI jetzt ein Link "Auslastung &
Details in der Compute-Flotte", der zum Satelliten-Tab wechselt und zur
Compute-Flotte scrollt — dort sitzt der Auslastung-Button pro Node (Live
nvidia-smi + GPU-/Token-Graphen). Anker #compute-fleet + gotoComputeFleet().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 11:46:51 +02:00
duffyduckandClaude Opus 4.8 ee654a6f6c fix(llm): Test-Chat-Timeout 15s -> 120s fuer kalten Modell-Swap
qwen3-8b antwortete in ~13s (unter 15s) und kam durch; qwen3-4b lief in
"Timeout", obwohl kleiner. Ursache ist nicht die Modellgroesse, sondern der
KALTE Modell-Swap: beim ersten Wechsel entlaedt llama-swap das alte Modell
und laedt das neue GGUF frisch in den VRAM (+ erste Inferenz) — das dauert
laenger als die 15s, die sendToRVS_withResponse hart als Timeout hatte.

sendToRVS_withResponse nimmt jetzt ein optionales timeoutMs (Default 15s
unveraendert fuer Voice-List etc.); llm_test uebergibt 120s. Der Adapter
selbst deckt den Swap mit LLM_TIMEOUT_SEC=60 ab und meldet waehrenddessen
llm_status "loading".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 11:45:45 +02:00
duffyduckandClaude Opus 4.8 f1c042c60a fix(satellite): ARIA nutzt Satelliten fuer Geraete in fremden Netzen — statt zu fabulieren
Symptom: Frage nach dem Patronenstand des Heim-Druckers -> ARIA antwortet
"da komm ich nicht ran", obwohl im Heimnetz ein Satellit online ist, der den
Drucker findet UND http.get kann. Sie zieht Drucker-Fakten aus dem Gedaechtnis
und gibt auf, ohne den Satelliten zu nutzen.

Ursachen + Fixes:
- Claude-Pfad: die Seed-Regel triggerte nur auf Formulierungen wie "im Buero"/
  "im Netz X". Eine schlichte Geraetefrage (Patronenstand) griff sie nicht.
  -> Regel umformuliert: IMMER wenn ein Geraet/Host in einem Netz liegt, auf
  dem ARIA nicht direkt sitzt (Zuhause/Buero/private IP), ZUERST satellite_list
  pruefen; NIEMALS "erreiche ich nicht" sagen, bevor satellite_list lief.
  Drucker-http.get-Beispiel ergaenzt.
- satellite_list-Tool-Beschreibung als dauerhaften Anker verstaerkt (gilt auch,
  wenn ein Seed mal driftet).
- Lokales Tier hatte gar keine Satelliten-Tools (_LOCAL_TOOL_NAMES) -> konnte
  strukturell kein Geraet im fremden Netz erreichen und fabulierte. satellite_
  list/devices/command ergaenzt, damit auch das lokale Qwen Satelliten nutzt.

Greift beim naechsten Brain-Neustart (seed_rules.apply im Lifespan, idempotent
per migration_key).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 11:35:35 +02:00
duffyduckandClaude Opus 4.8 042fd63d33 fix(rvs): Compute-Flotte-Typen in ALLOWED_TYPES — ai-box wird sichtbar
Der RVS filtert eingehende Nachrichten gegen die Allow-List ALLOWED_TYPES
und verwirft jeden nicht gelisteten Typ still (server.js ~Z. 312), bevor
er relayed wird. Die neuen Worker-/Auslastungs-Typen der Compute-Flotte
standen nicht drin -> worker_hello & Co. wurden lautlos weggeworfen, die
ai-box blieb in der Diagnostic-Flotte unsichtbar, obwohl sie sauberer
alle 30s sendete. sat_hello war gelistet -> Satellit sichtbar, Worker nicht.

Ergaenzt: worker_hello, worker_ping, worker_update, worker_list,
node_stats, node_stats_stream_start/stop, node_stats_history_request,
node_stats_history, node_stats_reset, node_stats_reset_done.

Das war die eigentliche Ursache der langen Fehlersuche; Token/IP/Port/RVS-
Topologie waren korrekt und nie das Problem (kein Infra-/Caddy-Bug).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 11:27:57 +02:00
duffyduckandClaude Opus 4.8 3a908d246a fix(fleet): Empfangs-Watchdog in Workern — halb-tote RVS-Verbindung erkennen + reconnect
Root Cause der unsichtbaren Box: die WS-Verbindung wird ueber die Zeit halb-tot
— Caddy (TLS-Terminator vor dem RVS) haelt sie offen und pongt die WS-Pings
selbst, waehrend der rvs-Backend sie laengst fallen liess (rooms:1 = Box nicht
mehr Raum-Mitglied). Die Box sendet worker_hello ins Leere; der WS-Ping erkennt
den toten Backend nie. (Token/URL/Port/IP/RVS-Instanz alle bewiesen identisch.)

Fix: recv() mit Timeout (RX_STALE_S=60s) statt `async for`. In einem echten Raum
kommt staendig Broadcast-Traffic rein (sat_hello alle 25s, Brain-Polling) →
Timer wird laufend resettet. Bleibt der Traffic aus, ist die Verbindung tot →
ConnectionError → Reconnect ueber die bestehende run_loop-Backoff-Logik.
Betrifft alle vier Worker (f5tts/whisper/voxtral/llm-adapter).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 11:10:49 +02:00
duffyduck 7f864bb431 debug(diagnostic): Roh-Empfang aller worker_*/sat_hello-Nachrichten loggen (BEVOR Handler) 2026-09-19 10:46:38 +02:00
duffyduckandClaude Opus 4.8 3474d2eb6f fix(fleet): Worker-RVS-URL ohne /ws — an Bridge/App/Satellit/Diagnostic angleichen
DIE Ursache, warum die Box nie in der Compute-Flotte auftauchte: die vier
xtts-Worker verbanden zu wss://host:PORT/ws?token=… — ALLE anderen Komponenten
(Bridge, App, Satellit, Diagnostic) verbinden ohne /ws (…:PORT?token=…). Das RVS
selbst ignoriert den Pfad, aber der TLS-Terminator vor :444 routet nach Pfad →
/ws landete in einem anderen Endpunkt/Raum. Folge: worker_hello/ping erreichten
den ARIA-Raum nie (rooms:1 = nur ARIA), das Diagnostic sah die Worker nicht (und
das erklaert auch das RVS-Flappen, das NUR die Worker hatten).

Fix: /ws aus der URL aller vier Worker entfernt → gleicher Pfad wie alle anderen
→ gleicher Raum. Danach: Box neu bauen, Worker erscheinen in der Flotte.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 04:47:03 +02:00
duffyduck 3df35d2293 debug(diagnostic): worker_hello-Empfang loggen (Flotten-Diagnose) 2026-09-19 04:19:30 +02:00
duffyduckandClaude Opus 4.8 fc198f90da fix(fleet): worker_hello periodisch wiederholen (wie Satellit) — Box wird nach Diagnostic/Bridge-Neustart wieder sichtbar
Worker sendeten worker_hello nur EINMAL beim Connect. Startet das Diagnostic
oder die Bridge NACH der Box neu, verpassen sie das hello (RVS spielt es nicht
nach) und sehen nur noch worker_ping → die Box taucht nicht in der Compute-
Flotte auf. Der Satellit ist genau deshalb zuverlaessig sichtbar: er wiederholt
sat_hello periodisch.

- alle vier Worker (f5tts/whisper/voxtral/llm-adapter): worker_hello wird jetzt
  zusaetzlich alle ~30s (jeder 3. Ping-Zyklus) wiederholt → ein neu gestartetes
  Diagnostic/Bridge lernt die Box innerhalb von 30s, ohne Box-Neustart.
- diagnostic/server.js: Voice-Reconcile (f5tts) laeuft nur beim ERSTEN/erneuten
  Auftauchen der Box, nicht bei jedem 30s-hello-Resend (sonst Push/Pull-Dauerlauf).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 04:12:16 +02:00
duffyduck 387b14a8ef revert(compute): xtts RVS_PORT zurueck auf 444 — ARIA-Stack laeuft auf 444, nicht 443 2026-09-19 04:03:52 +02:00
duffyduckandClaude Opus 4.8 18147eb24d fix(compute): xtts RVS_PORT-Default 444→443 (an ARIA-Stack angleichen)
Der ganze Stack (Bridge/Diagnostic/Satellit/App) nutzt Port 443, nur
xtts/.env.example stand auf 444. Das RVS gruppiert pro Server+Token in einen
Raum — bei abweichendem Port landet die Box in einem ANDEREN Raum: sie taucht
nicht in der Compute-Flotte auf und STT/TTS/LLM erreichen ARIA nicht (Satellit
auf 443 blieb sichtbar → typisches Symptom). Kommentar verschaerft: Host+Port
+Token muessen EXAKT zum ARIA-Stack passen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 03:58:01 +02:00
duffyduckandClaude Opus 4.8 8d3637079c fix(diagnostic): Modell-Katalog als Tabelle (Beschreibung + VRAM-Spalten), Button-Layout, Flotte nachziehen
- Katalog jetzt echte Tabelle: Spalten Modell | Kann gut/Beschreibung | VRAM |
  Status | Ziel-Box | Laden. VRAM aus sizeGB (>=12 GB gelb markiert).
- "Von HuggingFace aktualisieren" sitzt jetzt direkt neben der Ueberschrift
  (statt space-between ans rechte Ende); Card breiter (720→1100px), Tabelle
  horizontal scrollbar.
- loadLlmCatalog() zieht die Worker-Flotte aktiv nach (requestWorkers) — falls
  ein worker_update-Push waehrend RVS-Flappens verpasst wurde, erscheint die
  Box beim Oeffnen trotzdem.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 03:48:38 +02:00
duffyduckandClaude Opus 4.8 23d104a7c3 refactor: ai-box/ (bootstrap + README) nach xtts/ verschieben
Das Host-Bootstrap bereitet genau den xtts-Stack vor (legt xtts/.env an, startet
xtts-Compose) und gilt fuer alle Dienste (f5tts/whisper/voxtral/llm). Nach dem
Klonen sucht man das in xtts/ — dort war bisher kein README.

- git mv ai-box/bootstrap.sh xtts/bootstrap.sh; ai-box/README.md → xtts/README.md
- interne Verweise cd ARIA-AGENT/ai-box → cd ARIA-AGENT/xtts (Pfad-Logik
  SCRIPT_DIR/../xtts funktioniert unveraendert, da xtts jetzt das Script-Dir ist)
- README neu betitelt: "xtts — AI-Box (Compute-Node) Setup & Bootstrap" +
  Kontext (Profile/GPU im Haupt-README)
- leeres ai-box/ entfernt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 03:33:15 +02:00
duffyduckandClaude Opus 4.8 51985f7c2e feat(fleet): Auslastungs-Monitor pro Box — live nvidia-smi + Graphen (Stage E)
Pro Box ein "Auslastung"-Button in der Compute-Flotte → Modal mit live
nvidia-smi (1s), Graphen (GPU-Auslastung + Tokens/Intervall) und Besen-Reset.
Historie liegt auf der Box, Diagnostic holt sie via RVS.

- node_stats.py (identisch in allen 4 Worker-Build-Contexts): Sampler alle 15s
  (nvidia-smi + Token-Delta → Ringpuffer ~500 Punkte, persistent als JSON auf
  der Box), Live-Stream (node_stats, 1s, Auto-Stop 300s), History-Request,
  Reset. nvidia-smi via async subprocess, fail-safe ohne GPU.
- Worker-Wiring (f5tts/whisper/voxtral/llm-adapter): Import, Sampler-Task,
  _stats.handle() nach dem targetInstance-Filter. llm-adapter zaehlt Tokens
  (usage.total_tokens) → Token-Graph nur bei LLM-Boxen. Dockerfiles kopieren
  node_stats.py.
- compose: llm-adapter bekommt runtime:nvidia + NVIDIA_VISIBLE_DEVICES=all +
  DRIVER_CAPABILITIES=utility (nur nvidia-smi, KEIN VRAM/Compute).
- diagnostic/server.js: relay node_stats_* (Browser→Box) + forward (Box→Browser).
- diagnostic/index.html: Auslastung-Button pro Node (Ziel bevorzugt llm-Instanz),
  Modal mit live nvidia-smi + Inline-SVG-Sparklines, Besen-Reset.

Reporter-Wahl bevorzugt die llm-Instanz (sieht alle GPUs + Tokens); GPU-Worker
sehen ihre gepinnte Karte. Gitignored Historie stoert git-Baum der Box nicht.
Deploy: diagnostic + GPU-Boxen neu bauen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 03:26:17 +02:00
duffyduckandClaude Opus 4.8 f854303e7b feat(llm): Modell-Download + HuggingFace-Katalog (Stage D)
Neue lokale GGUF-Modelle per Knopf auf eine Box laden — ohne Image-Rebuild.

- llm-adapter besitzt jetzt llama-swaps Config: generiert
  /models/llama-swap.config.yaml aus Basis-Template (xtts/llama-swap/config.yaml)
  + persistenter Registry /models/aria_models.json. Neue RVS-Handler
  llm_provision_model / llm_remove_model (targetInstance-gefiltert): Registry+
  Config schreiben, llama-swap-Reload anstossen, neu announcen, Warmup (zieht das
  GGUF via -hf, Fortschritt via service_status loading→ready). pyyaml ergaenzt.
- compose: llama-swap liest --config /models/llama-swap.config.yaml; llm-adapter
  mountet ./models (rw) + ./llama-swap (ro Template).
- diagnostic/server.js: /shared/config/llm_catalog.json (kuratierte GGUF-Liste)
  + GET /api/llm-catalog + POST /api/llm-catalog/refresh (HuggingFace-API-Merge);
  Actions llm_provision_model / llm_remove_model / llm_test; llm_provision_result
  an Browser durchgereicht.
- diagnostic/index.html: "Modell-Katalog"-Card (HF-Refresh, Ziel-Box waehlen,
  Laden, Verfuegbarkeit) + Test-Chat-Zeile ans lokale LLM (Antwort + Latenz).

Download nutzt llama-swaps vorhandenen -hf-Pfad (kein neuer Download-Code).
Reload ist der einzige Deploy-Verify-Punkt (llama-swap-Image); Fallback Box-up.
Deploy: diagnostic neu bauen (VM) + llm-Boxen neu bauen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 03:12:31 +02:00
duffyduckandClaude Opus 4.8 39426dc70d feat(llm): Flotten-Katalog + modell-bewusstes LLM-Routing (Stage C)
ARIA nutzt lokale LLMs auf mehreren ai-boxen; jede Box (llama-swap) kann
mehrere Modelle fahren. Auswahl nach MODELL, Box wird automatisch gewaehlt.

- xtts/llm-adapter/adapter.py: fragt beim Connect llama-swap GET /v1/models ab
  und meldet die Modell-Liste in worker_hello (models:[...]), Fallback [LLM_MODEL].
- bridge/aria_bridge.py: Worker-Registry speichert models[]; _pick_worker(service,
  model=) beruecksichtigt nur Boxen, die das Modell fahren koennen (nachsichtig:
  keine → None → Broadcast/Claude-Fallback); Round-Robin je service+model
  verteilt mehrere Projekte auf mehrere Boxen; _local_llm reicht das Modell durch;
  _worker_list traegt models[].
- diagnostic/server.js: workers-Map + workerList um models[] erweitert.
- diagnostic/index.html: Compute-Flotte zeigt bei llm die Modell-Liste; das
  "Lokales Modell"-Dropdown wird LIVE aus den angemeldeten Boxen gebaut
  (Vereinigung + kuratierte Namen aus local_models.json, "· N Box(en)"/"offline"),
  darunter eine kompakte LLM-Box-Liste (Node→Modelle→Health). Speisung aus dem
  vorhandenen worker_update-Broadcast.

Kein Brain-/App-Eingriff. Routing greift nur bei aktivem Lokal-Schalter.
Deploy: diagnostic + bridge + llm-Boxen neu bauen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 02:49:30 +02:00
duffyduckandClaude Opus 4.8 99e4b63a06 feat(voice): zentraler Stimmen-Store + Auto-Provisioning der f5tts-Flotte
Stimmen lagen bisher nur pro Box (xtts/voices/). Jetzt haelt der Diagnostic-
Server sie zentral als /shared/voices/{name}.tar.gz und versorgt jede f5tts-Box
automatisch.

- diagnostic/server.js (Bibliothekar):
  * zentraler Store + Helfer (list/read/write/delete tar.gz)
  * reconcileVoices() beim worker_hello einer f5tts-Box: push fehlende zentrale
    Stimmen (xtts_import_voice, targetInstance) + pull box-eigene, zentral
    fehlende (xtts_export_voice) → seedet den Store aus der ersten Box
  * autonome RVS-Listener: xtts_voice_saved → zentraler Ingest (auch App-Uploads);
    xtts_voice_exported (nur eigene requestId) → in Store schreiben;
    xtts_delete_voice → zentrale Kopie mitloeschen
  * Delete-Action loescht zentrale Kopie direkt mit
- xtts/f5tts/bridge.py: worker_hello traegt jetzt voices:[names] (ohne
  default_ref) fuer den Abgleich; handle_export_voice echot requestId zurueck
  (Korrelation zentral vs. browser-initiiert)

Wiederverwendet den vorhandenen export/import-tar.gz-Transport + Stage-3-
targetInstance-Filter. Playback (preview_voice) und Lastrouting (freieste Box)
existierten bereits. Keine App-/aria-bridge-Aenderung.

Deploy: diagnostic + f5tts-Boxen neu bauen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 01:32:06 +02:00
duffyduckandClaude Opus 4.8 254031bb7d refactor: "Gamebox" ueberall durch "AI-Box(en)" ersetzen
Rein terminologisch: die feste "Gamebox" ist konzeptuell zu beliebig vielen
AI-Boxen geworden (Multi-Node-Compute-Fleet). Wort-Sweep ueber Code-Kommentare,
Strings, README, docs, .env.example, CHANGELOG — keine Identifier/Keys betroffen
(Gamebox kam nirgends in Variablennamen vor). Casing erhalten: Gamebox→AI-Box,
gamebox→ai-box. NODE_NAME-Default gamebox→ai-box, Instanz-IDs f5tts@ai-box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 00:34:21 +02:00
duffyduckandClaude Opus 4.8 eab43f5ea2 refactor(diagnostic): Einstellungen in 3 Sub-Tabs gliedern
Der Settings-Tab war auf 13 Sektionen untereinander gewachsen (unuebersichtlich).
Jetzt drei Sub-Tabs im #tab-settings:
- Modelle & KI: Sprachmodell (Brain), Lokales LLM, Externe Anbieter (OpenRouter
  & Co — Platzhalter), Sprachausgabe/F5-TTS, Spracherkennung (STT: Voxtral/
  Whisper), Voice-ID
- Integrationen: OAuth-Apps, FLUX Bildgenerierung
- System & Wartung: Reparatur/Restart, Komplett-Reset, Betriebsmodus,
  Runtime-Konfiguration, App-Onboarding-QR

Nur Re-Parenting: jede Sektion wird byte-identisch in ihr Panel verschoben,
alle Panels bleiben immer im DOM (nur CSS zeigt/versteckt), Parent bleibt
#tab-settings → alle Loader (get_voice_config, loadRuntimeConfig, …) und
Save/Load-Handler laufen unveraendert. Verifiziert: 46 Element-ids vorher =
46 nachher, keine verloren/doppelt; JS-Block node --check sauber.

Neu: settings-subnav + switchSettingsTab() (merkt den zuletzt gewaehlten
Sub-Tab in localStorage), STT-Section-Hinweis dass die Engine pro Compute-Node
via COMPOSE_PROFILES gewaehlt wird, Platzhalter-Section fuer externe Anbieter
(kein Backend, nur reservierter Platz). App unveraendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 23:49:25 +02:00
duffyduckandClaude Opus 4.8 f6f2b128c4 docs(compute): Whisper-als-STT-Beispiel fuer kleine GPUs
.env.example + README: eigene Beispiel-Zeile "kleine Karte" —
COMPOSE_PROFILES=whisper,f5tts (Whisper statt Voxtral, beide auf EINER GPU),
Default der Vorlage auf whisper,f5tts gesetzt (Voxtral-3B braucht ~9 GB).
GPU-Defaults angepasst (WHISPER_GPU=0 neben F5TTS_GPU=0) + Klarstellung:
pro Node genau EIN STT (Voxtral ODER Whisper).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 14:13:35 +02:00
duffyduckandClaude Opus 4.8 e39599ecea feat(compute): Redundanz-Routing per targetInstance (Stage 3)
Mehrere Instanzen pro Dienst nutzbar: Anfragen werden gezielt an eine freie
Instanz adressiert statt an alle gebroadcastet. Mehrere f5tts → naechstes
freies; mehrere LLM → parallele Turns (Multitasking); STT-Redundanz ueber
mehrere Apps via Lease.

- Worker (alle vier): filtern am Loop-Eingang — targetInstance gesetzt und
  != eigener INSTANCE_ID → Nachricht ignorieren. Feld fehlt → wie bisher.
- bridge (TTS/LLM, emittiert die Bridge selbst): _pick_worker() waehlt eine
  online+freie Instanz (Round-Robin), stempelt targetInstance auf
  xtts_request / llm_request. Keine Instanz bekannt → Broadcast.
- bridge (STT-Lease, emittiert die App): neuer stt_lease_request-Handler →
  _pick_stt_worker() (voxtral vor whisper) → stt_lease {instanceId}.
- app (audio.ts): requestSttLease() vor dem Stream, stempelt targetInstance
  auf stt_stream_start / stt_audio_chunk / stt_stream_end (+cancel). Kurzer
  Timeout → '' (Broadcast), Aufnahme haengt nie.

Voll rueckwaertskompatibel: Routing aktiviert sich erst, wenn Worker sich per
worker_hello (Stage 2) registriert haben — sonst bleibt alles Broadcast.
Braucht APK-Rebuild fuer die STT-Lease-Seite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 12:27:51 +02:00
duffyduckandClaude Opus 4.8 e255d7a659 feat(compute): Worker-Selbstanmeldung ueber RVS + Flotten-Anzeige (Stage 2)
Jeder GPU-Dienst meldet sich beim Connect mit worker_hello {instanceId,
service, node, gpus, model} und haelt die Registry per periodischem
worker_ping {instanceId, busy} (~10s) frisch. So weiss ARIA, was wo laeuft.

- xtts/{voxtral,whisper,f5tts}/bridge.py + llm-adapter/adapter.py:
  INSTANCE_ID=service@NODE_NAME, _worker_register()-Coroutine (hello + ping),
  busy-Quelle je Worker (aktive STT-Sessions / TTS-Render / in-flight LLM);
  Task sauber gecancelt bei Reconnect.
- bridge/aria_bridge.py: self._workers-Registry + Handler worker_hello/
  worker_ping (spiegelt sat_hello), _worker_list() (35s-Offline-TTL),
  _pick_worker() (Round-Robin freie Instanz, fuer Stage-3-Routing),
  /internal/worker-list-Endpoint.
- diagnostic/server.js: workers-Map, worker_hello/worker_ping-Tracking,
  worker_update-Broadcast + worker_list-Action + on-connect-Snapshot.
- diagnostic/index.html: "Compute-Flotte"-Panel im Satelliten-Tab — pro Node
  gruppiert, mit Dienst/Modell/GPU und frei/beschaeftigt/offline-Status.

Stage 2 von 3. Reine Sichtbarkeit, kein Routing-Verhalten geaendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 12:22:47 +02:00
duffyduckandClaude Opus 4.8 ebd25a1cc6 feat(compute): Multi-Node-Aufteilung via Compose-Profile + GPU-Wahl per .env
Die feste "Gamebox" wird zu beliebig vielen Compute-Nodes. Jeder Node startet
ueber COMPOSE_PROFILES nur die Dienste, die er anbieten soll, und pinnt sie per
*_GPU auf bestimmte Grafikkarten.

- xtts/docker-compose.yml: jeder GPU-Dienst hinter einem Profil
  (voxtral/whisper/f5tts/llm); deploy.devices-Block ersetzt durch
  runtime: nvidia + NVIDIA_VISIBLE_DEVICES=${SVC_GPU} (erlaubt auch "0,1");
  NODE_NAME an alle RVS-Dienste.
- xtts/.env.example: COMPOSE_PROFILES, NODE_NAME, VOXTRAL/WHISPER/F5TTS/LLM_GPU
  mit Beschreibungen; Beispiele STT-Box / TTS-Box / LLM-Box / All-in-One.
- README: "Gamebox-Stack" → "Compute-Nodes"; Diagramm, Deploy-Tabelle und
  Setup-Abschnitt auf Multi-Node umgeschrieben.

Stage 1 von 3 (Aufteilung+Config+Docs). Reine Config/Docs, kein Verhaltens-
Risiko: Single-Node mit COMPOSE_PROFILES=voxtral,f5tts,llm laeuft wie bisher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 12:14:58 +02:00
duffyduckandClaude Opus 4.8 633ed06adf fix(diagnostic): Bild-Upload landet im fokussierten Projekt statt Hauptchat
Datei-Uploads (Copy-Paste UND Datei-Picker) trugen im Diagnostic-Portal
den projectId des fokussierten Projekts nicht mit — anders als Text und
Zwischenrufe. Die Bridge las payload.projectId, bekam nichts und routete
die Datei-Bubble in den Hauptchat.

- index.html: sendDiagAttachments schickt projectId: focusedContextId
- server.js: send_file-Relay reicht projectId in die RVS-Payload weiter

Beide Upload-Wege laufen ueber handleDiagFileSelect → ein Fix deckt beide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 08:38:58 +02:00
duffyduckandClaude Opus 4.8 4f68a2a411 fix(voice): 'gute Nacht' loest nicht mehr faelschlich Ohr-Aus aus
Regression aus dem Schlaf-Idiom-Commit (70fbc16): 'gute nacht' im wake-off-Regex
matchte jeden Verabschiedungs-Gruss ('Ich wünsche dir eine gute Nacht') → Ohr
ging aus, Stefan hing in einer 'Ohr aus'-Schleife fest. 'gute nacht'/'schlaf
gut' sind Gruesse, kein Ohr-Aus-Befehl → raus aus dem Regex. Nur klare Imperative
an ARIA bleiben ('geh/leg dich schlafen', 'leg dich aufs Ohr/hin'); mehrdeutige
Faelle faengt ARIA per ear_control aus dem Kontext ab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 22:43:18 +02:00
duffyduckandClaude Opus 4.8 5689474c03 feat(voice): ear_control-Tool — ARIA versteht Ohr-Aus/-An in jeder Formulierung
Statt jede Schlaf-/Aus-Formulierung per Regex aufzuzählen: ein Tool ear_control
(action off|on), das ARIA aufruft, wenn Stefan sinngemäß "hör auf/wieder zu
zuhören" sagt — egal wie ("leg dich schlafen", "ich geh ins Bett, du kannst
aus", "mach Pause vom Zuhören"). Tools ruft ARIA zuverlässig (im Gegensatz zu
den [[..]]-Markern, die sie oft ignoriert).

Nutzt die komplette bestehende Plumbing: der Tool-Call setzt _ear_control →
answered_by wird "wake-off"/"wake-on" → main.py setzt wake_off/wake_on → Bridge
→ App stoppt/startet Listener. Reiner Steuerbefehl (still, kein Weiterlauschen).
Der deterministische Regex-Detektor bleibt als schnelles Netz für die
Standard-Sätze (instant, kein Claude-Call). Prompt-Hinweis in der Voice-Flow-
Sektion ergänzt. Brain-only — kein neues APK nötig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 22:24:04 +02:00
duffyduckandClaude Opus 4.8 70fbc164f4 fix(voice): wake-off erkennt mehr Schlaf-Idiome
"leg dich schlafen" / "leg dich aufs Ohr" / "leg dich hin" / "gute Nacht" /
"schlaf gut" zusätzlich zu "geh schlafen". Sofort-Netz (instant, kein Claude-
Call). Der robustere Weg (ear_control-Tool, ARIA versteht jede Formulierung)
folgt separat, wenn Stefan will.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 22:19:29 +02:00
duffyduck 603ce93197 release: bump version to 0.2.4.8 2026-08-16 22:01:07 +02:00
duffyduckandClaude Opus 4.8 7022cd3c24 fix(voxtral): Speech-Endpoint gegen laute Umgebungsmusik
Problem (Stefan): laeuft Musik, endet eine Aufnahme nie von selbst — die Energie
bleibt oben, der RMS-Stille-Endpoint feuert nicht (last_voice_at immer frisch).
Erst Stop druecken oder Musik leiser (dann echte Stille) beendet.

Fix: waehrend lauter Phasen (rms ueber Schwelle) periodisch (~700ms, gedrosselt)
mit Silero pruefen, ob im letzten endpoint_ms-Fenster ueberhaupt Sprache ist.
Nur Musik/Stille → Turn beenden (finalize endpoint). Real gesprochene Turns
haben Sprache im Fenster → laufen normal weiter; eine kurze Denk-Pause auch,
solange innerhalb der Toleranz noch Sprache im Fenster liegt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:57:06 +02:00
duffyduck daba5c0457 release: bump version to 0.2.4.7 2026-08-16 21:39:35 +02:00
duffyduckandClaude Opus 4.8 e96b97b1b9 feat(wake): Voxtral-Bestätigungsstufe gegen Musik-Fehltrigger
openWakeWord triggert oft auf Musik (Pet Shop Boys "West End Girls" & Co.). Neu:
nach dem Trigger prüft Voxtral den Vor-Trigger-Audio ("war das wirklich das
Wake-Wort oder nur Musik?") — erst bei Bestätigung Gong + Mikro, sonst still
verworfen + re-arm. Kostet ~0,5-1s vor dem Gong.

- Native (OpenWakeWordModule.kt): 2s Roh-PCM-Ringpuffer; bei Erkennung wird der
  1,5s-Vor-Trigger-Schnipsel base64 (s16le 16kHz) ans WakeWordDetected-Event
  gehängt (preTriggerPcm).
- Bridge (voxtral): neuer One-Shot-Handler stt_transcribe_blob → Silero + Voxtral
  → stt_transcribe_result. Musik/Rauschen → leerer Text.
- App: audio.transcribeBlob() schickt den Schnipsel, wakeword.confirmWake() prüft
  ob das distinktive Keyword (>=4 Zeichen) im Transkript steht. Gate sitzt in
  onWakeDetected VOR Gong/Dialog. Setting "Wake-Wort per Voxtral bestätigen"
  (default aus, opt-in).

FAIL-OPEN durchgängig: kein preTriggerPcm (altes APK) / Timeout / Fehler / VAD
weg → Wake läuft normal durch. Nie schlechter als heute. NATIVER TEIL UNGETESTET
(kein Gerät hier) — braucht Build + Test auf dem Handy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:37:15 +02:00
duffyduck fcd73d8171 release: bump version to 0.2.4.6 2026-08-16 21:17:07 +02:00
duffyduckandClaude Opus 4.8 d0b00d5c36 fix(app): Umgebungsmusik killt Weiterreden-Fenster nicht mehr (re-listen)
Wechselwirkung entdeckt: im Passiv-Lausch-Fenster (Weiterreden nach ARIAs
Antwort) nimmt das Mikro Umgebungsmusik auf → Silero verwirft sie korrekt als
vad_no_speech → leerer Endpoint. Die App wertete JEDEN leeren Endpoint als
"nichts gesagt" → sofort zurueck aufs Wake-Word. Bei laufender Musik in einer
Sprechpause endete so das Gespraech, obwohl die Stille-Toleranz noch lief.

Fix: bei leerem Endpoint im Passiv-Fenster wird — solange die Stille-Toleranz ab
Fenster-Oeffnung noch laeuft (Budget) und < 15 Runden — nochmal gelauscht statt
zu beenden. Musik wird verworfen, das Fenster bleibt bis zur Toleranz offen, du
kannst innerhalb reden. Erst wenn die Toleranz wirklich um ist → aufs Wake-Word.
Sprichst du (Text-Endpoint), geht es sofort normal weiter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:14:37 +02:00
duffyduckandClaude Opus 4.8 9d63a73135 fix(app): Konversationsmodus auch im Hintergrund, wenn Background-Wake an
Diagnose aus dem App-State-Trail (Bridge-Log): nach jeder gesprochenen Antwort
rief die App endConversation(bg || !converse) — der bg-Term (AppState != active)
erzwang "zurueck aufs Wake-Word", sobald die App im Hintergrund war. Das war vor
dem Background-Wake-Feature Absicht (kein Multi-Turn im Hintergrund). Jetzt, mit
aktivem Background-Wake, killt genau das den Konversationsmodus im Hintergrund —
im Vordergrund lief er weiter (daher "kommt beim Vorholen wieder"). converse kam
korrekt als true vom Brain (keine Marker, Default true); die App warf es im
Hintergrund weg.

Fix: bg erzwingt "armed" nur noch, wenn Background-Wake AUS ist
(isBgWakeEnabled()). Bei aktivem Background-Wake bleibt der Konversationsmodus
auch im Hintergrund offen — der User hat das Zuhoeren ja bewusst eingeschaltet.
Vordergrund-Verhalten unveraendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:06:40 +02:00
duffyduck 9981f0523d release: bump version to 0.2.4.5 2026-08-16 20:40:47 +02:00
duffyduckandClaude Opus 4.8 31444f1442 feat(voice): Wake-Word per Befehl wieder anschalten (Gegenstück zu wake_off)
"Wake-Word an" / "mach das Ohr an" / "hör wieder zu" / "wach auf" → App startet
den Listener wieder. Geht per Text-Nachricht ODER manuellem Aufnahme-Button —
beide laufen unabhängig vom Wake-Word-Listener, also funktioniert das Wieder-An
auch wenn das Ohr gerade taub ist (nur nicht per "Computer", das hört ja nicht).

Symmetrisch zu wake_off: Detektor _user_wants_wake_on → wake_on durch ChatOut →
Bridge → App löst wakeWordService.start() + setWakeWordActive(true) aus. An/Aus
kollidieren nicht (12 Fälle getestet). Damit: Ohr per Befehl ein UND aus, plus
weiterhin der Ohr-Button.

Fix nebenbei: gerades " in den Reply-Strings (hätte den Brain-Start gecrasht) →
einfache Anführungszeichen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 20:33:16 +02:00
duffyduckandClaude Opus 4.8 5398640078 feat(voice): Wake-Word per Sprachbefehl ausschalten
"Computer, Wake-Word aus" / "mach das Ohr aus" / "hör auf zuzuhören" / "geh
schlafen" → die App stoppt den Wake-Word-Listener KOMPLETT (Mikro frei, echte
Ruhe). Wieder-An nur über den Ohr-Button (bewusst, weil dann taub — Voice-
Wieder-An ist physikalisch unmöglich, wenn nichts mehr hört).

Deterministischer Detektor _user_wants_wake_off im Brain (kein LLM-Call nötig,
still). Signal fließt als wake_off durch ChatOut → Bridge → App-Payload; die App
löst denselben Pfad wie toggleWakeWord-off aus (cancelRecording +
wakeWordService.stop() + setWakeWordActive(false)). Detektor gegen 13 Positiv-
+ 9 Negativ-Fällen verifiziert (fängt nicht 'Licht aus' / 'Konversation Ende').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 20:27:48 +02:00
duffyduckandClaude Opus 4.8 5a00441966 feat(voxtral): Silero VAD — echte Sprach-Erkennung gegen generische Phantome + Musik
Der Muster-Filter fängt nur spezifische Artefakte; generische Silence-Phantome
("Ich bin ein guter Mann", "Die Serie wurde in 13 Episoden gedreht") kann ein
Text-Regex prinzipiell nicht vorhersehen. Lösung an der Wurzel: Silero VAD
(neuronal, ~2MB) entscheidet am AUDIO, ob überhaupt echte Sprache drin ist.

Im _finalize vor dem Transkribieren: kein Speech-Segment → no-speech, gar nicht
erst transkribieren → kein Phantom. Trennt zuverlässig Sprache von Stille /
Rauschen / MUSIK (löst damit auch den Musik-False-Positive). Bei Speech wird auf
die Sprach-Spanne getrimmt (Stille-Ränder weg → weniger Edge-Halluzination).

FAIL-OPEN: Import/Load/Inferenz-Fehler → normal transkribieren (die STT darf nie
komplett sterben — Speaker-ID-Lektion). Alles env-tunebar: SILERO_VAD_ENABLED,
_THRESHOLD (0.5), _MIN_SPEECH_MS (150), _PAD_MS (200). Modell im pip-Paket
gebündelt, kein Runtime-Download.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 20:18:06 +02:00
duffyduck dd71b0ce39 release: bump version to 0.2.4.4 2026-08-16 19:57:14 +02:00
duffyduckandClaude Opus 4.8 da36580994 fix(voxtral): Repetition-Loop bremsen ('vergiss das'-Schleife)
Stefans Repro: eine echte Nachricht endete mit "...vergiss das, das ist nur..."
und Voxtral hat den Satz ~15x geloopt, bis zur Brain durch. Klassische
Repetition-Halluzination bei Stille/Rauschen am Ende.

Zwei Ebenen: (1) Generation bekommt no_repeat_ngram_size=4 + repetition_penalty
=1.15 → erste echte Nennung bleibt, exakte 4-Gramm-Wiederholung verboten, Loop
bricht an der Quelle ab. (2) Post-Detektor _collapse_repetitions kassiert einen
durchgerutschten Loop auf eine Kopie ein. Gegen den echten Loop + legitime
Doppelungen ('ja ja ja', 'sehr sehr') verifiziert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 19:42:22 +02:00
duffyduckandClaude Opus 4.8 7fd945ad65 fix(voxtral): No-Speech-Timeout — Stille-Fenster schließt selbst
Stefans Repro: "die Stille-Ende wird nie erreicht, stop ich selbst ist es weg,
und geht automatisch auf lausche Computer". Ursache: der Endpoint feuert nur
wenn schon Stimme da war (last_voice_at>0). Bei totaler Stille bleibt
last_voice_at==0 → Endpoint feuert NIE → Fenster offen bis Hardcap/manuellem
Stop (→ stream_end → Phantom).

Fix: No-Speech-Timeout im _tick — wenn nach endpoint_ms (Stille-Toleranz) ab
Start noch KEINE Stimme kam, schließt der Bridge das Fenster selbst als
no-speech (leer, lautlos, zurück aufs Wake-Word). voiced_frames==0 →
_finalize verwirft ohne Transkript, also kein Phantom. Logik gegen totale
Stille / Sprache-dann-still / Dauer-Sprache verifiziert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 19:38:38 +02:00
duffyduck 3267b9a3cc release: bump version to 0.2.4.3 2026-08-16 11:33:45 +02:00
duffyduckandClaude Opus 4.8 672d921227 fix(voxtral): Phantom-Filter greift auch bei stream_end
Aus dem ai-box-Log gelernt: die realen Silence-Phantome ('Die Stadt hat eine
Fläche von 1,5 km²') kommen ALLE 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 mit bewusster Sprache'. Meine vorige Fassung nahm
stream_end aus → Phantome liefen durch.

Jetzt: (1) Pre-Guard greift auch bei stream_end, aber mit Schwelle voiced==0
(kurze bewusste Wörter am Button gehen durch, echte Stille nicht). (2) Phrase-
Filter gilt für ALLE reasons; das borderline-Band (wenig voiced_frames) schützt
echte, klar gesprochene Geo-Fragen. Gegen alle 3 Log-Fälle + reale Eingaben
verifiziert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 11:30:20 +02:00
136 changed files with 22215 additions and 1344 deletions
-7
View File
@@ -1,7 +0,0 @@
{
"permissions": {
"allow": [
"Bash(ssh root@10.0.0.1 \"ls -la /root/ARIA-AGENT/aria-shared/logs/\")"
]
}
}
+7
View File
@@ -73,3 +73,10 @@ GITEA_USER=duffyduck
# Format: user@host (z.B. root@aria-rvs oder root@rvs.example.de)
# Leer lassen = Auto-Update ueberspringen, APK manuell auf RVS kopieren.
RVS_UPDATE_HOST=
# ── LLM-Cluster ───────────────────────────────────────────────────────
# Die ARIA-Bridge verteilt Anfragen an die lokalen Modelle aller KI-Boxen
# (llm_cluster_* ueber RVS). Wie lange sie auf eine Antwort wartet, bevor sie
# eine andere Box versucht bzw. aufgibt:
#CLUSTER_LLM_TIMEOUT_S=600
# OpenAI-kompatible Programme (OpenHands & Co.): xtts/openai_rvs_wrapper
+2
View File
@@ -15,6 +15,8 @@
# Werte pro Maschine selbst pflegen.
.claude/*.env
!.claude/*.env.example
# Lokale Claude-Permission-Allowlist mit echten Server-IPs — nicht ins (oeffentliche) Repo.
.claude/settings.json
# brain-import/ ist nur ein Drop-Folder: Stefan packt MDs rein wenn er
# was migrieren will, klickt im Diagnostic „Migration aus brain-import/",
+1 -1
View File
@@ -235,7 +235,7 @@ Nur **App neu bauen** (kein Backend). APK 0.2.2.0.
## [0.2.0.4 – 0.2.0.5] — 2026-07-11 — Plan B: Lokales LLM („Gemini-Feeling")
Ein kleines, schnelles Modell (**Qwen3 8B** via llama.cpp/llama-swap auf der Gamebox-GPU) übernimmt einfache Turns in **<1 s**; alles Schwere/Technische/Werkzeug-artige reicht ein Router automatisch an **Claude** weiter. Ziel: schnelle Antworten ohne die Claude-Max-Subscription aufzugeben.
Ein kleines, schnelles Modell (**Qwen3 8B** via llama.cpp/llama-swap auf der AI-Box-GPU) übernimmt einfache Turns in **<1 s**; alles Schwere/Technische/Werkzeug-artige reicht ein Router automatisch an **Claude** weiter. Ziel: schnelle Antworten ohne die Claude-Max-Subscription aufzugeben.
### Hinzugefügt
+521 -71
View File
@@ -35,18 +35,17 @@ ARIA hat zwei Rollen:
│ WebSocket Tunnel │ WebSocket Tunnel
▼ ▼
┌─────────────────────────────────┐
│ Gamebox (Windows + WSL2) │
│ RTX 3060, Docker Desktop │
│ Compute-Node(s) (NVIDIA GPU) │
│ beliebig viele, je per .env │
│ konfiguriert (COMPOSE_PROFILES) │
│ ┌──────────────────────────┐ │
│ │ aria-f5tts-bridge │ │
│ │ F5-TTS Voice Cloning │ │
│ │ PCM-Streaming an die App │ │
│ ├──────────────────────────┤ │
│ │ aria-whisper-bridge │ │
│ │ Faster-Whisper CUDA │ │
│ │ STT in fast-Echtzeit │ │
│ │ aria-voxtral-bridge │ │ Profil: voxtral (Default-STT)
│ │ aria-f5tts-bridge │ │ Profil: f5tts (TTS)
│ │ aria-llm-adapter+swap │ │ Profil: llm (lokales LLM)
│ │ aria-whisper-bridge │ │ Profil: whisper (STT-Fallback)
│ └──────────────────────────┘ │
│ Beide teilen ./voices Volume │
│ Aufteilbar: 1 Node pro Dienst │
│ ODER All-in-One. GPU per *_GPU. │
│ xtts/docker-compose.yml │
└─────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
@@ -95,12 +94,18 @@ ARIA hat zwei Rollen:
|-----|----|-----|
| RVS | Rechenzentrum | `cd rvs && docker compose up -d` |
| ARIA Brain/Bridge/Diagnostic | Debian 13 VM | `./init.sh && ./aria-setup.sh && docker compose up -d` |
| Gamebox-Stack (F5-TTS + Whisper) | Gamebox (GPU) | `cd xtts && docker compose up -d` |
| Compute-Node(s) (STT/TTS/LLM) | 1..n GPU-Rechner | `cd xtts && cp .env.example .env && docker compose up -d` |
| Satellit(en) 🛰️ (optional) | Fremdes Netz (Büro …) | `cd satellite && cp .env.example .env && docker compose up -d --build` |
| Host-Agent(en) 💻 (optional) | Direkt auf einem Rechner | `cd host-agent && ./build.sh` → Binary + `.env` auf den Rechner, starten (Details unten & in [`host-agent/README.md`](host-agent/README.md)) |
| Android-Agent 📱📺 (optional) | Handy, Tablet, Fire TV | `cd host-agent/android && ./build.sh` → APK, oder per Satellit via ADB ausrollen (Details unten) |
| Android App | Stefans Handy | APK installieren (Auto-Update via RVS) |
> Der Gamebox-Stack ist optional: ohne ihn faellt STT auf lokales Whisper (CPU,
> Compute-Nodes sind optional: ohne sie faellt STT auf lokales Whisper (CPU,
> langsamer) zurueck; TTS bleibt aus (ARIA antwortet dann nur als Text).
> Jeder Node startet per `COMPOSE_PROFILES` in seiner `.env` nur die Dienste,
> die er anbieten soll (`voxtral`/`f5tts`/`llm`/`whisper`) — so laesst sich der
> GPU-Stack auf mehrere Maschinen verteilen (STT-Box, TTS-Box, LLM-Box) oder
> als All-in-One auf einer Kiste fahren (`voxtral,f5tts,llm`).
---
@@ -135,8 +140,8 @@ RVS_PORT=443
RVS_TLS=true
RVS_TLS_FALLBACK=true
# Pairing-Token: Verbindet App, Bridge, Diagnostic und Gamebox im gleichen RVS-Room
# MUSS auf allen Geraeten identisch sein (ARIA-VM, Gaming-PC, App)
# Pairing-Token: Verbindet App, Bridge, Diagnostic und Compute-Nodes im gleichen RVS-Room
# MUSS auf allen Geraeten identisch sein (ARIA-VM, Compute-Nodes, App)
RVS_TOKEN= # ./generate-token.sh
```
@@ -304,28 +309,28 @@ Danach wird der Proxy gepatcht:
## Voice Bridge
Die Bridge verbindet die Android App mit ARIA und orchestriert die GPU-Services
auf der Gamebox.
Die Bridge verbindet die Android App mit ARIA und orchestriert die GPU-Dienste
auf den Compute-Nodes.
**Nachrichtenfluss:**
```
Text: App → RVS → Bridge → aria-brain (HTTP)
Audio: App → RVS → Bridge → stt_request (RVS) → whisper-bridge (Gamebox)
→ stt_response → Bridge → aria-brain
Audio: App → RVS → STT-Node (voxtral/whisper) direkt (Streaming)
→ stt_endpoint/stt_stream_done → Bridge → aria-brain
Fallback bei Timeout: lokales faster-whisper (CPU)
Datei: App → RVS → Bridge → /shared/uploads/ → aria-brain (mit Pfad)
aria-brain → Antwort → Bridge → RVS → App
→ xtts_request (RVS) → f5tts-bridge
→ xtts_request (RVS) → f5tts-Node
→ audio_pcm Stream → RVS → App AudioTrack
```
### Features
- **STT primaer remote**: aria-bridge sendet `stt_request` an die Gamebox-Whisper
(faster-whisper CUDA, fast Echtzeit). 45s Timeout, dann Fallback auf lokales
CPU-Whisper. Modell-Wahl in Diagnostic, Hot-Swap via config-Broadcast.
- **TTS via F5-TTS**: aria-f5tts-bridge auf der Gamebox. Voice Cloning mit
- **STT primaer remote**: die App streamt Audio direkt an einen STT-Node
(Voxtral-3B default, faster-whisper Fallback-Profil), fast Echtzeit. Timeout →
Fallback auf lokales CPU-Whisper. Modell-Wahl in Diagnostic, Hot-Swap via config.
- **TTS via F5-TTS**: aria-f5tts-bridge auf einem Compute-Node. Voice Cloning mit
Referenz-Audio + automatisch transkribiertem Referenz-Text.
- **Text-Cleanup**: `<voice>...</voice>` Tag bevorzugt; Markdown, Code,
Einheiten und URLs werden TTS-gerecht aufbereitet. Dezimalzahlen werden
@@ -459,6 +464,44 @@ manueller Diagnostic-Klick) gehoeren seed_rules zum Brain-Code und werden
mit jedem Deploy ausgerollt. Editieren = `SEED_RULES`-Liste anpassen,
Brain neu starten.
### Beispiel: `flugwetter`
Zeigt gut, wie ein Skill mit Zugangsdaten und mehreren Quellen aussieht —
Briefing fuer **EDWI** (Wilhelmshaven-Mariensiel, Piste 02/20), nur mit
Python-Bordmitteln, keine pip-Pakete.
| Was | Woher | Kosten |
|---|---|---|
| METAR/TAF | aviationweather.gov | frei |
| Wind, Boeen | Open-Meteo **ICON-D2** | frei |
| Bedeckung, Sicht | DWD **MOSMIX** Station 10125 | frei |
| GAFOR, Karten | **ftp.pcmet.de** | Stefans pc_met-Konto |
Die pc_met-Zugangsdaten stehen in der **Skill-Konfiguration**
(`pcmet_user` / `pcmet_password`), nie im Chat. `ftp.pcmet.de` ist die vom DWD
dokumentierte Kundenschnittstelle — kein Nachbauen des Browser-Logins noetig.
`action=discover` listet, was auf dem Server liegt; `action=fetch` holt eine
Karte nach `/shared/uploads` und gibt sie als `[FILE:]`-Marker zurueck, sodass
ARIA sie anzeigen oder an einen Agenten schicken kann.
Zwei Dinge, die den Unterschied machen:
- **Seitenwind statt nur Wind.** Gerechnet gegen Piste 02/20 und gegen Stefans
15-kt-Grenze (`crosswind_limit_kt`), bewertet werden die **Boeen**.
- **Jede Zahl nennt ihre Herkunft.** Die Wolkenuntergrenze ist entweder
*gemessen* (METAR), ein ausdruecklich markierter *Schaetzwert*
(Temp/Taupunkt, 400-ft-Regel) oder *nicht verfuegbar* — dann steht da, dass
man ins GAFOR schauen muss. `CAVOK` wird als eigene Aussage behandelt und
unterdrueckt die Schaetzung, die sonst direkt widersprechen wuerde.
Genau daran war ein Briefing vorher gescheitert: METAR meldete `NCD`
(AUTO-Station), das Bodenmodell meldete bedeckt, und niemand wusste, was gilt.
Oben im Ergebnis steht eine **KURZFASSUNG in ganzen Saetzen zum Vorlesen**
(„fuenftausend Fuss", nicht „5000 ft") — eine vorgelesene Tabelle versteht
niemand. Fuer die Ausgabe auf einem Geraet kombiniert ARIA:
`run_flugwetter` → optional `action=fetch` → `host_notify(text=<Kurzfassung>,
image_path=<Karte>, mode='both', seconds=0)`.
---
## Diagnostic — Selbstcheck-UI und Einstellungen
@@ -467,8 +510,8 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
### Tabs
- **Main**: Brain/RVS/Proxy-Status, Chat-Test, "ARIA denkt..."-Indikator, **💭 Gedanken-Stream** (zentrales Modal, zeigt live alle Tool-Calls + Phasen mit Zeitstempel und Trennlinien bei langen Pausen), End-to-End-Trace, Container-Logs
- **Gehirn**: Memory-Browser (Vector-DB), Suche mit zwei Modi (**📝 Wortlich** = Substring-Match Default + **🧠 Semantisch** mit Score-Threshold), **Advanced Search** (aufklappbares Panel, beliebig viele AND/OR-verknuepfte Felder, + Button fuer mehr Zeilen), Type+Pinned-Filter (greifen auch in der Suche), klappbare Type-Kategorien (Default eingeklappt), Add/Edit/Delete mit Category-Autosuggest, **📎 Anhaenge** pro Memory (Bilder/PDFs/...): Upload + Thumbnail-Vorschau + Lightbox + Lösch-Button, 📎N-Badge in der Liste, automatischer Cleanup beim Memory-Delete. ℹ-Info-Modal das erklaert welche Types FEST in den Prompt vs. Cold Memory wandern. **📄 Druckansicht** (Strg+P → PDF). Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz)
- **Main**: Brain/RVS/Proxy-Status, Chat-Test mit **Kontext-Streifen** (Hauptchat + Projekte als Karten mit Status-Dot; Tap wechselt den Fokus, rechts daneben **„+ Projekt"** — legt an, aktiviert und fokussiert in einem Schritt, der Knopf steht ausserhalb des scrollenden Streifens damit er bei vielen Projekten erreichbar bleibt), "ARIA denkt..."-Indikator, **💭 Gedanken-Stream** (zentrales Modal, zeigt live alle Tool-Calls + Phasen mit Zeitstempel und Trennlinien bei langen Pausen), End-to-End-Trace, Container-Logs
- **Gehirn**: Memory-Browser (Vector-DB), Suche mit zwei Modi (**📝 Wortlich** = Substring-Match Default + **🧠 Semantisch** mit Score-Threshold), **Advanced Search** (aufklappbares Panel, beliebig viele AND/OR-verknuepfte Felder, + Button fuer mehr Zeilen), Type+Pinned-Filter (greifen auch in der Suche), klappbare Type-Kategorien (Default eingeklappt), Add/Edit/Delete mit Category-Autosuggest, **📎 Anhaenge** pro Memory (Bilder/PDFs/...): Upload + Thumbnail-Vorschau + Lightbox + Lösch-Button, 📎N-Badge in der Liste, automatischer Cleanup beim Memory-Delete. ℹ-Info-Modal das erklaert welche Types FEST in den Prompt vs. Cold Memory wandern. **📄 Druckansicht** (Strg+P → PDF). **Datum/Uhrzeit pro Gedanke** („angelegt 03.10.26, 09:14 · geändert … (vor 3 Std)") — `created_at`/`updated_at` lagen immer schon in der DB und wurden nur nie angezeigt. Dazu **Sortierung** (nach Typ gruppiert oder Anlage/Aenderung auf-/absteigend; bei Zeit-Sortierung flach statt gruppiert) und ein **🕒 Zeitraum-Filter** (von/bis, umschaltbar angelegt/geaendert, Schnellwahl Heute / 7 / 30 Tage) der auch fuer Such-Treffer gilt. Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz)
- **Skills**: Liste aller Skills mit Logs pro Run, Activate/Deactivate, Export/Import als tar.gz, "von ARIA"-Badge fuer selbst gebaute
- **Trigger**: passive Aufweck-Quellen. **Timer** (einmalig, ISO-Timestamp oder via `in_seconds` als Server-Berechnung) + **Watcher** (recurring, mit Condition + Throttle). Liste aktiver Trigger + Logs pro Feuer-Event. Modal mit Type-Dropdown, Live-Anzeige aller verfuegbaren Condition-Variablen (`disk_free_gb`, `hour_of_day`, `current_lat/lon`, `last_user_message_ago_sec`, …). **Drei GPS-Funktionen** mit unterschiedlicher Semantik:
- `near(lat, lon, r)` — SOLANGE im Radius (mit Throttle gegen Spam). Use-Case: „bin ich noch in der Nähe von X?"
@@ -478,22 +521,39 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
Sicherer Condition-Parser via Python `ast` (Whitelist, kein `eval`). Der System-Prompt enthaelt zusaetzlich einen `## Aktuelle Zeit`-Block (UTC + Europa/Berlin) damit ARIA Timer-Zeitpunkte korrekt setzen kann.
**Auflösung**: Background-Loop tickt alle 8s (vorher 30s — bei 100 km/h durch einen 300m-Radius war eine Vorbeifahrt nur ~22s drin und konnte verpasst werden). Plus event-getrieben: Bridge ruft nach jedem `location_update` von der App sofort einen `/triggers/check-now` im Brain — Watcher sehen die frische Position in Millisekunden statt im Polling-Takt. `near()`-Funktionen ignorieren GPS-Daten älter als 5 Minuten (verhindert Phantom-Fires bei abgeschaltetem Tracking).
**Projekt-Kontext**: ein Trigger merkt sich das Projekt, in dem er angelegt wurde (`project_id`), und die Antwort kommt beim Feuern **dort** zurueck. Ohne das landete jede Trigger-Antwort im Hauptchat — im Projekt-Thread blieb es still, und es sah aus wie „der Timer hat nicht gefeuert".
**Zustand ehrlich**: ein One-Shot-Timer wird nach dem Feuern automatisch deaktiviert. `trigger_list` zeigt darum ausdruecklich `SCHON GEFEUERT (Nx, zuletzt <Zeit>)` statt nur „DEAKTIVIERT" — sonst sind „hat sauber gefeuert" und „war nie an" nicht unterscheidbar, und ARIA berichtet guten Glaubens das Falsche.
- **Integrationen → Jellyfin**: beliebig viele Server (`+`-Button) mit URL + API-Key. ARIA steuert die Wiedergabe ueber die **API** (`jellyfin_search` / `jellyfin_devices` / `jellyfin_play` / `jellyfin_control`) statt sich per `ui_tap` durch die Oberflaeche zu klicken — schneller und zuverlaessiger. Eine eingeschlafene Sitzung erkennt ARIA selbst und baut sie neu auf.
- **Dateien**: Browser fuer `/shared/uploads/` mit Multi-Select + "Alle markieren" + Bulk-Download (ZIP bei 2+) + Bulk-Delete. Live-Update der Chat-Bubbles beim Delete.
- **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning + Voice Export/Import, **FLUX Bildgenerierung** (Default-Modell + Raw/Switch-Keywords + HF-Token), **OAuth-Apps** (Spotify Default, alle anderen Provider per ARIA on-demand oder "+ Custom"-Button mit auth_url/token_url/scopes) mit client_id+client_secret pro Service + One-Click-Autorisieren + Service-Loeschen, Whisper, Sprachmodell (brainModel), Onboarding-QR, App-Cleanup
### Was zusaetzlich noch drin steckt
- **Agent-Historie** (Satelliten → Host-Agenten): pro Agent eine aufklappbare Historie der Befehle und Screenshots, gruppiert **pro User-Nachricht** — man sieht, welche Geraete-Aktionen zu welcher Frage gehoerten. Brain schickt dafuer je Host-Aktion ein `agent_history`-Event; die Bridge haelt es und antwortet auf `agent_history_query`.
- **Auslastungs-Monitor** pro Compute-Box: Klick auf „Auslastung" zeigt **live `nvidia-smi`** (1s-Takt) plus zwei Verlaufs-Graphen (GPU-Last und Token-Verbrauch, 15s-Sampling, Ringpuffer ~2h) und ein Besen-Symbol zum Zuruecksetzen. Die **Historie liegt auf der Box**, nicht im Diagnostic — abgeholt per RVS (`node_stats_*`), kein SSH und kein Docker-Socket noetig.
- **Disk-Voll Banner** mit copy-baren Cleanup-Befehlen (safe + aggressiv)
- **Token/Call-Metrics**: pro Claude-Call ein Eintrag in `/data/metrics.jsonl` mit ts + Token-Schaetzung. Gehirn-Tab zeigt 1h/5h/24h/30d-Aggregat plus Progress-Bar gegen Plan-Limit (Pro / Max 5x / Max 20x / Custom). Warn-Schwelle 80%, kritisch 90%.
- **Voice Cloning**: Audio-Samples hochladen, Whisper transkribiert den Ref-Text automatisch
- **Voice Export/Import**: einzelne Stimmen als `.tar.gz` zwischen Gameboxen mitnehmen
- **Voice Export/Import**: einzelne Stimmen als `.tar.gz` zwischen Compute-Nodes mitnehmen
- **Settings Export/Import**: `voice_config.json` + `highlight_triggers.json` als JSON-Bundle
- **Claude Login**: Browser-Terminal zum Einloggen in den Proxy
- **ARIA Live**: read-only Mirror der Claude-Code-Session — alle Tool-Calls + Inputs + Outputs live in einer Monospace-Liste, farbcodiert. **Persistenz**: jeder `agent_stream`-Event wird parallel in `/shared/logs/agent_stream.jsonl` (soft-cap 50 MB) geschrieben, Live-View laedt beim Tab-Oeffnen / Page-Reload die letzten 200 Eintraege — Browser-Standby wirft nichts mehr weg. Plus ⛔ **Not-Aus**-Button der per RVS einen `cancel_request` mit `hard:true` ausloest → aria-bridge ruft den proxy-internen `/cancel-all` Side-Channel → alle Claude-Subprocesses werden sofort gekillt
- **Debug-API ohne SSH** (Diagnostic-Server, Port 3001):
- `GET /api/chat-backup?lines=N` — letzte N Zeilen aus `chat_backup.jsonl` (Default 200, max 5000) als geparstes JSON. Hilfreich um nachzuvollziehen was ARIA tatsaechlich gemacht hat.
- `GET /api/agent-stream?lines=N` — gleiche Mechanik fuer den persistierten Live-Stream (Tool-Calls + Inputs + Outputs).
- **OAuth-Callback-Pipeline**: Caddy davor terminiert TLS via Let's Encrypt, RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket. Provider (Spotify/Dropbox/Discord/...) redirecten den User an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet als `oauth_callback`-WS-Message → aria-bridge forwarded an Brain → Brain matched `state`, tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json`. Token-Refresh laeuft automatisch. ARIA hat vier Brain-Tools: **`oauth_register_provider`** (legt URLs eines neuen Providers wie Dropbox/Discord/Notion/... on-demand in `oauth_apps.json` an — Credentials bleiben Stefans Job), `oauth_authorize`, `oauth_get_token`, `oauth_revoke`
- **OAuth-Callback-Pipeline**: Caddy davor terminiert TLS via Let's Encrypt, RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket. Provider (Spotify/Dropbox/Discord/...) redirecten den User an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet als `oauth_callback`-WS-Message → aria-bridge forwarded an Brain → Brain matched `state`, tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json`. Token-Refresh laeuft automatisch. ARIA hat fuenf Brain-Tools: **`oauth_register_provider`** (legt URLs eines neuen Providers wie Dropbox/Discord/Notion/... on-demand in `oauth_apps.json` an — Credentials bleiben Stefans Job), `oauth_authorize`, `oauth_get_token`, **`oauth_call`**, `oauth_revoke`
- **LLM-Cluster: Programme fragen ein Modell, der Cluster waehlt die Karte** — die ARIA-Bridge ([`bridge/llm_cluster.py`](bridge/llm_cluster.py)) kennt alle Boxen und Karten. Programme schicken `llm_cluster_request` ueber RVS (ohne Box/Karte), die Bridge waehlt unter allen Karten mit diesem Modell die mit den wenigsten laufenden Anfragen, und faellt eine Box mitten in der Anfrage aus (kein Ping mehr), geht sie einmal an eine andere. `llm_cluster_models` liefert jedes Modell **einmal** samt `ctx` (kleinstes Kontextfenster unter seinen Karten). Fehler kommen als Fehler (`model_unavailable` …), nie als Antwort-Text. ARIA selbst nutzt denselben Router (mit ihrer kurzen 30-s-Frist). Fuer Programme, die nur OpenAI koennen: [`xtts/openai_rvs_wrapper/`](xtts/openai_rvs_wrapper/) — kleiner Container NEBEN dem Programm, fuer das der Cluster dann `http://127.0.0.1:8099/v1` ist; Hilfsseite auf `/` zeigt Status und Modelle; braucht nur eine ausgehende Verbindung zum RVS (getestet mit dem offiziellen OpenAI-SDK). Schnittstelle: [`docs/LLM-SCHNITTSTELLE.md`](docs/LLM-SCHNITTSTELLE.md)
- **Lokale Modelle: ein Modell pro Karte, Clients waehlen nur aus** — Schnittstelle fuer Drittprogramme: [`docs/LLM-SCHNITTSTELLE.md`](docs/LLM-SCHNITTSTELLE.md). Jede LLM-Karte einer Box (`LLM_GPUS=1,2` in der `.env`, alt `LLM_GPU=1`) haelt genau ein Modell, dauerhaft geladen; welches, legt nur die Diagnostic fest (Modell-Katalog → „Laden" → Ziel-**Karte**, in der Flotte pro Karte „Entladen"). Karten, die nicht fuers LLM vorgesehen sind, erscheinen dort gar nicht. Clients — ARIA, Fremdprogramme ueber RVS, OpenHands ueber den OpenAI-Port — sehen nur die geladenen Modelle aller Boxen und laden **nie** etwas: eine Anfrage fuer ein anderes Modell wird abgelehnt (`model_not_loaded` bzw. HTTP 404), nichts wird verdraengt. Mehrere Clients koennen dasselbe Modell gleichzeitig nutzen (gemessen: zwei parallel in 4,5 s statt 10 s nacheinander). Technik: der llm-adapter schreibt eine llama-swap-Config nur aus den belegten Karten — pro Modell `CUDA_VISIBLE_DEVICES=<Karte>` (mit `CUDA_DEVICE_ORDER=PCI_BUS_ID`, damit die Nummern wie bei nvidia-smi zaehlen), `ttl: 0`, eine Gruppe mit `swap: false` und Preload beim Start; llama-swap laeuft mit `--watch-config` (die frueher aufgerufenen Reload-Endpunkte gab es nicht). Laden oder Entladen startet die anderen Karten der Box kurz neu. Schlaegt das Laden fehl, wird die Karte auf ihr vorheriges Modell zurueckgesetzt. Bestehende Boxen: das bisherige Standardmodell (`LLM_MODEL`) landet beim ersten Start auf der ersten LLM-Karte
- **Lokale Modelle laden mit Fortschritt** (Modell-Katalog → „Laden", Knopf **⬇ Downloads**): echte Prozentanzeige mit Tempo und Restzeit, danach „laedt in die Karte" und am Ende „✓ in Karte N (Name) geladen" samt belegtem VRAM. llama.cpp zieht das GGUF per `-hf` selbst und meldet nichts; der llm-adapter fragt deshalb vorab bei Hugging Face, welche Dateien mit welcher Groesse und sha256 kommen, und misst genau diese im HF-Cache-Layout (`/models/models--org--repo/blobs/<sha256>.downloadInProgress`, nach Abschluss ohne Endung) — klappt auch bei gesplitteten Modellen und parallelen Downloads; liegt es schon im Cache, geht es direkt ans Laden. Der Warmup wartet jetzt bis zu `LLM_WARMUP_TIMEOUT_S` (1900 s) statt 60 s — vorher meldete ein mehrere GB grosses Modell „Fehler", waehrend llama-swap weiterlud. Ein Auftrag, auf den die Box nicht antwortet, erscheint nach 30 s als Warnung statt still zu verschwinden. Pro LLM-Box laeuft **ein** Modell auf ihrer LLM-Karte (`LLM_GPU`), ein anderes zu laden wechselt dorthin. *Wichtig:* `llm_provision_*` fehlte bis hierhin in der RVS-Allow-List — „Laden" hat seit Stage D nie eine Box erreicht
- **Sprachmodell**: die Auswahlliste kommt aus **Anthropics eigenem Modell-Katalog** (`downloads.claude.ai/model-catalog/v1/catalog.json`, Surface `cc`) — dieselbe Quelle, aus der sich Claude Code bedient. Oeffentlich, ohne API-Key. Oben die **Tier-Aliase** (opus/sonnet/haiku/fable), die automatisch der neuesten Version folgen; darunter die **exakten Versionen**, aeltere als solche markiert. „↻ Aktualisieren" holt frisch, sonst 6h-Cache mit Datei-Fallback fuer offline. **🔬 Testen** schickt eine echte Mini-Anfrage durch den ganzen Weg und zeigt, welches Modell tatsaechlich geantwortet hat (aus `modelUsage.canonicalModel` der CLI — das Modell selbst zu fragen waere unzuverlaessig). Vier Ergebnisse: Tier aufgeloest (gruen), exakte Version (gruen), **nicht erkannt** (gelb — der Proxy haette still `opus` eingesetzt), ungueltig (rot, mit CLI-Begruendung)
- **ARIA E-Mail** (Einstellungen → Integrationen): ARIA liest und sendet Mails ueber IMAP/SMTP (je SSL/TLS, STARTTLS oder unverschluesselt, Zertifikatspruefung abschaltbar fuer eigene Server) — Text, HTML, **Anhaenge** und **eingebettete Bilder** (`<img src="cid:…">`), Antworten mit `In-Reply-To`, Ordner anlegen (auch Unterordner) und Mails verschieben. Tools: `email_status`, `email_send`, `email_list`, `email_read`, `email_folders`, `email_folder_create`, `email_move`. Code in [`aria-brain/aria_mail.py`](aria-brain/aria_mail.py). **Whitelist** ist eine harte Grenze im Sendepfad, kein Prompt-Hinweis: alles, was nicht drinsteht, ist gesperrt, leer = ARIA sendet nirgendwohin; geprueft wird jeder Empfaenger (An/Cc/Bcc), ein fremder blockiert die ganze Mail. Eine Schreibweise, damit kein Eintrag mehrdeutig ist: `stefan@hacker-net.de` (genau die), `*@hacker-net.de` (die Domain, NICHT `evilhacker-net.de`, keine Subdomains), `*@*.hacker-net.de` (Subdomains), `*@*` (alles); Kurzformen wie `*hacker-net.de` werden beim Speichern mit Korrekturvorschlag abgelehnt. **Zugangsdaten sieht ARIA nie**: Konfiguration und Passwort liegen im Brain unter `/data` (in ARIAs Container nicht eingehaengt — `/shared` dagegen schon, darum NICHT dort), keine Schnittstelle gibt das Passwort zurueck (nur „gesetzt"), der Brain-Export laesst `email_secret.json` und `email_admin.key` aus. Konfiguration lesen/aendern nur mit Admin-Schluessel (`X-Aria-Admin`, liegt in `/data`), den die Diagnostic anhaengt — Anfragen aus ARIAs Container weist sie ab, sonst koennte eine eingehende Mail ARIA dazu bringen, per curl die Whitelist zu oeffnen. *Grenze:* ARIAs Container hat einen SSH-Schluessel auf die VM; legt sie es gezielt darauf an, kommt sie als root an jede Datei — das hier verhindert, dass Zugangsdaten von selbst irgendwo auftauchen, keinen bewussten Einbruch. Eingehende Mails sind fuer ARIA als **Daten, nicht Anweisungen** markiert. Kopie jeder gesendeten Mail landet in „Gesendet" (fehlt der Ordner, wird er angelegt). **📬 Postfach oeffnen** zeigt ARIAs ganzes Postfach in eigenem Fenster, Unterordner eingerueckt — nur fuer dich zum Reinschauen, markiert nichts als gelesen (sonst wuesste ARIA nicht mehr, was neu ist); Mail-HTML laeuft in einem Rahmen ohne Skripte und ohne Nachladen von aussen (kein Tracking-Pixel), eingebettete Bilder werden mitgeliefert
- **Modell pro Chat**: jeder Chat (Hauptchat und jedes Projekt) kann sein eigenes Modell haben, ohne die Einstellung fuer alle umzustellen. Reihenfolge: **Einstellung** (`brainModel`) < **pro Chat** (`/shared/config/context_models.json`, gesetzt per 🧠-Auswahl neben „+ Projekt") < **Ausweich** (Sicherheitsfilter, s.u.). Angezeigt wird immer, was der **naechste Turn wirklich nutzt**: in den Kontext-Karten, in der Auswahl (orange „↪ läuft auf …" plus **„↩ zurück zu …"**, wenn gerade ausgewichen wird) und an jeder ARIA-Bubble — dort das Modell, das tatsaechlich geantwortet hat (aus `modelUsage` der CLI, also die aufgeloeste Version statt des Alias). Als Grundeinstellung zaehlt das Modell, mit dem das Brain **laeuft**, nicht der Dateiwert — ein ausstehender Neustart nach Aenderung von `brainModel` faellt so auf. In der **App** dasselbe hinter der Quell-Anzeige (Einstellungen, Default aus): Modell-Chip in der Kopfzeile, Tippen oeffnet die Auswahl fuer diesen Chat, die Bubbles zeigen das Modell statt nur „Claude"
- **Sicherheitsfilter-Rettung** (🛡 Sicherheitsfilter): Anthropics Filter („*... safeguards flagged this message*", `Details: [cyber]`) trifft gern genau die Themen, um die es hier geht — Ports, Tunnel, Zugangsdaten. Die Meldung kommt **nicht** als HTTP-Fehler: die CLI liefert ein normales Ergebnis mit `is_error:true` und dem Fehlertext im `content`, also genau dort, wo sonst ARIAs Antwort steht. Ungeprueft landete dieser Text als ARIAs Wort im Verlauf — und weil der Verlauf bei jedem Turn mitgeht, kippte danach **jede** weitere Frage im selben Projekt an derselben Stelle. Jetzt zwei Stufen: **(1)** derselbe Turn wird automatisch mit dem **Ausweich-Modell** neu gefragt (einstellbar unter Sprachmodell, Default: anderes Tier — der Filter haengt am Modell, Anthropic raet selbst zu „change your model"); der ausloesende Turn wird vermerkt, damit die naechsten Turns nicht erst wieder gegen die Wand laufen, und der Vermerk verfaellt von selbst, sobald der Turn aus dem Rolling Window gerollt ist. **(2)** Blockt auch das Ausweich-Modell, liegt es am Verlauf: der ausloesende Turn fliegt aus dem Prompt-Fenster (**Quarantaene**) — nicht geloescht, er bleibt in `conversation.jsonl`, steht mit Text im Panel und ist per „↩ Zurueckholen" wieder drin. Der rohe API-Fehlertext wird nie persistiert; ARIA sagt in eigenen Worten, was passiert ist. Das Panel zeigt pro Vorfall Projekt, Modell, Klassifikator-Code und ob die Blockade **noch wirkt** (= Turn liegt noch im Fenster). Zustand in `/data/safeguard.json`, Code in [`aria-brain/safeguard.py`](aria-brain/safeguard.py); gilt auch fuer das Memory-Destillat, das denselben Verlauf nochmal durchschickt. **Altlasten** — Fehlertexte, die vor diesem Fix schon in `conversation.jsonl` stehen und dort weiter vergiften — raeumt [`aria-brain/clean_poisoned_turns.py`](aria-brain/clean_poisoned_turns.py) einmalig weg (Dry-Run per Default, `--apply` schreibt mit Backup; findet jetzt beide Giftsorten und in Projekten genauso wie im Hauptchat)
- **Datenschutz-Schalter**: die drei Kanaele, ueber die Claude Code zusaetzlich sendet (`DISABLE_ERROR_REPORTING`, `DISABLE_TELEMETRY`, `DISABLE_FEEDBACK_COMMAND`) pruefen und setzen. Geschrieben wird nach `~/.claude/settings.json` unter `env` — die CLI liest das bei jedem Aufruf, **kein Neustart noetig**. Geprueft wird, was wirklich im Prozess ankommt: eine per docker-compose gesetzte Variable gewinnt und wird als gesperrt angezeigt, statt einen wirkungslosen Haken anzubieten. **Nicht** dasselbe wie die Trainings-Einstellung — die steht im Anthropic-Konto (Link daneben)
- **Warnung bei veralteten Proxy-Patches**: der Proxy kopiert `proxy-patches/` beim **Start**. Wer nach einem `git pull` nur neu startet — oder neu startet, bevor der Pull durch ist — laeuft still mit der alten Fassung. Nichts deutet darauf hin. Die Einstellungen vergleichen jetzt die Zeitstempel und zeigen den fertigen Befehl (`--force-recreate proxy`)
- **Anhang-Limit** (System & Wartung): Groesse fuer Memory-Anhaenge, Default 64 MB. Liegt in `runtime.json`, das Brain liest es bei jedem Upload neu — greift sofort. Ueber 64 MB warnt die UI, weil die Handy-App darueber nichts mehr herunterlaedt
---
@@ -510,12 +570,15 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- **Wake-Word** (on-device, openWakeWord ONNX): "Hey Jarvis", "Alexa", "Hey Mycroft", "Hey Rhasspy" — Mikrofon hoert passiv mit, Konversation startet beim Schluesselwort. Komplett on-device via ONNX Runtime, kein API-Key, kein Cloud-Roundtrip, Audio verlaesst das Geraet nicht.
- **VAD (Voice Activity Detection)**: Adaptive Schwelle (Baseline aus ersten 500ms Mic-Pegel + 6dB Offset). Konfigurierbare Stille-Toleranz (1.0–8.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme einstellbar (1–30 min, Default 5 min)
- **Nachricht anstellen statt abbrechen** (Queue pro Projekt): Schickst du eine zweite Nachricht waehrend ARIA noch am aktuellen Task arbeitet, wird sie **angestellt** statt den laufenden abzubrechen — laeuft der Reihe nach, pro Projekt unabhaengig. Wartende zeigen als `⏸`-Bubble (tippen entfernt sie aus der Warteschlange). Explizites Abbrechen laeuft ueber den Stop-Button am „ARIA denkt". Eine neue Sprachnachricht stoppt nur akustisch das TTS (sauberes Mikro), bricht die laufende Arbeit aber nicht mehr ab
- **Zwischenruf** (📣): schiebt eine Korrektur in den **laufenden** Turn statt sie anzustellen — ohne Abbruch. Erscheint, sobald ARIA im Kontext arbeitet. Mit Text im Feld geht er sofort raus; **ohne Text schaltet er die naechste Aufnahme scharf**, dann geht der Zwischenruf per Sprache. Der laufende Turn wird dabei nicht gecancelt, nur die Wiedergabe gestoppt (sonst redet ARIA ins offene Mikro)
- **Sprachnachricht zu Anhaengen**: Bild anhaengen und den Auftrag dazu **sprechen** statt tippen. Die Dateien gehen beim Antippen des Mikros sofort hoch (Upload laeuft parallel zum Reden); die Bridge fuehrt Anhang und gesprochenen Satz ueber die `clientMsgId` zu **einem** Turn zusammen und legt beides in **eine** Bubble. Ohne das sah ARIA ein Bild ohne Auftrag und danach einen Satz ohne Bild. Das Mikro ist darum immer sichtbar — auch mit Anhang oder getipptem Text
- **Teilen-Ziel** (Android-Share): „Teilen → ARIA" aus WhatsApp, Galerie, Browser … → Dialog zeigt *was* geteilt wurde, dann *wohin* (Projekt oder Hauptchat), dann optional ein Text dazu (darf leer bleiben). Die native Seite kopiert die Dateien sofort in den App-Cache, weil die Leseerlaubnis einer `content://`-URI nur fuer diesen Intent gilt und nach der Projektauswahl schon erloschen waere
- **Rueckfrage-Loop**: Stellt ARIA eine echte, blockierende Rueckfrage, **pausiert** die Queue und deine naechste Eingabe beantwortet sie — bis eine finale Antwort kommt, dann laeuft der naechste Queue-Eintrag (gleiches Muster). Banner „❓ ARIA fragt nach — deine Eingabe beantwortet das". ARIA signalisiert das ueber einen unsichtbaren `[[AWAIT]]`-Marker im Antworttext (das Modell deklariert den Zustand selbst, kein „endet-mit-?"-Raten; Brain strippt ihn und gibt `awaiting_reply` an App + Diagnostic durch)
- **Pro-Projekt-Textfeld-Entwuerfe**: Der Feldinhalt bleibt beim Projektwechsel erhalten — in Projekt X tippen, zu Y wechseln (leeres Feld), zurueck zu X → dein Entwurf steht wieder da. Persistiert ueber Neustart. Gleiches Verhalten im Diagnostic
- **Wake-Word waehrend TTS**: Du kannst "Computer" sagen waehrend ARIA noch redet — AcousticEchoCanceler verhindert dass ARIAs eigene Stimme das Wake-Word triggert
- **Anruf-Pause + Auto-Resume**: TTS verstummt bei klassischem Anruf oder VoIP-Call (WhatsApp/Signal/Discord). Nach dem Auflegen geht ARIA von der **genauen Stelle** weiter wo sie unterbrochen wurde — die App misst die Position vom Wiedergabe-Anfang und nutzt den WAV-Cache der Antwort
- **Speech Gate**: Aufnahme wird verworfen wenn keine Sprache erkannt
- **STT (Speech-to-Text)**: 16kHz mono → Bridge → Gamebox-Whisper (CUDA) → Text im Chat. Fast in Echtzeit.
- **STT (Speech-to-Text)**: 16kHz mono → STT-Node (Voxtral-3B, CUDA) → Text im Chat. Fast in Echtzeit.
- **"ARIA denkt..." Indicator**: Zeigt live den Status vom Core (Denken, Tool, Schreiben) + Abbrechen-Button
- **TTS-Wiedergabe**: F5-TTS PCM-Streaming direkt in AudioTrack mit konfigurierbarem Pre-Roll-Buffer (1.0–6.0s, Default 3.5s) gegen Gaps bei Render-Pausen
- **Audio-Pause**: Andere Apps (Spotify, YouTube etc.) pausieren komplett waehrend ARIA spricht und kommen erst wieder nach echtem Wiedergabe-Ende
@@ -655,7 +718,7 @@ Der Update-Flow:
App (Mikrofon) → AAC/MP4 Aufnahme → Base64 → RVS → Bridge
Bridge: FFmpeg (16kHz PCM) → Whisper STT → Text → aria-brain
Bridge: STT-Ergebnis → RVS → App (Placeholder wird durch transkribierten Text ersetzt)
aria-brain → Antwort → Bridge → F5-TTS (Gaming-PC) → PCM-Stream → RVS → App
aria-brain → Antwort → Bridge → F5-TTS (Compute-Node) → PCM-Stream → RVS → App
App: AudioTrack MODE_STREAM (nahtlos), Cache als WAV pro Message
```
@@ -762,17 +825,31 @@ Wer sich mit dem gleichen Token verbindet, landet im gleichen Room.
```bash
cd rvs
cp .env.example .env # PUBLIC_URL eintragen (Domain die auf den Server zeigt)
cp .env.example .env # Modus waehlen (COMPOSE_PROFILES) + Werte eintragen
docker compose up -d
```
**Zwei Modi** — in der `rvs/.env` per `COMPOSE_PROFILES`:
| Modus | `.env` | Wer macht TLS | Apps/Satelliten verbinden auf |
|---|---|---|---|
| **mit Caddy** | `COMPOSE_PROFILES=caddy` + `PUBLIC_URL` | Caddy im Stack (Let's Encrypt) | `PUBLIC_URL:CADDY_HTTPS_PORT` |
| **ohne Caddy** | `COMPOSE_PROFILES=` (leer) | dein Reverse-Proxy (nginx, Traefik …) | Name + Port deines Proxys (meist 443) |
**Stack:**
- `caddy` (TLS-Terminator + Let's Encrypt, lauscht auf 80+443)
- `rvs` (WebSocket Relay + OAuth-Callback HTTP, nur intern auf Port 3000)
- `rvs` (WebSocket Relay + OAuth-Callback HTTP). Unverschluesselter Port
`RVS_HTTP_BIND:RVS_HTTP_PORT` (Default `127.0.0.1:3000`) — fuer den Reverse-Proxy.
- `caddy` (nur Modus Caddy: TLS + Let's Encrypt auf 80 + `CADDY_HTTPS_PORT`)
Caddy holt automatisch ein Zertifikat fuer `PUBLIC_URL` via HTTP-01-Challenge.
ACME-State persistent in `./data/caddy/` (gitignored) — kein Rate-Limit-Drama
bei Container-Restart. WebSocket-Upgrades reicht Caddy transparent durch.
Dafuer muss **Port 80 von aussen auf diesen Rechner** gehen — sonst schlaegt die
Erneuerung still fehl und das Zertifikat laeuft nach 90 Tagen ab (Apps und
Satelliten verbinden dann nicht mehr). ACME-State persistent in `./data/caddy/`
(gitignored). WebSocket-Upgrades reicht Caddy transparent durch.
> **Bestehende Installation aktualisieren:** Caddy startet nur noch mit
> `COMPOSE_PROFILES=caddy` in der `rvs/.env`. Wer Caddy weiter nutzt, traegt das
> dort ein (sonst laeuft nach `git pull` + `up -d` kein TLS mehr).
**Features:**
- WebSocket Relay (alle Message-Types: chat, audio, file, config, xtts, update, etc.)
@@ -790,43 +867,143 @@ cp ARIA-v0.0.3.0.apk ~/ARIA-AGENT/rvs/updates/
**Multi-Instanz:** Mehrere ARIA-VMs koennen denselben RVS nutzen — jede mit eigenem Token.
**Ohne Caddy / eigener TLS-Terminator:** Wenn Du schon einen Reverse-Proxy
(nginx/Traefik) davor hast, kommentier den `caddy`-Service in der
`rvs/docker-compose.yml` aus und gib `rvs` wieder einen `ports`-Block
(z.B. `["3000:3000"]`). Dein Reverse-Proxy macht dann TLS und reicht weiter.
**Ohne Caddy — eigener Reverse-Proxy:** in der `rvs/.env`
```bash
COMPOSE_PROFILES=
RVS_HTTP_BIND=0.0.0.0 # Proxy auf anderem Rechner; gleicher Rechner: 127.0.0.1
RVS_HTTP_PORT=3000
```
Steht der Proxy auf einem anderen Rechner, den Port per Firewall **nur fuer den
Proxy** freigeben.
**Strecke Proxy → RVS verschluesseln:** `RVS_HTTP_TLS=true` — der RVS spricht
dann HTTPS auf `RVS_HTTP_PORT`, mit einem selbstsignierten Zertifikat (beim
ersten Start in `rvs/data/rvs-tls/` erzeugt; eigenes `rvs.crt`/`rvs.key` darf
dort liegen). Im Proxy als Ziel `https://<ip>:<port>`, das Zertifikat dabei
**nicht** pruefen (nginx: Default; Nginx Proxy Manager: Scheme `https`,
„Websockets Support" an). Nur ohne Caddy — der spricht den RVS per HTTP an.
Umstellen von Caddy:
`docker compose rm -sf caddy && docker compose up -d`.
> ⚠️ **WebSocket nicht vergessen!** Der RVS ist fast nur WebSocket. Ohne
> WebSocket-Weiterleitung laedt im Browser zwar eine Seite (404 o.ae.), aber
> App, Bridge, Satelliten und KI-Boxen bekommen **keine Verbindung** — ohne
> klare Fehlermeldung. Beim Neueinrichten des Proxys immer pruefen:
>
> - **Nginx Proxy Manager:** im Proxy Host den Schalter **„Websockets Support"**
> einschalten (bei `RVS_HTTP_TLS=true` zusaetzlich Scheme **`https`**).
> - **nginx von Hand:** `proxy_http_version 1.1;` plus die beiden Header
> `Upgrade` / `Connection "upgrade"` (siehe Beispiel unten) und lange
> Timeouts — sonst trennt nginx die Verbindung nach 60 s Stille.
> - **Test:** `curl -i -H "Connection: Upgrade" -H "Upgrade: websocket"
> -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGVzdHRlc3R0ZXN0dGVzdA=="
> "https://<dein-name>/?token=test"` muss mit **`101 Switching Protocols`**
> antworten. Kommt 200/400/404, fehlt die WebSocket-Weiterleitung.
nginx-Beispiel (WebSocket braucht HTTP/1.1 + Upgrade-Header und lange Timeouts):
```nginx
server {
listen 443 ssl;
listen [::]:443 ssl; # Handys nehmen bevorzugt IPv6 — mit AAAA-Eintrag Pflicht
server_name rvs.example.de;
ssl_certificate /etc/letsencrypt/live/rvs.example.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/rvs.example.de/privkey.pem;
location / {
proxy_pass http://192.168.1.10:3000; # RVS_HTTP_BIND-IP : RVS_HTTP_PORT (https:// bei RVS_HTTP_TLS=true)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 3600s; # WebSocket bleibt lange offen
proxy_send_timeout 3600s;
proxy_buffering off;
client_max_body_size 0; # APK-Download, Uploads
}
}
```
Apps, Satelliten, KI-Boxen, Host-Agenten und die Bridge (`RVS_HOST`/`RVS_PORT`
in deren `.env`) dann auf den Namen und Port des Proxys umstellen.
---
## Gamebox-Stack — F5-TTS + Whisper (GPU-Services)
## Compute-Nodes — STT / TTS / LLM (GPU-Dienste)
Laeuft auf einem separaten Rechner mit NVIDIA GPU (z.B. Gaming-PC mit RTX 3060).
Verbindet sich ueber RVS mit der ARIA-Infrastruktur — kein VPN noetig, funktioniert
ueber verschiedene Netze hinweg.
Die GPU-Dienste laufen auf einem oder mehreren separaten Rechnern mit NVIDIA GPU.
Jeder **Compute-Node** verbindet sich ueber RVS mit der ARIA-Infrastruktur — kein
VPN noetig, funktioniert ueber verschiedene Netze hinweg. Frueher war das *eine*
feste „AI-Box"; jetzt sind es beliebig viele Nodes, jeder per `.env` konfiguriert.
### Architektur
### Dienste & Profile
Jeder Dienst haengt an einem Compose-Profil. Ein Node startet ueber
`COMPOSE_PROFILES` (in seiner `.env`) nur die Profile, die er anbieten soll:
| Profil | Container | Rolle |
|-----------|------------------------------|-------|
| `voxtral` | aria-voxtral-bridge | Default-STT (Voxtral-Mini-3B, ~9 GB) |
| `whisper` | aria-whisper-bridge | STT-Fallback (faster-whisper CUDA) |
| `f5tts` | aria-f5tts-bridge | TTS (F5-TTS Voice Cloning) |
| `llm` | aria-llama-swap + llm-adapter| Lokales LLM (llama-swap, OpenAI-kompat.) |
| `openhands`| aria-openhands | OpenHands gegen das lokale LLM (optional) |
#### OpenAI-Endpunkt fuers lokale LLM
Das lokale LLM ist zusaetzlich als **OpenAI-kompatibler Endpunkt** erreichbar —
damit laesst sich z.B. OpenHands oder jedes andere OpenAI-SDK darauf richten.
Port und Key kommen aus der `.env` der Box (`LLM_OPENAI_PORT`,
`LLM_OPENAI_API_KEY`, siehe `xtts/.env.example`).
> **Die `.env` gewinnt immer.** Steht der Wert dort, sind die Felder in der
> Diagnostic **nur lesend** — sonst traegt man dort etwas ein, es wirkt nach dem
> naechsten Container-Start nicht, und niemand versteht warum. Ist in der `.env`
> nichts gesetzt, laesst sich der Key pro Box in der Diagnostic pflegen
> (`llm_openai_config`).
Der Port ist ein Docker-Port-Mapping und darum **nie** aus der Diagnostic
aenderbar. Zur Absicherung nach aussen: SSH-Tunnel statt den Port im LAN
aufzureissen (Details in [`xtts/README.md`](xtts/README.md)).
### Architektur (Aufteilung auf mehrere Nodes)
```
Gamebox (Windows, RTX 3060, Docker Desktop + WSL2)
├── aria-f5tts-bridge F5-TTS Voice Cloning + RVS-Relay
│ Hoert auf xtts_request, streamt audio_pcm
├── aria-whisper-bridge faster-whisper auf CUDA (float16)
│ Hoert auf stt_request, antwortet mit stt_response
└── ./voices/ Geteilt zwischen beiden:
{name}.wav — Referenz-Audio (~6-10s)
{name}.txt — Referenz-Text (auto via Whisper)
STT-Box COMPOSE_PROFILES=voxtral VOXTRAL_GPU=0
TTS-Box COMPOSE_PROFILES=f5tts F5TTS_GPU=0
LLM-Box COMPOSE_PROFILES=llm LLM_GPU=0
-- kleine Karte: Whisper (klein) statt Voxtral, neben F5-TTS --
Klein-Box COMPOSE_PROFILES=whisper,f5tts WHISPER_GPU=0 F5TTS_GPU=0
── oder All-in-One ──
AI-Box COMPOSE_PROFILES=voxtral,f5tts,llm VOXTRAL_GPU=1 F5TTS_GPU=0 LLM_GPU=0
↕ RVS (Rechenzentrum, WebSocket Relay)
ARIA-VM
└── aria-bridge: STT primaer remote (45s Timeout, dann lokaler CPU-Fallback)
TTS via xtts_request → audio_pcm Stream
└── aria-bridge: orchestriert TTS/LLM (xtts_request/llm_request),
lauscht passiv auf den STT-Stream App↔STT-Node.
STT-Timeout → lokaler CPU-Whisper-Fallback.
```
### Voraussetzungen
> STT: pro Node **genau einen** — Voxtral-3B (~9 GB, beste Qualitaet) *oder*
> Whisper (klein, passt neben F5-TTS auf eine GPU mit wenig VRAM). Beide zusammen
> beantworten dieselbe Anfrage doppelt.
- Docker Desktop mit WSL2 (Windows) oder Docker mit NVIDIA Runtime (Linux)
- NVIDIA Container Toolkit
- GPU mit mindestens 6GB VRAM (Whisper-large + F5-TTS gemeinsam)
Die STT-Node teilt sich das `./voices/`-Volume mit F5-TTS nur, wenn beide auf
demselben Node laufen (Referenz-Text-Transkription beim Voice-Upload). Auf
getrennten Nodes transkribiert F5-TTS ueber den STT-Node via RVS.
### Voraussetzungen (pro Node)
- Docker + **NVIDIA Container Toolkit** (registriert die `nvidia`-Runtime — die
Compose nutzt `runtime: nvidia` + `NVIDIA_VISIBLE_DEVICES`).
- Genug VRAM fuer die gewaehlten Profile (Voxtral-3B ~9 GB, F5-TTS ~1 GB, LLM je Modell).
- **Gleicher RVS_TOKEN wie auf der ARIA-VM!**
### Setup
@@ -834,13 +1011,17 @@ ARIA-VM
```bash
cd xtts
cp .env.example .env
# .env mit RVS-Verbindungsdaten fuellen (gleicher Token wie ARIA-VM!)
# .env anpassen:
# COMPOSE_PROFILES → welche Dienste dieser Node fahren soll
# NODE_NAME → Name des Rechners (erscheint in Diagnostic + Logs)
# *_GPU → welche Grafikkarte pro Dienst (NVIDIA_VISIBLE_DEVICES)
# RVS_* → gleiche Verbindungsdaten wie die ARIA-VM
docker compose up -d
# Erster Start laedt die Modelle (Whisper ~1-3GB je nach Groesse, F5-TTS ~1GB)
# Erster Start laedt die Modelle der aktiven Profile (Voxtral ~9GB, F5-TTS ~1GB)
```
Die Modelle werden in den Volumes `f5tts-models` und `whisper-models` gecacht
und muessen nur einmal geladen werden.
Die Modelle liegen im Bind-Mount `./hf-cache/` (bzw. `./models/` fuer LLM-GGUFs)
und muessen pro Node nur einmal geladen werden.
### Features
@@ -862,7 +1043,7 @@ In der Diagnostic unter Einstellungen → Sprachausgabe:
- **TTS aktiv**: Global An/Aus
- **F5-TTS Stimme**: Default oder gecloned (Maia etc.)
> F5-TTS ist die einzige Engine — wenn die Gamebox offline ist, bleibt ARIA stumm.
> F5-TTS ist die einzige Engine — wenn kein f5tts-Node online ist, bleibt ARIA stumm.
> Chat-Antworten kommen weiter an (nur kein Audio).
### Stimme klonen
@@ -908,6 +1089,181 @@ dem Cache wiederverwendet.
---
## Host-Agenten 💻 — Direktzugriff auf einen Rechner
Ein **Host-Agent** läuft als **Standalone-Binary direkt auf einem Rechner**
(Linux) und verbindet sich **ausgehend** zum RVS (gleicher Token). Damit steuert
ARIA diesen Rechner direkt — auch wenn er sonst aus dem Netz **nicht erreichbar**
ist (hinter NAT/Firewall, kein offener Port). Unterschied zum Satelliten: der
Satellit ist ein LAN-Gateway (entdeckt/steuert *andere* Geräte); der Host-Agent
steuert den Rechner, auf dem er *läuft*.
**Fähigkeiten** (ARIA-Tools `host_list` / `host_exec` / `host_read` /
`host_write` / `host_info` / `host_screenshot`): Shell-Kommandos (optional
`sudo`), Datei lesen/schreiben, System-Info (CPU/RAM/Disk/Uptime), Screenshot
(ARIA öffnet ihn mit ihrem Read-Tool und *sieht* den Bildschirm; erscheint zudem
inline im Chat).
### 1. Binary bauen (portabel, Linux x86_64)
```bash
cd host-agent
./build.sh # braucht Docker; erzeugt dist/aria-host-agent (~15 MB)
```
Gebaut wird via PyInstaller in einem bullseye-Container (altes glibc) → läuft auf
möglichst vielen Distributionen. Keine Runtime auf dem Ziel nötig.
### 2. Auf dem Ziel-Rechner installieren
```bash
# dist/aria-host-agent auf den Rechner kopieren, dann:
cp .env.example .env # RVS-Zugang + CONTROL_ENABLED=true eintragen
chmod +x aria-host-agent && ./aria-host-agent
```
**Als systemd-Dienst** (Dauerbetrieb) — der Installer kopiert Binary + `.env`
an ihre Plätze und richtet den Dienst ein:
```bash
sudo ./install-service.sh /pfad/zur/.env # .env-Pfad direkt
sudo ./install-service.sh # oder: ncurses-Dateidialog (dialog)
```
→ Binary nach `/usr/local/bin`, `.env` nach `/etc/aria-host-agent/.env` (0600),
Unit installiert + `enable --now`. Danach `journalctl -u aria-host-agent -f`.
### TLS / SNI — Agent im selben Netz wie der RVS
Verbindet der Agent direkt auf die **interne RVS-IP** (statt über den externen
Hostnamen per NAT-Hairpin), scheitert TLS sonst am fehlenden Cert für die IP
(`tlsv1 alert internal error`). Dann in der `.env`: `RVS_HOST=<interne-ip>` +
`RVS_SNI=<zert-name>` (z.B. `example.com`). Gilt genauso für Satelliten
und Compute-Nodes im RZ-Netz (`RVS_SNI` in deren `.env`).
### sudo
Der Agent wählt automatisch: **root** → direkt · `SUDO_PASSWORD` in der `.env`
→ `sudo -S` · **`SUDO_NOPASSWD=true`** → `sudo -n` (Live-ISO / passwortloses
sudo, z.B. Linux Mint vom Stick) · sonst klare Fehlermeldung.
### Sicherheit
Reagiert nur auf den eigenen RVS-Raum (Token) und nur bei `CONTROL_ENABLED=true`;
keine offenen Ports; alle Kommandos werden geloggt. Gibt **vollen** Rechnerzugriff
— nur auf Maschinen einsetzen, denen du ARIA anvertraust. Details:
[`host-agent/README.md`](host-agent/README.md).
> Sichtbar in der Diagnostic unter **Satelliten → Host-Agenten 💻**.
### GUI-Steuerung (Desktop)
Auf Linux/Windows/macOS kann der Agent zusaetzlich die **grafische Oberflaeche**
bedienen (`host_ui_tap` / `host_ui_text` / `host_ui_swipe` / `host_ui_key` /
`host_app_launch`). Das ist **capability-gated**: der Agent meldet beim
`host_hello` nur, was er wirklich kann — auf einem Linux-Server ohne X/Wayland
fehlt die GUI-Faehigkeit einfach in der Liste, und ARIA sieht das. Eingabe-Weg
je System: `windows` · `macos` · `xdotool` (X11) · `ydotool` (Wayland).
---
## Android-Agent 📱📺 — Handy, Tablet, Fire TV
Dieselbe APK laeuft auf **Handy/Tablet** und auf **Fire TV / Android TV**
(`host-agent/android/`). Sie ist das Android-Gegenstueck zum Host-Agenten:
ausgehende Verbindung zum RVS, kein offener Port, erscheint in der Diagnostic
unter **Satelliten → Host-Agenten 💻**.
**Sehen** — Screenshot (MediaProjection) und Bildschirm-Inhalt als Struktur
(`ui_dump` via Accessibility-Service, nur lesend). Beides geht auch bei
gesperrtem Geraet.
**Steuern** — `ui_tap` / `ui_text` / `ui_swipe` / `ui_key` / `app_launch` ueber
den Accessibility-Service. **Nur bei entsperrtem Geraet** — Android laesst
Gesten auf dem Sperrbildschirm nicht zu; der Agent sagt das klar, statt stumm
nichts zu tun. Auf dem Fire TV gibt es keinen Touchscreen, dafuer das D-Pad.
**Ausgeben** (v.a. Fire TV, dort ist ARIA ein echter Endpunkt):
| Weg | Was passiert |
|-----|--------------|
| Einblendung | Text ueber dem laufenden Bild (Overlay, braucht „Ueber anderen Apps anzeigen") |
| Sprache | ARIAs **echte** Stimme — der Agent fordert das Audio per `tts_request` ueber den normalen F5-TTS-Pfad an und spielt das PCM ab |
| Einstellung | Overlay oder Sprache als Standard, pro Anfrage ueberschreibbar (`mode`) |
| Bild + Text | `host_notify(image_path=/shared/uploads/...)` blendet ein Bild MIT Text ein — z.B. eine Wetterkarte |
**Wie die Einblendung wieder weggeht.** Eine feste Anzeigedauer ist immer
geraten: mal zu kurz zum Lesen, mal steht sie laengst im Weg. Darum bedeutet
`seconds=0`: **bleibt, bis Stefan etwas tut.**
- *Fernbedienung*: der Accessibility-Dienst bekommt `onKeyEvent` und raeumt die
Einblendung weg — gibt die Taste aber mit `return false` **weiter an den
Film**. Es geht kein Tastendruck verloren, der Player bleibt bedienbar.
- *Touch*: `FLAG_WATCH_OUTSIDE_TOUCH` meldet eine Beruehrung daneben; die geht
dank `FLAG_NOT_TOUCHABLE` gleichzeitig an die App darunter.
- *Notbremse*: nach 10 Minuten verschwindet sie in jedem Fall.
> Bewusst **nicht** der naheliegende Weg (Overlay faengt Tasten selbst ab):
> dafuer muessten `FLAG_NOT_FOCUSABLE` und `FLAG_NOT_TOUCHABLE` weg — und dann
> nimmt die Einblendung dem laufenden Film JEDE Eingabe, solange sie steht.
>
> Der Accessibility-Dienst braucht dafuer `canRequestFilterKeyEvents`. Nach
> einem Update also pruefen, ob er in den Bedienungshilfen noch aktiv ist —
> Android schaltet Dienste bei geaenderter Konfiguration gern ab.
> Fire TV Sticks haben oft **keine deutsche TTS-Engine**. Darum laeuft die
> Sprachausgabe bewusst nicht ueber die Geraete-Stimme, sondern ueber ARIAs
> eigene. Die Geraete-Stimme gibt es nur noch auf ausdrueckliche Anforderung
> (`engine=device`).
**Audio-Fokus** — bevor ARIA redet, wird der laufende Film leiser (`duck`) oder
angehalten (`pause`); danach laeuft er weiter. Sonst redet ARIA mitten in
Netflix hinein und man versteht beides nicht.
**Sprachdialog** — der Agent kann auch *zuhoeren* (`listen` / `listen_stop`) und
streamt das Mikrofon mit demselben Protokoll wie die App
(`stt_stream_start` → `stt_audio_chunk` → `stt_stream_end`).
> **Turn-Ownership:** der RVS ist ein Broadcast-Relay ohne Adressierung — jeder
> Client im Raum bekommt jede Antwort. Ohne Filter wuerden Handy und Fire TV
> dieselbe Antwort gleichzeitig vorlesen. Jedes Geraet gibt darum nur aus, was
> aus *seiner* eigenen Frage entstanden ist (`audioRequestId` → `messageId`).
### Bauen + ausrollen
```bash
cd host-agent/android && ./build.sh # Docker (Android-SDK + Gradle)
# -> dist/aria-android-agent.apk
# Release mit allen Agent-Artefakten (Linux/Windows/Android) als Gitea-Assets:
cd host-agent && ./release_agent.sh 0.0.1.4
```
Der Signaturschluessel liegt fest in `host-agent/android/aria-agent.keystore`
(gitignored). Ohne ihn bekaeme jeder Build einen neuen Debug-Key, und Android
verweigert die Installation mit *„Konflikt mit einem bestehenden Paket"* statt
zu aktualisieren.
### Ausrollen per Satellit (ohne Kabel, ohne Abtippen)
Ein Satellit im selben Netz kann Android-Geraete per **ADB over Network**
bespielen — besonders fuer den Fire TV, wo QR-Code-Scannen nicht geht:
```
Diagnostic → Satelliten → Geraet → „Agent ausrollen"
```
Der Satellit verbindet per ADB (Port 5555, wartet auf die Bestaetigung am
Geraet — ein Klick genuegt), installiert die APK, erteilt die Berechtigungen per
`appops`/`pm grant` und **provisioniert die RVS-Zugangsdaten per Intent** — man
tippt am Fernseher nichts ein. Ein schon vergebener Geraetename wird dabei
vorgeschlagen; nur wenn es keinen gibt, kommt der automatisch generierte.
> Die Berechtigungen bleiben ueber Neustarts erhalten; die Bildschirm-Aufnahme
> schaltet sich nach einem Reboot selbst wieder scharf.
---
## Docker Volumes
| Volume / Bind | Pfad im Container | Zweck |
@@ -934,6 +1290,40 @@ dem Cache wiederverwendet.
6. **Panic Button**: `docker compose down` → sofort stoppen
7. **Alles loggen** — Stefan sieht immer was passiert
### Secrets und was bei Anthropic landet
ARIA laeuft ueber Claude Code auf einem Claude-Abo. **Alles, was ARIA sieht —
auch jedes Tool-Ergebnis — wird uebertragen und gespeichert.** Bei Consumer-
Plaenen (Free/Pro/Max) haengt es an einer Einstellung im Konto, ob daraus auch
Trainingsdaten werden; die Aufbewahrung betraegt entsprechend 5 Jahre oder
30 Tage ([claude.ai/settings/data-privacy-controls](https://claude.ai/settings/data-privacy-controls),
Details in Anthropics [Data-usage-Doku](https://code.claude.com/docs/en/data-usage)).
Daraus folgen drei Regeln:
1. **Secrets nie in den Chat tippen.** Das ist der Weg, ueber den hier
tatsaechlich einmal ein `client_secret` abgeflossen ist — nicht ueber ein
Tool, sondern weil es eingetippt und von ARIA zurueckzitiert wurde. Der
System-Prompt und die seed_rules weisen ARIA inzwischen an, das abzulehnen
und ein Secret nie zu wiederholen.
2. **Zugangsdaten gehoeren in eine Konfiguration, nicht in Code oder Chat.**
OAuth → Diagnostic → OAuth-Apps. Alles andere (Benutzername/Passwort,
API-Keys) → **Skill-Konfiguration** (`skill_set_config`), kommt zur Laufzeit
als `CFG_<NAME>` an und wird in der Oberflaeche maskiert.
3. **ARIA bekommt Zugangstokens nicht mehr zu sehen.** `oauth_get_token` sagt
nur noch, OB authentifiziert ist. Fuer API-Aufrufe gibt es **`oauth_call`**:
das Brain haengt die Authorization selbst an, fuehrt den Request aus und
gibt nur die Antwort zurueck — der Token verlaesst den Brain-Prozess nicht.
Abgesichert durch eine Host-Sperre: nur https und nur Hosts, die zum
Provider gehoeren (Vergleich auf den letzten zwei Labels, **nicht** per
`endswith` — sonst waere `boesespotify.com` durchgerutscht).
> Was ARIA NICHT schuetzt: sie laeuft mit Root im Proxy-Container und hat
> `/shared` gemountet. Sie *kann* `/shared/config/*.json` lesen, wenn sie
> hinschaut — und was sie liest, ist Tool-Ergebnis. Die Dateirechte (600)
> helfen dagegen nicht. Behandle jedes dort erreichbare Secret als
> „sichtbar, sobald ARIA hinschaut".
---
## Haeufige Befehle
@@ -975,14 +1365,28 @@ docker exec aria-brain curl localhost:8080/memory/stats
`Hey Rhasspy` funktionieren sofort, eigene Wake-Words muessen aktuell noch als
`.onnx`-Datei ins App-Bundle gelegt + zur Liste in `wakeword.ts` hinzugefuegt werden.
Die Diagnostic-Upload-UI ist Phase 2.
- **Audio-Format**: App nimmt AAC/MP4 auf, Bridge konvertiert via FFmpeg zu 16kHz PCM.
- **Audio-Format**: Die App streamt live 16 kHz mono s16le an die STT-Bridge
(`stt_stream_start` → `stt_audio_chunk` → `stt_stream_end`). Der alte Weg
(AAC/MP4 aufnehmen, Bridge konvertiert per FFmpeg) existiert noch als Fallback.
- **Sprachaufnahme ist nicht wiederaufsetzbar**: Die Audio-Chunks gehen live
raus. Bricht die Verbindung mitten in der Aufnahme ab, fehlt dieses Stueck —
es wird nicht gepuffert und nicht nachgeschickt. Gleiches gilt fuer die
Sprachausgabe: F5-TTS streamt saetzeweise PCM, ein Abbruch verliert den Rest.
Lokal aufnehmen + wiederaufsetzbar hochladen (Queue mit Teil-Uebertragung)
waere der naechste Schritt, ist aber noch nicht gebaut.
- **RVS Zombie-Connections**: WebSocket-Verbindungen sterben gelegentlich ohne Fehlermeldung.
Bridge hat Ping-Check (5s), Diagnostic nutzt frische Verbindungen pro Request.
- **Bildanalyse eingeschraenkt**: Bilder werden in `/shared/uploads/` gespeichert. ARIA kann
sie per Bash/Read-Tool oeffnen, aber Claude Vision (direkte Bildanalyse) ist ueber den
Proxy-Pfad (`claude --print`) noch nicht moeglich. ARIA sieht den Dateipfad, nicht das Bild.
- **Dateigroesse**: Grosse Dateien (>5MB) koennen WebSocket-Limits ueberschreiten.
Bilder werden in der App auf max 1920x1920px @ 80% Qualitaet komprimiert.
App-seitig fragt die Aufnahme-Sperre den Socket statt den gemerkten Zustand.
- **Dateigroesse**: Der RVS und alle Worker fahren `max_size` 1500 MB (≈1 GB
binaer plus base64-Overhead); groessere Dateien weist die Bridge aktiv ab,
statt die Verbindung reissen zu lassen. Die App holt ueber **25 MB** nichts
mehr ungefragt nach und bremst beim ausdruecklichen Antippen bei **64 MB** —
base64-in-JSON muss komplett in den Speicher, und ein 150-MB-Video hat das
Handy per OOM umgebracht. Bilder werden in der App auf max 1920x1920px @ 80%
Qualitaet komprimiert.
- **Lautstarke Umgebung stoert die STT**: Laute Musik im Raum verschlechtert die
Erkennung messbar. Bewusst akzeptiert — Wake-Wort-Bestaetigung und der
Silero-Endpoint stehen als Opt-in bereit.
---
@@ -1024,7 +1428,7 @@ docker exec aria-brain curl localhost:8080/memory/stats
- [x] Pre-Roll-Buffer einstellbar in App-Settings
- [x] Decimal-zu-Worte fuer TTS + generisches Acronym-Buchstabieren
- [x] voice_preload/voice_ready: visueller Status-Indikator beim Stimmen-Wechsel
- [x] Whisper STT auf die Gamebox ausgelagert (CUDA float16, fast Echtzeit)
- [x] Whisper STT auf die AI-Box ausgelagert (CUDA float16, fast Echtzeit)
- [x] **F5-TTS ersetzt XTTS** — bessere Voice-Cloning-Qualitaet, Whisper-auto-transkribierter Referenz-Text
- [x] Audio-Pause statt Ducking (TRANSIENT statt MAY_DUCK) + release-Timing fix
- [x] VAD-Stille-Toleranz einstellbar (1-8s) + adaptive Mikro-Baseline + Max-Aufnahme einstellbar (1-30 min)
@@ -1079,7 +1483,7 @@ docker exec aria-brain curl localhost:8080/memory/stats
- [x] App: Chat-Suche mit Next/Prev Navigation statt Filter
- [x] Token/Call-Metrics + Subscription-Quota-Tracking (Pro / Max 5x / Max 20x / Custom)
- [x] Datei-Manager Multi-Select: Bulk-Download als ZIP + Bulk-Delete (Diagnostic + App)
- [x] **FLUX.1 Bildgenerierung**: eigener `flux-bridge`-Container auf der Gamebox (analog xtts/whisper) mit Hot-Swap zwischen FLUX.1-dev (Quali) und FLUX.1-schnell (Tempo). Default-Modell + Raw-/Switch-Keywords + HuggingFace-Token in Diagnostic-UI verwaltet, automatischer Pipeline-Reload bei Modell-Wechsel. ARIA bekommt `flux_generate`-Tool, Output landet als `/shared/uploads/aria_generated_<ts>.png` und wird via `[FILE: ...]`-Marker als Anhang-Bubble in App + Diagnostic gerendert. Download-Status (mehrere GB) sichtbar als 🎉-Toast wenn fertig
- [x] **FLUX.1 Bildgenerierung**: eigener `flux-bridge`-Container auf der AI-Box (analog xtts/whisper) mit Hot-Swap zwischen FLUX.1-dev (Quali) und FLUX.1-schnell (Tempo). Default-Modell + Raw-/Switch-Keywords + HuggingFace-Token in Diagnostic-UI verwaltet, automatischer Pipeline-Reload bei Modell-Wechsel. ARIA bekommt `flux_generate`-Tool, Output landet als `/shared/uploads/aria_generated_<ts>.png` und wird via `[FILE: ...]`-Marker als Anhang-Bubble in App + Diagnostic gerendert. Download-Status (mehrere GB) sichtbar als 🎉-Toast wenn fertig
- [x] **ARIA Live (Diagnostic) + Not-Aus**: read-only Mirror der Claude-Code-Session ersetzt den SSH-Tab. Tool-Calls + Inputs + Outputs (truncated 4 KB) live, farbcodiert. Roter ⛔ Not-Aus-Button schickt `cancel_request` mit `hard:true` → Bridge ruft den proxy-internen `/cancel-all` Side-Channel (Port 3457) → alle Claude-Subprocesses sofort tot. Plus: Idle-Watchdog im Proxy (20 min Inaktivitaet → Subprocess-Kill) + httpx-Timeout-Split im Brain (connect 10s / read 24h) damit lange Pentests durchlaufen
- [x] **OAuth2-Pipeline ueber RVS-Callback**: Caddy mit Let's Encrypt vor dem RVS, HTTP-Route `/oauth/callback/{service}` broadcastet als `oauth_callback`-WS-Message, aria-bridge forwarded an Brain, Token landet in `/shared/config/oauth_tokens.json` (mode 0600). ARIAs `oauth_register_provider`-Tool legt neue Provider on-demand an (URLs/scopes, nicht Credentials). Diagnostic + App haben beide Provider-Verwaltung inklusive Custom-Provider-Anlage
- [x] **Skill-Mgmt-Tools fuer ARIA**: `skill_update` (Code/README/pip_packages mit venv-Rebuild) + `skill_delete` — verhindert Skill-Friedhof mit `-v2`/`-fixed`-Suffixen. Plus App-seitiger SkillBrowser (Run + Live-Output + Logs der letzten 20 Runs) in Settings → 🛠️ Skills
@@ -1092,6 +1496,52 @@ docker exec aria-brain curl localhost:8080/memory/stats
- [x] **Bridge-Hang-Schutz + Voice-Speed persistent**: 3-Schichten-Watchdog (TCP-Keepalive + Asyncio-Watchdog + File-Based Liveness mit Self-Kill), TLS-Fallback klebt nicht mehr beim Reconnect. `xttsSpeed` jetzt im voice_config.json persistiert — greift auch bei Diagnostic-Chats und nach Bridge-Restart
- [x] **Bubble-Aktionen in der App**: Long-Press oder ⎘-Icon auf einer Chat-Bubble → Aktions-Menu mit "📋 Ganzen Text teilen" plus pro extrahierte URL/E-Mail/Telefonnummer eine eigene Teilen-Option (System-Share-Sheet → Zwischenablage / Apps / Browser)
### Phase C — ARIA greift auf echte Geraete durch (09–10/2026)
- [x] **Host-Agenten** (Linux/Windows/macOS): Standalone-Binary, ausgehend zum RVS, steuert den Rechner auf dem es laeuft. Windows-Build per Docker+Wine samt `setup.exe` (NSIS + nssm) das einen Dienst installiert und seine `.env` aus ProgramData liest. Plus **GUI-Steuerung** capability-gated (`windows`/`macos`/`xdotool`/`ydotool`) — ein Server ohne X meldet die Faehigkeit einfach nicht
- [x] **Android-Agent** (eine APK fuer Handy + Fire TV): sehen (Screenshot via MediaProjection, `ui_dump` via Accessibility), steuern (tap/text/swipe/key/app_launch, nur entsperrt), ausgeben (Overlay ueber dem laufenden Bild + ARIAs **echte** Stimme per `tts_request`, weil Fire TV Sticks oft keine deutsche TTS-Engine haben), Audio-Fokus (Film wird leiser/angehalten), Sprachdialog mit demselben STT-Protokoll wie die App. Fester Signaturschluessel, damit Builds als Update statt als Paket-Konflikt ankommen
- [x] **Ausrollen per Satellit** (ADB over Network): APK installieren, Berechtigungen per `appops`/`pm grant` erteilen und die **RVS-Zugangsdaten per Intent provisionieren** — am Fire TV tippt man nichts ein, QR-Scannen geht dort nicht. Geraetename am PC vergeben, vorhandener Name wird vorgeschlagen
- [x] **Agent-Historie** in der Diagnostic: Befehle + Screenshots pro Agent, gruppiert pro User-Nachricht, aufklappbar
- [x] **Jellyfin-Integration**: mehrere Server mit API-Key, ARIA steuert die Wiedergabe ueber die API statt sich durch die Oberflaeche zu klicken; eingeschlafene Sitzungen baut sie selbst neu auf
- [x] **Auslastungs-Monitor** pro Compute-Box: live `nvidia-smi` + Verlaufs-Graphen (GPU + Tokens) ueber RVS, Historie liegt auf der Box
- [x] **OpenAI-Endpunkt fuers lokale LLM** (+ optionales `openhands`-Profil); `.env` gewinnt immer, Diagnostic-Felder dann nur lesend
- [x] **Teilen-Ziel in der App** + **Sprachnachricht zu Anhaengen** + **Zwischenruf per Sprache**
- [x] **Trigger mit Projekt-Kontext**: die Antwort kommt dort zurueck, wo der Timer gestellt wurde (vorher immer Hauptchat), und `trigger_list` unterscheidet „schon gefeuert" von „deaktiviert"
- [x] **Modell-Liste von Anthropic** statt handgepflegt — und feste Versionen greifen wirklich. Vorher schmolz `extractModel` jeden unbekannten Namen still auf `opus` ein: eine bewusst gewaehlte Version wurde ohne Meldung durch eine andere ersetzt. Dazu ein **Test-Knopf**, der zeigt, welches Modell tatsaechlich geantwortet hat — moeglich wurde das erst, nachdem `normalizeModelName` aufhoerte, den echten Namen zu ueberschreiben
- [x] **Datenschutz**: die drei Claude-Code-Sendekanaele in der Diagnostic pruef- und abschaltbar (ohne Neustart), **`oauth_call`** statt Token-Herausgabe, Secrets-Regeln in Prompt und seed_rules, Warnung bei veralteten Proxy-Patches
- [x] **Anhang-Limit einstellbar** (Default 20 → 64 MB, greift sofort) und **Zeitstempel/Sortierung/Zeitraum** bei den Gedanken
- [x] **Flugwetter-Skill** mit pc_met-Anbindung, Seitenwind gegen Pistenrichtung und Herkunftsangabe pro Zahl; Ausgabe als Einblendung + Sprache auf einem Agenten
- [x] **Einblendung geht per Tastendruck weg** statt nach geratener Zeit — ueber `onKeyEvent` des Accessibility-Dienstes, der die Taste an den Film weiterreicht
#### Jagd auf verlorene Sprachnachrichten (die lange Kette)
Ein hartnaeckiges „die erste Sprachnachricht kommt nicht durch" waren am Ende
**fuenf unabhaengige Ursachen** — hier dokumentiert, weil jede einzelne fuer
sich plausibel aussah:
- [x] **200-MB-Broadcast** — der RVS ist ein Broadcast-Relay: ein per
`file_request` nachgeladenes 150-MB-Video ging als ~200 MB base64 an **alle**
Clients. Die STT/TTS-Worker starben daran mit Code 1009 und nahmen die
laufende Aufnahme mit; die App starb am OOM und wurde von Android neu
gestartet → Auto-Re-Download → von vorn (Schleife alle ~70s). Fix dreifach:
Worker-`max_size` an den RVS angeglichen, **Datei-Antworten gehen nur noch an
den Fragenden** (requestId-Korrelation im RVS), und die App holt ueber 25 MB
nichts mehr ungefragt
- [x] **`spawn E2BIG`** im Proxy — der System-Prompt lief als Kommandozeilen-
Argument gegen `ARG_MAX` (131072). Jetzt `--system-prompt-file`
- [x] **RVS-Allow-List** verwarf fuenf Nachrichtentypen still (`stt_lease*`,
`chat_ack`, `stt_transcribe_*`) — jede Aufnahme startete verspaetet, erste
Silben weg
- [x] **Whisper-Kaltstart** — Session wurde angelegt, bevor das Modell geladen
war; Audio lief ins Leere
- [x] **Zombie-Verbindung** in der App — zeigte „getrennt", empfing aber weiter;
die Aufnahme-Sperre fragt jetzt den Socket statt den gemerkten Zustand
- [x] **Lange Sprachnachrichten brachen mitten im Satz ab** — der Endpointer
zaehlte seine **eigene** Rechenzeit als Stille: der 200ms-Tick wartete auf die
Zwischen-Transkription, und die lief jedes Mal ueber den kompletten Buffer
(bei zwei Minuten mehrere Sekunden). Jetzt laeuft sie nebenher und sieht nur
die letzten 30s; der finale Text kommt unveraendert aus dem ganzen Buffer
### Phase 2 — ARIA wird produktiv
- [ ] Erste Skills bauen lassen (yt-dlp, pdf-extract, etc. — durch normale Anfragen)
@@ -1106,4 +1556,4 @@ docker exec aria-brain curl localhost:8080/memory/stats
- [ ] Desktop Client (Tauri)
- [ ] bKVM Remote IT-Support
- [ ] Custom-`.onnx`-Upload fuer Wake-Word ueber Diagnostic (ohne App-Rebuild)
- [ ] Claude Vision direkt (Bildanalyse ohne Dateipfad-Umweg)
- [x] Bildanalyse / Vision — ARIA sieht App-Uploads & Screenshots via Read-Tool (`/shared/uploads/` ist im Proxy-Container gemountet). „Direkt ohne Dateipfad" waere reine Politur (ein Read weniger) und ist bewusst NICHT geplant.
-63
View File
@@ -1,63 +0,0 @@
# 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
```
-277
View File
@@ -1,277 +0,0 @@
#!/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
+21 -3
View File
@@ -6,15 +6,16 @@
*/
import React, { useEffect } from 'react';
import { AppState, AppStateStatus, PermissionsAndroid, Platform, StatusBar, StyleSheet } from 'react-native';
import { AppState, AppStateStatus, PermissionsAndroid, Platform, StatusBar, StyleSheet, View } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { BottomTabBar, createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import WorkspaceScreen from './src/workspace/WorkspaceScreen';
import SettingsScreen from './src/screens/SettingsScreen';
import ViewModeToggle from './src/components/ViewModeToggle';
import SecurityLock from './src/components/SecurityLock';
import rvs from './src/services/rvs';
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
@@ -138,6 +139,15 @@ const App: React.FC = () => {
<StatusBar barStyle="light-content" backgroundColor="#0D0D1A" />
<NavigationContainer theme={DarkTheme}>
<Tab.Navigator
tabBar={(props) => (
// Tab-Leiste + Schloss-Symbol rechts neben dem Zahnrad
<View style={styles.tabBarRow}>
<View style={styles.tabBarTabs}>
<BottomTabBar {...props} />
</View>
<SecurityLock onOpenSettings={() => props.navigation.navigate('Einstellungen')} />
</View>
)}
screenOptions={({ route }) => ({
headerStyle: styles.header,
headerTitleStyle: styles.headerTitle,
@@ -205,10 +215,18 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: '700',
},
tabBar: {
tabBarRow: {
flexDirection: 'row',
backgroundColor: '#12122A',
borderTopColor: '#1E1E2E',
borderTopWidth: 1,
},
tabBarTabs: {
flex: 1,
},
tabBar: {
backgroundColor: '#12122A',
borderTopWidth: 0,
height: 60,
paddingBottom: 6,
paddingTop: 4,
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20402
versionName "0.2.4.2"
versionCode 20508
versionName "0.2.5.8"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -49,6 +49,28 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Teilen-Ziel: ARIA erscheint im Teilen-Menue anderer Apps (WhatsApp,
Galerie, Browser ...). EINE Datei bzw. reiner Text. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="application/*" />
</intent-filter>
<!-- Mehrere Dateien auf einmal. -->
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="application/*" />
</intent-filter>
</activity>
<provider
@@ -1,5 +1,6 @@
package com.ariacockpit
import android.content.Intent
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
@@ -19,4 +20,26 @@ class MainActivity : ReactActivity() {
*/
override fun createReactActivityDelegate(): ReactActivityDelegate =
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
/**
* Teilen in die LAUFENDE App (launchMode=singleTask liefert das hier ab).
* setIntent() ist wichtig, damit getSharedData() denselben Inhalt sieht, und
* das Event weckt die JS-Seite sofort — ohne es wuerde der geteilte Inhalt
* erst beim naechsten Kaltstart auftauchen.
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
// Best effort: je nach RN-Architektur ist der Kontext anders erreichbar.
// Scheitert es, greift der zuverlaessige Zweitweg — die JS-Seite fragt beim
// Zurueckkehren in den Vordergrund ohnehin nochmal via getSharedData() nach
// (setIntent() oben sorgt dafuer, dass sie denselben Inhalt sieht).
try {
val ctx = (application as? com.facebook.react.ReactApplication)
?.reactNativeHost?.reactInstanceManager?.currentReactContext
ShareIntentModule.notifyNewIntent(
ctx as? com.facebook.react.bridge.ReactApplicationContext, intent)
} catch (_: Throwable) {
}
}
}
@@ -25,6 +25,8 @@ class MainApplication : Application(), ReactApplication {
add(OpenWakeWordPackage())
add(PhoneCallPackage())
add(BackgroundAudioPackage())
add(ShareIntentPackage())
add(TlsTrustPackage())
}
override fun getJSMainModuleName(): String = "index"
@@ -41,6 +43,8 @@ class MainApplication : Application(), ReactApplication {
override fun onCreate() {
super.onCreate()
SoLoader.init(this, false)
// Vor der ersten Verbindung: Zertifikatspruefung (abgelaufen erlaubt?)
TlsTrust.install(this)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
load()
@@ -60,6 +60,11 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
// weil der False-Positive die AudioFocus-Switch-Logik anwirft (Stefan-Bug 06/2026).
// Loesung: in dieser Phase keine Detections an JS weiterleiten.
private const val STARTUP_SUPPRESSION_MS = 600L
// PCM-Ringpuffer fuer die Wake-Wort-Bestaetigung: letzte 2s roh (16kHz
// mono s16). Bei einer Erkennung wird der Vor-Trigger-Schnipsel an JS
// gereicht und dort von Voxtral verifiziert (gegen Musik-Fehltrigger).
private const val PCM_RING_SAMPLES = 32000 // 2.0s @ 16kHz
private const val PRE_TRIGGER_SAMPLES = 24000 // 1.5s Schnipsel an JS
}
private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
@@ -106,6 +111,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
private val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings
private var consecutiveAboveThreshold: Int = 0
private var lastDetectionMs: Long = 0L
// Roh-PCM-Ringpuffer (letzte ~2s) fuer die Wake-Wort-Bestaetigung. Bei einer
// Erkennung wird der Vor-Trigger-Schnipsel base64-kodiert an JS gereicht und
// dort von Voxtral verifiziert ("war das wirklich 'Computer' oder Musik?").
private val pcmRing = ShortArray(PCM_RING_SAMPLES)
private var pcmRingPos = 0
private var pcmRingFilled = false
private val pcmRingLock = Any()
// Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster
private var recordingStartedMs: Long = 0L
@@ -430,6 +442,36 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
embBuffer.clear()
consecutiveAboveThreshold = 0
lastDetectionMs = 0L
// PCM-Ring frisch: sonst koennte Alt-Audio aus dem vorigen Arm-Zyklus
// in den Bestaetigungs-Schnipsel bluten.
synchronized(pcmRingLock) { pcmRingPos = 0; pcmRingFilled = false }
}
/** Letzte ~1.5s Roh-PCM aus dem Ringpuffer als Base64 (s16le, 16kHz mono),
* fuer die Voxtral-Wake-Bestaetigung. null wenn noch zu wenig Audio da ist
* oder das Kodieren scheitert (dann macht JS fail-open weiter wie bisher). */
private fun snapshotPreTrigger(): String? {
val out: ByteArray
synchronized(pcmRingLock) {
val available = if (pcmRingFilled) PCM_RING_SAMPLES else pcmRingPos
val n = if (available < PRE_TRIGGER_SAMPLES) available else PRE_TRIGGER_SAMPLES
if (n <= 0) return null
out = ByteArray(n * 2)
var idx = (pcmRingPos - n + PCM_RING_SAMPLES) % PCM_RING_SAMPLES
for (i in 0 until n) {
val s = pcmRing[idx].toInt()
out[i * 2] = (s and 0xFF).toByte()
out[i * 2 + 1] = ((s shr 8) and 0xFF).toByte()
idx += 1
if (idx >= PCM_RING_SAMPLES) idx = 0
}
}
return try {
android.util.Base64.encodeToString(out, android.util.Base64.NO_WRAP)
} catch (e: Exception) {
Log.w(TAG, "snapshotPreTrigger base64 fehlgeschlagen: ${e.message}")
null
}
}
private fun emitDetected() {
@@ -438,8 +480,10 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
Log.i(TAG, "Wake-Word emit unterdrueckt (sinceStart=${sinceStart}ms < ${STARTUP_SUPPRESSION_MS}ms — Mikro-Spin-up-Spike)")
return
}
val preTriggerB64 = snapshotPreTrigger()
val params = com.facebook.react.bridge.Arguments.createMap().apply {
putString("model", modelName)
if (preTriggerB64 != null) putString("preTriggerPcm", preTriggerB64)
}
try {
reactApplicationContext
@@ -466,6 +510,14 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
read += n
}
if (!running.get()) break
// Chunk in den PCM-Ringpuffer schreiben (fuer Wake-Wort-Bestaetigung).
synchronized(pcmRingLock) {
for (i in 0 until CHUNK_SAMPLES) {
pcmRing[pcmRingPos] = buf[i]
pcmRingPos += 1
if (pcmRingPos >= PCM_RING_SAMPLES) { pcmRingPos = 0; pcmRingFilled = true }
}
}
try {
processChunk(buf)
} catch (e: Exception) {
@@ -0,0 +1,154 @@
package com.ariacockpit
import android.content.Intent
import android.net.Uri
import android.provider.OpenableColumns
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.WritableArray
import com.facebook.react.bridge.WritableMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import java.io.File
/**
* Teilen-Ziel: nimmt Inhalte entgegen, die aus anderen Apps (WhatsApp, Galerie,
* Browser ...) per "Teilen → ARIA" geschickt werden.
*
* Die geteilten Dateien kommen als content://-URI, auf die nur DIESE App und nur
* JETZT zugreifen darf. Deshalb kopieren wir sie sofort in den App-Cache und
* geben einen echten Dateipfad zurueck — den kann die JS-Seite wie eine normal
* ausgewaehlte Datei weiterverarbeiten (RNFS.readFile → Base64 → Upload).
*
* Ablauf:
* App geschlossen → Intent liegt beim Start an → getSharedData()
* App laeuft → onNewIntent (launchMode=singleTask) → Event 'ShareReceived'
*/
class ShareIntentModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = "ShareIntent"
companion object {
private const val EVENT = "ShareReceived"
/** Vom MainActivity.onNewIntent gerufen, wenn die App schon laeuft. */
@JvmStatic
fun notifyNewIntent(ctx: ReactApplicationContext?, intent: Intent?) {
if (ctx == null || intent == null || !isShare(intent)) return
try {
val data = extract(ctx, intent)
ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit(EVENT, data)
} catch (_: Throwable) {
}
}
private fun isShare(i: Intent): Boolean =
i.action == Intent.ACTION_SEND || i.action == Intent.ACTION_SEND_MULTIPLE
/** Baut {text, items:[{path,name,type,size}]} aus dem Intent. */
private fun extract(ctx: ReactApplicationContext, intent: Intent): WritableMap {
val out = Arguments.createMap()
out.putString("text", intent.getStringExtra(Intent.EXTRA_TEXT) ?: "")
val uris = mutableListOf<Uri>()
if (intent.action == Intent.ACTION_SEND) {
@Suppress("DEPRECATION")
(intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM))?.let { uris.add(it) }
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
?.forEach { uris.add(it) }
}
val items: WritableArray = Arguments.createArray()
for (uri in uris) {
copyToCache(ctx, uri)?.let { items.pushMap(it) }
}
out.putArray("items", items)
return out
}
/**
* Kopiert den geteilten Inhalt in den Cache. Noetig, weil die Leseerlaubnis
* fuer die content://-URI nur fuer diesen Intent gilt — spaeter (nach der
* Projektauswahl) waere sie womoeglich schon erloschen.
*/
private fun copyToCache(ctx: ReactApplicationContext, uri: Uri): WritableMap? {
return try {
val cr = ctx.contentResolver
var name = "geteilt"
var size = 0L
cr.query(uri, null, null, null, null)?.use { c ->
val ni = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val si = c.getColumnIndex(OpenableColumns.SIZE)
if (c.moveToFirst()) {
if (ni >= 0) c.getString(ni)?.let { name = it }
if (si >= 0) size = c.getLong(si)
}
}
val type = cr.getType(uri) ?: "application/octet-stream"
val dir = File(ctx.cacheDir, "shared").apply { mkdirs() }
// Eindeutiger Name, damit zwei gleichnamige Bilder sich nicht ueberschreiben.
val safe = name.replace(Regex("[^A-Za-z0-9._-]"), "_").take(80)
val dst = File(dir, "${System.currentTimeMillis()}_$safe")
cr.openInputStream(uri)?.use { input ->
dst.outputStream().use { output -> input.copyTo(output) }
} ?: return null
Arguments.createMap().apply {
putString("path", dst.absolutePath)
putString("name", name)
putString("type", type)
putDouble("size", (if (size > 0) size else dst.length()).toDouble())
}
} catch (_: Throwable) {
null
}
}
}
/**
* Holt den Inhalt, mit dem die App GESTARTET wurde (App war zu).
* Gibt {text, items:[]} zurueck — items leer, wenn nichts geteilt wurde.
*/
@ReactMethod
fun getSharedData(promise: Promise) {
try {
val activity = currentActivity
val intent = activity?.intent
if (intent == null || !isShare(intent)) {
promise.resolve(Arguments.createMap().apply {
putString("text", "")
putArray("items", Arguments.createArray())
})
return
}
val data = extract(reactContext, intent)
// Verbraucht markieren, damit ein Rotieren/Zurueckkehren den Dialog
// nicht erneut oeffnet.
activity.intent = Intent(Intent.ACTION_MAIN)
promise.resolve(data)
} catch (e: Throwable) {
promise.reject("SHARE_READ_FAILED", e.message, e)
}
}
/** Aufgeraeumte Kopien loeschen (nach erfolgreichem Senden). */
@ReactMethod
fun clearCache(promise: Promise) {
try {
File(reactContext.cacheDir, "shared").listFiles()?.forEach { it.delete() }
promise.resolve(true)
} catch (e: Throwable) {
promise.resolve(false)
}
}
// Fuer NativeEventEmitter (RN verlangt diese beiden Methoden).
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
}
@@ -0,0 +1,16 @@
package com.ariacockpit
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class ShareIntentPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(ShareIntentModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
@@ -0,0 +1,185 @@
package com.ariacockpit
import android.content.Context
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.modules.network.NetworkingModule
import com.facebook.react.modules.websocket.WebSocketModule
import java.net.Socket
import java.security.KeyStore
import java.security.cert.CertPathValidator
import java.security.cert.CertificateException
import java.security.cert.CertificateExpiredException
import java.security.cert.CertificateFactory
import java.security.cert.PKIXParameters
import java.security.cert.X509Certificate
import java.util.Date
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedTrustManager
import javax.net.ssl.X509TrustManager
/**
* Zertifikatspruefung fuer RVS-Verbindung (WebSocket) und fetch.
*
* Normal pruefen wir wie Android selbst. Mit dem Schalter "Abgelaufenes
* Zertifikat akzeptieren" wird GENAU EIN Fehler toleriert: das Ablaufdatum.
* Kette und Aussteller werden dann zum Zeitpunkt kurz vor dem Ablauf geprueft
* (war das Zertifikat damals gueltig, ist es echt), der Hostname prueft OkHttp
* ohnehin separat. Ein fremdes oder selbstgebautes Zertifikat kommt also auch
* mit Schalter nicht durch — sonst koennte sich jeder dazwischenhaengen.
*
* Nebenbei merkt sich das Modul, wie die letzte Pruefung ausging, damit die
* App das Schloss-Symbol richtig zeigen kann.
*/
object TlsTrust {
private const val PREFS = "aria_tls"
private const val KEY_ALLOW_EXPIRED = "allow_expired"
// ok | expired_accepted | expired | invalid | unknown
@Volatile var lastStatus: String = "unknown"
@Volatile var lastDetail: String = ""
@Volatile var lastNotAfter: Long = 0
@Volatile var lastSubject: String = ""
@Volatile var allowExpired: Boolean = false
private set
private var appContext: Context? = null
fun install(context: Context) {
appContext = context.applicationContext
allowExpired = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getBoolean(KEY_ALLOW_EXPIRED, false)
try {
val tm = ExpiryTolerantTrustManager(defaultTrustManager())
val ssl = SSLContext.getInstance("TLS").apply { init(null, arrayOf(tm), null) }
val factory = ssl.socketFactory
WebSocketModule.setCustomClientBuilder { b -> b.sslSocketFactory(factory, tm) }
NetworkingModule.setCustomClientBuilder { b -> b.sslSocketFactory(factory, tm) }
} catch (e: Exception) {
// Lieber normale Pruefung als gar keine Verbindung
android.util.Log.w("TlsTrust", "Eigene Zertifikatspruefung nicht aktiv: ${e.message}")
}
}
fun saveAllowExpired(value: Boolean) {
allowExpired = value
appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
?.edit()?.putBoolean(KEY_ALLOW_EXPIRED, value)?.apply()
}
private fun defaultTrustManager(): X509TrustManager {
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
tmf.init(null as KeyStore?)
return tmf.trustManagers.filterIsInstance<X509TrustManager>().first()
}
private fun remember(status: String, chain: Array<X509Certificate>?, detail: String = "") {
lastStatus = status
lastDetail = detail
val leaf = chain?.firstOrNull()
lastNotAfter = leaf?.notAfter?.time ?: 0
lastSubject = leaf?.subjectX500Principal?.name ?: ""
}
private fun isExpired(chain: Array<X509Certificate>): Boolean =
chain.any { c ->
try { c.checkValidity(); false } catch (_: CertificateExpiredException) { true } catch (_: Exception) { false }
}
/** Kette gegen die System-Zertifizierungsstellen pruefen — zum Zeitpunkt
* kurz vor dem fruehesten Ablauf in der Kette. */
private fun chainValidBeforeExpiry(chain: Array<X509Certificate>) {
val ks = KeyStore.getInstance("AndroidCAStore").apply { load(null) }
val params = PKIXParameters(ks).apply {
isRevocationEnabled = false
date = Date(chain.minOf { it.notAfter.time } - 60_000)
}
// Mitgeschickte Wurzel (selbstsigniert) gehoert nicht in den Pfad — aber NIE
// das Server-Zertifikat selbst: ein leerer Pfad gilt als gueltig, ein
// selbstgebautes Zertifikat kaeme sonst durch.
if (chain.isEmpty()) throw CertificateException("Leere Zertifikatskette")
val path = listOf(chain[0]) + chain.drop(1).filter { it.subjectX500Principal != it.issuerX500Principal }
val certPath = CertificateFactory.getInstance("X.509").generateCertPath(path)
CertPathValidator.getInstance("PKIX").validate(certPath, params)
}
private class ExpiryTolerantTrustManager(private val inner: X509TrustManager) : X509ExtendedTrustManager() {
private val ext = inner as? X509ExtendedTrustManager
private fun check(chain: Array<X509Certificate>, run: () -> Unit) {
try {
run()
remember("ok", chain)
} catch (e: CertificateException) {
if (!isExpired(chain)) {
remember("invalid", chain, e.message ?: "")
throw e
}
if (!allowExpired) {
remember("expired", chain, e.message ?: "")
throw e
}
try {
chainValidBeforeExpiry(chain)
} catch (inner: Exception) {
// Abgelaufen UND sonst nicht in Ordnung → nicht akzeptieren
remember("invalid", chain, inner.message ?: "")
throw e
}
remember("expired_accepted", chain)
}
}
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) =
check(chain) { inner.checkServerTrusted(chain, authType) }
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String, socket: Socket?) =
check(chain) {
if (ext != null) ext.checkServerTrusted(chain, authType, socket)
else inner.checkServerTrusted(chain, authType)
}
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String, engine: SSLEngine?) =
check(chain) {
if (ext != null) ext.checkServerTrusted(chain, authType, engine)
else inner.checkServerTrusted(chain, authType)
}
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) =
inner.checkClientTrusted(chain, authType)
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String, socket: Socket?) =
if (ext != null) ext.checkClientTrusted(chain, authType, socket) else inner.checkClientTrusted(chain, authType)
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String, engine: SSLEngine?) =
if (ext != null) ext.checkClientTrusted(chain, authType, engine) else inner.checkClientTrusted(chain, authType)
override fun getAcceptedIssuers(): Array<X509Certificate> = inner.acceptedIssuers
}
}
class TlsTrustModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "TlsTrust"
@ReactMethod
fun setAllowExpired(value: Boolean, promise: Promise) {
TlsTrust.saveAllowExpired(value)
promise.resolve(value)
}
@ReactMethod
fun getStatus(promise: Promise) {
val m = Arguments.createMap()
m.putString("status", TlsTrust.lastStatus)
m.putString("detail", TlsTrust.lastDetail)
m.putDouble("notAfter", TlsTrust.lastNotAfter.toDouble())
m.putString("subject", TlsTrust.lastSubject)
m.putBoolean("allowExpired", TlsTrust.allowExpired)
promise.resolve(m)
}
}
@@ -0,0 +1,16 @@
package com.ariacockpit
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class TlsTrustPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(TlsTrustModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.4.2",
"version": "0.2.5.8",
"private": true,
"scripts": {
"android": "react-native run-android",
+5 -8
View File
@@ -34,13 +34,10 @@ interface FileUploadProps {
onCancel: () => void;
}
// Unterstuetzte Dateitypen
const SUPPORTED_TYPES = [
DocumentPicker.types.images,
DocumentPicker.types.pdf,
DocumentPicker.types.docx,
DocumentPicker.types.plainText,
];
// Alle Dateitypen zulassen — Stefan will ueber die Bueroklammer jede Datei
// hochladen koennen, nicht nur Bilder/Dokumente. Die Komponente verarbeitet
// beliebige Typen ohnehin (Base64 + application/octet-stream als Fallback).
const SUPPORTED_TYPES = [DocumentPicker.types.allFiles];
// --- Komponente ---
@@ -112,7 +109,7 @@ const FileUpload: React.FC<FileUploadProps> = ({ onFileSelected, onCancel }) =>
<TouchableOpacity style={styles.pickButton} onPress={pickFile} activeOpacity={0.7}>
<Text style={styles.pickIcon}>{'\uD83D\uDCC1'}</Text>
<Text style={styles.pickText}>Datei ausw\u00E4hlen</Text>
<Text style={styles.pickHint}>JPG, PNG, PDF, DOCX, TXT</Text>
<Text style={styles.pickHint}>Alle Dateitypen</Text>
</TouchableOpacity>
) : (
// Vorschau und Senden
+150
View File
@@ -0,0 +1,150 @@
/**
* Schloss-Symbol in der Tab-Leiste (neben dem Zahnrad): zeigt, ob die
* Verbindung zu ARIA verschluesselt ist.
*
* 🔒 verschluesselt, alles in Ordnung
* 🔒 + ! Problem mit dem Zertifikat (abgelaufen / abgelehnt)
* 🔒 durchgestrichen keine Verschluesselung (Fallback oder so eingestellt)
*
* Antippen erklaert in einfachen Worten, was das Symbol bedeutet.
* Einmal pro App-Start gibt's ausserdem einen Hinweis, wenn das Zertifikat
* abgelaufen ist — damit es nicht unbemerkt bleibt.
*/
import React, { useEffect, useRef, useState } from 'react';
import { Alert, Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import rvs, { SecurityState } from '../services/rvs';
interface Props {
onOpenSettings: () => void;
}
const fmtDate = (ms: number) =>
ms ? new Date(ms).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit' }) : 'unbekannt';
const SecurityLock: React.FC<Props> = ({ onOpenSettings }) => {
const [sec, setSec] = useState<SecurityState>(rvs.getSecurity());
const [open, setOpen] = useState(false);
const alerted = useRef<Set<string>>(new Set());
useEffect(() => rvs.onSecurityChange(setSec), []);
// Einmaliger Hinweis pro App-Start
useEffect(() => {
if (sec.kind === 'expired' && !alerted.current.has('expired')) {
alerted.current.add('expired');
Alert.alert('Zertifikat abgelaufen',
`Die Verbindung zu ARIA ist weiterhin verschlüsselt, aber das Sicherheits-Zertifikat `
+ `des Servers ist seit ${fmtDate(sec.notAfter)} abgelaufen. Bitte dem Admin Bescheid geben.`);
} else if (sec.kind === 'cert_error' && sec.certStatus === 'expired' && !alerted.current.has('cert_error')) {
alerted.current.add('cert_error');
Alert.alert('Keine Verbindung — Zertifikat abgelaufen',
`Das Sicherheits-Zertifikat des Servers ist seit ${fmtDate(sec.notAfter)} abgelaufen, `
+ `deshalb verbindet sich die App nicht.\n\nIn den Einstellungen unter „Verbindung“ kann man `
+ `abgelaufene Zertifikate vorübergehend erlauben.`,
[{ text: 'OK' }, { text: 'Einstellungen', onPress: onOpenSettings }]);
}
}, [sec, onOpenSettings]);
const warn = sec.kind === 'expired' || sec.kind === 'cert_error';
const crossed = sec.kind === 'fallback' || sec.kind === 'plain';
if (sec.kind === 'unknown') return <View style={styles.slot} />;
let title = '';
let text = '';
if (sec.kind === 'secure') {
title = 'Verbindung verschlüsselt';
text = 'Alles in Ordnung. Die Verbindung zu ARIA ist verschlüsselt und das Zertifikat des Servers ist gültig.';
} else if (sec.kind === 'expired') {
title = 'Zertifikat abgelaufen';
text = `Die Verbindung ist verschlüsselt, aber das Zertifikat des Servers ist seit ${fmtDate(sec.notAfter)} `
+ 'abgelaufen. Die App akzeptiert es, weil das in den Einstellungen so erlaubt ist. '
+ 'Geprüft wird trotzdem, dass es wirklich das Zertifikat dieses Servers ist.\n\n'
+ 'Das Zertifikat sollte erneuert werden.';
} else if (sec.kind === 'cert_error') {
title = 'Keine Verbindung — Zertifikat-Problem';
text = sec.certStatus === 'expired'
? `Das Zertifikat des Servers ist seit ${fmtDate(sec.notAfter)} abgelaufen. Deshalb verbindet sich die App nicht.\n\n`
+ 'In den Einstellungen unter „Verbindung“ kann man abgelaufene Zertifikate vorübergehend erlauben.'
: 'Das Zertifikat des Servers ist ungültig oder gehört nicht zu diesem Server. Zur Sicherheit verbindet sich '
+ 'die App nicht.' + (sec.reason ? `\n\nGrund: ${sec.reason}` : '');
} else if (sec.kind === 'fallback') {
title = 'Nicht verschlüsselt (Notlösung)';
text = 'Die verschlüsselte Verbindung hat nicht geklappt. Die App ist deshalb ersatzweise UNVERSCHLÜSSELT '
+ 'verbunden (TLS-Fallback).'
+ (sec.certStatus === 'expired' ? `\n\nGrund: Das Zertifikat ist seit ${fmtDate(sec.notAfter)} abgelaufen.`
: sec.certStatus === 'invalid' ? '\n\nGrund: Das Zertifikat des Servers ist ungültig.'
: '\n\nGrund: Die verschlüsselte Verbindung kam nicht zustande.');
} else if (sec.kind === 'plain') {
title = 'Nicht verschlüsselt';
text = 'Für diese Verbindung ist keine Verschlüsselung eingestellt. Das ist nur im eigenen Heimnetz in Ordnung.';
}
return (
<>
<TouchableOpacity style={styles.slot} onPress={() => setOpen(true)} accessibilityLabel={title}>
<View>
<Text style={[styles.lock, crossed && styles.lockDim]}>{'🔒'}</Text>
{crossed && <View style={styles.strike} />}
{warn && <Text style={styles.bang}>!</Text>}
</View>
</TouchableOpacity>
<Modal visible={open} transparent animationType="fade" onRequestClose={() => setOpen(false)}>
<TouchableOpacity style={styles.overlay} activeOpacity={1} onPress={() => setOpen(false)}>
<View style={styles.box}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.text}>{text}</Text>
<View style={styles.legend}>
<Text style={styles.legendRow}>{'🔒'} verschlüsselt, alles in Ordnung</Text>
<Text style={styles.legendRow}>{'🔒'}<Text style={styles.legendBang}>!</Text> Problem mit dem Zertifikat</Text>
<Text style={styles.legendRow}>{'🔒'}<Text style={styles.legendStrike}>/</Text> nicht verschlüsselt</Text>
</View>
<View style={styles.buttons}>
{(sec.kind === 'cert_error' || sec.kind === 'expired' || sec.kind === 'fallback') && (
<TouchableOpacity style={styles.btn} onPress={() => { setOpen(false); onOpenSettings(); }}>
<Text style={styles.btnText}>Einstellungen</Text>
</TouchableOpacity>
)}
<TouchableOpacity style={[styles.btn, styles.btnPrimary]} onPress={() => setOpen(false)}>
<Text style={styles.btnText}>OK</Text>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</Modal>
</>
);
};
const styles = StyleSheet.create({
slot: { width: 52, alignItems: 'center', justifyContent: 'center' },
lock: { fontSize: 20 },
lockDim: { opacity: 0.7 },
strike: {
position: 'absolute', left: -3, right: -3, top: '50%', height: 2.5,
backgroundColor: '#FF3B30', transform: [{ rotate: '-45deg' }],
},
bang: {
position: 'absolute', right: -8, top: -6, minWidth: 15, height: 15, borderRadius: 8,
backgroundColor: '#FF9F0A', color: '#000', fontSize: 11, fontWeight: '900',
textAlign: 'center', lineHeight: 15, overflow: 'hidden',
},
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', padding: 24 },
box: { backgroundColor: '#1A1A2E', borderRadius: 14, padding: 20 },
title: { color: '#FFFFFF', fontSize: 17, fontWeight: '700', marginBottom: 10 },
text: { color: '#C8C8D8', fontSize: 14, lineHeight: 20 },
legend: { marginTop: 16, paddingTop: 12, borderTopWidth: 1, borderTopColor: '#2A2A3E' },
legendRow: { color: '#9090A8', fontSize: 13, marginVertical: 2 },
legendBang: { color: '#FF9F0A', fontWeight: '900' },
legendStrike: { color: '#FF3B30', fontWeight: '900' },
buttons: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 16, gap: 10 },
btn: { paddingVertical: 9, paddingHorizontal: 16, borderRadius: 8, backgroundColor: '#2A2A3E' },
btnPrimary: { backgroundColor: '#0096FF' },
btnText: { color: '#FFFFFF', fontWeight: '600' },
});
export default SecurityLock;
+183
View File
@@ -0,0 +1,183 @@
/**
* Teilen-Dialog — was aus anderen Apps per "Teilen → ARIA" hereinkommt.
*
* Ablauf wie gewuenscht: erst sehen WAS geteilt wurde, dann WOHIN (Projekt
* oder Hauptchat), dann optional ein Text dazu. Ohne Text geht genauso.
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator, FlatList, Image, Modal, StyleSheet, Text,
TextInput, TouchableOpacity, View,
} from 'react-native';
import brainApi, { Project } from '../services/brainApi';
export interface SharedItem {
path: string;
name: string;
type: string;
size: number;
}
interface Props {
visible: boolean;
items: SharedItem[];
/** Text, den die sendende App mitgegeben hat (z.B. geteilter Link). */
initialText?: string;
onCancel: () => void;
/** projectId: '' = Hauptchat. text: optional, darf leer sein. */
onSend: (projectId: string, text: string) => void;
}
const fmtSize = (n: number) =>
n > 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB`
: n > 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`;
const ShareModal: React.FC<Props> = ({ visible, items, initialText, onCancel, onSend }) => {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(false);
const [pid, setPid] = useState<string | null>(null); // null = noch nichts gewaehlt
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
useEffect(() => {
if (!visible) return;
setPid(null);
setText(initialText || '');
setSending(false);
setLoading(true);
brainApi.listProjects(false)
.then(ps => setProjects(ps || []))
.catch(() => setProjects([]))
.finally(() => setLoading(false));
}, [visible, initialText]);
const send = useCallback(() => {
if (pid === null || sending) return;
setSending(true);
onSend(pid, text.trim());
}, [pid, text, sending, onSend]);
const preview = items.find(i => i.type?.startsWith('image/'));
return (
<Modal visible={visible} animationType="slide" transparent onRequestClose={onCancel}>
<View style={s.backdrop}>
<View style={s.box}>
<Text style={s.title}>Mit ARIA teilen</Text>
{/* Was wird geteilt */}
<View style={s.itemsBox}>
{preview ? (
<Image source={{ uri: 'file://' + preview.path }} style={s.thumb} resizeMode="cover" />
) : null}
<View style={{ flex: 1 }}>
{items.length === 0 ? (
<Text style={s.dim}>Nur Text</Text>
) : items.slice(0, 3).map((it, i) => (
<Text key={i} style={s.itemLine} numberOfLines={1}>
{it.name} <Text style={s.dim}>({fmtSize(it.size)})</Text>
</Text>
))}
{items.length > 3 ? (
<Text style={s.dim}>+ {items.length - 3} weitere</Text>
) : null}
</View>
</View>
{/* Wohin */}
<Text style={s.label}>Wohin soll das?</Text>
{loading ? (
<ActivityIndicator color="#FFD60A" style={{ marginVertical: 12 }} />
) : (
<FlatList
style={s.list}
data={[{ id: '', name: 'Hauptchat', description: '' } as Project, ...projects]}
keyExtractor={p => p.id || '__main__'}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => {
const sel = pid === item.id;
return (
<TouchableOpacity
style={[s.row, sel && s.rowSel]}
onPress={() => setPid(item.id)}>
<Text style={[s.rowText, sel && s.rowTextSel]} numberOfLines={1}>
{item.id ? '📁 ' : '💬 '}{item.name}
</Text>
{sel ? <Text style={s.check}>✓</Text> : null}
</TouchableOpacity>
);
}}
/>
)}
{/* Optionaler Text */}
<Text style={s.label}>Text dazu <Text style={s.dim}>(optional)</Text></Text>
<TextInput
style={s.input}
value={text}
onChangeText={setText}
placeholder="z.B. „Schau dir das mal an“ — kann auch leer bleiben"
placeholderTextColor="#666680"
multiline
/>
<View style={s.actions}>
<TouchableOpacity style={s.btnGhost} onPress={onCancel} disabled={sending}>
<Text style={s.btnGhostText}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity
style={[s.btn, (pid === null || sending) && s.btnDisabled]}
onPress={send}
disabled={pid === null || sending}>
<Text style={s.btnText}>
{sending ? 'Senden …' : pid === null ? 'Ziel wählen' : 'Senden'}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
const s = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.75)', justifyContent: 'flex-end' },
box: {
backgroundColor: '#0D0D1A', borderTopLeftRadius: 16, borderTopRightRadius: 16,
padding: 18, maxHeight: '88%',
},
title: { color: '#E0E0F0', fontSize: 19, fontWeight: '700', marginBottom: 12 },
itemsBox: {
flexDirection: 'row', gap: 10, alignItems: 'center',
backgroundColor: '#12121E', borderRadius: 10, padding: 10, marginBottom: 14,
},
thumb: { width: 56, height: 56, borderRadius: 8, backgroundColor: '#1E1E2E' },
itemLine: { color: '#E0E0F0', fontSize: 13 },
dim: { color: '#8888AA', fontSize: 12 },
label: { color: '#8888AA', fontSize: 12, marginBottom: 6 },
list: { maxHeight: 190, marginBottom: 14 },
row: {
flexDirection: 'row', alignItems: 'center', paddingVertical: 10,
paddingHorizontal: 12, borderRadius: 8, marginBottom: 4, backgroundColor: '#12121E',
},
rowSel: { backgroundColor: '#1E2A1E', borderWidth: 1, borderColor: '#3FFF9F' },
rowText: { color: '#E0E0F0', fontSize: 14, flex: 1 },
rowTextSel: { color: '#3FFF9F', fontWeight: '600' },
check: { color: '#3FFF9F', fontSize: 16 },
input: {
backgroundColor: '#12121E', borderRadius: 8, color: '#E0E0F0',
paddingHorizontal: 12, paddingVertical: 10, minHeight: 56,
textAlignVertical: 'top', marginBottom: 16, fontSize: 14,
},
actions: { flexDirection: 'row', gap: 10, justifyContent: 'flex-end' },
btnGhost: { paddingVertical: 11, paddingHorizontal: 18 },
btnGhostText: { color: '#8888AA', fontSize: 15 },
btn: {
backgroundColor: '#FFD60A', borderRadius: 8,
paddingVertical: 11, paddingHorizontal: 24,
},
btnDisabled: { backgroundColor: '#3A3A2A' },
btnText: { color: '#0D0D1A', fontSize: 15, fontWeight: '700' },
});
export default ShareModal;
+541 -79
View File
@@ -37,7 +37,7 @@ import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio';
import wakeWordService from '../services/wakeword';
import ProjectsBrowser from '../components/ProjectsBrowser';
import brainApi, { Project as BrainProject } from '../services/brainApi';
import brainApi, { Project as BrainProject, ContextModels, prettyModel } from '../services/brainApi';
import projectFocus from '../services/projectFocus';
import phoneCallService from '../services/phoneCall';
import { playWakeReadySound } from '../services/wakeReadySound';
@@ -49,6 +49,8 @@ import updateService from '../services/updater';
import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload';
import CameraUpload, { PhotoData } from '../components/CameraUpload';
import ShareModal, { SharedItem } from '../components/ShareModal';
import shareIntent from '../services/shareIntent';
import MessageText from '../components/MessageText';
import { loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio';
import Geolocation from '@react-native-community/geolocation';
@@ -85,6 +87,11 @@ interface ChatMessage {
/** Welcher Backend die Antwort erzeugt hat: 'local' | 'claude' | 'fast-path'.
* Fuer den optionalen Quell-Badge (Einstellung aria_show_source, default aus). */
answeredBy?: string;
/** Welches Claude-Modell geantwortet hat (aufgeloeste Version) und warum:
* 'einstellung' | 'pro-chat' | 'ausweich' (Sicherheitsfilter). Leer bei
* Fast-Path/lokal und bei aelteren Bubbles. */
model?: string;
modelReason?: string;
/** Bridge-Message-ID zur Zuordnung von TTS-Audio */
messageId?: string;
/** Lokaler Pfad zur gecachten TTS-Audio-Datei (file://...) */
@@ -185,7 +192,10 @@ function stripSystemHints(text: string): string {
if (!m) break;
out = out.slice(m[0].length);
}
return out;
// Bestand die Nachricht NUR aus Klammer-Bloecken (z.B. "[Tool-Loop-Limit ...]"
// oder "[Fehler: ...]"), waere sie jetzt leer → dann das ORIGINAL zeigen. Sonst
// haette der User eine leere Bubble. Fehler-/Meta-Meldungen sollen sichtbar sein.
return out.trim() ? out : text;
}
/** Sekunden → "M:SS" fuer die Sprachnachricht-Dauer. */
function formatDur(sec: number): string {
@@ -195,6 +205,35 @@ function formatDur(sec: number): string {
return `${m}:${r.toString().padStart(2, '0')}`;
}
// ─── Grenzen fuer den Anhang-Re-Download ────────────────────────────
//
// Ein Re-Download kommt als base64 in EINER WebSocket-Nachricht. Die muss die
// App komplett im Speicher puffern und dann durch JSON.parse schicken — bei
// einem 150-MB-Video sind das ~200 MB base64, und das Handy stirbt daran mit
// einem nativen OOM (kein JS-Fehler, darum tauchte nie ein Stacktrace auf).
//
// Genau das war die Absturzschleife: App startet → Auto-Re-Download fordert
// ungefragt das 150-MB-Video an → OOM → Android startet den Vordergrund-
// Dienst neu → App startet → von vorn. Alle ~70s, stundenlang.
//
// Darum zwei Grenzen:
// AUTO — was die App OHNE Nachfrage holt. Klein halten; Bilder und
// Dokumente passen rein, Videos nicht.
// TAP — was sie auf ausdruecklichen Tipp holt. Hoeher, aber immer noch
// weit unter der Schmerzgrenze. Drueber gibt's eine ehrliche
// Meldung statt eines Absturzes.
/** Signatur von sendPendingAttachments — fuer den Ref weiter unten. */
type SendAttachmentsFn = (
messageText: string,
explicitAttachments?: { file: any; isPhoto: boolean }[],
voiceMode?: { audioRequestId: string; clientMsgId: string },
) => Promise<void>;
const AUTO_DL_MAX_BYTES = 25 * 1024 * 1024;
const TAP_DL_MAX_BYTES = 64 * 1024 * 1024;
const fmtMB = (n: number) => `${(n / 1024 / 1024).toFixed(0)} MB`;
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
@@ -315,6 +354,11 @@ const ChatScreen: React.FC = () => {
// Wechsel des Focus stoppt NICHT ARIAs Arbeit in anderen Projekten —
// die laufen im Brain weiter, wir sehen sie hier nur nicht.
const [focusedProjectId, setFocusedProjectId] = useState<string>('');
// Modell pro Chat (Brain /context-model/list). Nur geladen, wenn die
// Quell-Anzeige an ist — Mama braucht weder Chip noch Netzlast dafuer.
const [ctxModels, setCtxModels] = useState<ContextModels | null>(null);
const [modelPickerVisible, setModelPickerVisible] = useState(false);
const refreshCtxModelsRef = useRef<() => void>(() => {});
// Lookup-Map id → Projekt (fuer Drawer + Referenzen)
const [projectNameById, setProjectNameById] = useState<Record<string, string>>({});
// Queue-Status pro Kontext — polled alle 2s, fuer Status-Dots im Drawer
@@ -345,6 +389,10 @@ const ChatScreen: React.FC = () => {
} | null>(null);
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
// Teilen-Ziel: Inhalte, die aus anderen Apps hereingereicht wurden.
const [shareItems, setShareItems] = useState<SharedItem[]>([]);
const [shareText, setShareText] = useState('');
const [shareVisible, setShareVisible] = useState(false);
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
// Multi-Threading: Activity pro Kontext (key = projectId, '' = Hauptchat).
// Der Indikator zeigt nur den fokussierten Kontext — nicht global.
@@ -357,9 +405,19 @@ const ChatScreen: React.FC = () => {
// folgende identische Events (z.B. zwei 'thinking' hintereinander) den
// Stream zumuellen. Eigentlich seltener Fall, aber billig zu pruefen.
const lastThoughtKeyRef = useRef<string>('');
// Service-Status (Gamebox: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
// Service-Status (AI-Box: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string, downloading?: boolean, freshlyDownloaded?: boolean}>>({});
const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false);
// Zwischenruf per Sprache: der 📣-Knopf ohne Text "spannt" die naechste
// Aufnahme als Zwischenruf. Mit Text schickt er sofort (wie bisher). Der Ref
// wird gelesen, wenn die Aufnahme startet — der State nur fuer die Optik.
const [interjectArmed, setInterjectArmed] = useState(false);
const interjectArmedRef = useRef(false);
// sendPendingAttachments ueber einen Ref: handleVoiceButtonStart steht weiter
// oben in der Datei und wuerde die Funktion sonst in ihrer Temporal Dead Zone
// greifen (TS2448 — siehe Kommentar an sendTextMessage). Umsortieren waere
// riskanter als dieser Ref, weil an der Reihenfolge mehrere Callbacks haengen.
const sendPendingAttachmentsRef = useRef<SendAttachmentsFn | null>(null);
// Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button)
const [ttsDeviceEnabled, setTtsDeviceEnabled] = useState(true);
const [ttsMuted, setTtsMuted] = useState(false);
@@ -384,6 +442,14 @@ const ChatScreen: React.FC = () => {
// stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest
// es. Default true (Konversation). false = Einzelaktion/Skill-Antwort.
const converseRef = useRef<boolean>(true);
// Passiv-Lausch-Fenster (Weiterreden nach ARIAs Antwort): Umgebungsgeraeusch
// (Musik/TV) darf das Fenster NICHT vorzeitig beenden. Bei einem no-speech-
// Endpoint (Silero verwirft Musik) wird — solange die Stille-Toleranz ab
// Fenster-Oeffnung noch laeuft — nochmal gelauscht statt sofort aufs Wake-Word
// zurueckzufallen. Start-Zeit + Re-Listen-Zaehler + Budget hier gemerkt.
const passiveListenStartRef = useRef<number>(0);
const passiveReListenCountRef = useRef<number>(0);
const passiveToleranceRef = useRef<number>(5000);
// Barge-in erlaubt? Default false = Halb-Duplex (waehrend TTS kein Mikro).
const bargeInEnabledRef = useRef<boolean>(false);
@@ -408,6 +474,33 @@ const ChatScreen: React.FC = () => {
// Intent geoeffnet (PDF-Viewer, Galerie, etc.).
const autoOpenPaths = useRef<Set<string>>(new Set());
/**
* Re-Download anfordern — mit Groessen-Bremse.
* Gibt false zurueck (und sagt warum), wenn die Datei zu gross fuer einen
* Transfer durch die WebSocket ist. Ohne das holt sich die App ein 150-MB-
* Video als ~200 MB base64 und stirbt am nativen OOM (siehe TAP_DL_MAX_BYTES).
*/
const requestAttachment = useCallback((att: Attachment, reqId: string): boolean => {
if (!att.serverPath) {
if (Platform.OS === 'android') {
ToastAndroid.show('Datei kann nicht nachgeladen werden (kein serverPath)', ToastAndroid.LONG);
}
return false;
}
if (typeof att.size === 'number' && att.size > TAP_DL_MAX_BYTES) {
if (Platform.OS === 'android') {
ToastAndroid.show(
`${att.name}: ${fmtMB(att.size)} — zu gross fuers Handy. `
+ `Liegt auf dem Server unter ${att.serverPath}`,
ToastAndroid.LONG,
);
}
return false;
}
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: reqId });
return true;
}, []);
// Eindeutige Message-ID generieren
const nextId = (): string => {
messageIdCounter.current += 1;
@@ -677,6 +770,17 @@ const ChatScreen: React.FC = () => {
return () => clearInterval(interval);
}, []);
// Modell pro Chat laden — bei eingeschalteter Quell-Anzeige und bei jedem
// Kontextwechsel. Kein Polling: ein Ausweich-Vermerk aendert sich nur durch
// einen Turn, und nach jedem Turn wird ohnehin neu geladen (s. chat-Handler).
useEffect(() => {
refreshCtxModelsRef.current = () => {
if (!showSource) return;
brainApi.getContextModels().then(setCtxModels).catch(() => {});
};
refreshCtxModelsRef.current();
}, [showSource, focusedProjectId]);
// Wake Word: einmalig laden + Porcupine vorbereiten (wenn Access Key gesetzt)
useEffect(() => {
wakeWordService.loadFromStorage().catch(() => {});
@@ -843,13 +947,26 @@ const ChatScreen: React.FC = () => {
setTimeout(() => {
setMessages(prev => {
const missing: {id: string, serverPath: string}[] = [];
const skipped: string[] = [];
for (const msg of prev) {
for (const att of msg.attachments || []) {
if (att.serverPath && !att.uri) {
missing.push({ id: msg.id, serverPath: att.serverPath });
if (!att.serverPath || att.uri) continue;
// Grosse Dateien NICHT ungefragt holen (siehe AUTO_DL_MAX_BYTES).
// Sie bleiben als "tippen zum Laden" stehen — der User
// entscheidet, ob er sie wirklich aufs Handy zieht.
if (typeof att.size === 'number' && att.size > AUTO_DL_MAX_BYTES) {
skipped.push(`${att.name} (${fmtMB(att.size)})`);
continue;
}
missing.push({ id: msg.id, serverPath: att.serverPath });
}
}
if (skipped.length > 0) {
console.log('[Chat] Auto-Download uebersprungen (zu gross): %s', skipped.join(', '));
import('../services/logger')
.then(m => m.reportAppDebug('chat.autodl', `uebersprungen: ${skipped.join(', ')}`))
.catch(() => {});
}
if (missing.length > 0) {
console.log(`[Chat] ${missing.length} fehlende Anhaenge — lade nach...`);
for (const m of missing) {
@@ -949,6 +1066,8 @@ const ChatScreen: React.FC = () => {
backupTs: typeof m.ts === 'number' ? m.ts : undefined,
projectId: typeof m.project_id === 'string' ? m.project_id : '',
answeredBy: typeof m.answeredBy === 'string' ? m.answeredBy : '',
model: typeof m.model === 'string' ? m.model : '',
modelReason: typeof m.modelReason === 'string' ? m.modelReason : '',
...(cmid && { clientMsgId: cmid }),
// Server-Bubble = vom Brain verarbeitet → 'delivered' (✓✓)
...(role === 'user' && cmid && { deliveryStatus: 'delivered' as const }),
@@ -1245,7 +1364,11 @@ const ChatScreen: React.FC = () => {
return;
}
setMessages(prev => {
const newText = `\uD83C\uDFA4 ${sttText}`;
// Zwischenruf per Sprache bekommt das Megafon statt des Mikros \u2014
// sonst sieht die Bubble wie eine normale Frage aus, obwohl sie in
// den laufenden Turn gegangen ist und keine eigene Antwort bekommt.
const isInterject = !!(message.payload as any).interject;
const newText = isInterject ? `\uD83D\uDCE3 Zwischenruf: ${sttText}` : `\uD83C\uDFA4 ${sttText}`;
// Primaer: matche per audioRequestId (eindeutig pro Aufnahme).
// So gibt's keine Verwechslung wenn zwei Audios kurz hintereinander
// gesendet wurden und ihre STT-Results ueberlappen.
@@ -1260,7 +1383,9 @@ const ChatScreen: React.FC = () => {
// Fallback: alte Bridge-Version ohne audioRequestId \u2014 match per Substring,
// nimmt die ERSTE noch unaufgeloeste Placeholder.
const idx = prev.findIndex(m =>
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
m.sender === 'user'
&& (m.text.includes('Spracheingabe wird verarbeitet')
|| m.text.includes('Zwischenruf wird verarbeitet'))
);
if (idx >= 0) {
const next = prev.slice();
@@ -1292,6 +1417,10 @@ const ChatScreen: React.FC = () => {
sender: 'user',
text: diagText,
timestamp: message.timestamp,
// projectId MUSS mit: die Chat-Ansicht filtert danach. Ohne das
// landete die Nachricht im Hauptchat-Topf und war in jedem Projekt
// unsichtbar — man sah nur ARIAs Antwort, nicht die eigene Frage.
projectId: (message.payload.projectId as string) || '',
}]));
}
return;
@@ -1333,7 +1462,12 @@ const ChatScreen: React.FC = () => {
backupTs: (message.payload.backupTs as number) || undefined,
projectId: ((message.payload as any).projectId as string) || '',
answeredBy: ((message.payload as any).answeredBy as string) || '',
model: ((message.payload as any).model as string) || '',
modelReason: ((message.payload as any).modelReason as string) || '',
};
// Modell-Chip oben nachziehen — ein Turn kann aufs Ausweich-Modell
// gewechselt sein, das soll man sofort sehen.
if ((message.payload as any).model) refreshCtxModelsRef.current();
// Die separate file_from_aria-Bubble (leerer Text, nur Datei), die kurz
// zuvor fuer DIESE Datei ankam, entfernen — sonst doppelt (einmal solo,
// einmal an der Text-Bubble). Nur solche mit passendem serverPath.
@@ -1406,8 +1540,39 @@ const ChatScreen: React.FC = () => {
// ner Bestaetigung) landete im 30s-Fenster. Jetzt: einzelne Befehle enden
// sofort (zurueck aufs Wake-Word), nur echte Gespraeche lauschen weiter.
converseRef.current = (message.payload as any).converse === true;
const _wakeOff = (message.payload as any).wake_off === true;
const _wakeOn = (message.payload as any).wake_on === true;
const _isSilent = (message.payload as any).speak === false;
if (_isSilent) {
if (_wakeOn) {
// "Wake-Word an" per Text/Aufnahme-Button → Listener wieder starten
// (gleicher Weg wie toggleWakeWord-on). Geht auch wenn das Ohr taub war,
// weil der Befehl NICHT ueber "Computer" kam.
(async () => {
try {
const started = await wakeWordService.start();
setWakeWordActive(started);
console.log('[Chat] Wake-Word per Befehl AN gestartet:', started);
} catch (e) {
console.warn('[Chat] Wake-Word AN fehlgeschlagen:', e);
}
})();
} else if (_wakeOff) {
// ARIA hat "Wake-Word aus" per Sprache bekommen → Listener KOMPLETT
// stoppen (Mikro frei, echte Ruhe). Gleicher Weg wie der Ohr-Button
// (toggleWakeWord-off). Wieder-An nur ueber den Button (dann taub).
(async () => {
try {
if (audioService.isStreamingRecording()) {
await audioService.cancelStreamingRecording('wake-off-voice');
} else {
await audioService.stopRecording();
}
} catch {}
try { await wakeWordService.stop(); } catch {}
setWakeWordActive(false);
console.log('[Chat] Wake-Word per Sprachbefehl AUS — Ohr-Button zum Wieder-Anmachen');
})();
} else if (_isSilent) {
// Steuerbefehl (speak=false) ist ausgefuehrt und wird NICHT vorgelesen.
// Ohne TTS feuert onPlaybackFinished nie — der Mikro-/Konversations-
// Lifecycle muss hier selbst weitergeschaltet werden, sonst haengt das Ohr.
@@ -1552,7 +1717,7 @@ const ChatScreen: React.FC = () => {
}
}
// Gamebox-Bridges (f5tts/whisper/flux) melden Lade-Status — Banner oben.
// AI-Box-Bridges (f5tts/whisper/flux) melden Lade-Status — Banner oben.
// Toast bei Download-Ende: erstmaliger HF-Download (mehrere GB) → User
// soll wissen dass er Bilder/Stimmen jetzt nutzen kann ohne in den
// Banner gucken zu muessen.
@@ -1659,8 +1824,12 @@ const ChatScreen: React.FC = () => {
// Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive).
// converse=false (Skill-/Einzelantwort, z.B. 'was laeuft gerade') → auch
// ohne 30s: vorlesen + direkt zurueck aufs Wake-Word.
// Im Hintergrund normalerweise direkt re-armen (kein Multi-Turn) — ABER
// wenn Hintergrund-Wake bewusst AN ist, will der User auch im Hintergrund
// ein Gespraech fuehren, also den Konversationsmodus offen halten.
const bg = AppState.currentState !== 'active';
wakeWordService.endConversation(bg || !converseRef.current).catch(() => {});
const bgForcesArmed = bg && !wakeWordService.isBgWakeEnabled();
wakeWordService.endConversation(bgForcesArmed || !converseRef.current).catch(() => {});
});
return () => unsubPlayback();
}, []);
@@ -1756,16 +1925,39 @@ const ChatScreen: React.FC = () => {
// dem STT-Text gefuellte Bubble NICHT loeschen, sonst verschwindet die
// Sprachnachricht obwohl ARIA schon an der Antwort arbeitet.
if (ev.audioRequestId) {
setMessages(prev => prev.filter(m =>
!(m.audioRequestId === ev.audioRequestId
&& m.text.includes('Spracheingabe wird verarbeitet'))));
// 'no-response' heisst: die Spracherkennung hat GAR NICHT geantwortet
// (Worker neu gestartet / nicht erreichbar) — die Aufnahme ist also
// wirklich verloren gegangen, Du hast nicht etwa nichts gesagt. Das
// zeigen wir an, statt die Bubble stillschweigend zu entfernen; sonst
// wirkt es, als waere die Nachricht nie gesendet worden.
const lost = String(ev.reason || '').includes('no-response');
setMessages(prev => lost
? prev.map(m => (m.audioRequestId === ev.audioRequestId
&& m.text.includes('Spracheingabe wird verarbeitet'))
? { ...m, text: '⚠️ Aufnahme ging verloren — die Spracherkennung war gerade nicht erreichbar. Bitte nochmal aufnehmen.' }
: m)
: prev.filter(m =>
!(m.audioRequestId === ev.audioRequestId
&& m.text.includes('Spracheingabe wird verarbeitet'))));
}
// Kein Re-Arm mehr: nach ARIAs Antwort gab es EIN Stille-Fenster (=
// Stille-Toleranz). Kam nichts, ist Schluss → zurück aufs Wake-Word.
// Kein 30s-Nachlauschen. (speaker_mismatch/no-speech landen beide hier.)
// Passiv-Lauschen: leeres Endpoint (no-speech / Silero-Musik / speaker_
// mismatch). NICHT sofort beenden — solange die Stille-Toleranz ab
// Fenster-Oeffnung noch laeuft, nochmal lauschen. So killt Umgebungs-
// musik das Weiterreden nicht: die Musik wird verworfen, das Fenster
// bleibt bis zur Toleranz offen, du kannst innerhalb reden. Erst wenn
// die Toleranz wirklich um ist (oder zu viele Runden) → aufs Wake-Word.
if (wakeWordService.getState() === 'listening') {
console.log('[Chat] Passive-Listen: leeres Endpoint — Ende, zurueck aufs Wake-Word');
wakeWordService.exitPassiveListening('timeout').catch(() => {});
const elapsed = Date.now() - passiveListenStartRef.current;
const budget = passiveToleranceRef.current || 5000;
if (elapsed < budget && passiveReListenCountRef.current < 15) {
passiveReListenCountRef.current += 1;
console.log('[Chat] Passive-Listen: leeres Endpoint (%s) — Umgebung, re-listen (%dms/%dms, #%d)',
ev.reason, elapsed, budget, passiveReListenCountRef.current);
startPassiveStreamingRecording();
} else {
console.log('[Chat] Passive-Listen: Stille-Toleranz aufgebraucht (%dms) — Ende, zurueck aufs Wake-Word', elapsed);
wakeWordService.exitPassiveListening('timeout').catch(() => {});
}
} else {
wakeWordService.endConversation();
if (!wakeWordService.isActive()) setWakeWordActive(false);
@@ -1777,8 +1969,14 @@ const ChatScreen: React.FC = () => {
// geschaltet (nach endConversation). Wir starten eine streaming-Aufnahme
// OHNE User-Bubble + ohne wake-ready-Sound. Speaker-ID-Gating in der
// Whisper-Bridge filtert fremde Stimmen weg.
const unsubPassive = wakeWordService.onPassiveListen(() => {
const unsubPassive = wakeWordService.onPassiveListen(async () => {
console.log('[Chat] Passive-Listen aktiviert — starte stille Streaming-Aufnahme');
// Fenster NEU geoeffnet (nach ARIAs Antwort): Budget-Uhr + Re-Listen-Zaehler
// zuruecksetzen. Re-Listen ruft startPassiveStreamingRecording direkt (nicht
// ueber diesen Callback), also bleibt der Startzeitpunkt erhalten.
passiveListenStartRef.current = Date.now();
passiveReListenCountRef.current = 0;
passiveToleranceRef.current = await loadSttEndpointMs();
startPassiveStreamingRecording();
});
@@ -2228,7 +2426,7 @@ const ChatScreen: React.FC = () => {
// Aufraeumen von "verarbeitet"-Placeholder die nie ein STT-Result bekommen
// haben (leere Aufnahme, Wake-Word-Echo, STT-Fehler etc). Timeout skaliert
// mit der Aufnahmedauer — Whisper braucht auf der Gamebox grob real-time/5,
// mit der Aufnahmedauer — Whisper braucht auf der AI-Box grob real-time/5,
// plus Bridge-Roundtrip + Network. Formel: 60s Buffer + 1x Aufnahmedauer.
// Bei 5min Aufnahme = 6 min Wait, bei 5s Aufnahme = 65s. Sicher genug damit
// langsame STTs nicht versehentlich aufgeraeumt werden.
@@ -2266,11 +2464,18 @@ const ChatScreen: React.FC = () => {
// 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(() => {
// forInterject: bei einem Zwischenruf darf der laufende Turn NICHT abgebrochen
// werden — das ist der ganze Sinn. Die Wiedergabe stoppen wir trotzdem, sonst
// redet ARIA in das offene Mikro hinein.
const interruptAriaIfBusy = useCallback((forInterject = false) => {
if (audioService.isPlayingAudio()) {
audioService.haltAllPlayback('user startet Aufnahme — Interrupt');
rvs.send('cancel_request' as any, { hard: true, source: 'voice-interrupt' });
return true;
audioService.haltAllPlayback(forInterject
? 'user ruft dazwischen — nur Wiedergabe stoppen'
: 'user startet Aufnahme — Interrupt');
if (!forInterject) {
rvs.send('cancel_request' as any, { hard: true, source: 'voice-interrupt' });
}
return !forInterject;
}
return false;
}, []);
@@ -2283,18 +2488,35 @@ const ChatScreen: React.FC = () => {
const handleVoiceButtonStart = useCallback(async (): Promise<boolean> => {
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
rememberMyRequest(audioRequestId);
const wasInterrupted = interruptAriaIfBusy();
const asInterject = interjectArmedRef.current;
const wasInterrupted = interruptAriaIfBusy(asInterject);
const location = await getCurrentLocation();
const userMsg: ChatMessage = {
id: nextId(),
sender: 'user',
text: '🎙 Spracheingabe wird verarbeitet...',
timestamp: Date.now(),
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
audioRequestId,
};
setMessages(prev => capMessages([...prev, userMsg]));
// Warten Anhaenge? Dann sind sie das Thema dieser Aufnahme: sofort hochladen
// (laeuft parallel zum Reden) und der Bridge per clientMsgId sagen, dass der
// Satz dazu noch kommt. Die Bubble mit den Bildern macht
// sendPendingAttachments — sie traegt die audioRequestId und bekommt den
// gesprochenen Text spaeter eingesetzt. Also hier KEINE zweite Bubble.
let attachmentCmid = '';
if (pendingAttachments.length > 0 && !asInterject && sendPendingAttachmentsRef.current) {
attachmentCmid = nextClientMsgId();
const forVoice = pendingAttachments;
setPendingAttachments([]);
await sendPendingAttachmentsRef.current('', forVoice, {
audioRequestId, clientMsgId: attachmentCmid,
});
} else {
const userMsg: ChatMessage = {
id: nextId(),
sender: 'user',
text: asInterject ? '📣 Zwischenruf wird verarbeitet...'
: '🎙 Spracheingabe wird verarbeitet...',
timestamp: Date.now(),
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
audioRequestId,
};
setMessages(prev => capMessages([...prev, userMsg]));
}
const { ok } = await audioService.startStreamingRecording({
audioRequestId,
@@ -2309,15 +2531,37 @@ const ChatScreen: React.FC = () => {
endpointMs: await loadSttEndpointMs(),
hardCapMs: await loadMaxRecordingMs(),
projectId: focusedProjectIdRef.current,
...(attachmentCmid && { clientMsgId: attachmentCmid }),
...(asInterject && { interject: true }),
});
if (!ok) {
if (attachmentCmid) {
// Die Anhaenge sind SCHON hochgeladen — die Bubble darf nicht weg,
// sonst verschwinden die Bilder aus der Ansicht, obwohl sie auf dem
// Server liegen. Stattdessen ehrlich kennzeichnen; der Nutzer kann den
// Auftrag einfach tippen.
setMessages(prev => prev.map(m => m.audioRequestId === audioRequestId
? { ...m, text: '📎 Anhang gesendet — Mikro war belegt, bitte tippen',
audioRequestId: undefined }
: m));
if (Platform.OS === 'android') {
ToastAndroid.show('Mikro nicht verfügbar — Anhang ist aber da', ToastAndroid.LONG);
}
return false;
}
// Mikro nicht verfuegbar (Anruf? OpenWakeWord blockiert?) — Bubble weg.
setMessages(prev => prev.filter(m => m.audioRequestId !== audioRequestId));
return false;
}
// Zwischenruf-Scharfschaltung gilt genau fuer EINE Aufnahme.
if (asInterject) {
interjectArmedRef.current = false;
setInterjectArmed(false);
}
scheduleStaleAudioCleanup(audioRequestId, 60000);
return true;
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup,
pendingAttachments]);
// ARIA schliesst die Aufnahme SELBST — das programmatische Gegenstueck zum
// Stop-Button. Aufgerufen nach einem stillen Steuerbefehl (speak=false): der
@@ -2392,16 +2636,52 @@ const ChatScreen: React.FC = () => {
setPendingAttachments(prev => [...prev, { file: photo, isPhoto: true }]);
}, []);
// ── Teilen-Ziel ────────────────────────────────────────────────
// Beim Start pruefen (App war zu) und auf spaetere Shares lauschen
// (App laeuft schon → onNewIntent im MainActivity).
useEffect(() => {
let alive = true;
const open = (p: { text: string; items: SharedItem[] }) => {
if (!alive) return;
if (!p.items.length && !p.text) return; // nichts dabei
setShareItems(p.items);
setShareText(p.text);
setShareVisible(true);
};
shareIntent.getShared().then(open).catch(() => {});
const unsub = shareIntent.onShare(open);
// Zweitweg: Teilen holt die App in den Vordergrund. Beim Wechsel nach
// 'active' nochmal nachfragen — das greift auch, wenn das native Event
// (je nach RN-Architektur) nicht durchkommt. getSharedData() markiert den
// Intent als verbraucht, es kann also nicht doppelt aufpoppen.
const appSub = AppState.addEventListener('change', st => {
if (st === 'active') shareIntent.getShared().then(open).catch(() => {});
});
return () => { alive = false; unsub(); try { appSub.remove(); } catch (_) {} };
}, []);
// Alle Pending Anhaenge + Text senden
const sendPendingAttachments = useCallback(async (messageText: string) => {
if (pendingAttachments.length === 0) return;
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', pendingAttachments.length);
const sendPendingAttachments = useCallback(async (
messageText: string,
// Explizite Liste (Teilen-Weg): der State waere beim direkten Aufruf noch
// nicht aktualisiert. Ohne Angabe wie bisher die Pending-Liste.
explicitAttachments?: { file: any; isPhoto: boolean }[],
// Sprach-Modus: der Nutzer spricht die Beschreibung gerade erst ein. Dann
// gehen die Dateien SOFORT raus (der Upload laeuft parallel zum Reden),
// aber ohne Text — den liefert spaeter der stt_endpoint, und die Bridge
// merged beides ueber die clientMsgId zu einem Turn.
voiceMode?: { audioRequestId: string; clientMsgId: string },
) => {
const atts = explicitAttachments ?? pendingAttachments;
if (atts.length === 0) return;
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', atts.length);
const location = await getCurrentLocation();
const msgId = nextId();
// Alle Attachments fuer die Chat-Nachricht sammeln
const attachments: Attachment[] = [];
for (const { file, isPhoto } of pendingAttachments) {
for (const { file, isPhoto } of atts) {
const isImage = isPhoto || (file.type && file.type.startsWith('image/'));
const name = isPhoto ? file.fileName : file.name;
const base64 = file.base64 || '';
@@ -2419,16 +2699,23 @@ const ChatScreen: React.FC = () => {
// Chat-Nachricht mit allen Anhaengen. clientMsgId nur wenn Text dabei
// ist — files selber haben (noch) kein ACK-Tracking auf der Bridge.
const cmid = messageText ? nextClientMsgId() : undefined;
// Im Sprach-Modus kommt sie von aussen: dieselbe ID muss am
// stt_stream_start haengen, damit die Bridge Anhang und Satz zusammenfuehrt.
const cmid = voiceMode ? voiceMode.clientMsgId : (messageText ? nextClientMsgId() : undefined);
const activePid = focusedProjectIdRef.current;
const userMsg: ChatMessage = {
id: msgId,
sender: 'user',
text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
// Im Sprach-Modus bekommt DIESE Bubble gleich den gesprochenen Text —
// via audioRequestId-Match, genau wie eine normale Sprachnachricht. So
// stehen Bild und Satz zusammen in einer Bubble statt in zwei.
text: voiceMode ? '🎙 Spracheingabe wird verarbeitet...'
: (messageText || `${atts.length} Anhang/Anhaenge`),
timestamp: Date.now(),
attachments,
projectId: activePid,
...(cmid && {
...(voiceMode && { audioRequestId: voiceMode.audioRequestId }),
...(cmid && !voiceMode && {
clientMsgId: cmid,
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
sendAttempts: 1,
@@ -2437,7 +2724,7 @@ const ChatScreen: React.FC = () => {
setMessages(prev => capMessages([...prev, userMsg]));
// Alle Dateien an RVS senden + auf Disk speichern
for (const { file, isPhoto } of pendingAttachments) {
for (const { file, isPhoto } of atts) {
const name = isPhoto ? file.fileName : file.name;
const base64 = file.base64 || '';
const mimeType = file.type || '';
@@ -2466,6 +2753,9 @@ const ChatScreen: React.FC = () => {
// die Datei genau DIESER Nachricht zuordnet — auch wenn Files (fire-
// and-forget) und Text (ACK-getrackt, bei Queue verzoegert) desyncen.
...(cmid && { clientMsgId: cmid }),
// awaitVoice: die Bridge soll den Anhang NICHT nach 0.8s alleine ans
// Brain schicken, sondern auf die Sprachnachricht warten.
...(voiceMode && { awaitVoice: true }),
...(isPhoto && file.width && { width: file.width, height: file.height }),
...(location && { location }),
});
@@ -2473,8 +2763,8 @@ const ChatScreen: React.FC = () => {
// Text als separate Nachricht (damit ARIA weiss was zu tun ist) — mit
// dem clientMsgId der Bubble, damit Bridge+ACK die richtige Bubble
// adressieren.
if (messageText && cmid) {
// adressieren. Im Sprach-Modus gibt es hier noch keinen Text.
if (messageText && cmid && !voiceMode) {
dispatchWithAck(cmid, 'chat', {
text: messageText,
voice: localXttsVoiceRef.current,
@@ -2487,11 +2777,41 @@ const ChatScreen: React.FC = () => {
setPendingAttachments([]);
setInputText('');
}, [pendingAttachments, getCurrentLocation, dispatchWithAck]);
// Ref aktuell halten — handleVoiceButtonStart greift die Funktion von hier
// (steht weiter oben in der Datei, siehe sendPendingAttachmentsRef).
sendPendingAttachmentsRef.current = sendPendingAttachments;
// Reine Text-Nachricht senden. Steht bewusst NACH interruptAriaIfBusy und
// sendPendingAttachments — beide stehen in seinem deps-Array, und wenn es
// davor deklariert waere, laendeten sie dort in der Temporal Dead Zone
// (tsc TS2448/2454; lief nur zufaellig, weil Babel const→var hebt).
/** Geteiltes senden: Ziel setzen, Dateien anhaengen, abschicken. */
const handleShareSend = useCallback(async (projectId: string, text: string) => {
try {
// Chat-Focus aufs gewaehlte Ziel — die Nachricht landet dort, und Stefan
// steht nach dem Senden gleich im richtigen Kontext.
setFocusedProjectId(projectId);
focusedProjectIdRef.current = projectId;
const files = await shareIntent.toFileData(shareItems);
if (files.length) {
// Liste explizit uebergeben: der State waere hier noch leer.
await sendPendingAttachments(text, files.map(f => ({ file: f, isPhoto: false })));
} else if (text) {
// Reiner Text (z.B. geteilter Link): ins Eingabefeld, Stefan schickt ab.
setInputText(text);
}
shareIntent.clearShareCache();
} catch (e) {
console.warn('[Share] Senden fehlgeschlagen:', e);
Alert.alert('Teilen fehlgeschlagen', String((e as any)?.message || e));
} finally {
setShareVisible(false);
setShareItems([]);
setShareText('');
}
}, [shareItems, sendPendingAttachments]);
const sendTextMessage = useCallback(async () => {
const text = inputText.trim();
@@ -2726,16 +3046,15 @@ const ChatScreen: React.FC = () => {
) : att.type === 'image' && !att.uri ? (
<TouchableOpacity
style={styles.attachmentFile}
onPress={() => {
if (att.serverPath) {
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
}
}}
onPress={() => { requestAttachment(att, item.id); }}
>
<Text style={styles.attachmentFileIcon}>{'\uD83D\uDDBC\uFE0F'}</Text>
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
<Text style={styles.attachmentFileSize}>
{att.serverPath ? '(tippen zum Laden)' : '(nicht verfuegbar)'}
{!att.serverPath ? '(nicht verfuegbar)'
: typeof att.size === 'number' && att.size > AUTO_DL_MAX_BYTES
? `(${fmtMB(att.size)} — tippen zum Laden)`
: '(tippen zum Laden)'}
</Text>
</TouchableOpacity>
) : att.type === 'audio' ? (
@@ -2772,11 +3091,9 @@ const ChatScreen: React.FC = () => {
}
// Re-Download via file_request \u2192 bei file_response wird die
// Datei gespeichert UND geoeffnet (autoOpenPaths-Tracking).
if (att.serverPath) {
autoOpenPaths.current.add(att.serverPath);
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
} else if (Platform.OS === 'android') {
ToastAndroid.show('Datei kann nicht nachgeladen werden (kein serverPath)', ToastAndroid.LONG);
if (att.serverPath) autoOpenPaths.current.add(att.serverPath);
if (!requestAttachment(att, item.id) && att.serverPath) {
autoOpenPaths.current.delete(att.serverPath);
}
}}
>
@@ -2848,7 +3165,15 @@ const ChatScreen: React.FC = () => {
claude: { t: 'Claude', c: '#0096FF' },
'fast-path': { t: '⚡ fast', c: '#AF7BFF' },
};
const b = map[ab] || { t: ab, c: '#8A8AA0' };
let b = map[ab] || { t: ab, c: '#8A8AA0' };
// Bei Claude das echte Modell — aeltere Bubbles ohne Modell
// bleiben bei "Claude".
if (ab === 'claude' && item.model) {
const n = prettyModel(item.model, ctxModels?.names);
b = item.modelReason === 'ausweich'
? { t: '↪ ' + n, c: '#FF9F0A' }
: { t: n, c: '#0096FF' };
}
return <Text style={{ fontSize: 9, fontWeight: 'bold', color: b.c, marginLeft: 6 }}>{b.t}</Text>;
})() : null}
{item.text.length > 0 ? (
@@ -2999,7 +3324,7 @@ const ChatScreen: React.FC = () => {
</TouchableOpacity>
</View>
{/* Service-Status Banner (Gamebox: F5-TTS / Whisper Lade-Status) */}
{/* Service-Status Banner (AI-Box: F5-TTS / Whisper Lade-Status) */}
{(() => {
const entries = Object.entries(serviceStatus);
if (entries.length === 0 || serviceBannerDismissed) return null;
@@ -3094,6 +3419,27 @@ const ChatScreen: React.FC = () => {
</Text>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
<Text style={{ fontSize: 10, color: '#8888AA' }}>{dot.label}</Text>
{/* Modell dieses Chats — nur mit Quell-Anzeige (Einstellungen),
sonst bleibt die Kopfzeile so schlicht wie bisher. Orange mit
↪, wenn der Chat gerade aufs Ausweich-Modell ausweicht. */}
{showSource && (() => {
const row = ctxModels?.rows.find(r => r.context_id === (focusedProjectId || ''));
if (!row) return null;
const fb = row.reason === 'ausweich';
const color = fb ? '#FF9F0A' : row.reason === 'pro-chat' ? '#0096FF' : '#8888AA';
return (
<TouchableOpacity
onPress={() => setModelPickerVisible(true)}
hitSlop={{top:8,bottom:8,left:8,right:8}}
style={{ paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10,
borderWidth: 1, borderColor: color + '88', backgroundColor: color + '22' }}
>
<Text style={{ color, fontSize: 10, fontWeight: '700' }} numberOfLines={1}>
{(fb ? '↪ ' : '🧠 ') + prettyModel(row.effective, ctxModels?.names)}
</Text>
</TouchableOpacity>
);
})()}
{/* Direkter Zurueck-zum-Hauptchat-Button — nur wenn man in einem
Projekt ist. Ein Tap statt Drawer→Hauptchat. */}
{!isMain && (
@@ -3114,6 +3460,83 @@ const ChatScreen: React.FC = () => {
);
})()}
{/* Modell-Auswahl fuer den fokussierten Chat */}
<Modal visible={modelPickerVisible} transparent animationType="fade"
onRequestClose={() => setModelPickerVisible(false)}>
<Pressable style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', padding: 24 }}
onPress={() => setModelPickerVisible(false)}>
<Pressable style={{ backgroundColor: '#15151E', borderRadius: 12, padding: 16, maxHeight: '80%' }}
onPress={() => {}}>
{(() => {
const cid = focusedProjectId || '';
const row = ctxModels?.rows.find(r => r.context_id === cid);
const names = ctxModels?.names;
const pick = (model: string) => {
brainApi.setContextModel(cid, model)
.then(d => { setCtxModels(d); setModelPickerVisible(false); })
.catch(e => Alert.alert('Modell', 'Setzen fehlgeschlagen: ' + (e?.message || e)));
};
const Item = ({ id, label, active }: { id: string; label: string; active: boolean }) => (
<TouchableOpacity onPress={() => pick(id)}
style={{ paddingVertical: 10, paddingHorizontal: 10, borderRadius: 8,
backgroundColor: active ? 'rgba(0,150,255,0.18)' : 'transparent' }}>
<Text style={{ color: active ? '#0096FF' : '#E0E0F0', fontSize: 14, fontWeight: active ? '700' : '400' }}>
{(active ? '● ' : '○ ') + label}
</Text>
</TouchableOpacity>
);
const sections: [string, string][] = [
['tier', 'Automatisch (neueste Version)'],
['main', 'Aktuelle Versionen'],
['overflow', 'Ältere Versionen'],
];
return (
<>
<Text style={{ color: '#E0E0F0', fontSize: 16, fontWeight: '700' }}>
Modell für {cid ? (projectNameById[cid] || cid) : 'den Hauptchat'}
</Text>
<Text style={{ color: '#8888AA', fontSize: 12, marginTop: 4, marginBottom: 10 }}>
{row ? 'Läuft gerade auf ' + prettyModel(row.effective, names)
+ (row.reason === 'ausweich' ? ' (Ausweich — Sicherheitsfilter)'
: row.reason === 'pro-chat' ? ' (für diesen Chat gewählt)' : ' (aus der Einstellung)')
: 'Lade …'}
</Text>
{row?.reason === 'ausweich' && (
<TouchableOpacity
onPress={() => brainApi.contextModelBack(cid)
.then(d => { setCtxModels(d); setModelPickerVisible(false); })
.catch(e => Alert.alert('Modell', 'Zurück fehlgeschlagen: ' + (e?.message || e)))}
style={{ backgroundColor: 'rgba(255,159,10,0.18)', borderColor: '#FF9F0A', borderWidth: 1,
borderRadius: 8, padding: 10, marginBottom: 10 }}>
<Text style={{ color: '#FF9F0A', fontSize: 13, fontWeight: '700' }}>
↩ Zurück zu {prettyModel(row.selected, names)}
</Text>
<Text style={{ color: '#B0905A', fontSize: 11, marginTop: 2 }}>
Blockt der Filter erneut, weicht ARIA eben wieder aus.
</Text>
</TouchableOpacity>
)}
<ScrollView>
<Item id="" label={'Einstellung (' + prettyModel(ctxModels?.default || '', names) + ')'}
active={!row?.override} />
{sections.map(([sec, label]) => {
const list = (ctxModels?.choices || []).filter(c => (c.section || 'tier') === sec);
if (!list.length) return null;
return (
<View key={sec} style={{ marginTop: 8 }}>
<Text style={{ color: '#555570', fontSize: 11, marginBottom: 2 }}>{label}</Text>
{list.map(c => <Item key={c.id} id={c.id} label={c.name} active={row?.override === c.id} />)}
</View>
);
})}
</ScrollView>
</>
);
})()}
</Pressable>
</Pressable>
</Modal>
{/* Projekt-Drawer als Modal */}
<ProjectsBrowser
visible={projectsVisible}
@@ -3327,7 +3750,9 @@ const ChatScreen: React.FC = () => {
style={styles.textInput}
value={inputText}
onChangeText={setInputText}
placeholder={pendingAttachments.length > 0 ? "Text zu den Anhaengen (optional)..." : "Nachricht an ARIA..."}
placeholder={pendingAttachments.length > 0
? "Text zu den Anhaengen — oder einsprechen 🎤"
: "Nachricht an ARIA..."}
placeholderTextColor="#555570"
multiline
maxLength={4000}
@@ -3335,29 +3760,52 @@ const ChatScreen: React.FC = () => {
returnKeyType="send"
/>
{/* Senden oder Sprache */}
{/* Rechte Knopfreihe.
Das Mikro ist jetzt IMMER da \u2014 auch mit Anhang oder getipptem Text.
Vorher war es ein Entweder-oder: sobald etwas im Feld stand oder ein
Bild dranhing, verschwand das Mikro. Damit ging weder "Bild anhaengen
und den Auftrag dazu sprechen" noch ein Zwischenruf per Sprache.
Der Senden-Pfeil und die Mund-/Ohr-Knoepfe weichen dafuer je nach
Lage, damit die Zeile nicht ueberlaeuft. */}
{/* Zwischenruf \u2014 sichtbar, wenn ARIA im aktiven Kontext arbeitet (oder
noch scharf geschaltet ist). Mit Text: sofort abschicken, wie bisher.
Ohne Text: die naechste Aufnahme geht als Zwischenruf in den
laufenden Turn, statt sich als neue Anfrage anzustellen. */}
{interjectArmed || (agentActivityByCtx[focusedProjectId]?.activity || 'idle') !== 'idle' ? (
<TouchableOpacity
style={[styles.interjectButton, interjectArmed && styles.interjectButtonArmed]}
onPress={() => {
if (inputText.trim()) { sendInterject(); return; }
const next = !interjectArmedRef.current;
interjectArmedRef.current = next;
setInterjectArmed(next);
if (Platform.OS === 'android') {
ToastAndroid.show(
next ? 'N\u00E4chste Aufnahme geht als Zwischenruf raus'
: 'Zwischenruf abgew\u00E4hlt',
ToastAndroid.SHORT);
}
}}
accessibilityLabel={inputText.trim() ? 'Zwischenruf senden' : 'Zwischenruf per Sprache'}
>
<Text style={styles.interjectIcon}>{'\uD83D\uDCE3'}</Text>
</TouchableOpacity>
) : null}
<VoiceButton
onTapStart={handleVoiceButtonStart}
onTapStop={handleVoiceButtonStop}
disabled={connectionState !== 'connected'}
wakeWordActive={wakeWordActive}
/>
{inputText.trim() || pendingAttachments.length > 0 ? (
<>
{/* 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>
</>
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
</TouchableOpacity>
) : (
<>
<VoiceButton
onTapStart={handleVoiceButtonStart}
onTapStop={handleVoiceButtonStop}
disabled={connectionState !== 'connected'}
wakeWordActive={wakeWordActive}
/>
{/* Mund-Button: TTS auf diesem Geraet muten/aufheben.
Nur sichtbar wenn TTS in den Settings grundsaetzlich aktiv ist. */}
{ttsDeviceEnabled && (
@@ -3629,6 +4077,13 @@ const ChatScreen: React.FC = () => {
{/* Datei-Upload Modal */}
<Modal visible={showFileUpload} transparent animationType="slide">
<View style={styles.modalOverlay}>
<ShareModal
visible={shareVisible}
items={shareItems}
initialText={shareText}
onCancel={() => { setShareVisible(false); setShareItems([]); setShareText(''); }}
onSend={handleShareSend}
/>
<FileUpload
onFileSelected={handleFileSelected}
onCancel={() => setShowFileUpload(false)}
@@ -3876,6 +4331,13 @@ const styles = StyleSheet.create({
justifyContent: 'center',
marginRight: 6,
},
// Scharf geschaltet: die naechste Aufnahme geht als Zwischenruf raus. Heller
// plus Rand, damit man das vor dem Sprechen sieht und nicht erst danach.
interjectButtonArmed: {
backgroundColor: '#FFB340',
borderWidth: 2,
borderColor: '#FFF',
},
interjectIcon: {
fontSize: 18,
},
+53
View File
@@ -114,6 +114,8 @@ import wakeWordService, {
saveWakeThreshold,
loadBgWakeEnabled,
saveBgWakeEnabled,
loadWakeConfirmEnabled,
saveWakeConfirmEnabled,
} from '../services/wakeword';
import ModeSelector from '../components/ModeSelector';
import QRScanner from '../components/QRScanner';
@@ -176,6 +178,14 @@ const SettingsScreen: React.FC = () => {
const winDims = useWindowDimensions();
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
const [manualToken, setManualToken] = useState('');
// Abgelaufenes Server-Zertifikat trotzdem akzeptieren (Kette + Hostname werden weiter geprueft)
const [allowExpiredCert, setAllowExpiredCert] = useState(false);
useEffect(() => { rvs.getAllowExpiredCert().then(setAllowExpiredCert); }, []);
const handleAllowExpiredCert = useCallback(async (value: boolean) => {
setAllowExpiredCert(value);
await rvs.setAllowExpiredCert(value);
rvs.connect(true); // gleich mit der neuen Regel neu verbinden
}, []);
const [manualHost, setManualHost] = useState('');
const [manualPort, setManualPort] = useState('8765');
const [currentMode, setCurrentMode] = useState('normal');
@@ -216,6 +226,8 @@ const SettingsScreen: React.FC = () => {
const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT);
// Hintergrund-Wake: auch bei gesperrtem Bildschirm auf das Wake-Wort hoeren. Default aus.
const [bgWake, setBgWake] = useState<boolean>(false);
// Wake-Wort per Voxtral bestaetigen (gegen Musik-Fehltrigger). Default aus.
const [wakeConfirm, setWakeConfirm] = useState<boolean>(false);
const [editingPath, setEditingPath] = useState(false);
const [xttsVoice, setXttsVoice] = useState('');
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
@@ -341,6 +353,7 @@ const SettingsScreen: React.FC = () => {
isWakeReadySoundEnabled().then(setWakeReadySound);
loadWakeThreshold().then(setWakeThreshold).catch(() => {});
loadBgWakeEnabled().then(setBgWake).catch(() => {});
loadWakeConfirmEnabled().then(setWakeConfirm).catch(() => {});
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
@@ -1445,6 +1458,24 @@ const SettingsScreen: React.FC = () => {
<TouchableOpacity style={styles.connectButton} onPress={connectManually}>
<Text style={styles.connectButtonText}>Verbinden</Text>
</TouchableOpacity>
<View style={[styles.toggleRow, {marginTop: 16, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 12}]}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Abgelaufenes Zertifikat akzeptieren</Text>
<Text style={styles.toggleHint}>
Notlösung, wenn das Zertifikat des Servers abgelaufen ist. Die Verbindung bleibt
verschlüsselt, und es wird weiter geprüft, dass es wirklich das Zertifikat dieses
Servers ist — nur das Ablaufdatum wird ignoriert. Wieder ausschalten, sobald das
Zertifikat erneuert ist.
</Text>
</View>
<Switch
value={allowExpiredCert}
onValueChange={handleAllowExpiredCert}
trackColor={{ false: '#2A2A3E', true: '#FF9F0A' }}
thumbColor={allowExpiredCert ? '#FFFFFF' : '#666680'}
/>
</View>
</View>
{/* === Verbindungslog === */}
@@ -1921,6 +1952,28 @@ const SettingsScreen: React.FC = () => {
/>
</View>
<View style={[styles.toggleRow, {marginTop: 20, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 16}]}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Wake-Wort per Voxtral bestätigen</Text>
<Text style={styles.toggleHint}>
Gegen Musik-Fehltrigger: nach „{KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS] || wakeKeyword}"
prüft Voxtral kurz nach, ob's wirklich das Wake-Wort war (oder nur
Musik/TV) — erst dann Gong + Mikro. Kostet ~0,5–1 s Extra vor dem
Gong. Empfohlen zusammen mit Hintergrund-Zuhören.
</Text>
</View>
<Switch
value={wakeConfirm}
onValueChange={(val) => {
setWakeConfirm(val);
saveWakeConfirmEnabled(val).catch(() => {});
wakeWordService.setWakeConfirmEnabled(val);
}}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={wakeConfirm ? '#FFFFFF' : '#666680'}
/>
</View>
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden nach der Antwort</Text>
<Text style={styles.toggleHint}>
Nach einer gesprochenen ARIA-Antwort geht das Mikro auf — du kannst ohne
+148 -20
View File
@@ -171,6 +171,72 @@ export async function saveBargeInEnabled(enabled: boolean): Promise<void> {
} catch {}
}
/** One-Shot-Transkription eines PCM-Schnipsels (base64, s16le 16kHz mono) via
* Voxtral — fuer die Wake-Wort-Bestaetigung. Schickt stt_transcribe_blob und
* wartet auf stt_transcribe_result (matching requestId) mit Timeout.
* Rueckgabe: Text (evtl. '') bei Antwort, oder null bei Timeout/Fehler →
* Aufrufer macht dann fail-open (Wake normal durchlassen). */
export async function transcribeBlob(pcmBase64: string, timeoutMs = 2500): Promise<string | null> {
if (!pcmBase64) return null;
const requestId = `wakeverify_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
return new Promise<string | null>((resolve) => {
let done = false;
let unsub: (() => void) | null = null;
const timer = setTimeout(() => finish(null), timeoutMs);
function finish(val: string | null) {
if (done) return;
done = true;
try { unsub && unsub(); } catch {}
clearTimeout(timer);
resolve(val);
}
try {
unsub = rvs.onMessage((msg: any) => {
if (msg?.type !== 'stt_transcribe_result') return;
const p = (msg as any).payload || {};
if (String(p.requestId || '') !== requestId) return;
finish(typeof p.text === 'string' ? p.text : '');
});
rvs.send('stt_transcribe_blob' as any, { requestId, pcm: pcmBase64, language: 'de' });
} catch {
finish(null);
}
});
}
/** Fragt die Bridge vor dem Aufnahme-Stream, welche STT-Instanz adressiert
* werden soll (Redundanz ueber mehrere STT-Nodes/Apps). Schickt
* stt_lease_request, wartet kurz auf stt_lease (matching requestId).
* Rueckgabe: instanceId (z.B. "voxtral@box-a") oder '' bei Timeout/keine
* Instanz — dann streamt die App wie bisher an ALLE (Broadcast, Single-Node
* unveraendert). Bewusst kurzer Timeout, damit die Aufnahme nie haengt. */
export async function requestSttLease(timeoutMs = 250): Promise<string> {
const requestId = `sttlease_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
return new Promise<string>((resolve) => {
let done = false;
let unsub: (() => void) | null = null;
const timer = setTimeout(() => finish(''), timeoutMs);
function finish(val: string) {
if (done) return;
done = true;
try { unsub && unsub(); } catch {}
clearTimeout(timer);
resolve(val);
}
try {
unsub = rvs.onMessage((msg: any) => {
if (msg?.type !== 'stt_lease') return;
const p = (msg as any).payload || {};
if (String(p.requestId || '') !== requestId) return;
finish(typeof p.instanceId === 'string' ? p.instanceId : '');
});
rvs.send('stt_lease_request' as any, { requestId });
} catch {
finish('');
}
});
}
export async function loadSttEndpointMs(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);
@@ -350,6 +416,9 @@ class AudioService {
// lich Chunks einer alten Session in eine neue mischen.
private streamRequestId: string = '';
private streamAudioRequestId: string = '';
// Adressierte STT-Instanz fuer diesen Stream (Redundanz-Routing). '' =
// Broadcast an alle STT-Nodes (Single-Node / kein Lease = wie bisher).
private streamTargetInstance: string = '';
// Latch: ist endpointListeners fuer den aktuellen Session-Cycle schon gefeuert
// worden? Wird auf false gesetzt beim startStreamingRecording, auf true beim
// ersten Endpoint (egal ob via RVS oder Fallback). Verhindert Doppel-Fires.
@@ -1072,7 +1141,14 @@ class AudioService {
* Leer = Hauptchat. Ohne Prefix / Sticky landet die STT-Nachricht damit
* automatisch in dem Kontext den Stefan gerade sieht. */
projectId?: string;
}): Promise<{ requestId: string; ok: boolean }> {
/** clientMsgId wartender Anhaenge: diese Aufnahme ist ihre Beschreibung.
* Die Bridge fuehrt Anhang + gesprochenen Satz darueber zu EINEM Turn
* zusammen, statt das Bild ohne Auftrag und den Satz ohne Bild zu sehen. */
clientMsgId?: string;
/** Zwischenruf: der Text geht in den LAUFENDEN Turn, nicht als neue
* Anfrage in die Warteschlange (Sprach-Gegenstueck zum 📣-Knopf). */
interject?: boolean;
}): Promise<{ requestId: string; ok: boolean; error?: string }> {
if (this.recordingState !== 'idle') {
console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState);
return { requestId: '', ok: false };
@@ -1086,13 +1162,42 @@ class AudioService {
console.warn('[Audio] Keine Mikrofon-Berechtigung');
return { requestId: '', ok: false };
}
// Ohne Verbindung gar nicht erst aufnehmen: rvs.send() verwirft still, wenn
// der Socket nicht offen ist (kein Puffer, kein Retry). Genau deshalb fiel
// nach App-Resume/Reconnect die ERSTE Aufnahme komplett unter den Tisch —
// start, Chunks und end waren weg, die Bubble verschwand, und erst der
// zweite Versuch klappte (da war der Reconnect durch). Nachtraeglich
// gepuffertes Audio waere sinnlos: die STT-Sitzung laeuft in Echtzeit.
// WICHTIG: den SOCKET fragen, nicht den gemerkten Zustand. Der kann
// faelschlich 'disconnected' sein, obwohl die Verbindung laeuft (Zombie) —
// dann wuerde hier jede Aufnahme abgelehnt, bevor sie beginnt, und der
// Nutzer kann gar nicht mehr sprechen.
if (!rvs.isSocketOpen()) {
console.warn('[Audio] startStreamingRecording: Socket nicht offen (state=%s)',
rvs.getState());
return { requestId: '', ok: false, error: 'not-connected' };
}
// Laufende Wiedergabe stoppen (damit ARIA sich nicht selbst hoert)
this.stopPlayback();
const requestId = `sttstr_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
this.streamRequestId = requestId;
// Zustand SOFORT setzen (nicht erst nach den awaits weiter unten): sonst ist
// isStreamingRecording() bereits true, recordingState aber noch 'idle' —
// ein Abbruch in diesem Fenster liess den Start danach weiterlaufen und der
// Recorder blieb haengen, sodass die NAECHSTE Aufnahme abgelehnt wurde.
this.setState('recording');
this.streamAudioRequestId = opts.audioRequestId || '';
// Redundanz-Routing: freie STT-Instanz leasen BEVOR Chunks fliessen, damit
// start + alle Chunks + end dieselbe Instanz adressieren. Kurzer Timeout →
// '' (Broadcast) falls keine Instanz/keine Antwort. Nie blockierend genug
// um die Aufnahme spuerbar zu verzoegern.
try {
this.streamTargetInstance = await requestSttLease();
} catch {
this.streamTargetInstance = '';
}
this.streamGotPartial = false;
this.streamEndpointFired = false;
this.recordingStartTime = Date.now();
@@ -1113,6 +1218,7 @@ class AudioService {
requestId: sessionId,
pcm: String(e?.pcm || ''),
seq: Number(e?.seq || 0),
targetInstance: this.streamTargetInstance,
});
});
this.streamPcmErrorSub = emitter.addListener('PcmStreamError', (e: any) => {
@@ -1123,6 +1229,33 @@ class AudioService {
console.warn('[Audio] PcmStreamChunk-Subscription fehlgeschlagen:', err);
}
// Abbruch waehrend der awaits oben? Dann hier aussteigen, statt eine
// Sitzung zu starten, die niemand mehr erwartet.
if (this.streamRequestId !== requestId) {
console.warn('[Audio] Start abgebrochen (Session gewechselt) — breche ab');
return { requestId: '', ok: false, error: 'superseded' };
}
// stt_stream_start MUSS vor dem Recorder raus: sonst treffen die ersten
// Chunks (200ms-Takt) auf eine Bridge, die die Sitzung noch nicht kennt,
// und werden verworfen — der Wortanfang fehlt, bei kurzen Kommandos bleibt
// gar nichts uebrig.
rvs.send('stt_stream_start' as any, {
requestId,
audioRequestId: opts.audioRequestId || '',
voice: opts.voice || '',
speed: typeof opts.speed === 'number' ? opts.speed : 1.0,
interrupted: !!opts.interrupted,
location: opts.location || null,
endpointMs: typeof opts.endpointMs === 'number' ? opts.endpointMs : STT_ENDPOINT_DEFAULT_MS,
hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000,
sampleRate: 16000,
projectId: opts.projectId || '',
targetInstance: this.streamTargetInstance,
...(opts.clientMsgId ? { clientMsgId: opts.clientMsgId } : {}),
...(opts.interject ? { interject: true } : {}),
});
const started = await PcmStreamRecorder.start();
if (!started) {
throw new Error('PcmStreamRecorder.start returned false');
@@ -1134,34 +1267,29 @@ class AudioService {
this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {});
this.setState('recording');
// stt_stream_start — der Whisper-Bridge mitteilen dass jetzt Chunks kommen.
rvs.send('stt_stream_start' as any, {
requestId,
audioRequestId: opts.audioRequestId || '',
voice: opts.voice || '',
speed: typeof opts.speed === 'number' ? opts.speed : 1.0,
interrupted: !!opts.interrupted,
location: opts.location || null,
endpointMs: typeof opts.endpointMs === 'number' ? opts.endpointMs : STT_ENDPOINT_DEFAULT_MS,
hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000,
sampleRate: 16000,
projectId: opts.projectId || '',
});
// No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer.
// Wenn nach Konversationsfenster kein einziger stt_partial gekommen ist,
// hat der User vermutlich nix gesagt → Session beenden.
// WICHTIG: Der Aufrufer uebergibt hier die Sprechpausen-Toleranz
// (sttEndpointMs, Default 2400ms). Die misst aber ab dem ENDE des
// Sprechens — dieser Watchdog laeuft ab dem START der Aufnahme. Bis das
// erste Zwischenergebnis zurueck sein KANN, vergehen realistisch 1,3-2,2s
// (Mindest-Puffer 600ms + Transkriptions-Takt 700ms + Netzweg hin/zurueck).
// Ohne Zuschlag reisst schon ein kurzes Ueberlegen nach dem Gong die
// Grenze: die Aufnahme wurde abgebrochen und die Bubble verschwand — erst
// der zweite Versuch klappte, weil man da sofort losredet.
const NO_SPEECH_GRACE_MS = 2500;
const noSpeechMs = Number(opts.noSpeechTimeoutMs || 0);
if (noSpeechMs > 0) {
const effectiveMs = noSpeechMs + NO_SPEECH_GRACE_MS;
this.streamNoSpeechTimer = setTimeout(() => {
if (this.streamRequestId === requestId && !this.streamGotPartial) {
console.log('[Audio] Stream %s: no-speech nach %dms → cancel',
requestId.slice(0, 12), noSpeechMs);
requestId.slice(0, 12), effectiveMs);
this.cancelStreamingRecording('no-speech').catch(() => {});
}
}, noSpeechMs);
}, effectiveMs);
}
// Hard-Cap als zweite Sicherheitsleine (App-seitig zusaetzlich zur Bridge).
@@ -1197,7 +1325,7 @@ class AudioService {
if (!reqId) return;
const audioReqId = this.streamAudioRequestId;
try {
rvs.send('stt_stream_end' as any, { requestId: reqId, reason });
rvs.send('stt_stream_end' as any, { requestId: reqId, reason, targetInstance: this.streamTargetInstance });
} catch (e) {
console.warn('[Audio] stt_stream_end senden fehlgeschlagen:', e);
}
@@ -1232,7 +1360,7 @@ class AudioService {
if (!reqId) return;
const audioReqId = this.streamAudioRequestId;
try {
rvs.send('stt_stream_end' as any, { requestId: reqId, reason: `cancel:${reason}` });
rvs.send('stt_stream_end' as any, { requestId: reqId, reason: `cancel:${reason}`, targetInstance: this.streamTargetInstance });
} catch {}
this._cleanupStreamLocal(`cancel:${reason}`);
// Listener feuern damit ChatScreen reagieren kann (endConversation etc.)
+56
View File
@@ -260,6 +260,43 @@ export interface Trigger {
// ── Memory CRUD ──────────────────────────────────────────────────────
// ── Modell pro Chat ──────────────────────────────────────────────────
// reason: 'einstellung' (brainModel) | 'pro-chat' (fuer diesen Chat gewaehlt)
// | 'ausweich' (Sicherheitsfilter hat das gewaehlte Modell blockiert)
export interface ContextModelRow {
context_id: string;
name: string;
override: string; // '' = folgt der Einstellung
selected: string; // gewaehlt (Einstellung oder pro Chat)
effective: string; // was der naechste Turn tatsaechlich nutzt
reason: 'einstellung' | 'pro-chat' | 'ausweich' | string;
blocked: string[];
}
export interface ContextModelChoice {
id: string;
name: string;
section: 'tier' | 'main' | 'overflow' | string;
}
export interface ContextModels {
default: string;
rows: ContextModelRow[];
choices: ContextModelChoice[];
names: Record<string, string>;
}
/** "claude-sonnet-5-5" → "Sonnet 5.5". Katalog-Namen (names) haben Vorrang;
* Datums-Suffixe ("-20251001") gehoeren nicht zur Version. Gleiche Logik wie
* prettyModel() in der Diagnostic. */
export function prettyModel(id: string, names?: Record<string, string>): string {
if (!id) return '';
if (names && names[id]) return names[id];
const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-|$)/.exec(id);
if (m) return m[1][0].toUpperCase() + m[1].slice(1) + ' ' + m[2] + (m[3] ? '.' + m[3] : '');
return id.charAt(0).toUpperCase() + id.slice(1);
}
export const brainApi = {
/** Einzelne Memory holen (mit allen Feldern inkl. Anhaenge) */
getMemory(id: string): Promise<Memory> {
@@ -682,6 +719,25 @@ export const brainApi = {
getProjectQueueStatus(): Promise<ProjectQueueStatus> {
return _send('/projects/queue-status');
},
/** Modelle aller Chats: gewaehlt, effektiv, Grund + Auswahl-Liste. */
getContextModels(): Promise<ContextModels> {
return _send('/context-model/list');
},
/** Modell fuer einen Chat setzen ('' als Modell = wieder die Einstellung). */
setContextModel(contextId: string, model: string): Promise<ContextModels> {
return _send('/context-model/set', {
method: 'POST',
body: { context_id: contextId || '', model: model || '' },
});
},
/** Ausweich-Modell beenden: zurueck aufs gewaehlte Modell dieses Chats. */
contextModelBack(contextId: string): Promise<ContextModels> {
return _send('/context-model/back-to-selected', {
method: 'POST',
body: { context_id: contextId || '' },
});
},
};
export default brainApi;
+8 -1
View File
@@ -49,8 +49,15 @@ 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. */
/** Letzte App-Aktivitaet merken — Crash-Kontext fuer den naechsten Boot.
*
* Die Crash-Meldung selbst darf den Brotkrumen NICHT ueberschreiben: sonst
* merkt sich der zweite Absturz nur noch "letzte Aktivitaet: Crash-Meldung",
* der dritte "letzte Aktivitaet: Crash-Meldung ueber Crash-Meldung" — und bei
* einer Absturzschleife ist nach dem ersten Durchlauf jede Spur weg. Genau
* das ist passiert; die eigentliche Ursache stand nur in der ERSTEN Meldung. */
export function noteBreadcrumb(scope: string, message: string): void {
if (scope === 'app.crash-detected') return;
_breadcrumb = { ts: Date.now(), scope: scope || '', message: String(message || '').slice(0, 120) };
_breadcrumbDirty = true;
}
+161 -7
View File
@@ -7,6 +7,9 @@
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { NativeModules, Platform } from 'react-native';
const TlsTrust: any = NativeModules.TlsTrust;
// --- Typen ---
@@ -27,6 +30,29 @@ export interface ConnectionConfig {
useTLS: boolean;
}
/**
* Wie sicher ist die Verbindung? Fuer das Schloss-Symbol in der Tab-Leiste.
* secure — verschluesselt, Zertifikat in Ordnung
* expired — verschluesselt, aber das Zertifikat ist abgelaufen (per Schalter erlaubt)
* cert_error— keine Verbindung, weil das Zertifikat abgelehnt wurde
* fallback — verbunden OHNE Verschluesselung, weil TLS fehlschlug
* plain — verbunden ohne Verschluesselung, so konfiguriert
* unknown — (noch) nichts Genaues
*/
export type SecurityKind = 'secure' | 'expired' | 'cert_error' | 'fallback' | 'plain' | 'unknown';
export interface SecurityState {
kind: SecurityKind;
/** Pruefergebnis der App: ok | expired_accepted | expired | invalid | unknown */
certStatus: string;
/** Ablaufdatum des Server-Zertifikats (ms), 0 = unbekannt */
notAfter: number;
/** Bei fallback/cert_error: warum TLS nicht ging */
reason: string;
}
type SecurityCallback = (state: SecurityState) => void;
type MessageCallback = (message: RVSMessage) => void;
type StateCallback = (state: ConnectionState) => void;
@@ -64,6 +90,10 @@ class RVSConnection {
private logListeners: LogCallback[] = [];
private connectionLog: ConnectionLogEntry[] = [];
private usingTLSFallback: boolean = false;
private securityListeners: SecurityCallback[] = [];
private security: SecurityState = { kind: 'unknown', certStatus: 'unknown', notAfter: 0, reason: '' };
/** Letzter Zertifikats-Fehler beim wss://-Versuch (bleibt bis wss:// wieder klappt) */
private certFailure: { status: string; notAfter: number; detail: string } | null = null;
// --- Konfiguration ---
@@ -104,11 +134,7 @@ class RVSConnection {
// Verbindungen + doppelte Events.
if (this.ws) {
this.log('info', 'Bestehende WS-Verbindung wird geschlossen vor Neu-Connect');
try {
this.ws.onclose = null; // verhindert dass scheduleReconnect doppelt feuert
this.ws.onerror = null;
this.ws.close();
} catch (_) {}
this.detachSocket(this.ws);
this.ws = null;
}
@@ -134,6 +160,15 @@ class RVSConnection {
this.setState('disconnected');
}
/**
* Ist die Verbindung WIRKLICH offen? Fragt den Socket selbst, nicht den
* gemerkten Zustand — der kann (z.B. nach einem Zombie) falsch sein. Wer
* davon abhaengig macht, ob ueberhaupt gesendet wird, muss das Echte wissen.
*/
isSocketOpen(): boolean {
return !!this.ws && this.ws.readyState === WebSocket.OPEN;
}
/** Nachricht an den RVS senden */
send(type: MessageType, payload: Record<string, unknown>): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
@@ -211,11 +246,18 @@ class RVSConnection {
const tlsInfo = this.usingTLSFallback ? ' (TLS-Fallback: ws://)' : '';
this.log('info', `Verbunden${tlsInfo}`);
this.setState('connected');
this.updateSecurityOnOpen(useTLS);
this.sendClientHello();
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
this.startHeartbeat();
};
const socket = this.ws; // genau DIESE Verbindung (nicht 'this.ws' spaeter)
this.ws.onmessage = (event: WebSocketMessageEvent) => {
// Nur die AKTUELLE Verbindung darf Nachrichten einspeisen. Sonst
// koennte eine alte, laengst aufgegebene Verbindung weiter Daten
// liefern und vortaeuschen, es sei alles in Ordnung.
if (socket !== this.ws) return;
try {
const message: RVSMessage = JSON.parse(event.data as string);
this.notifyMessageListeners(message);
@@ -229,6 +271,10 @@ class RVSConnection {
this.clearTimers();
this.ws = null;
this.setState('disconnected');
this.setSecurity(this.certFailure
? { kind: 'cert_error', certStatus: this.certFailure.status, notAfter: this.certFailure.notAfter,
reason: this.certFailure.detail }
: { kind: 'unknown', certStatus: 'unknown', notAfter: 0, reason: '' });
// Sticky-Fallback-Reset: beim naechsten Reconnect wieder primary
// (wss://) versuchen statt fuer immer auf ws:// zu kleben. War
@@ -248,6 +294,7 @@ class RVSConnection {
this.ws.onerror = (error) => {
const errorMsg = (error as any)?.message || 'Unbekannter Fehler';
this.log('error', `WebSocket-Fehler: ${errorMsg}`);
if (useTLS) this.checkCertFailure(errorMsg);
// TLS-Fallback: Wenn wss:// fehlschlaegt, auf ws:// wechseln
if (this.config?.useTLS && !this.usingTLSFallback) {
@@ -258,8 +305,7 @@ class RVSConnection {
this.log('warn', 'TLS fehlgeschlagen — Fallback auf ws:// (ohne TLS)');
this.clearTimers();
if (this.ws) {
this.ws.onclose = null; // onclose-Handler entfernen um Doppel-Reconnect zu verhindern
try { this.ws.close(); } catch (_) {}
this.detachSocket(this.ws); // inkl. onmessage — sonst bleibt ein Zombie, der weiter empfaengt
}
this.ws = null;
this.shouldReconnect = true;
@@ -278,6 +324,95 @@ class RVSConnection {
}
}
/** Beim RVS vorstellen — Diagnostic zeigt so, welches Handy im Raum ist. */
private sendClientHello(): void {
try {
const c: any = (Platform as any).constants || {};
const device = [c.Manufacturer, c.Model].filter(Boolean).join(' ') || Platform.OS;
let version = '';
try { version = require('../../package.json').version || ''; } catch (_) {}
this.send('client_hello', { kind: 'App', name: device,
detail: `Android ${c.Release || Platform.Version}`, version });
} catch (_) {}
}
// --- Verbindungs-Sicherheit (Schloss-Symbol) ---
getSecurity(): SecurityState {
return this.security;
}
onSecurityChange(callback: SecurityCallback): () => void {
this.securityListeners.push(callback);
return () => {
this.securityListeners = this.securityListeners.filter(cb => cb !== callback);
};
}
private setSecurity(next: SecurityState): void {
const cur = this.security;
if (cur.kind === next.kind && cur.certStatus === next.certStatus
&& cur.notAfter === next.notAfter && cur.reason === next.reason) return;
this.security = next;
this.securityListeners.forEach(cb => cb(next));
}
private async nativeCertStatus(): Promise<{ status: string; notAfter: number; detail: string }> {
try {
const r = await TlsTrust?.getStatus();
return { status: r?.status || 'unknown', notAfter: Number(r?.notAfter) || 0, detail: r?.detail || '' };
} catch (_) {
return { status: 'unknown', notAfter: 0, detail: '' };
}
}
private async updateSecurityOnOpen(viaTLS: boolean): Promise<void> {
if (viaTLS) {
this.certFailure = null;
const c = await this.nativeCertStatus();
if (c.status === 'expired_accepted') {
this.log('warn', `Zertifikat abgelaufen (${new Date(c.notAfter).toLocaleDateString()}) — per Einstellung akzeptiert`);
}
this.setSecurity({ kind: c.status === 'expired_accepted' ? 'expired' : 'secure',
certStatus: c.status, notAfter: c.notAfter, reason: '' });
} else if (this.config?.useTLS) {
// wss:// ging nicht, ws:// schon → unverschluesselt per Fallback
this.setSecurity({ kind: 'fallback', certStatus: this.certFailure?.status || 'unknown',
notAfter: this.certFailure?.notAfter || 0, reason: this.certFailure?.detail || '' });
} else {
this.setSecurity({ kind: 'plain', certStatus: 'unknown', notAfter: 0, reason: '' });
}
}
/** Lag der wss://-Fehler am Zertifikat? Dann merken (fuer Schloss + Hinweis). */
private async checkCertFailure(errorMsg: string): Promise<void> {
if (!/cert|trust anchor|chain validation|handshake|ssl/i.test(errorMsg)) return;
const c = await this.nativeCertStatus();
if (c.status !== 'expired' && c.status !== 'invalid') return;
this.certFailure = { status: c.status, notAfter: c.notAfter, detail: c.detail || errorMsg };
this.log('error', c.status === 'expired'
? `Server-Zertifikat abgelaufen seit ${new Date(c.notAfter).toLocaleString()}`
: 'Server-Zertifikat ungueltig');
if (this.state !== 'connected') {
this.setSecurity({ kind: 'cert_error', certStatus: c.status, notAfter: c.notAfter,
reason: this.certFailure.detail });
} else if (this.security.kind === 'fallback') {
// ws://-Fallback war schneller verbunden als diese Pruefung — Grund nachtragen
this.setSecurity({ ...this.security, certStatus: c.status, notAfter: c.notAfter,
reason: this.certFailure.detail });
}
}
/** Schalter "Abgelaufenes Zertifikat akzeptieren" */
async getAllowExpiredCert(): Promise<boolean> {
try { return !!(await TlsTrust?.getStatus())?.allowExpired; } catch (_) { return false; }
}
async setAllowExpiredCert(value: boolean): Promise<void> {
try { await TlsTrust?.setAllowExpired(value); } catch (_) {}
this.log('info', `Abgelaufene Zertifikate ${value ? 'werden akzeptiert' : 'werden abgelehnt'}`);
}
/** Reconnect mit exponentiellem Backoff planen */
private scheduleReconnect(): void {
this.log('info', `Reconnect in ${this.reconnectDelay / 1000}s...`);
@@ -302,6 +437,25 @@ class RVSConnection {
}, HEARTBEAT_INTERVAL_MS);
}
/**
* Klemmt eine Verbindung VOLLSTAENDIG ab, bevor sie verworfen wird.
*
* Wichtig: frueher wurden nur onclose/onerror entfernt — onmessage blieb
* haengen. Eine aufgegebene (Zombie-)Verbindung lieferte dadurch weiter
* Nachrichten an die App, waehrend gesendet wurde ueber die neue/tote.
* Symptom: die App zeigt "getrennt", ARIAs Sprachausgabe kommt aber trotzdem
* an — und nichts, was man schreibt oder spricht, erreicht den Server.
*/
private detachSocket(ws: WebSocket): void {
try {
ws.onopen = null;
ws.onmessage = null; // <- das fehlte
ws.onclose = null; // verhindert doppelten Reconnect
ws.onerror = null;
ws.close();
} catch (_) {}
}
private clearTimers(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
+77
View File
@@ -0,0 +1,77 @@
/**
* Teilen-Ziel — Bruecke zum nativen ShareIntent-Modul.
*
* Zwei Faelle:
* App war ZU → der Inhalt liegt beim Start am Intent → getShared()
* App LAEUFT → onNewIntent feuert das Event 'ShareReceived' → onShare()
*
* Die Dateien hat die native Seite schon in den App-Cache kopiert und liefert
* echte Pfade; content://-URIs waeren nach dem Dialog womoeglich nicht mehr
* lesbar (die Leseerlaubnis gilt nur fuer den Intent).
*/
import { NativeEventEmitter, NativeModules } from 'react-native';
import RNFS from 'react-native-fs';
import type { FileData } from '../components/FileUpload';
import type { SharedItem } from '../components/ShareModal';
const { ShareIntent } = NativeModules;
export interface SharedPayload {
text: string;
items: SharedItem[];
}
const EMPTY: SharedPayload = { text: '', items: [] };
/** Inhalt, mit dem die App gestartet wurde (leer, wenn normal geoeffnet). */
export async function getShared(): Promise<SharedPayload> {
if (!ShareIntent?.getSharedData) return EMPTY;
try {
const r = await ShareIntent.getSharedData();
return { text: r?.text || '', items: Array.isArray(r?.items) ? r.items : [] };
} catch (e) {
console.warn('[Share] getSharedData fehlgeschlagen:', e);
return EMPTY;
}
}
/** Abonniert Shares in die bereits laufende App. Gibt Unsubscribe zurueck. */
export function onShare(cb: (p: SharedPayload) => void): () => void {
if (!ShareIntent) return () => {};
const emitter = new NativeEventEmitter(ShareIntent);
const sub = emitter.addListener('ShareReceived', (r: any) => {
cb({ text: r?.text || '', items: Array.isArray(r?.items) ? r.items : [] });
});
return () => { try { sub.remove(); } catch (_) {} };
}
/** Zwischenkopien loeschen (nach erfolgreichem Senden). */
export async function clearShareCache(): Promise<void> {
try { await ShareIntent?.clearCache?.(); } catch (_) {}
}
/**
* Macht aus geteilten Dateien das Format, das der bestehende Upload-Weg
* erwartet (Base64) — so laeuft Teilen durch exakt dieselbe Strecke wie die
* Bueroklammer, inklusive Anhang-Darstellung im Chat.
*/
export async function toFileData(items: SharedItem[]): Promise<FileData[]> {
const out: FileData[] = [];
for (const it of items) {
try {
const base64 = await RNFS.readFile(it.path, 'base64');
out.push({
name: it.name || 'geteilt',
type: it.type || 'application/octet-stream',
size: it.size || 0,
base64,
uri: 'file://' + it.path,
});
} catch (e) {
console.warn('[Share] Datei nicht lesbar (%s): %s', it.name, e);
}
}
return out;
}
export default { getShared, onShare, clearShareCache, toFileData };
+92 -6
View File
@@ -87,6 +87,27 @@ export async function saveBgWakeEnabled(enabled: boolean): Promise<void> {
} catch {}
}
// Wake-Wort-Bestaetigung: nach einem openWakeWord-Trigger den Vor-Trigger-Audio
// von Voxtral gegenpruefen lassen ("war das wirklich 'Computer' oder Musik?").
// Killt Musik-Fehltrigger (z.B. Pet Shop Boys), kostet ~0.5-1s Extra-Latenz pro
// Wake. Default AUS (opt-in), fail-open. Braucht das native preTriggerPcm im
// Event (neueres APK) — ohne das macht die App normal weiter.
export const WAKE_CONFIRM_STORAGE_KEY = 'aria_wake_confirm_enabled';
export async function loadWakeConfirmEnabled(): Promise<boolean> {
try {
return (await AsyncStorage.getItem(WAKE_CONFIRM_STORAGE_KEY)) === 'true';
} catch {
return false;
}
}
export async function saveWakeConfirmEnabled(enabled: boolean): Promise<void> {
try {
await AsyncStorage.setItem(WAKE_CONFIRM_STORAGE_KEY, String(enabled));
} catch {}
}
/** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
@@ -159,6 +180,9 @@ class WakeWordService {
* Bildschirm. Default false. Wird beim Arm aus AsyncStorage geladen und
* bei Aenderung in den Einstellungen via setBgWakeEnabled() aktualisiert. */
private bgWakeEnabled: boolean = false;
/** Wake-Wort per Voxtral bestaetigen (gegen Musik-Fehltrigger)? Default false.
* Wird beim Arm geladen + per setWakeConfirmEnabled aus den Einstellungen. */
private wakeConfirmEnabled: boolean = false;
/** Re-Entry-Guard fuer onWakeDetected: native kann mehrere
* WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS
* resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite
@@ -241,15 +265,19 @@ class WakeWordService {
try {
const threshold = await loadWakeThreshold();
this.bgWakeEnabled = await loadBgWakeEnabled();
console.log('[WakeWord] init mit threshold=%s, bgWake=%s', threshold, this.bgWakeEnabled);
this.wakeConfirmEnabled = await loadWakeConfirmEnabled();
console.log('[WakeWord] init mit threshold=%s, bgWake=%s, confirm=%s',
threshold, this.bgWakeEnabled, this.wakeConfirmEnabled);
await OpenWakeWord.init(this.keyword, threshold, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
// Subscribe nur einmal
if (!this.eventSub) {
const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord);
this.eventSub = emitter.addListener('WakeWordDetected', () => {
this.eventSub = emitter.addListener('WakeWordDetected', (payload: any) => {
console.log('[WakeWord] Native Detection-Event empfangen');
this.onWakeDetected().catch(err =>
console.warn('[WakeWord] onWakeDetected crashed:', err));
// payload.preTriggerPcm (base64 s16le 16kHz) fuer die Bestaetigung —
// nur in neueren APKs vorhanden; ohne = fail-open (kein Verify).
this.onWakeDetected(payload && payload.preTriggerPcm ? String(payload.preTriggerPcm) : null)
.catch(err => console.warn('[WakeWord] onWakeDetected crashed:', err));
});
}
this.nativeReady = true;
@@ -348,6 +376,18 @@ class WakeWordService {
console.log('[WakeWord] Hintergrund-Wake = %s', enabled);
}
/** Wake-Wort-Bestaetigung (Voxtral) ein/aus (aus den Einstellungen). */
setWakeConfirmEnabled(enabled: boolean): void {
this.wakeConfirmEnabled = enabled;
console.log('[WakeWord] Wake-Bestaetigung = %s', enabled);
}
/** Ist Hintergrund-Wake an? Steuert u.a. ob der Konversationsmodus auch im
* Hintergrund weiterlaeuft (sonst: im Hintergrund direkt zurueck aufs Wake-Word). */
isBgWakeEnabled(): boolean {
return this.bgWakeEnabled;
}
/** App im Vordergrund: Detections wieder freigeben, plus kurzer Cooldown
* als Schutz gegen den AudioFocus-/AudioTrack-Spike direkt nach dem Resume.
* 1s statt 3s — 3s hat sich "traege" angefuehlt (Trigger direkt nach dem
@@ -358,8 +398,10 @@ class WakeWordService {
console.log('[WakeWord] App im Vordergrund — Cooldown 1s aktiv');
}
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
private async onWakeDetected(): Promise<void> {
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten.
* preTriggerPcm: base64 s16le 16kHz Vor-Trigger-Audio fuer die Bestaetigung
* (null = nicht verfuegbar → keine Bestaetigung, normal weiter). */
private async onWakeDetected(preTriggerPcm: string | null = null): Promise<void> {
if (this.inBackground && !this.bgWakeEnabled) {
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund, Hintergrund-Wake aus)');
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background (bg-wake off)')).catch(()=>{});
@@ -405,6 +447,22 @@ class WakeWordService {
// Kein erneutes setState — wir bleiben in 'conversing'.
return;
}
// Wake-Wort-Bestaetigung (gegen Musik-Fehltrigger): den Vor-Trigger-Schnipsel
// von Voxtral gegenpruefen. Bestaetigt → weiter (Gong + Mikro). Verworfen
// (Musik/Rauschen, kein "Computer") → kein Dialog, kein Gong, re-arm. Fail-
// open: ohne PCM / bei Timeout/Fehler laeuft es normal durch.
if (this.wakeConfirmEnabled && preTriggerPcm) {
const confirmed = await this.confirmWake(preTriggerPcm);
if (!confirmed) {
this.detectionInProgress = false;
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.start(); } catch (e) {
console.warn('[WakeWord] re-arm nach verworfener Bestaetigung failed:', e);
}
}
return;
}
}
this.setState('conversing');
// Direkt feuern — KEIN setTimeout. Im Hintergrund (Display aus) parkt
// Android den JS-Thread; ein setTimeout(200ms) kann dann Minuten lang
@@ -418,6 +476,34 @@ class WakeWordService {
});
}
/** Voxtral-Bestaetigung des Vor-Trigger-Schnipsels. true = Wake-Wort erkannt
* (oder fail-open bei Timeout/Fehler), false = Musik/Rauschen → verwerfen. */
private async confirmWake(pcm: string): Promise<boolean> {
try {
const audio = await import('./audio');
const text = await audio.transcribeBlob(pcm);
if (text === null) {
console.log('[WakeWord] Bestaetigung: Timeout/Fehler → fail-open (durchlassen)');
return true;
}
const norm = text.toLowerCase();
// Distinktive Wake-Wort-Bestandteile (>= 4 Zeichen; 'hey' o.ae. rausfiltern,
// taucht sonst in Song-Texten auf und wuerde faelschlich bestaetigen).
const kwWords = this.keyword.toLowerCase().replace(/_/g, ' ')
.split(/\s+/).filter(w => w.length >= 4);
if (kwWords.length === 0) return true; // zu kurzes Keyword → nicht pruefbar
const ok = kwWords.some(w => norm.includes(w));
console.log('[WakeWord] Bestaetigung: text=%o kw=%o → %s',
text, kwWords, ok ? 'BESTAETIGT' : 'verworfen (Musik-FP?)');
import('./logger').then(m => m.reportAppDebug('wake.confirm',
`text="${text.slice(0, 40)}" kw=${kwWords.join('|')} → ${ok ? 'ok' : 'reject'}`)).catch(() => {});
return ok;
} catch (e) {
console.warn('[WakeWord] confirmWake err → fail-open:', e);
return true;
}
}
/** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann
* "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im
* Native-Modul verhindert dass ARIAs eigene Stimme triggert.
+1622 -44
View File
File diff suppressed because it is too large Load Diff
+950
View File
@@ -0,0 +1,950 @@
"""ARIAs E-Mail: senden (SMTP) und lesen (IMAP).
Drei Grundsaetze, aus denen sich der Rest ergibt:
1. Die Zugangsdaten sieht ARIA nie.
ARIAs Claude-Container (proxy) hat /shared eingehaengt — alles unter
/shared/config kann sie lesen. Deshalb liegen Konfiguration und Passwort im
Brain unter /data, das dort NICHT eingehaengt ist. Keine Schnittstelle gibt
das Passwort je zurueck (nur `password_set: true`), kein Tool-Ergebnis
enthaelt es, der Brain-Export laesst die Geheimnis-Datei aus.
2. Die Whitelist ist eine harte Grenze, kein Prompt-Hinweis.
Geprueft wird hier im Sendepfad, fuer JEDEN Empfaenger (To, Cc, Bcc). Leere
Liste = ARIA kann nirgendwohin senden. Aendern darf die Liste nur, wer den
Admin-Schluessel hat (/data/email_admin.key) — den liest die Diagnostic,
ARIAs Container nicht. Wichtig, weil eingehende Mails Text enthalten, den
ARIA liest: eine Mail mit "schick das an X" darf nicht reichen.
3. Eingehende Mails sind Daten, keine Anweisungen.
Tool-Ergebnisse mit Mail-Inhalt sind entsprechend markiert.
Grenze, die man kennen sollte: ARIAs Container hat einen SSH-Schluessel auf die
VM. Legt sie es gezielt darauf an, kommt sie als root an jede Datei. Dieses
Modul sorgt dafuer, dass die Zugangsdaten nirgends von selbst auftauchen — es
ist kein Schutz gegen einen bewussten Einbruch.
"""
from __future__ import annotations
import hmac
import html as html_mod
import imaplib
import json
import logging
import mimetypes
import os
import re
import secrets
import smtplib
import ssl
import threading
import time
from email import policy
from email.message import EmailMessage
from email.parser import BytesParser
from email.utils import formataddr, formatdate, getaddresses, make_msgid, parseaddr
from pathlib import Path
from typing import Iterable, Optional
logger = logging.getLogger(__name__)
DATA_DIR = Path(os.environ.get("ARIA_MAIL_DATA_DIR", "/data"))
CONFIG_FILE = DATA_DIR / "email.json" # Server, Ports, Whitelist, Benutzername
SECRET_FILE = DATA_DIR / "email_secret.json" # nur Passwoerter — vom Export ausgenommen
ADMIN_KEY_FILE = DATA_DIR / "email_admin.key" # Schreibrecht auf die Konfiguration
# Wohin ARIA Anhaenge ablegt bzw. woher sie welche nehmen darf. Alles andere
# (vor allem /data, wo die Passwoerter liegen) ist tabu — auch ueber Symlinks.
UPLOAD_ROOT = Path(os.environ.get("ARIA_MAIL_UPLOAD_ROOT", "/shared/uploads"))
ATTACH_ROOTS = [Path(p) for p in os.environ.get(
"ARIA_MAIL_ATTACH_ROOTS", "/shared/uploads:/shared/projects").split(":") if p]
MAX_SEND_BYTES = 25 * 1024 * 1024 # gaengiges Limit der meisten Mailserver
MAX_BODY_CHARS = 20000 # so viel Mailtext bekommt ARIA pro Mail
NET_TIMEOUT = 30
ENCRYPTIONS = ("ssl", "starttls", "none")
DEFAULTS = {
"from_name": "ARIA",
"from_address": "",
"username": "",
"imap_host": "", "imap_port": 993, "imap_encryption": "ssl",
"smtp_host": "", "smtp_port": 465, "smtp_encryption": "ssl",
# Abweichender SMTP-Login (selten, z.B. Relay). Leer = wie IMAP.
"smtp_username": "",
"verify_tls": True,
"save_sent": True, # Kopie jeder gesendeten Mail in "Gesendet" ablegen
"whitelist": [],
}
_lock = threading.Lock()
class MailError(Exception):
"""Fehler, dessen Text ARIA/Stefan sehen duerfen (kein Geheimnis darin)."""
# ── Speicher ────────────────────────────────────────────────────────
def _read_json(path: Path) -> dict:
try:
if path.exists():
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict):
return data
except Exception as exc:
logger.warning("[mail] %s nicht lesbar: %s", path.name, exc)
return {}
def _write_private(path: Path, data: dict) -> None:
"""Atomar und mit 0600 — die Datei existiert nie mit offeneren Rechten."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp, path)
def load_config() -> dict:
cfg = dict(DEFAULTS)
cfg.update({k: v for k, v in _read_json(CONFIG_FILE).items() if k in DEFAULTS})
cfg["whitelist"] = [str(p).strip() for p in (cfg.get("whitelist") or []) if str(p).strip()]
return cfg
def _secrets() -> dict:
return _read_json(SECRET_FILE)
def public_config() -> dict:
"""Konfiguration fuer die Diagnostic. Passwoerter NUR als Ja/Nein."""
cfg = load_config()
sec = _secrets()
cfg["password_set"] = bool(sec.get("password"))
cfg["smtp_password_set"] = bool(sec.get("smtp_password"))
return cfg
def save_config(patch: dict) -> dict:
"""Konfiguration aendern. Passwort-Felder:
password / smtp_password — nicht leer = neu setzen, leer/fehlt = unveraendert
clear_password / clear_smtp_password — True = loeschen
"""
with _lock:
cfg = load_config()
for key in DEFAULTS:
if key not in patch:
continue
val = patch[key]
if key in ("imap_port", "smtp_port"):
try:
val = int(val)
except (TypeError, ValueError):
raise MailError(f"{key}: keine Zahl")
if not 1 <= val <= 65535:
raise MailError(f"{key}: {val} ist kein gueltiger Port")
elif key in ("imap_encryption", "smtp_encryption"):
val = str(val or "").lower()
if val not in ENCRYPTIONS:
raise MailError(f"{key}: erlaubt sind {', '.join(ENCRYPTIONS)}")
elif key in ("verify_tls", "save_sent"):
val = bool(val)
elif key == "whitelist":
if isinstance(val, str):
val = re.split(r"[\s,;]+", val)
val = [str(p).strip() for p in (val or []) if str(p).strip()]
for p in val:
_check_pattern(p)
else:
val = _no_newlines(str(val or "").strip())
cfg[key] = val
_write_private(CONFIG_FILE, cfg)
sec = _secrets()
changed = False
for field in ("password", "smtp_password"):
if patch.get("clear_" + field):
changed |= sec.pop(field, None) is not None
elif patch.get(field):
sec[field] = str(patch[field])
changed = True
if changed:
_write_private(SECRET_FILE, sec)
# Bewusst ohne Werte geloggt.
logger.info("[mail] Konfiguration gespeichert (Whitelist: %d Eintraege)", len(cfg["whitelist"]))
return public_config()
def admin_key() -> str:
"""Schreibschluessel fuer die Konfiguration. Wird beim ersten Zugriff
erzeugt. Die Diagnostic liest ihn aus ./aria-data/brain/data — ARIAs
Container hat diesen Pfad nicht."""
with _lock:
try:
if ADMIN_KEY_FILE.exists():
k = ADMIN_KEY_FILE.read_text(encoding="utf-8").strip()
if k:
return k
except Exception:
pass
k = secrets.token_urlsafe(32)
ADMIN_KEY_FILE.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(ADMIN_KEY_FILE), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
f.write(k)
return k
def check_admin(provided: Optional[str]) -> bool:
if not provided:
return False
return hmac.compare_digest(provided.strip(), admin_key())
def status() -> dict:
"""Was ARIA ueber ihr Postfach wissen darf — ohne Zugangsdaten."""
cfg = load_config()
sec = _secrets()
can_login = bool(cfg["username"] and sec.get("password"))
return {
"configured_imap": bool(cfg["imap_host"] and can_login),
"configured_smtp": bool(cfg["smtp_host"] and (can_login or (
cfg["smtp_username"] and sec.get("smtp_password")))),
"from_address": cfg["from_address"],
"from_name": cfg["from_name"],
"whitelist": list(cfg["whitelist"]),
}
# ── Whitelist ───────────────────────────────────────────────────────
_ADDR_RE = re.compile(r"^[^@\s<>()\[\],;:\"]+@([a-z0-9-]+(\.[a-z0-9-]+)+)$", re.IGNORECASE)
_DOMAIN_RE = re.compile(r"^[a-z0-9-]+(\.[a-z0-9-]+)+$", re.IGNORECASE)
def _pattern(p: str) -> tuple:
"""Muster zerlegen → (art, wert). Wirft bei ungueltigen Mustern.
Genau EINE Schreibweise: name@domain, Platzhalter nur als ganzes Teil.
stefan@hacker-net.de → ('adresse', 'stefan@hacker-net.de')
*@hacker-net.de → ('domain', 'hacker-net.de')
*@*.hacker-net.de → ('subdomain', 'hacker-net.de')
*@* → ('alles', '')
Kurzformen wie '*hacker-net.de' oder 'hacker-net.de' werden abgelehnt:
'*hacker-net.de' liest sich, als passte es auch auf 'evilhacker-net.de'.
In einer Sicherheitsliste soll niemand raten muessen, was ein Eintrag tut.
"""
q = (p or "").strip().lower()
if q == "*@*":
return "alles", ""
if q.count("@") != 1:
hint = q.lstrip("*@.")
raise MailError(f"Whitelist: '{p}' — Schreibweise ist name@domain"
+ (f", fuer alle Adressen der Domain: *@{hint}" if _DOMAIN_RE.match(hint) else ""))
local, dom = q.split("@")
if local == "*":
if dom.startswith("*."):
if _DOMAIN_RE.match(dom[2:]):
return "subdomain", dom[2:]
elif _DOMAIN_RE.match(dom):
return "domain", dom
raise MailError(f"Whitelist: '{p}' — Domain ungueltig ('*' nur als *@domain oder *@*.domain)")
if "*" in q or not _ADDR_RE.match(q):
raise MailError(f"Whitelist: '{p}' ist keine gueltige Adresse")
return "adresse", q
def _check_pattern(p: str) -> None:
"""Ungueltige Muster schon beim Speichern abweisen — ein Tippfehler soll
nicht still 'nichts erlaubt' oder gar 'zu viel erlaubt' bedeuten."""
_pattern(p)
def address_allowed(address: str, whitelist: Iterable[str]) -> bool:
"""Darf an diese Adresse gesendet werden? Muster siehe _pattern().
Verglichen wird die Domain als Ganzes, nie ein Textende: '*@hacker-net.de'
trifft weder 'evilhacker-net.de' noch 'mail.hacker-net.de'.
"""
addr = (address or "").strip().lower()
m = _ADDR_RE.match(addr)
if not m:
return False
domain = m.group(1)
for raw in whitelist or ():
try:
kind, val = _pattern(str(raw))
except MailError:
continue # kaputtes Muster erlaubt nichts
if kind == "alles":
return True
if kind == "adresse" and addr == val:
return True
if kind == "domain" and domain == val:
return True
# Mit Punkt davor: "mail.hacker-net.de" endet auf ".hacker-net.de",
# "evilhacker-net.de" nicht.
if kind == "subdomain" and domain.endswith("." + val):
return True
return False
def _parse_recipients(values) -> list:
"""Liste aus Strings/Listen → reine Adressen. Namen ("Max <m@x.de>") sind
erlaubt, zaehlen aber nur ueber die Adresse."""
if not values:
return []
if isinstance(values, str):
values = [values]
out = []
for name, addr in getaddresses([str(v) for v in values]):
addr = addr.strip()
if addr:
out.append((name.strip(), addr))
return out
def check_recipients(to, cc=None, bcc=None) -> tuple:
"""(erlaubt, abgelehnt) ueber ALLE Empfaenger. Ein einziger abgelehnter
blockiert die ganze Mail — teilweises Senden waere schlimmer als keins."""
wl = load_config()["whitelist"]
allowed, rejected = [], []
for name, addr in _parse_recipients(to) + _parse_recipients(cc) + _parse_recipients(bcc):
(allowed if address_allowed(addr, wl) else rejected).append(addr)
return allowed, rejected
# ── Pfade ───────────────────────────────────────────────────────────
def _safe_attach_path(p: str) -> Path:
"""Nur Dateien unter den erlaubten Wurzeln. realpath zuerst: ein Symlink in
/shared/uploads, der auf /data/email_secret.json zeigt, wuerde sonst im
Brain-Container aufgeloest — und das Passwort als Anhang verschickt."""
if not p:
raise MailError("Leerer Dateipfad")
real = Path(os.path.realpath(p))
for root in ATTACH_ROOTS:
root_real = Path(os.path.realpath(str(root)))
if real == root_real or root_real in real.parents:
if not real.is_file():
raise MailError(f"{p}: keine Datei")
return real
raise MailError(f"{p}: nur Dateien unter {', '.join(str(r) for r in ATTACH_ROOTS)} erlaubt")
def _no_newlines(s: str) -> str:
"""Kopfzeilen ohne Zeilenumbruch — sonst liessen sich Header einschmuggeln."""
return re.sub(r"[\r\n]+", " ", s or "")
def _html_to_text(html: str) -> str:
t = re.sub(r"(?is)<(script|style|head)[^>]*>.*?</\1>", "", html or "")
t = re.sub(r"(?i)<br\s*/?>", "\n", t)
t = re.sub(r"(?i)</(p|div|tr|h[1-6]|li)>", "\n", t)
t = re.sub(r"(?s)<[^>]+>", "", t)
t = html_mod.unescape(t)
t = re.sub(r"[ \t]+", " ", t)
return re.sub(r"\n\s*\n+", "\n\n", t).strip()
# ── Nachricht bauen ─────────────────────────────────────────────────
def build_message(cfg: dict, to, subject: str, text: str = "", html: str = "",
cc=None, attachments: Optional[list] = None,
inline_images: Optional[list] = None,
in_reply_to: str = "", references: str = "") -> EmailMessage:
"""Baut die MIME-Struktur:
multipart/mixed (nur wenn Anhaenge)
multipart/alternative
text/plain (immer — aus HTML erzeugt, wenn keiner da)
multipart/related (nur wenn eingebettete Bilder)
text/html
image/* Content-ID <cid>
application/... Anhaenge
Bcc steht absichtlich NICHT im Header — er geht nur in die Umschlag-
Empfaenger beim Senden.
"""
if not (text or html):
raise MailError("Leere Mail: text oder html angeben")
if inline_images and not html:
raise MailError("Eingebettete Bilder brauchen html (<img src=\"cid:...\">)")
msg = EmailMessage(policy=policy.SMTP)
msg["From"] = formataddr((_no_newlines(cfg["from_name"]), cfg["from_address"]))
msg["To"] = ", ".join(formataddr(r) for r in _parse_recipients(to))
if cc:
msg["Cc"] = ", ".join(formataddr(r) for r in _parse_recipients(cc))
msg["Subject"] = _no_newlines(subject or "")
msg["Date"] = formatdate(localtime=True)
domain = (cfg["from_address"].split("@", 1)[1] if "@" in cfg["from_address"] else None)
msg["Message-ID"] = make_msgid(domain=domain)
if in_reply_to:
msg["In-Reply-To"] = _no_newlines(in_reply_to)
msg["References"] = _no_newlines((references + " " + in_reply_to).strip())
msg.set_content(text or _html_to_text(html))
if html:
msg.add_alternative(html, subtype="html")
if inline_images:
html_part = msg.get_payload()[-1]
for img in inline_images:
path = _safe_attach_path(img.get("path", ""))
cid = (img.get("cid") or path.name).strip("<>")
ctype = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
maintype, subtype = ctype.split("/", 1)
if maintype != "image":
raise MailError(f"{path.name}: eingebettet werden nur Bilder")
html_part.add_related(path.read_bytes(), maintype=maintype, subtype=subtype,
cid=f"<{cid}>", filename=path.name,
disposition="inline")
for p in attachments or []:
path = _safe_attach_path(p)
ctype = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
maintype, subtype = ctype.split("/", 1)
msg.add_attachment(path.read_bytes(), maintype=maintype, subtype=subtype,
filename=path.name)
return msg
# ── Verbindungen ────────────────────────────────────────────────────
def _tls_context(cfg: dict) -> ssl.SSLContext:
ctx = ssl.create_default_context()
if not cfg.get("verify_tls", True):
# Fuer eigene Server mit selbstsigniertem Zertifikat. Bewusste Wahl in
# der Diagnostic — verschluesselt bleibt es trotzdem.
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def _login_error(proto: str, exc: Exception) -> MailError:
# Fehlertexte der Server enthalten nie das Passwort — trotzdem nur den
# Typ + gekuerzte Meldung weitergeben.
return MailError(f"{proto}: {type(exc).__name__}: {str(exc)[:200]}")
def _smtp_open(cfg: dict):
sec = _secrets()
user = cfg["smtp_username"] or cfg["username"]
pw = sec.get("smtp_password") if cfg["smtp_username"] else sec.get("password")
if not (cfg["smtp_host"] and user and pw):
raise MailError("SMTP ist nicht eingerichtet (Diagnostic → Einstellungen → ARIA E-Mail)")
enc = cfg["smtp_encryption"]
try:
if enc == "ssl":
s = smtplib.SMTP_SSL(cfg["smtp_host"], cfg["smtp_port"], timeout=NET_TIMEOUT,
context=_tls_context(cfg))
else:
s = smtplib.SMTP(cfg["smtp_host"], cfg["smtp_port"], timeout=NET_TIMEOUT)
s.ehlo()
if enc == "starttls":
s.starttls(context=_tls_context(cfg))
s.ehlo()
s.login(user, pw)
return s
except MailError:
raise
except Exception as exc:
raise _login_error("SMTP", exc)
def _imap_open(cfg: dict):
sec = _secrets()
if not (cfg["imap_host"] and cfg["username"] and sec.get("password")):
raise MailError("IMAP ist nicht eingerichtet (Diagnostic → Einstellungen → ARIA E-Mail)")
enc = cfg["imap_encryption"]
try:
if enc == "ssl":
m = imaplib.IMAP4_SSL(cfg["imap_host"], cfg["imap_port"],
ssl_context=_tls_context(cfg), timeout=NET_TIMEOUT)
else:
m = imaplib.IMAP4(cfg["imap_host"], cfg["imap_port"], timeout=NET_TIMEOUT)
if enc == "starttls":
m.starttls(ssl_context=_tls_context(cfg))
m.login(cfg["username"], sec["password"])
return m
except MailError:
raise
except Exception as exc:
raise _login_error("IMAP", exc)
def _imap_close(m) -> None:
try:
m.logout()
except Exception:
pass
def test_connection() -> dict:
"""Login an beiden Servern pruefen, ohne etwas zu senden."""
cfg = load_config()
out = {}
for proto, opener, closer in (("imap", _imap_open, _imap_close),
("smtp", _smtp_open, lambda s: s.quit())):
try:
conn = opener(cfg)
try:
closer(conn)
except Exception:
pass
out[proto] = {"ok": True}
except MailError as exc:
out[proto] = {"ok": False, "error": str(exc)}
return out
# ── Senden ──────────────────────────────────────────────────────────
def send(to, subject: str, text: str = "", html: str = "", cc=None, bcc=None,
attachments: Optional[list] = None, inline_images: Optional[list] = None,
reply_to_uid: str = "", reply_folder: str = "INBOX") -> dict:
cfg = load_config()
if not cfg["from_address"]:
raise MailError("Keine Absender-Adresse eingerichtet")
recipients = _parse_recipients(to) + _parse_recipients(cc) + _parse_recipients(bcc)
if not recipients:
raise MailError("Kein Empfaenger")
if not cfg["whitelist"]:
raise MailError("Die Whitelist ist leer — ich darf an niemanden senden. "
"Stefan traegt erlaubte Adressen in der Diagnostic ein.")
_, rejected = check_recipients(to, cc, bcc)
if rejected:
raise MailError("Nicht auf der Whitelist: " + ", ".join(rejected)
+ " — nichts gesendet. Erlaubt: " + ", ".join(cfg["whitelist"]))
in_reply_to = references = ""
if reply_to_uid:
orig = _fetch_message(reply_folder, reply_to_uid, mark_read=False)
in_reply_to = orig.get("Message-ID", "") or ""
references = orig.get("References", "") or ""
msg = build_message(cfg, to, subject, text=text, html=html, cc=cc,
attachments=attachments, inline_images=inline_images,
in_reply_to=in_reply_to, references=references)
raw = msg.as_bytes()
if len(raw) > MAX_SEND_BYTES:
raise MailError(f"Mail ist {len(raw) // (1024 * 1024)} MB gross — Grenze "
f"{MAX_SEND_BYTES // (1024 * 1024)} MB")
s = _smtp_open(cfg)
try:
s.send_message(msg, from_addr=cfg["from_address"],
to_addrs=[a for _, a in recipients])
except Exception as exc:
raise MailError(f"SMTP-Versand: {type(exc).__name__}: {str(exc)[:200]}")
finally:
try:
s.quit()
except Exception:
pass
saved = ""
if cfg.get("save_sent"):
try:
saved = _append_sent(cfg, raw)
except Exception as exc:
# Gesendet ist gesendet — die Kopie ist Komfort, kein Fehler.
logger.warning("[mail] Kopie in Gesendet fehlgeschlagen: %s", exc)
logger.info("[mail] gesendet an %d Empfaenger, %d Byte", len(recipients), len(raw))
return {"ok": True, "message_id": msg["Message-ID"], "recipients": [a for _, a in recipients],
"bytes": len(raw), "saved_to": saved}
def _find_sent_folder(m) -> str:
typ, data = m.list()
names = []
for line in data or []:
if not line:
continue
s = line.decode("utf-8", "replace") if isinstance(line, bytes) else str(line)
name = _parse_list_name(s)
if "\\Sent" in s:
return name
names.append(name)
for cand in ("Sent", "Gesendet", "INBOX.Sent", "Sent Items", "Gesendete Objekte",
"INBOX.Gesendet"):
for n in names:
if n.lower() == cand.lower():
return n
return ""
SENT_FALLBACK = "Gesendet"
def _append_sent(cfg: dict, raw: bytes) -> str:
m = _imap_open(cfg)
try:
folder = _find_sent_folder(m)
if not folder:
# Kein Gesendet-Ordner (frisches Postfach, manche Hoster): anlegen.
# Sonst ginge die Kopie still verloren — und Stefan saehe in der
# Diagnostic nicht, was ARIA verschickt hat.
typ, _ = m.create(_quote(SENT_FALLBACK))
if typ != "OK":
logger.warning("[mail] Ordner %r nicht anlegbar — keine Kopie", SENT_FALLBACK)
return ""
folder = SENT_FALLBACK
m.append(_quote(folder), "(\\Seen)", imaplib.Time2Internaldate(time.time()), raw)
return folder
finally:
_imap_close(m)
# ── Lesen ───────────────────────────────────────────────────────────
_LIST_RE = re.compile(r'\((?P<flags>[^)]*)\)\s+(?P<delim>"[^"]*"|NIL)\s+(?P<name>.+)$')
def _parse_list_name(line: str) -> str:
m = _LIST_RE.match(line.strip())
if not m:
return line.strip()
name = m.group("name").strip()
if name.startswith('"') and name.endswith('"'):
name = name[1:-1].replace('\\"', '"').replace("\\\\", "\\")
return name
def imap_utf7_decode(name: str) -> str:
"""'Entw&APw-rfe' → 'Entwürfe'. IMAP kodiert Ordnernamen in modifiziertem
UTF-7 (RFC 3501). Nur fuer die ANZEIGE — an den Server geht der Rohname."""
import base64
def dec(m):
chunk = m.group(1)
if chunk == "":
return "&"
b = chunk.replace(",", "/")
b += "=" * (-len(b) % 4)
try:
return base64.b64decode(b).decode("utf-16-be")
except Exception:
return m.group(0)
return re.sub(r"&([A-Za-z0-9+,]*)-", dec, name or "")
def _quote(folder: str) -> str:
return '"' + folder.replace("\\", "\\\\").replace('"', '\\"') + '"'
_SENT_NAMES = {"sent", "gesendet", "inbox.sent", "sent items", "gesendete objekte",
"inbox.gesendet", "gesendete elemente"}
def list_folders() -> list:
"""[{name (roh, fuer den Server), label (lesbar), role}], Posteingang und
Gesendet zuerst. role: 'inbox' | 'sent' | ''. Gesendet wird ueber das
Server-Flag \\Sent erkannt, sonst ueber die ueblichen Namen."""
m = _imap_open(load_config())
try:
typ, data = m.list()
out = []
for line in data or []:
if not line:
continue
s = line.decode("utf-8", "replace") if isinstance(line, bytes) else str(line)
if "\\Noselect" in s:
continue
name = _parse_list_name(s)
label = imap_utf7_decode(name)
lm = _LIST_RE.match(s.strip())
delim = (lm.group("delim").strip('"') if lm and lm.group("delim") != "NIL" else "")
role = ("inbox" if name.upper() == "INBOX" else
"sent" if "\\Sent" in s or label.lower() in _SENT_NAMES else "")
# Unterordner: Tiefe + letzter Namensteil fuer die Baumansicht.
parts = label.split(delim) if delim else [label]
out.append({"name": name, "label": label, "role": role,
"depth": len(parts) - 1, "short": parts[-1]})
# Posteingang + Gesendet nach oben; der Rest nach vollem Pfad, damit
# Unterordner direkt unter ihrem Elternordner stehen.
order = {"inbox": 0, "sent": 1}
out.sort(key=lambda f: (order.get(f["role"], 2), f["label"].lower()))
return out
finally:
_imap_close(m)
def _uid_ok(uid) -> str:
u = str(uid or "").strip()
if not u.isdigit():
raise MailError(f"Ungueltige UID: {uid!r}")
return u
def _select(m, folder: str, readonly: bool) -> None:
typ, data = m.select(_quote(folder or "INBOX"), readonly=readonly)
if typ != "OK":
raise MailError(f"Ordner {folder!r} nicht gefunden")
def list_messages(folder: str = "INBOX", limit: int = 20, offset: int = 0,
unread_only: bool = False, search: str = "") -> dict:
limit = max(1, min(int(limit or 20), 100))
offset = max(0, int(offset or 0))
m = _imap_open(load_config())
try:
_select(m, folder, readonly=True)
if search:
m.literal = search.encode("utf-8")
crit = ["CHARSET", "UTF-8"] + (["UNSEEN"] if unread_only else []) + ["TEXT"]
typ, data = m.uid("SEARCH", *crit)
else:
typ, data = m.uid("SEARCH", "UNSEEN" if unread_only else "ALL")
uids = (data[0] or b"").split() if data else []
uids = sorted((int(u) for u in uids), reverse=True)
total = len(uids)
page = uids[offset:offset + limit]
items = []
if page:
fields = "FROM TO CC SUBJECT DATE CONTENT-TYPE MESSAGE-ID"
typ, data = m.uid("FETCH", ",".join(str(u) for u in page),
f"(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS ({fields})])")
by_uid = {}
last = None
# imaplib liefert pro Mail ein Tupel (Meta, Header-Bytes) und danach
# ein Bytes-Stueck mit dem Rest der Zeile. Manche Server (Dovecot je
# nach Reihenfolge, Exchange) schreiben FLAGS erst DORT hin — nur im
# Meta-Teil zu suchen hiesse: jede Mail "ungelesen".
for part in data or []:
if isinstance(part, tuple):
meta = part[0].decode("utf-8", "replace")
um = re.search(r"UID (\d+)", meta)
if not um:
last = None
continue
hdr = BytesParser(policy=policy.default).parsebytes(part[1], headersonly=True)
sm = re.search(r"RFC822\.SIZE (\d+)", meta)
ctype = str(hdr.get("Content-Type", "") or "").lower()
last = int(um.group(1))
by_uid[last] = {
"uid": um.group(1),
"from": str(hdr.get("From", "") or ""),
"to": str(hdr.get("To", "") or ""),
"subject": str(hdr.get("Subject", "") or ""),
"date": str(hdr.get("Date", "") or ""),
"seen": False,
"size": int(sm.group(1)) if sm else 0,
"maybe_attachments": "multipart/mixed" in ctype,
}
meta_flags = re.search(r"FLAGS \(([^)]*)\)", meta)
if meta_flags:
by_uid[last]["seen"] = "\\Seen" in meta_flags.group(1)
elif isinstance(part, bytes) and last is not None:
tail_flags = re.search(r"FLAGS \(([^)]*)\)", part.decode("utf-8", "replace"))
if tail_flags:
by_uid[last]["seen"] = "\\Seen" in tail_flags.group(1)
items = [by_uid[u] for u in page if u in by_uid]
return {"folder": folder, "total": total, "offset": offset, "messages": items}
finally:
_imap_close(m)
def _fetch_message(folder: str, uid, mark_read: bool):
u = _uid_ok(uid)
m = _imap_open(load_config())
try:
_select(m, folder, readonly=not mark_read)
typ, data = m.uid("FETCH", u, "(BODY.PEEK[])")
raw = next((p[1] for p in (data or []) if isinstance(p, tuple)), None)
if raw is None:
raise MailError(f"Mail UID {u} in {folder!r} nicht gefunden")
if mark_read:
m.uid("STORE", u, "+FLAGS", "(\\Seen)")
return BytesParser(policy=policy.default).parsebytes(raw)
finally:
_imap_close(m)
def _safe_name(name: str) -> str:
n = re.sub(r"[^\w.\- ]+", "_", os.path.basename(name or "")).strip(" .")
return n[:120] or "anhang"
def _bodies(msg) -> tuple:
text_part = msg.get_body(preferencelist=("plain",))
html_part = msg.get_body(preferencelist=("html",))
text = text_part.get_content() if text_part is not None else ""
html = html_part.get_content() if html_part is not None else ""
return text, html
def read_message(folder: str, uid, mark_read: bool = True) -> dict:
"""Fuer ARIA: Kopfzeilen, Text (HTML → Text, wenn es nur HTML gibt) und
Anhaenge — die landen unter /shared/uploads/email/, damit ARIA sie wie
jeden anderen Upload ansehen kann."""
msg = _fetch_message(folder, uid, mark_read)
text, html = _bodies(msg)
body = text or _html_to_text(html)
truncated = len(body) > MAX_BODY_CHARS
attachments = []
target = UPLOAD_ROOT / "email" / f"{_safe_name(folder)}-{_uid_ok(uid)}"
for i, part in enumerate(msg.iter_attachments()):
name = _safe_name(part.get_filename() or f"anhang-{i + 1}")
data = part.get_payload(decode=True) or b""
target.mkdir(parents=True, exist_ok=True)
path = target / name
path.write_bytes(data)
attachments.append({"name": name, "path": str(path), "size": len(data),
"type": part.get_content_type()})
return {
"uid": _uid_ok(uid), "folder": folder,
"from": str(msg.get("From", "") or ""), "to": str(msg.get("To", "") or ""),
"cc": str(msg.get("Cc", "") or ""), "subject": str(msg.get("Subject", "") or ""),
"date": str(msg.get("Date", "") or ""),
"message_id": str(msg.get("Message-ID", "") or ""),
"body": body[:MAX_BODY_CHARS], "truncated": truncated,
"had_html": bool(html), "attachments": attachments,
}
def view_message(folder: str, uid) -> dict:
"""Fuer den Postfach-Viewer: wie read_message, aber OHNE als gelesen zu
markieren und mit dem HTML. Eingebettete Bilder (cid:) werden zu data:-URIs,
damit der Viewer sie zeigen kann, ohne irgendetwas nachzuladen."""
import base64
msg = _fetch_message(folder, uid, mark_read=False)
text, html = _bodies(msg)
if html:
for part in msg.walk():
cid = (part.get("Content-ID") or "").strip().strip("<>")
if cid and part.get_content_maintype() == "image":
b64 = base64.b64encode(part.get_payload(decode=True) or b"").decode("ascii")
html = html.replace(f"cid:{cid}", f"data:{part.get_content_type()};base64,{b64}")
attachments = [{"index": i, "name": _safe_name(p.get_filename() or f"anhang-{i + 1}"),
"type": p.get_content_type(),
"size": len(p.get_payload(decode=True) or b"")}
for i, p in enumerate(msg.iter_attachments())]
return {
"uid": _uid_ok(uid), "folder": folder,
"from": str(msg.get("From", "") or ""), "to": str(msg.get("To", "") or ""),
"cc": str(msg.get("Cc", "") or ""), "subject": str(msg.get("Subject", "") or ""),
"date": str(msg.get("Date", "") or ""), "text": text, "html": html,
"attachments": attachments,
}
def get_attachment(folder: str, uid, index: int) -> tuple:
msg = _fetch_message(folder, uid, mark_read=False)
for i, part in enumerate(msg.iter_attachments()):
if i == int(index):
return (_safe_name(part.get_filename() or f"anhang-{i + 1}"),
part.get_content_type(), part.get_payload(decode=True) or b"")
raise MailError("Anhang nicht gefunden")
# ── Ordner + Verschieben ────────────────────────────────────────────
def imap_utf7_encode(name: str) -> str:
"""'Entwürfe' → 'Entw&APw-rfe' (Gegenstueck zu imap_utf7_decode)."""
import base64
out, buf = [], []
def flush():
if buf:
b = base64.b64encode("".join(buf).encode("utf-16-be")).decode("ascii")
out.append("&" + b.rstrip("=").replace("/", ",") + "-")
buf.clear()
for ch in name:
if 0x20 <= ord(ch) <= 0x7E:
flush()
out.append("&-" if ch == "&" else ch)
else:
buf.append(ch)
flush()
return "".join(out)
def _delimiter(m) -> str:
typ, data = m.list('""', '""')
for line in data or []:
s = line.decode("utf-8", "replace") if isinstance(line, bytes) else str(line)
lm = _LIST_RE.match(s.strip())
if lm and lm.group("delim") != "NIL":
return lm.group("delim").strip('"')
return "/"
def _resolve_folder(folder: str) -> str:
"""Ordner per Rohname ODER lesbarem Namen finden (ARIA sagt 'Entwürfe',
der Server kennt 'Entw&APw-rfe'). Gross/klein egal."""
want = (folder or "").strip()
if not want:
raise MailError("Kein Ordner angegeben")
folders = list_folders()
for f in folders:
if f["name"] == want or f["label"] == want:
return f["name"]
for f in folders:
if want.lower() in (f["name"].lower(), f["label"].lower()):
return f["name"]
raise MailError(f"Ordner {folder!r} gibt es nicht. Vorhanden: "
+ ", ".join(f["label"] for f in folders))
def create_folder(name: str, parent: str = "") -> dict:
"""Ordner anlegen, optional als Unterordner. Der Name darf das
Trennzeichen des Servers nicht enthalten — sonst entstuende still eine
ganze Kette von Ordnern statt des einen gewuenschten."""
name = (name or "").strip()
if not name:
raise MailError("Kein Ordnername")
parent_raw = _resolve_folder(parent) if parent else ""
m = _imap_open(load_config())
try:
delim = _delimiter(m)
if delim and delim in name:
raise MailError(f"Ordnername darf {delim!r} nicht enthalten — fuer Unterordner 'parent' angeben")
raw = (parent_raw + delim if parent_raw else "") + imap_utf7_encode(name)
typ, data = m.create(_quote(raw))
if typ != "OK":
msg = (data[0].decode("utf-8", "replace") if data and isinstance(data[0], bytes) else str(data))
raise MailError(f"Anlegen abgelehnt: {msg[:200]}")
m.subscribe(_quote(raw)) # sonst zeigen manche Mailprogramme ihn nicht
return {"name": raw, "label": imap_utf7_decode(raw)}
finally:
_imap_close(m)
def move_message(folder: str, uid, target: str) -> dict:
"""Mail in einen anderen Ordner verschieben.
Mit MOVE (RFC 6851) in einem Schritt. Ohne: kopieren, als geloescht
markieren und NUR diese Mail entfernen (UID EXPUNGE, braucht UIDPLUS). Ein
nacktes EXPUNGE wuerde alles mitnehmen, was im Ordner sonst noch als
geloescht markiert ist — dann bleibt die Mail lieber markiert liegen.
"""
u = _uid_ok(uid)
src = _resolve_folder(folder or "INBOX")
dst = _resolve_folder(target)
if src == dst:
raise MailError("Quelle und Ziel sind derselbe Ordner")
m = _imap_open(load_config())
try:
_select(m, src, readonly=False)
caps = {c.upper() for c in (m.capabilities or ())}
if "MOVE" in caps:
typ, data = m.uid("MOVE", u, _quote(dst))
if typ != "OK":
raise MailError(f"Verschieben abgelehnt: {data!r}"[:200])
return {"ok": True, "from": imap_utf7_decode(src), "to": imap_utf7_decode(dst), "how": "move"}
typ, data = m.uid("COPY", u, _quote(dst))
if typ != "OK":
raise MailError(f"Kopieren abgelehnt: {data!r}"[:200])
m.uid("STORE", u, "+FLAGS", "(\\Deleted)")
if "UIDPLUS" in caps:
m.uid("EXPUNGE", u)
how = "copy+expunge"
else:
how = "copy (Original als geloescht markiert)"
return {"ok": True, "from": imap_utf7_decode(src), "to": imap_utf7_decode(dst), "how": how}
finally:
_imap_close(m)
+15 -4
View File
@@ -36,7 +36,8 @@ TICK_SEC = 8
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
def _push_to_bridge(reply: str, trigger_name: str, ttype: str, events: list) -> None:
def _push_to_bridge(reply: str, trigger_name: str, ttype: str, events: list,
project_id: str = "") -> None:
"""POSTed eine Trigger-Antwort an die Bridge fuer RVS-Broadcast + TTS.
Synchron via urllib — wird per run_in_executor aus dem async-Loop
@@ -47,6 +48,10 @@ def _push_to_bridge(reply: str, trigger_name: str, ttype: str, events: list) ->
"trigger_name": trigger_name,
"type": ttype,
"events": events or [],
# Kontext, in dem der Trigger angelegt wurde. Leer = Hauptchat. Ohne das
# landete JEDE Trigger-Antwort im Hauptchat, auch wenn der Timer mitten
# in einem Projekt-Thread gestellt wurde.
"projectId": project_id or "",
}).encode("utf-8")
url = f"{BRIDGE_URL}/internal/trigger-fired"
try:
@@ -156,17 +161,23 @@ async def _fire(trigger: dict, agent_factory) -> None:
# Executor auslagern, damit der Event-Loop frei bleibt.
loop = asyncio.get_running_loop()
# Projekt, in dem der Trigger angelegt wurde — der Turn laeuft darin, und
# die Antwort geht auch dorthin zurueck (statt immer in den Hauptchat).
trigger_pid = str(trigger.get("project_id") or "")
def _run_turn():
a = agent_factory()
rep, *_rest = a.chat(prompt, source="trigger")
rep, *_rest = a.chat(prompt, source="trigger", project_id=trigger_pid)
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])
logger.info("[trigger] %s gefeuert (project=%s) → ARIA-Reply: %s",
name, trigger_pid or "(main)", 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.
await loop.run_in_executor(None, _push_to_bridge, reply, name, ttype, events)
await loop.run_in_executor(None, _push_to_bridge, reply, name, ttype,
events, trigger_pid)
except Exception as e:
logger.exception("Trigger %s feuern fehlgeschlagen: %s", name, e)
triggers_mod.append_log(name, {"event": "error", "error": str(e)[:300]})
+69 -9
View File
@@ -13,11 +13,27 @@ Haltung fort (Self-Grounding rueckwaerts). Der --system-prompt-Fix verhindert
NEUE Vergiftung, aber die bestehenden Gift-Turns muessen einmalig raus, sonst
zieht die History das Modell weiter aus der Rolle.
Zweite Giftquelle: Anthropics Sicherheitsfilter
----------------------------------------------
Dasselbe Muster, anderer Auslöser. Der Filter ("... safeguards flagged this
message", `Details: [cyber]`) antwortet nicht mit einem HTTP-Fehler: die CLI
liefert ein normales Ergebnis mit is_error=true und dem Fehlertext im content.
Vor dem safeguard.py-Fix wurde dieser Text als ARIAs Antwort persistiert — und
die ausloesende Frage steht direkt davor. Beides geht bei jedem Folge-Turn
wieder mit, also greift der Filter wieder: das Projekt ist tot.
Neue Vorfaelle faengt das Brain jetzt selbst ab (Modellwechsel, im Notfall
Quarantaene). Was VORHER schon in der Datei steht, muss einmalig raus — dafuer
ist dieses Script da. Diese Sorte wird in ALLEN Kontexten gesucht, nicht nur im
Hauptthread: Projekt-Chats sind genau die, die daran sterben.
Was das Script tut
------------------
- Findet Hauptthread-Assistant-Turns (KEIN project_id), deren Inhalt eindeutig
eine Rollen-Ablehnung ist: enthaelt "claude code" UND einen zweiten Marker
(injiz/inject/fabriz/fabricat/adoptier/adopting/prompt injection/keine echten).
- Findet in JEDEM Kontext Assistant-Turns, die der Fehlertext des
Sicherheitsfilters sind.
- Entfernt diese Assistant-Turns PLUS den unmittelbar davor stehenden
Hauptthread-User-Turn (die ausloesende Frage) — also den ganzen Fehl-Dialog.
- Laesst ALLES andere unangetastet: projekt-getaggte Turns, distill-Marker,
@@ -61,10 +77,39 @@ _BREAK = re.compile(
)
# Der Fehlertext des Sicherheitsfilters. Gleiche Marker wie safeguard.py; hier
# dupliziert, damit das Script ohne Brain-Imports auf dem Host laeuft.
# Verlangt wird ZWEIERLEI: der Text muss mit "API Error" beginnen UND einen
# Filter-Marker enthalten. Sonst traefe es auch Turns, in denen ARIA ueber den
# Filter *spricht* — und eine Erklaerung ist kein Gift.
_FILTER_MARKERS = (
"safeguards flagged this message",
"anthropic.com/legal/aup",
"can't respond to this message with",
"cannot respond to this message with",
"try rephrasing the request in a new session",
)
def is_poison(content: str) -> bool:
return bool(_BREAK.search(content or ""))
def is_filter_error(content: str) -> bool:
t = (content or "").strip().lower()
if not t.startswith("api error"):
return False
return any(m in t for m in _FILTER_MARKERS)
def project_of(obj: dict) -> str:
"""Projekt-Tag eines Turns. Brain nutzt 'project_id', UI/Bridge 'projectId'."""
pid = obj.get("project_id")
if pid is None:
pid = obj.get("projectId")
return str(pid or "").strip()
def get_content(obj: dict) -> str:
"""conversation.jsonl nutzt 'content', chat_backup.jsonl nutzt 'text'."""
v = obj.get("content")
@@ -112,24 +157,38 @@ def main() -> int:
continue
if obj.get("op") == "distill":
continue
if obj.get("role") != "assistant" or not is_main_thread(obj):
if obj.get("role") != "assistant":
continue
content = get_content(obj)
if not content or not is_poison(content):
if not content:
continue
# Zwei Giftsorten mit unterschiedlichem Geltungsbereich:
# Rollen-Ablehnung — nur Hauptthread (so war es immer; in Projekten
# waere das Risiko eines False Positive hoeher als der Nutzen).
# Sicherheitsfilter — ueberall, denn genau Projekt-Chats sterben daran.
if is_filter_error(content):
reason = "Sicherheitsfilter"
elif is_main_thread(obj) and is_poison(content):
reason = "Rollen-Ablehnung"
else:
continue
# Gift-Assistant-Turn -> droppen
drop[i] = True
user_idx = None
# Unmittelbar davor stehenden Hauptthread-User-Turn (die Frage) mit weg.
pid = project_of(obj)
# Unmittelbar davor stehenden User-Turn DESSELBEN Kontexts (die
# ausloesende Frage) mit weg — sonst bleibt der Ausloeser im Fenster.
for j in range(i - 1, -1, -1):
pj = parsed[j][1]
if not isinstance(pj, dict) or pj.get("op") == "distill":
continue
if pj.get("role") == "user" and is_main_thread(pj):
if project_of(pj) != pid:
continue # fremder Kontext — ueberspringen, nicht abbrechen
if pj.get("role") == "user":
drop[j] = True
user_idx = j
break # nur der direkt vorangehende Turn
poisoned_pairs.append((i, user_idx))
break # nur der direkt vorangehende Turn dieses Kontexts
poisoned_pairs.append((i, user_idx, reason, pid))
n_drop = sum(drop)
if n_drop == 0:
@@ -138,12 +197,13 @@ def main() -> int:
print(f"Gefundene Fehl-Dialoge: {len(poisoned_pairs)} "
f"(insgesamt {n_drop} Zeilen zu entfernen)\n")
for a_idx, u_idx in poisoned_pairs:
for a_idx, u_idx, reason, pid in poisoned_pairs:
print(f" [{reason}] Kontext: {pid or 'Hauptchat'}")
if u_idx is not None:
uq = get_content(parsed[u_idx][1] or {})
print(f" Frage (Zeile {u_idx + 1}): {uq[:90]!r}")
print(f" Frage (Zeile {u_idx + 1}): {uq[:90]!r}")
ac = get_content(parsed[a_idx][1] or {})
print(f" Ablehng (Zeile {a_idx + 1}): {ac[:90]!r}")
print(f" Antwort (Zeile {a_idx + 1}): {ac[:90]!r}")
print()
if not apply:
+95
View File
@@ -0,0 +1,95 @@
"""Welches Modell gilt in welchem Chat.
Drei Ebenen, von unten nach oben:
1. Einstellung — `brainModel` aus runtime.json (Diagnostic → Sprachmodell).
Gilt fuer alles, was keine eigene Wahl hat.
2. Pro Chat — ein Override je Kontext ("" = Hauptchat, sonst project_id).
Damit kann ein Projekt bewusst auf einem anderen Modell laufen, ohne das
Hauptmodell fuer alles umzustellen.
3. Ausweich — Anthropics Sicherheitsfilter hat das gewaehlte Modell an einem
Turn blockiert, der noch im Fenster liegt (safeguard.py). Das sticht alles,
ist aber nur so lange aktiv, wie der Turn im Fenster steht.
Warum ueberhaupt ein eigener Speicher und kein Feld im Projekt-Manifest: der
Hauptchat IST kein Projekt. Ein Manifest-Feld haette ihn aussen vor gelassen,
und genau dort faellt eine Modellwahl am ehesten an.
"""
from __future__ import annotations
import json
import logging
import os
import threading
from pathlib import Path
from typing import Tuple
logger = logging.getLogger(__name__)
STORE_FILE = Path(os.environ.get("CONTEXT_MODELS_FILE",
"/shared/config/context_models.json"))
# Begruendungen, wie sie die Oberflaechen anzeigen. Bewusst kurze Schluessel —
# App und Diagnostic uebersetzen sie selbst, damit die Texte dort hingehoeren,
# wo sie gelesen werden.
REASON_SETTING = "einstellung"
REASON_PER_CHAT = "pro-chat"
REASON_FALLBACK = "ausweich"
_lock = threading.Lock()
def _load() -> dict:
try:
if STORE_FILE.exists():
data = json.loads(STORE_FILE.read_text(encoding="utf-8")) or {}
if isinstance(data, dict):
# Nur String→String; alles andere waere ein Schreibfehler von
# aussen und wuerde sonst still als Modellname durchgehen.
return {str(k): str(v) for k, v in data.items() if isinstance(v, str)}
except Exception as exc:
logger.warning("[context-model] lesen fehlgeschlagen: %s", exc)
return {}
def _save(data: dict) -> None:
try:
STORE_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = STORE_FILE.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(STORE_FILE)
except Exception as exc:
logger.warning("[context-model] schreiben fehlgeschlagen: %s", exc)
def all_overrides() -> dict:
return _load()
def get(context_id: str) -> str:
"""Der Override dieses Kontexts, oder "" wenn keiner gesetzt ist."""
return _load().get(context_id or "", "")
def set(context_id: str, model: str) -> str:
"""Override setzen. Leerer Modellname loescht ihn (= wieder Einstellung)."""
cid = context_id or ""
m = (model or "").strip()
with _lock:
data = _load()
if m:
data[cid] = m
else:
data.pop(cid, None)
_save(data)
logger.info("[context-model] %s → %s", cid or "(Hauptchat)", m or "(Einstellung)")
return m
def resolve(context_id: str, default_model: str) -> Tuple[str, str]:
"""(Modell, Begruendung) ohne Sicherheitsfilter — den kennt nur der Agent."""
ov = get(context_id)
if ov:
return ov, REASON_PER_CHAT
return default_model, REASON_SETTING
+1 -1
View File
@@ -1,7 +1,7 @@
"""
Local-LLM-Client (Plan B) — Brain-Seite.
Ruft das schnelle lokale LLM (Qwen3 auf der Gamebox) ueber die Bridge:
Ruft das schnelle lokale LLM (Qwen3 auf der AI-Box) ueber die Bridge:
Brain → HTTP /internal/local-llm → Bridge → RVS → llm-adapter → llama.cpp
Analog zum Claude-`proxy_client`, nur ueber die Bridge (die ist der RVS-Client;
+269 -2
View File
@@ -23,7 +23,7 @@ from typing import List, Optional
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request, UploadFile, File
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request, UploadFile, File, Header
from fastapi.responses import Response
from pydantic import BaseModel, Field
@@ -38,6 +38,9 @@ import watcher as watcher_mod
import background as background_mod
import oauth as oauth_mod
import seed_rules as seed_rules_mod
import safeguard as safeguard_mod
import context_model as context_model_mod
import aria_mail
import projects as projects_mod
import project_vms as project_vms_mod
@@ -398,6 +401,19 @@ def memory_delete(point_id: str):
# ─── Memory-Anhaenge ──────────────────────────────────────────────────
@app.get("/attachments/limit")
def memory_attachments_limit():
"""Aktuelles Groessen-Limit fuer Memory-Anhaenge.
Die Oberflaechen zeigen damit die ECHTE Zahl an statt eines fest
eingetippten "max 20 MB" — das stand vorher im HTML und stimmte nicht mehr,
sobald jemand das Limit anhob.
"""
import memory_attachments as mem_att
limit = mem_att.max_bytes()
return {"maxBytes": limit, "maxMb": round(limit / 1024 / 1024, 1)}
@app.get("/memory/{point_id}/attachments")
def memory_attachments_list(point_id: str):
"""Liste der Anhaenge zum Memory. Source-of-Truth ist das Payload
@@ -666,6 +682,11 @@ class ChatOut(BaseModel):
# Welcher Backend die Antwort erzeugt hat: "local" (Qwen), "claude",
# "fast-path" (Skill/Regex). Fuer den Quell-Badge in Diagnostic.
answered_by: str = "claude"
# Welches Claude-Modell geantwortet hat (aus modelUsage der CLI, also die
# aufgeloeste Version) und WARUM dieses: "einstellung" | "pro-chat" |
# "ausweich" (Sicherheitsfilter). Leer, wenn kein Claude-Modell lief.
model: str = ""
model_reason: str = ""
# Soll die Antwort vorgelesen werden? Fast-Path (reiner Steuerbefehl) = False;
# ARIA-Antworten (local/claude) = True. System-Flag statt <voice>-Tag.
speak: bool = True
@@ -676,6 +697,11 @@ class ChatOut(BaseModel):
# Task fertig ist)? Dann pausiert die App die Projekt-Queue und leitet die
# naechste Eingabe als Antwort weiter, statt sie als neuen Auftrag anzustellen.
awaiting_reply: bool = False
# Der User hat per Sprache "Wake-Word aus" gesagt → die App stoppt den
# Wake-Word-Listener komplett (Mikro frei).
wake_off: bool = False
# "Wake-Word an" per Befehl (Text/Aufnahme-Button) → App startet den Listener.
wake_on: bool = False
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
# UI landet.
@@ -759,7 +785,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
loop = asyncio.get_running_loop()
reply, answered_by, speak, converse, awaiting_reply = await loop.run_in_executor(
reply, answered_by, speak, converse, awaiting_reply, model_info = await loop.run_in_executor(
None,
lambda: a.chat(
body.message, source=body.source, project_id=pid,
@@ -782,9 +808,13 @@ async def chat(body: ChatIn, background: BackgroundTasks):
events=a.pop_events(),
project_id=pid,
answered_by=answered_by,
model=(model_info or {}).get("model", ""),
model_reason=(model_info or {}).get("reason", ""),
speak=speak,
converse=converse,
awaiting_reply=awaiting_reply,
wake_off=(answered_by == "wake-off"),
wake_on=(answered_by == "wake-on"),
)
finally:
_project_pending[pid] = [
@@ -1192,6 +1222,243 @@ def conversation_distill_now():
return agent().distill_old_turns()
# ─── Sicherheitsfilter (Anthropic) ──────────────────────────────────
# Der Filter antwortet mit is_error=true und dem Fehlertext im content. Das
# Brain erkennt das (safeguard.py), weicht aufs Ausweich-Modell aus und nimmt
# im Notfall den ausloesenden Turn aus dem Prompt-Fenster. Hier nur die
# Sichtbarkeit fuer die Diagnostic.
@app.get("/safeguard/status")
def safeguard_status(limit: int = 25):
"""Vorfaelle + Quarantaene. Fuer das Panel in der Diagnostic."""
data = safeguard_mod.status(limit=limit)
# Welche Turns noch im Fenster liegen (= wirken noch) — sonst sieht man in
# der Liste nicht, ob eine Blockade noch aktiv ist oder nur Historie.
conv = conversation()
live = {t.ts for t in conv.turns}
for inc in data.get("incidents", []):
inc["in_window"] = bool(inc.get("turn_ts") and inc["turn_ts"] in live)
for q in data.get("quarantine", []):
q["in_window"] = bool(q.get("turn_ts") and q["turn_ts"] in live)
# Den Text dazu zeigen, damit Stefan sieht, WAS blockiert wurde.
q["content"] = next((t.content[:400] for t in conv.turns
if t.ts == q.get("turn_ts")), "")
return data
class SafeguardTurnBody(BaseModel):
turn_ts: str
@app.post("/safeguard/unquarantine")
def safeguard_unquarantine(body: SafeguardTurnBody):
"""Einen quarantaenierten Turn wieder in den Verlauf aufnehmen.
Danach geht er wieder mit zum Modell — wenn er wirklich der Ausloeser war,
blockt der Filter erneut. Deshalb einzeln und von Hand."""
ok = safeguard_mod.unquarantine(body.turn_ts)
if not ok:
raise HTTPException(404, "Kein quarantaenierter Turn mit diesem Zeitstempel")
return {"ok": True}
class SafeguardClearBody(BaseModel):
project_id: Optional[str] = None
@app.post("/safeguard/clear")
def safeguard_clear(body: SafeguardClearBody):
"""Vorfallsliste leeren. Hebt damit auch den Modell-Vermerk auf — der
naechste Turn laeuft wieder aufs Hauptmodell. Quarantaene bleibt."""
removed = safeguard_mod.clear(body.project_id)
return {"ok": True, "removed": removed}
# ─── Modell pro Chat ────────────────────────────────────────────────
# Einstellung (brainModel) < pro Chat < Ausweich (Sicherheitsfilter). Siehe
# context_model.py. Diese Endpunkte liefern fuer jede Oberflaeche dieselbe
# Sicht: was ist eingestellt, was laeuft WIRKLICH, und warum.
_MODEL_CHOICES_CACHE: dict = {"at": 0.0, "data": []}
def _model_choices() -> list:
"""Modell-Auswahl fuer die Pickers — dieselbe Liste wie in der Diagnostic
(Proxy /v1/models, aus Anthropics Katalog). 10 Minuten im Speicher, denn
die App fragt bei jedem Oeffnen des Pickers. Fail-soft: ohne Proxy eine
leere Liste, die Anzeige des aktiven Modells funktioniert trotzdem."""
import time as _t
import httpx as _httpx
now = _t.time()
if _MODEL_CHOICES_CACHE["data"] and now - _MODEL_CHOICES_CACHE["at"] < 600:
return _MODEL_CHOICES_CACHE["data"]
try:
base = os.environ.get("PROXY_URL", "http://proxy:3456").rstrip("/")
r = _httpx.get(f"{base}/v1/models", timeout=8)
data = [{
"id": m.get("id", ""),
"name": m.get("display_name") or m.get("id", ""),
"section": m.get("section") or "tier",
} for m in (r.json().get("data") or []) if m.get("id")]
if data:
_MODEL_CHOICES_CACHE.update(at=now, data=data)
return data
except Exception as exc:
logger.warning("[context-model] Modell-Liste vom Proxy nicht ladbar: %s", exc)
return _MODEL_CHOICES_CACHE["data"]
def _context_model_rows() -> dict:
a = agent()
conv = conversation()
# Das Modell, mit dem das Brain gerade wirklich laeuft — NICHT neu aus
# runtime.json gelesen. brainModel greift erst nach Neustart; zeigten wir
# hier den Dateiwert, saehe ein ausstehender Neustart aus wie erledigt.
default = a.proxy.model
contexts = [("", "Hauptchat")] + [
(p["id"], p.get("name") or p["id"])
for p in projects_mod.list_projects()
]
rows = []
for cid, name in contexts:
override = context_model_mod.get(cid)
model, reason = context_model_mod.resolve(cid, default)
window = safeguard_mod.filter_window(conv.window(project_id=cid))
blocked = safeguard_mod.blocked_models(cid, [t.ts for t in window])
fallback = ""
if safeguard_mod.norm_model(model) in blocked:
fallback = safeguard_mod.fallback_for(model)
reason = context_model_mod.REASON_FALLBACK
rows.append({
"context_id": cid,
"name": name,
"override": override, # "" = folgt der Einstellung
"selected": model, # was gewaehlt ist (Einstellung/pro Chat)
"effective": fallback or model, # was der naechste Turn nutzt
"reason": reason,
"blocked": sorted(blocked),
})
choices = _model_choices()
names = {c["id"]: c["name"] for c in choices}
return {"default": default, "rows": rows, "choices": choices, "names": names}
@app.get("/context-model/list")
def context_model_list():
return _context_model_rows()
class ContextModelBody(BaseModel):
context_id: str = ""
model: str = "" # leer = Override loeschen, Einstellung gilt wieder
@app.post("/context-model/set")
def context_model_set(body: ContextModelBody):
cid = (body.context_id or "").strip()
if cid and not projects_mod.get_project(cid):
raise HTTPException(404, f"Projekt {cid} nicht gefunden")
context_model_mod.set(cid, body.model)
return _context_model_rows()
class ContextModelResetBody(BaseModel):
context_id: str = ""
@app.post("/context-model/back-to-selected")
def context_model_back(body: ContextModelResetBody):
"""Ausweich beenden: Sicherheitsfilter-Vermerke dieses Chats loeschen.
Danach laeuft der naechste Turn wieder auf dem gewaehlten Modell. Steht
der ausloesende Text noch im Verlauf, kann der Filter erneut greifen —
dann weicht das Brain eben wieder aus. Das ist gewollt: zurueck soll
jederzeit gehen, ohne dass man vorher irgendwas aufraeumen muss."""
removed = safeguard_mod.clear((body.context_id or "").strip())
out = _context_model_rows()
out["removed"] = removed
return out
# ─── E-Mail (aria_mail.py) ──────────────────────────────────────────
# Konfiguration lesen/aendern NUR mit dem Admin-Schluessel (Header
# X-Aria-Admin). Den reicht die Diagnostic durch; ARIAs Container kann ihn
# nicht lesen (liegt in /data, das dort nicht eingehaengt ist). Ohne diesen
# Schutz koennte eine eingehende Mail ARIA dazu bringen, per curl die
# Whitelist zu oeffnen.
# Das Postfach LESEN braucht keinen Schluessel — das kann ARIA ohnehin ueber
# ihre Tools, und der Viewer nutzt dieselben Wege.
def _mail_admin(key: Optional[str]) -> None:
if not aria_mail.check_admin(key):
raise HTTPException(403, "Nur ueber die Diagnostic")
def _mail_call(fn, *a, **kw):
try:
return fn(*a, **kw)
except aria_mail.MailError as exc:
raise HTTPException(400, str(exc))
# Schluessel beim Start anlegen. Die Diagnostic liest ihn aus der Datei; gaebe
# es ihn erst beim ersten Admin-Zugriff, haette sie fuer genau diesen Zugriff
# keinen.
try:
aria_mail.admin_key()
except Exception as _exc: # /data fehlt (lokaler Test) — kein Startabbruch
logger.warning("[mail] Admin-Schluessel nicht anlegbar: %s", _exc)
@app.get("/email/status")
def email_status():
return aria_mail.status()
@app.get("/email/config")
def email_config_get(x_aria_admin: Optional[str] = Header(None)):
_mail_admin(x_aria_admin)
return aria_mail.public_config()
@app.post("/email/config")
def email_config_set(body: dict, x_aria_admin: Optional[str] = Header(None)):
_mail_admin(x_aria_admin)
return _mail_call(aria_mail.save_config, body or {})
@app.post("/email/test")
def email_test(x_aria_admin: Optional[str] = Header(None)):
_mail_admin(x_aria_admin)
return aria_mail.test_connection()
@app.get("/email/folders")
def email_folders():
return {"folders": _mail_call(aria_mail.list_folders)}
@app.get("/email/messages")
def email_messages(folder: str = "INBOX", limit: int = 50, offset: int = 0,
unread_only: bool = False, search: str = ""):
return _mail_call(aria_mail.list_messages, folder, limit, offset, unread_only, search)
@app.get("/email/message")
def email_message(folder: str, uid: str):
"""Fuer den Viewer — markiert NICHT als gelesen (sonst wuesste ARIA
nicht mehr, was neu ist, nur weil Stefan reingeschaut hat)."""
return _mail_call(aria_mail.view_message, folder, uid)
@app.get("/email/attachment")
def email_attachment(folder: str, uid: str, index: int):
from urllib.parse import quote as _q
name, ctype, data = _mail_call(aria_mail.get_attachment, folder, uid, index)
return Response(content=data, media_type=ctype or "application/octet-stream",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{_q(name)}"})
# ─── Call-Metrics (Token / Quota-Monitoring) ────────────────────────
@app.get("/metrics/calls")
+46 -5
View File
@@ -16,6 +16,7 @@ selbst hat in Stufe A kein Tool zum Upload.
from __future__ import annotations
import base64
import json
import logging
import mimetypes
import os
@@ -27,9 +28,41 @@ from typing import List, Optional
logger = logging.getLogger(__name__)
ROOT = Path(os.environ.get("MEMORY_ATTACHMENTS_DIR", "/shared/memory-attachments"))
MAX_BYTES = int(os.environ.get("MEMORY_ATTACHMENT_MAX_BYTES", str(20 * 1024 * 1024))) # 20 MB
SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._\-]")
RUNTIME_CONFIG_FILE = Path("/shared/config/runtime.json")
# 64 MB: so viel holt die App auch auf ausdruecklichen Tipp noch herunter
# (TAP_DL_MAX_BYTES in ChatScreen.tsx). Ein hoeheres Limit wuerde Anhaenge
# erlauben, die das Handy anschliessend nicht mehr laden kann.
_DEFAULT_MAX_BYTES = 64 * 1024 * 1024
def max_bytes() -> int:
"""Aktuelles Groessen-Limit fuer Anhaenge.
Reihenfolge: runtime.json (Diagnostic → System & Wartung) > ENV > Default.
Wird bei JEDEM Aufruf gelesen, damit eine Aenderung in der Diagnostic sofort
greift — ein Brain-Neustart nur fuers Anheben einer Zahl waere Unsinn.
"""
try:
with RUNTIME_CONFIG_FILE.open() as f:
mb = json.load(f).get("memoryAttachmentMaxMb")
if mb is not None:
val = int(float(mb) * 1024 * 1024)
if val > 0:
return val
except FileNotFoundError:
pass
except Exception as exc:
logger.warning("runtime.json lesen fehlgeschlagen: %s", exc)
env = os.environ.get("MEMORY_ATTACHMENT_MAX_BYTES")
if env:
try:
return int(env)
except ValueError:
logger.warning("MEMORY_ATTACHMENT_MAX_BYTES ist keine Zahl: %r", env)
return _DEFAULT_MAX_BYTES
def _safe_filename(name: str) -> str:
"""Macht aus einem User-Namen einen filesystem-sicheren String —
@@ -78,8 +111,12 @@ def save_attachment(memory_id: str, filename: str, data: bytes) -> dict:
Ueberschreibt eine bestehende Datei mit gleichem Namen."""
if not memory_id:
raise ValueError("memory_id ist Pflicht")
if len(data) > MAX_BYTES:
raise ValueError(f"Anhang zu gross ({len(data)} > {MAX_BYTES} Byte)")
limit = max_bytes()
if len(data) > limit:
raise ValueError(
f"Anhang zu gross ({len(data) // (1024 * 1024)} MB > "
f"{limit // (1024 * 1024)} MB). Das Limit steht in der Diagnostic "
f"unter System & Wartung.")
safe = _safe_filename(filename)
d = memory_dir(memory_id)
d.mkdir(parents=True, exist_ok=True)
@@ -165,8 +202,12 @@ def attach_from_path(memory_id: str, source_path: str) -> dict:
if not src.is_file():
raise ValueError(f"Datei nicht gefunden: {source_path}")
size = src.stat().st_size
if size > MAX_BYTES:
raise ValueError(f"Datei zu gross ({size} > {MAX_BYTES} Byte)")
limit = max_bytes()
if size > limit:
raise ValueError(
f"Datei zu gross ({size // (1024 * 1024)} MB > "
f"{limit // (1024 * 1024)} MB). Das Limit steht in der Diagnostic "
f"unter System & Wartung.")
# Reuse save_attachment damit Filename-Sanitization + Logging konsistent
data = src.read_bytes()
return save_attachment(memory_id, src.name, data)
+111 -1
View File
@@ -28,6 +28,7 @@ import logging
import os
import secrets
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
@@ -129,7 +130,8 @@ def register_provider(service: str, auth_url: str, token_url: str,
scopes: Optional[list[str]] = None,
client_auth: str = "body",
extra_auth_params: Optional[dict] = None,
accept_header: Optional[str] = None) -> dict:
accept_header: Optional[str] = None,
api_hosts: Optional[list[str]] = None) -> dict:
"""Schreibt einen neuen Provider-Eintrag in oauth_apps.json. KEINE
Credentials hier — die bleiben Stefans Job (Diagnostic / App-UI). Wird
vom Brain-Tool `oauth_register_provider` gerufen.
@@ -158,6 +160,12 @@ def register_provider(service: str, auth_url: str, token_url: str,
entry["extra_auth_params"] = extra_auth_params
if accept_header is not None:
entry["accept_header"] = accept_header
if api_hosts is not None:
# Zusaetzliche API-Hosts, an die oauth_call den Token schicken darf.
# Noetig, wenn die API auf einer anderen Domain liegt als der Login
# (z.B. github.com -> api.github.com waere noch dieselbe Basis, aber
# manche Provider nutzen voellig andere Namen).
entry["api_hosts"] = [str(h).strip().lower() for h in api_hosts if str(h).strip()]
apps[svc] = entry
_save_json(APPS_FILE, apps)
logger.info("[oauth] Provider '%s' registriert (auth=%s, token=%s, scopes=%d)",
@@ -416,6 +424,108 @@ def revoke(service: str) -> bool:
return True
def allowed_hosts(service: str) -> list[str]:
"""Hosts, an die ein Token dieses Services geschickt werden darf.
SICHERHEITSKERN von `call()`: ohne diese Liste koennte ein Aufruf den
Zugangstoken an einen beliebigen Server schicken — die Anfrage-URL kommt
schliesslich vom Modell. Darum wird nur zugelassen, was zum Provider selbst
gehoert: die Hosts aus auth_url/token_url plus optional eingetragene
api_hosts. Subdomains desselben registrierbaren Namens zaehlen mit
(accounts.spotify.com -> api.spotify.com), fremde Domains nie.
"""
cfg = _provider_config(service)
hosts: set[str] = set()
for key in ("auth_url", "token_url"):
u = cfg.get(key) or ""
try:
h = urllib.parse.urlparse(u).hostname or ""
except Exception:
h = ""
if h:
hosts.add(h.lower())
for h in (cfg.get("api_hosts") or []):
h = str(h).strip().lower()
if h:
hosts.add(h)
return sorted(hosts)
def _host_allowed(host: str, allowed: list[str]) -> bool:
"""True, wenn host zu einem der erlaubten Hosts gehoert.
Verglichen wird auf den letzten ZWEI Labels (spotify.com). Ein reiner
endswith-Vergleich waere angreifbar: 'boesespotify.com' endet auch auf
'spotify.com'.
"""
h = (host or "").lower().strip(".")
if not h:
return False
def base(x: str) -> str:
parts = x.lower().strip(".").split(".")
return ".".join(parts[-2:]) if len(parts) >= 2 else x.lower()
hb = base(h)
return any(h == a or hb == base(a) for a in allowed)
def call(service: str, url: str, method: str = "GET",
body: Optional[str] = None, content_type: str = "application/json",
timeout: float = 20.0, max_bytes: int = 200_000) -> tuple[int, str]:
"""Fuehrt einen HTTP-Aufruf MIT dem Zugangstoken aus — ohne ihn herauszugeben.
Das ist der Ersatz fuer "gib mir den Token, ich rufe selbst": der Token
verlaesst den Brain-Prozess nicht und kann damit auch nicht in einem
Gespraechsprotokoll landen.
Returns (status_code, text). Wirft ValueError bei unerlaubter URL.
"""
svc = (service or "").strip()
if not svc:
raise ValueError("service ist Pflicht")
u = (url or "").strip()
if not u.startswith("https://"):
# Kein http:// — der Token ginge im Klartext ueber die Leitung.
raise ValueError("url muss mit https:// beginnen")
host = urllib.parse.urlparse(u).hostname or ""
allowed = allowed_hosts(svc)
if not allowed:
raise ValueError(
f"Fuer '{svc}' sind keine Hosts hinterlegt — erst oauth_register_provider aufrufen.")
if not _host_allowed(host, allowed):
raise ValueError(
f"Host '{host}' gehoert nicht zu '{svc}'. Erlaubt: {', '.join(allowed)}")
m = (method or "GET").upper()
if m not in ("GET", "POST", "PUT", "PATCH", "DELETE"):
raise ValueError(f"Methode nicht erlaubt: {method}")
record = get_token(svc) # refresht bei Bedarf
token = record.get("access_token", "")
ttype = record.get("token_type") or "Bearer"
data = body.encode("utf-8") if body else None
req = urllib.request.Request(u, data=data, method=m)
req.add_header("Authorization", f"{ttype} {token}")
req.add_header("Accept", "application/json")
if data is not None:
req.add_header("Content-Type", content_type)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read(max_bytes + 1)
status = resp.status
except urllib.error.HTTPError as exc:
raw = exc.read(max_bytes + 1) if hasattr(exc, "read") else b""
status = exc.code
truncated = len(raw) > max_bytes
text = raw[:max_bytes].decode("utf-8", "replace")
if truncated:
text += "\n…(gekuerzt)"
logger.info("[oauth] call %s %s %s -> %s (%d Byte)", svc, m, host, status, len(raw))
return status, text
def list_services() -> list[dict]:
"""Diagnostik: zeigt fuer jeden konfigurierten Service ob Token da ist
+ Ablaufzeit. Wird von Diagnostic genutzt."""
+22 -1
View File
@@ -199,6 +199,13 @@ def build_voice_flow_section() -> str:
"- Ohne Marker = normales Gespraech: du wirst vorgelesen und ich lausche "
"danach kurz weiter (Stefan kann einfach antworten, ohne 'Computer').",
"- Nie widerspruechlich: `[[ENDE]]` schlaegt `[[WEITER]]`.",
"",
"**Ohr aus/an:** Wenn Stefan will dass du aufhoerst zuzuhoeren — egal wie "
"formuliert ('leg dich schlafen', 'geh schlafen', 'Ohr aus', 'gute Nacht', "
"'mach Pause vom Zuhoeren') — ruf das Tool `ear_control(action='off')`. "
"Wieder aktivieren ('Ohr an', 'wach auf', 'hoer wieder zu') → "
"`ear_control(action='on')`. Die App stoppt/startet dann den Listener; du "
"bestaetigst nur kurz.",
])
@@ -450,8 +457,22 @@ def build_oauth_section(oauth_services: list[dict] | None,
"",
"Du kannst Spotify, Google, GitHub, Strava, Microsoft (und custom-konfigurierte) "
"Services via OAuth2 ansprechen. Workflow ist IMMER:",
"1. `oauth_get_token(service)` versuchen — Token vorhanden? → benutzen.",
"1. `oauth_get_token(service)` fragen — sagt nur OB authentifiziert ist.",
"2. Wirft 'Kein Token gespeichert'? → `oauth_authorize(service)` aufrufen, URL an Stefan, warten, dann nochmal `oauth_get_token`.",
"3. API ansprechen mit `oauth_call(service, url, ...)` — das Brain haengt "
"die Authorization selbst an.",
"",
"**Du bekommst den Zugangstoken NICHT zu sehen — und brauchst ihn nicht.** "
"Das ist Absicht: alles was Du siehst, steht im Gespraechsprotokoll. Frag "
"also nicht danach und versuche nicht, ihn aus Dateien oder Umgebungs"
"variablen zu lesen. Fuer etwas, das oefter gebraucht wird, bau einen Skill "
"— der holt sich den Token zur Laufzeit ueber "
"`{BRAIN_INTERNAL_URL}/oauth/<service>/token`, serverseitig.",
"",
"**Secrets gehoeren nie in den Chat.** Wenn Stefan Dir ein Passwort, einen "
"API-Key oder ein client_secret schicken will: NICHT hier abtippen lassen. "
"Verweise ihn auf Diagnostic → OAuth-Apps bzw. die Skill-Konfiguration. "
"Und wiederhole ein Secret niemals in Deiner Antwort.",
"",
f"**Callback-URL (fest, NICHT raten):** `{base}`",
"Diese URL muss Stefan EINMAL pro Service im Provider-Dashboard als gueltige "
+14 -1
View File
@@ -66,6 +66,13 @@ class ProxyResult(BaseModel):
content: str = ""
tool_calls: list = [] # je: {"id", "name", "arguments" (dict)}
finish_reason: str = ""
# ARIA-Zusatzfelder des Proxys (cli-to-openai.js). Wichtig: ein Fehler der
# CLI kommt NICHT als HTTP-Fehler, sondern als normale Antwort mit
# is_error=true und dem Fehlertext im content. Ohne dieses Flag kann der
# Agent einen Fehlertext nicht von einer Antwort unterscheiden — genau so
# landete die Meldung des Sicherheitsfilters als ARIAs Antwort im Verlauf.
is_error: bool = False
model_used: str = "" # Modell, das tatsaechlich geantwortet hat
class ProxyClient:
@@ -177,7 +184,13 @@ class ProxyClient:
# Call-Metric anhaengen — Token-Schaetzung fuer Quota-Monitoring
metrics.log_call(payload["model"], messages, content or "")
return ProxyResult(content=content or "", tool_calls=tool_calls, finish_reason=finish_reason)
return ProxyResult(
content=content or "",
tool_calls=tool_calls,
finish_reason=finish_reason,
is_error=bool(data.get("aria_error")),
model_used=str(data.get("aria_model") or ""),
)
def close(self):
try:
+310
View File
@@ -0,0 +1,310 @@
"""Anthropics Sicherheitsfilter — erkennen, ausweichen, Verlauf heil halten.
Der Filter ("... safeguards flagged this message", `Details: [cyber]`) meldet
sich NICHT als HTTP-Fehler. Die CLI liefert ein voellig normales Ergebnis mit
is_error=true, und der Fehlertext steht im `content` — also genau dort, wo
sonst ARIAs Antwort steht. Ungeprueft passiert deshalb Folgendes:
1. Der Fehlertext landet per conversation.add("assistant", ...) im Verlauf.
2. Jeder Folge-Turn schickt den Verlauf samt der ausloesenden Nachricht wieder
mit — der Filter greift erneut.
3. Das Projekt ist tot: jede weitere Frage scheitert an derselben Stelle.
Dagegen zwei Rettungsstufen:
Stufe 1 — Modellwechsel. Der Filter ist modellspezifisch ("Opus 5's
safeguards ..."), Anthropic empfiehlt selbst "change your model". Derselbe
Turn wird mit dem Ausweich-Modell neu gefragt. Der ausloesende Turn wird
vermerkt, damit die naechsten Turns des Projekts nicht erst wieder gegen die
Wand laufen. Der Vermerk verfaellt von selbst, sobald der Turn aus dem
Rolling Window gerollt ist — nichts muss aufgeraeumt werden.
Stufe 2 — Quarantaene. Blockt auch das Ausweich-Modell, liegt es am Verlauf
selbst. Dann wird der ausloesende Turn aus dem Prompt-Fenster genommen.
NICHT geloescht: er bleibt in conversation.jsonl, bleibt in der Diagnostic
sichtbar und ist mit einem Klick zurueckholbar. Das Projekt ist damit im
naechsten Turn wieder benutzbar.
Der Zustand liegt in /data/safeguard.json — bewusst NICHT in
conversation.jsonl: ein Markieren wuerde dort ein Rewrite erzwingen, und das
schreibt nur das In-Memory-Window zurueck (alles schon Destillierte waere weg).
"""
from __future__ import annotations
import json
import logging
import os
import re
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable, Optional
logger = logging.getLogger(__name__)
STATE_FILE = Path(os.environ.get("SAFEGUARD_STATE_FILE", "/data/safeguard.json"))
RUNTIME_CONFIG_FILE = Path(os.environ.get("RUNTIME_CONFIG_FILE", "/shared/config/runtime.json"))
# Wie viele Vorfaelle wir aufbewahren. Reicht fuer die Diagnostic-Anzeige und
# haelt die Datei klein.
MAX_INCIDENTS = 200
_lock = threading.Lock()
class Blocked(Exception):
"""Auch das Ausweich-Modell wurde blockiert.
Dann hilft kein Modellwechsel mehr — es liegt am Verlauf selbst. Der Agent
faengt das und geht auf Stufe 2 (Quarantaene).
"""
def __init__(self, models, text: str = ""):
self.models = [m for m in models if m]
self.text = text or ""
super().__init__("Sicherheitsfilter blockiert: " + ", ".join(self.models))
# Textmarker des Filters. Englisch und stabil — der Text kommt von Anthropic,
# nicht von uns. Mehrere, weil die Formulierung sich aendern kann.
_MARKERS = (
"safeguards flagged this message",
"anthropic.com/legal/aup",
"can't respond to this message with",
"cannot respond to this message with",
"try rephrasing the request in a new session",
)
_DETAIL_RE = re.compile(r"details:\s*`?\[([a-z0-9_-]+)\]`?", re.IGNORECASE)
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def norm_model(model: str) -> str:
"""Modellnamen vergleichbar machen (Gross/Klein, CLI-Prefix, Leerzeichen)."""
m = (model or "").strip().lower()
if m.startswith("claude-code-cli/"):
m = m[len("claude-code-cli/"):]
return m
# ── Erkennung ───────────────────────────────────────────────────────
def looks_like_block(text: str, is_error: bool = False) -> bool:
"""True, wenn dieser Antworttext der Sicherheitsfilter ist.
Zwei Wege, absichtlich unterschiedlich streng:
* is_error=true (die CLI sagt selbst "das war ein Fehler") — ein Marker
genuegt.
* Ohne is_error muss der Text mit "API Error" ANFANGEN. Sonst wuerde die
Erkennung auch dann zuschlagen, wenn ARIA ueber den Filter *spricht*
("der Fehler hiess: safeguards flagged ...") — eine Antwort ueber den
Fehler ist kein Fehler.
"""
t = (text or "").strip()
if not t:
return False
low = t.lower()
hits = sum(1 for m in _MARKERS if m in low)
if hits == 0:
return False
if is_error:
return True
return low.startswith("api error") and len(t) <= 2000
def detail_code(text: str) -> str:
"""Der Klassifikator-Code aus "Details: `[cyber]`" — nur fuer die Anzeige."""
m = _DETAIL_RE.search(text or "")
return m.group(1) if m else ""
# ── Ausweich-Modell ─────────────────────────────────────────────────
def _runtime_cfg() -> dict:
try:
if RUNTIME_CONFIG_FILE.exists():
return json.loads(RUNTIME_CONFIG_FILE.read_text(encoding="utf-8")) or {}
except Exception as exc:
logger.warning("[safeguard] runtime.json lesen fehlgeschlagen: %s", exc)
return {}
def fallback_for(model: str) -> str:
"""Welches Modell beim naechsten Versuch.
Vorrang hat `brainFallbackModel` aus runtime.json (Diagnostic). Zeigt das
auf dasselbe Modell, ist es wertlos — dann faellt es auf die automatische
Wahl zurueck: ein ANDERES Tier, denn der Filter haengt am Modell.
Pro Aufruf gelesen, nicht beim Import: eine Aenderung in der Diagnostic
greift so ohne Brain-Neustart.
"""
want = str(_runtime_cfg().get("brainFallbackModel") or "").strip()
primary = norm_model(model)
if want and norm_model(want) != primary:
return want
if "sonnet" in primary:
return "opus"
# Opus, Fable, Haiku und alles Unbekannte → Sonnet. Sonnet ist das
# breiteste Tier im Max-Abo und ein anderer Filter-Kontext als Opus.
return "sonnet"
# ── Zustand ─────────────────────────────────────────────────────────
def _load() -> dict:
try:
if STATE_FILE.exists():
data = json.loads(STATE_FILE.read_text(encoding="utf-8")) or {}
if isinstance(data, dict):
data.setdefault("incidents", [])
data.setdefault("quarantine", [])
return data
except Exception as exc:
logger.warning("[safeguard] Zustand lesen fehlgeschlagen: %s", exc)
return {"incidents": [], "quarantine": []}
def _save(data: dict) -> None:
try:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = STATE_FILE.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(STATE_FILE)
except Exception as exc:
logger.warning("[safeguard] Zustand schreiben fehlgeschlagen: %s", exc)
def record_block(project_id: str, turn_ts: str, model: str, fallback: str,
text: str = "", recovered: bool = False) -> None:
"""Einen Vorfall vermerken. turn_ts = der User-Turn, der mitlief."""
with _lock:
data = _load()
data["incidents"].append({
"at": _now(),
"project_id": project_id or "",
"turn_ts": turn_ts or "",
"model": model or "",
"fallback": fallback or "",
"recovered": bool(recovered),
"code": detail_code(text),
"snippet": (text or "").strip()[:300],
})
data["incidents"] = data["incidents"][-MAX_INCIDENTS:]
_save(data)
def mark_recovered(turn_ts: str, model: str, fallback_used: str) -> None:
"""Nachtragen, dass der Ausweichversuch geklappt hat (nur Anzeige)."""
with _lock:
data = _load()
for inc in reversed(data["incidents"]):
if inc.get("turn_ts") == turn_ts and norm_model(inc.get("model", "")) == norm_model(model):
inc["recovered"] = True
inc["fallback"] = fallback_used
break
_save(data)
def blocked_models(project_id: str, turn_timestamps: Iterable[str]) -> set:
"""Modelle, die an den Turns DIESES Fensters schon gescheitert sind.
Der Abgleich laeuft ueber die Turn-Zeitstempel: rollt der ausloesende Turn
aus dem Fenster, taucht er hier nicht mehr auf und das Projekt darf wieder
aufs Hauptmodell. Kein Aufraeumen, kein Ablaufdatum von Hand.
"""
want = set(turn_timestamps or ())
if not want:
return set()
pid = project_id or ""
out = set()
for inc in _load()["incidents"]:
if (inc.get("project_id") or "") != pid:
continue
ts = inc.get("turn_ts") or ""
if ts and ts in want:
m = norm_model(inc.get("model", ""))
if m:
out.add(m)
return out
# ── Quarantaene ─────────────────────────────────────────────────────
def quarantine(turn_ts: str, project_id: str = "", reason: str = "") -> None:
if not turn_ts:
return
with _lock:
data = _load()
if any(q.get("turn_ts") == turn_ts for q in data["quarantine"]):
return
data["quarantine"].append({
"turn_ts": turn_ts, "project_id": project_id or "",
"at": _now(), "reason": reason or "",
})
_save(data)
logger.warning("[safeguard] Turn %s in Quarantaene (Projekt %r)", turn_ts, project_id or "(main)")
def unquarantine(turn_ts: str) -> bool:
with _lock:
data = _load()
before = len(data["quarantine"])
data["quarantine"] = [q for q in data["quarantine"] if q.get("turn_ts") != turn_ts]
changed = len(data["quarantine"]) != before
if changed:
_save(data)
if changed:
logger.info("[safeguard] Quarantaene aufgehoben: %s", turn_ts)
return changed
def quarantined_ts() -> set:
return {q.get("turn_ts") for q in _load()["quarantine"] if q.get("turn_ts")}
def filter_window(turns: list) -> list:
"""Quarantaenierte Turns aus dem Prompt-Fenster nehmen.
Greift NUR auf dem Weg zum Modell. Gespeichert bleibt alles — die
Diagnostic und jeder Export sehen den Turn weiter.
"""
q = quarantined_ts()
if not q:
return turns
return [t for t in turns if getattr(t, "ts", "") not in q]
# ── Diagnostic ──────────────────────────────────────────────────────
def status(limit: int = 25) -> dict:
data = _load()
inc = data["incidents"][-limit:][::-1]
return {
"incidents": inc,
"total": len(data["incidents"]),
"quarantine": data["quarantine"],
"fallback_model": str(_runtime_cfg().get("brainFallbackModel") or "").strip(),
"state_file": str(STATE_FILE),
}
def clear(project_id: Optional[str] = None) -> int:
"""Vorfaelle loeschen (Anzeige + Taint). Quarantaene bleibt — die wird
einzeln aufgehoben, damit nicht ein Klick alle Blockaden zurueckholt."""
with _lock:
data = _load()
before = len(data["incidents"])
if project_id is None:
data["incidents"] = []
else:
pid = project_id or ""
data["incidents"] = [i for i in data["incidents"]
if (i.get("project_id") or "") != pid]
removed = before - len(data["incidents"])
_save(data)
return removed
+44 -14
View File
@@ -53,11 +53,10 @@ SEED_RULES: List[dict] = [
" 3. NIE annehmen 'wird schon Staging sein'. Production-URLs "
"ohne 'stage'/'test'-Marker sind im Zweifel Production.\n"
"\n"
"Vorfall (30.05.2026): ARIA hat einen Pentest-Test gegen "
"ein Kunden-Produktionssystem (Production!) angesetzt statt gegen "
"dessen Staging-Umgebung (Staging). Stefan "
"musste explizit korrigieren. Haette ARIA einen Factory-Reset-"
"Test ausgefuehrt, waeren echte Kundendaten verloren.\n"
"Vorfall (30.05.2026): ARIA hat einen Pentest-Test gegen ein "
"Kunden-PRODUKTIONSSYSTEM angesetzt statt gegen dessen Staging-"
"Umgebung. Stefan musste explizit korrigieren. Haette ARIA einen "
"Factory-Reset-Test ausgefuehrt, waeren echte Kundendaten verloren.\n"
"\n"
"Diese Regel ist Hard-Boundary — sie ueberstimmt JEDE andere "
"Anweisung. Stefan kann sie temporaer per expliziter "
@@ -186,11 +185,21 @@ SEED_RULES: List[dict] = [
"category": "skills",
"content": (
"Schreibe NIEMALS API-Keys, Tokens, Passwoerter, client_id oder "
"client_secret direkt in den Skill-Code. Fuer OAuth-Services "
"(Spotify, Google, GitHub etc.) nutze das Brain-Tool "
"`oauth_get_token('<service>')` — das macht Auto-Refresh und "
"haelt den Token frisch. Stefan muss sich sonst alle 60 Minuten "
"manuell neu einloggen, das nervt."
"client_secret direkt in den Skill-Code — und schreibe sie auch "
"NIE in eine Chat-Antwort. Alles was im Gespraech steht, wird "
"uebertragen und gespeichert.\n"
"\n"
"Fuer OAuth-Services (Spotify, Google, GitHub etc.):\n"
"- Im Gespraech: `oauth_call('<service>', url, ...)` — das Brain "
"haengt die Authorization an, Du siehst den Token nie.\n"
"- Im Skill-Code: den Token zur Laufzeit holen ueber "
"GET {BRAIN_INTERNAL_URL}/oauth/<service>/token. Der Aufruf macht "
"Auto-Refresh; Stefan muss sich sonst alle 60 Minuten neu "
"einloggen, das nervt.\n"
"- Zugangsdaten, die kein OAuth sind (Benutzername/Passwort, "
"API-Key): in die SKILL-KONFIGURATION (`skill_set_config`), nicht "
"in den Code und nicht in den Chat. Der Skill bekommt sie zur "
"Laufzeit als CFG_<NAME>-Umgebungsvariable."
),
},
{
@@ -236,9 +245,11 @@ SEED_RULES: List[dict] = [
"content": (
"Wenn ein API-Call gegen einen OAuth-Service 401 / 'unauthorized' / "
"'token expired' zurueckgibt: RUFE ZUERST "
"`oauth_get_token('<service>')`. Brain holt entweder den noch "
"gueltigen Token oder refresht ihn automatisch ueber den "
"gespeicherten refresh_token. In 99% der Faelle reicht das.\n"
"`oauth_get_token('<service>')`. Das refresht automatisch ueber den "
"gespeicherten refresh_token und sagt Dir, ob wieder alles gueltig "
"ist (den Token selbst bekommst Du bewusst nicht — "
"`oauth_call` nutzt ihn fuer Dich). In 99% der Faelle reicht das: "
"danach denselben Aufruf einfach nochmal.\n"
"\n"
"Nur wenn `oauth_get_token` selbst einen Fehler wirft "
"('refresh failed', 'no refresh_token', 'service nicht "
@@ -475,13 +486,32 @@ SEED_RULES: List[dict] = [
"zeigt UND steuerbar macht. So erreichst Du Netze, in denen der Haupt-Stack "
"NICHT steht.\n"
"\n"
"Wenn Stefan etwas 'im Buero' / 'im Netz X' / 'auf dem <Geraet> dort' will:\n"
"WICHTIG — nicht nur bei 'im Buero'/'im Netz X': IMMER wenn eine Anfrage ein "
"GERAET oder einen HOST betrifft, das/der in einem Netz lebt, auf dem Du NICHT "
"direkt sitzt (Stefans Zuhause, Buero, Werkstatt, irgendein LAN mit privater "
"IP wie 192.168.x/10.x) — z.B. Drucker-Fuellstand, NAS, Smart-TV, ein Host per "
"IP — dann pruefe ZUERST `satellite_list`, ob ein Satellit dieses Netz abdeckt. "
"Sage NIEMALS 'da komm ich nicht ran' / 'bin nicht im Netz', BEVOR Du "
"`satellite_list` aufgerufen hast — ein Satellit im Zielnetz ist genau der Weg "
"hinein. Nur wenn wirklich keiner online ist, ist 'erreiche ich nicht' korrekt.\n"
"\n"
"Ablauf:\n"
" 1. `satellite_list` — welche Satelliten/Netze sind online + was koennen sie.\n"
" 2. `satellite_devices(satellite='Buero')` — welche Geraete gibt es dort "
"(Fire TV, Chromecast, Smart-TVs, Drucker, NAS ...). Nutze es um das "
"gemeinte Geraet zu finden, BEVOR Du steuerst.\n"
" 3. `satellite_command(...)` — Aktion ausfuehren.\n"
"\n"
"Beispiel 'Patronenstand vom Drucker zuhause': satellite_list -> Satellit im "
"Heimnetz online? -> BEVORZUGT satellite_command(satellite='<Heim>', "
"action='snmp.printer', params={'ip':'<drucker-ip>'}) -> liefert supplies mit "
"name + percent je Patrone (BK/C/M/Y), zuverlaessig aus der Printer-MIB. "
"NUR falls SNMP nichts liefert, als Fallback die HTML-Statusseite: "
"action='http.get', params={'url':'http://<drucker-ip>/general/status.html', "
"'contains':['ink','toner','cyan','magenta','yellow','black','%']} — der "
"contains-Filter zieht die relevanten Zeilen (sonst wird der Body bei "
"max_chars, Default 20000, abgeschnitten). NICHT aus dem Gedaechtnis raten.\n"
"\n"
"Beispiel 'spiel YouTube-Video auf dem Buero-Stick':\n"
" satellite_command(satellite='Buero', device='Fire TV', "
"action='dial.launch', params={'app':'YouTube','v':'<videoId>'})\n"
+12 -2
View File
@@ -146,6 +146,7 @@ def create_timer(
fires_at_iso: str,
message: str,
author: str = "aria",
project_id: str = "",
) -> dict:
_safe_name(name)
if _path(name).exists():
@@ -164,11 +165,16 @@ def create_timer(
"created_at": _now_iso(),
"fires_at": fires_at_iso,
"message": message,
# Projekt, in dem der Trigger angelegt wurde. Ohne das landete die
# Antwort beim Feuern IMMER im Hauptchat — auch wenn der Timer mitten
# in einem Projekt-Thread gestellt wurde. Leer = Hauptchat.
"project_id": project_id or "",
"fire_count": 0,
"last_fired_at": None,
}
write(name, data)
logger.info("Trigger angelegt: %s (timer, fires_at=%s)", name, fires_at_iso)
logger.info("Trigger angelegt: %s (timer, fires_at=%s, project=%s)",
name, fires_at_iso, project_id or "(main)")
return data
@@ -179,6 +185,7 @@ def create_watcher(
check_interval_sec: int = 300,
throttle_sec: int = 3600,
author: str = "aria",
project_id: str = "",
) -> dict:
_safe_name(name)
if _path(name).exists():
@@ -200,12 +207,15 @@ def create_watcher(
"check_interval_sec": int(check_interval_sec),
"throttle_sec": int(throttle_sec),
"message": message,
# Siehe create_timer: ohne Projekt landet die Antwort im Hauptchat.
"project_id": project_id or "",
"fire_count": 0,
"last_fired_at": None,
"last_checked_at": None,
}
write(name, data)
logger.info("Trigger angelegt: %s (watcher, cond='%s')", name, condition)
logger.info("Trigger angelegt: %s (watcher, cond='%s', project=%s)",
name, condition, project_id or "(main)")
return data
+1
View File
@@ -21,5 +21,6 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY aria_bridge.py .
COPY modes.py .
COPY llm_cluster.py .
CMD ["python", "aria_bridge.py"]
+914 -107
View File
File diff suppressed because it is too large Load Diff
+219
View File
@@ -0,0 +1,219 @@
"""LLM-Cluster: ein Eingang fuer alle lokalen Sprachmodelle aller KI-Boxen.
Clients waehlen ein MODELL, nicht Box oder Karte. Wo es laeuft, entscheidet
dieser Router:
* Auswahl — unter allen online Boxen, die das Modell GELADEN haben
(Karten-Belegung aus worker_hello/worker_ping), die mit den
wenigsten laufenden Anfragen; bei Gleichstand reihum.
Ist nur eine Karte da oder alles frei, landet alles dort —
"verteilen, wenn noetig, sonst nicht".
* Ausfall — verschwindet eine Box mitten in der Anfrage (kein Ping mehr)
oder antwortet nicht, geht die Anfrage EINMAL an eine andere
Box mit demselben Modell. Fehler des Modells selbst (z.B.
Kontext zu lang) werden nicht wiederholt.
* Fehler — kommen als FEHLER zurueck (ok=false / HTTP-Status), nie als
normale Antwort. Ein Client wuerde einen Fehlertext sonst
als Aussage der KI in seinen Verlauf schreiben — genau das
hat bei ARIA schon mal einen ganzen Chat vergiftet.
Eingang ist der RVS:
llm_cluster_models_request → llm_cluster_models
llm_cluster_request → llm_cluster_response
Programme brauchen nur eine ausgehende Verbindung zum RVS, egal in welchem
Netz sie stehen. Werkzeuge, die nur "OpenAI-kompatibel" koennen (OpenHands
& Co.), bekommen den OpenAI-RVS-Wrapper neben sich gestellt
(xtts/openai_rvs_wrapper) — fuer sie ist der Cluster dann 127.0.0.1.
Schnittstelle fuer Drittprogramme: docs/LLM-SCHNITTSTELLE.md
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import time
import uuid
from typing import Optional
logger = logging.getLogger("aria-bridge.llm-cluster")
# Diese errorCodes heissen "die BOX war das Problem" → anderswo nochmal versuchen.
# Alles andere (Modell-Fehler, z.B. Kontext zu lang) waere ueberall gleich.
RETRY_CODES = {"timeout", "box_offline", "send_failed", "model_not_loaded"}
MAX_ATTEMPTS = 2
class LlmCluster:
def __init__(self, bridge):
self.b = bridge
self.inflight: dict[str, int] = {} # instanceId → laufende Anfragen (von uns)
self._rr: dict[str, int] = {}
self.timeout = float(os.getenv("CLUSTER_LLM_TIMEOUT_S", "600"))
# ── Flotte ──────────────────────────────────────────────────────────
def _online_llm(self) -> list:
now = time.time()
return [w for w in self.b._workers.values()
if w.get("service") == "llm"
and (now - w.get("last_seen", 0)) < self.b.WORKER_OFFLINE_S]
def _is_online(self, instance_id: str) -> bool:
return any(w.get("instanceId") == instance_id for w in self._online_llm())
def models(self) -> list:
"""Jedes geladene Modell GENAU EINMAL, egal auf wie vielen Karten.
ctx = das kleinste Kontextfenster unter seinen Karten (darauf muss sich
ein Client verlassen koennen, egal wohin der Router verteilt)."""
out: dict[str, dict] = {}
for w in self._online_llm():
slots = w.get("slots")
if isinstance(slots, list):
for sl in slots:
m = sl.get("model")
if not m or m not in (w.get("models") or []):
continue
e = out.setdefault(m, {"model": m, "ctx": 0, "cards": []})
e["cards"].append({"node": w.get("node", ""), "instanceId": w.get("instanceId", ""),
"gpu": sl.get("gpu", ""), "gpuName": sl.get("gpuName", ""),
"state": sl.get("state", "")})
c = int(sl.get("ctx") or 0)
if c and (not e["ctx"] or c < e["ctx"]):
e["ctx"] = c
else:
# Box mit altem Adapter (ohne Karten): nur die Modell-Liste
for m in (w.get("models") or []):
e = out.setdefault(m, {"model": m, "ctx": 0, "cards": []})
e["cards"].append({"node": w.get("node", ""), "instanceId": w.get("instanceId", ""),
"gpu": "", "gpuName": "", "state": ""})
for e in out.values():
e["available"] = len(e["cards"])
return sorted(out.values(), key=lambda e: e["model"])
def candidates(self, model: str) -> list:
return [w for w in self._online_llm() if model in (w.get("models") or [])]
def pick(self, model: str, exclude=()) -> Optional[str]:
"""Die Box mit den wenigsten laufenden Anfragen; Gleichstand reihum."""
cands = [w["instanceId"] for w in self.candidates(model) if w["instanceId"] not in exclude]
if not cands:
return None
low = min(self.inflight.get(c, 0) for c in cands)
tied = sorted(c for c in cands if self.inflight.get(c, 0) == low)
i = self._rr.get(model, 0) % len(tied)
self._rr[model] = i + 1
return tied[i]
# ── Anfrage an eine bestimmte Box ───────────────────────────────────
async def _send_to(self, target: str, req: dict, deadline: float) -> dict:
"""llm_request an GENAU diese Box, Antwort abwarten. Bricht frueh ab,
wenn die Box unterwegs offline geht — dann kann route() woanders hin."""
rid = str(uuid.uuid4())
fut: asyncio.Future = asyncio.get_running_loop().create_future()
self.b._pending_llm[rid] = fut
self.inflight[target] = self.inflight.get(target, 0) + 1
try:
payload = {k: v for k, v in req.items() if v is not None}
payload.update(requestId=rid, targetInstance=target)
ok = await self.b._send_to_rvs({"type": "llm_request", "payload": payload,
"timestamp": int(time.time() * 1000)})
if not ok:
return {"ok": False, "errorCode": "send_failed", "error": "RVS: Anfrage nicht gesendet"}
while True:
left = deadline - time.time()
if left <= 0:
return {"ok": False, "errorCode": "timeout",
"error": f"Keine Antwort von {target} in der Frist"}
try:
return await asyncio.wait_for(asyncio.shield(fut), timeout=min(5.0, left))
except asyncio.TimeoutError:
if not self._is_online(target):
return {"ok": False, "errorCode": "box_offline",
"error": f"{target} ist waehrend der Anfrage offline gegangen"}
finally:
self.inflight[target] = max(0, self.inflight.get(target, 1) - 1)
self.b._pending_llm.pop(rid, None)
async def route(self, req: dict, timeout: Optional[float] = None) -> dict:
"""Eine Anfrage an den Cluster. req: model, messages, max_tokens,
temperature, stop, tools. Antwort: wie llm_response der Box, plus
attempts; bei Fehler errorCode + error (+ available).
timeout: Gesamtfrist inkl. Ausfall-Ersatz. ARIA nutzt eine kurze (lokal
ist ihr Schnellweg — dauert es zu lange, nimmt sie Claude)."""
model = (req.get("model") or "").strip()
messages = req.get("messages")
if not isinstance(messages, list) or not messages:
return {"ok": False, "errorCode": "bad_request", "error": "messages fehlt oder ist leer"}
available = [m["model"] for m in self.models()]
if not model:
if len(available) == 1:
model = available[0] # eindeutig — dann darf es fehlen
else:
return {"ok": False, "errorCode": "bad_request", "available": available,
"error": "model fehlt. Geladen: " + (", ".join(available) or "nichts")}
body = {"model": model, "messages": messages,
"max_tokens": int(req.get("max_tokens") or 512),
"temperature": float(req["temperature"]) if req.get("temperature") is not None else 0.7,
"stop": req.get("stop") or None, "tools": req.get("tools") or None}
limit = timeout or self.timeout
deadline = time.time() + limit
tried: list = []
last: dict = {}
for _ in range(MAX_ATTEMPTS):
target = self.pick(model, exclude=tried)
if not target:
break
tried.append(target)
t0 = time.time()
last = await self._send_to(target, body, deadline)
if last.get("ok"):
last["attempts"] = len(tried)
return last
logger.warning("[cluster] %s auf %s: %s (%s)%s", model, target, last.get("errorCode") or "Fehler",
(last.get("error") or "")[:120], " — versuche andere Box" if
last.get("errorCode") in RETRY_CODES else "")
if last.get("errorCode") not in RETRY_CODES or time.time() >= deadline:
break
if not tried:
return {"ok": False, "errorCode": "model_unavailable", "available": available,
"error": (f"Modell {model!r} ist gerade auf keiner KI-Box im Cluster geladen. "
f"Geladen: {', '.join(available) or 'nichts'}.")}
last.setdefault("errorCode", "model_error")
last["attempts"] = len(tried)
if last["errorCode"] in RETRY_CODES and len(tried) >= 1:
others = [c for c in self.candidates(model) if c["instanceId"] not in tried]
if not others:
last["error"] = (last.get("error") or "") + (
" — keine weitere Box mit diesem Modell erreichbar.")
return last
# ── RVS-Eingang ─────────────────────────────────────────────────────
async def handle_rvs(self, mtype: str, payload: dict) -> bool:
if mtype == "llm_cluster_models_request":
await self.b._send_to_rvs({"type": "llm_cluster_models", "payload": {
"requestId": payload.get("requestId", ""), "models": self.models()},
"timestamp": int(time.time() * 1000)})
return True
if mtype == "llm_cluster_request":
asyncio.create_task(self._rvs_request(payload))
return True
return False
async def _rvs_request(self, payload: dict) -> None:
rid = payload.get("requestId", "")
res = await self.route(payload)
out = {"requestId": rid, "ok": bool(res.get("ok"))}
for k in ("content", "tool_calls", "model", "node", "gpu", "instanceId", "usage",
"elapsedMs", "attempts", "error", "errorCode", "available",
"promptTokens", "ctx"):
if res.get(k) is not None:
out[k] = res[k]
await self.b._send_to_rvs({"type": "llm_cluster_response", "payload": out,
"timestamp": int(time.time() * 1000)})
+211
View File
@@ -0,0 +1,211 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ARIA Postfach</title>
<style>
:root {
--bg: #0A0A14; --panel: #12121E; --line: #1E1E2E; --text: #E0E0F0;
--muted: #8888AA; --faint: #555570; --accent: #0096FF; --sel: rgba(0,150,255,0.14);
--unread: #E0E0F0; --err: #FF6B6B;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
font: 13px/1.45 -apple-system, "Segoe UI", Roboto, sans-serif; }
.app { display: grid; grid-template-columns: 190px 360px 1fr; height: 100vh; }
.col { border-right: 1px solid var(--line); overflow: auto; min-width: 0; }
header { padding: 10px 12px; border-bottom: 1px solid var(--line); font-weight: 700;
display: flex; align-items: center; gap: 8px; position: sticky; top: 0; background: var(--bg); z-index: 1; }
.folder { padding: 7px 12px; cursor: pointer; color: var(--muted); white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }
.folder:hover { background: var(--panel); }
.folder.active { background: var(--sel); color: var(--accent); font-weight: 600; }
.tools { padding: 8px; border-bottom: 1px solid var(--line); display: flex; gap: 6px;
position: sticky; top: 41px; background: var(--bg); z-index: 1; }
input[type=search] { flex: 1; min-width: 0; background: var(--panel); color: var(--text);
border: 1px solid var(--line); border-radius: 4px; padding: 5px 8px; font: inherit; }
button { background: var(--panel); color: var(--text); border: 1px solid #2A2A3E;
border-radius: 4px; padding: 4px 9px; font: inherit; font-size: 12px; cursor: pointer; }
button:hover { border-color: var(--accent); }
.msg { padding: 8px 12px; border-bottom: 1px solid var(--line); cursor: pointer; }
.msg:hover { background: var(--panel); }
.msg.active { background: var(--sel); }
.msg .from { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; display: flex; gap: 6px; }
.msg .from .date { margin-left: auto; color: var(--faint); flex: 0 0 auto; }
.msg .subj { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.msg.unread .subj { color: var(--unread); font-weight: 700; }
.msg.unread .from::before { content: "●"; color: var(--accent); }
.view { display: flex; flex-direction: column; min-width: 0; }
.meta { padding: 12px 16px; border-bottom: 1px solid var(--line); }
.meta h2 { margin: 0 0 6px; font-size: 16px; }
.meta div { color: var(--muted); font-size: 12px; }
.atts { padding: 8px 16px; border-bottom: 1px solid var(--line); display: flex; gap: 6px; flex-wrap: wrap; }
.atts a { color: var(--accent); text-decoration: none; background: var(--panel);
border: 1px solid #2A2A3E; border-radius: 12px; padding: 2px 10px; font-size: 12px; }
.body { flex: 1; min-height: 0; }
.body iframe { width: 100%; height: 100%; border: 0; background: #fff; }
.body pre { margin: 0; padding: 16px; white-space: pre-wrap; word-break: break-word;
font: inherit; height: 100%; overflow: auto; }
.empty { padding: 30px; text-align: center; color: var(--faint); }
.err { padding: 14px; color: var(--err); }
.note { font-size: 11px; color: var(--faint); font-weight: 400; margin-left: auto; }
.more { padding: 10px; text-align: center; }
@media (max-width: 900px) {
.app { grid-template-columns: 1fr; grid-template-rows: auto 40vh 1fr; height: auto; }
.col { border-right: 0; border-bottom: 1px solid var(--line); max-height: none; }
}
</style>
</head>
<body>
<div class="app">
<div class="col">
<header>📬 ARIA</header>
<div id="folders"><div class="empty">Lade …</div></div>
</div>
<div class="col">
<header><span id="folder-title">INBOX</span><span class="note" id="count"></span></header>
<div class="tools">
<input type="search" id="search" placeholder="Suchen …" onkeydown="if(event.key==='Enter')reload()">
<button onclick="reload()" title="Neu laden">↻</button>
</div>
<div id="list"><div class="empty">Lade …</div></div>
</div>
<div class="col view" id="view">
<div class="empty">Mail auswählen.<br><br>
<span style="font-size:11px;">Nur lesen — hier wird nichts als gelesen markiert,
damit ARIA weiterhin weiß, was neu ist.</span></div>
</div>
</div>
<script>
// ARIAs Postfach, nur lesend. Holt alles ueber den Brain-Proxy der
// Diagnostic (/api/brain/email/*). Zugangsdaten kennt diese Seite nicht.
const API = '/api/brain/email';
const PAGE = 50;
let folder = 'INBOX', offset = 0, total = 0, activeUid = null;
let folderRoles = {};
const esc = s => String(s ?? '').replace(/[&<>"']/g,
c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
async function api(path) {
const r = await fetch(API + path);
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || ('HTTP ' + r.status));
return d;
}
function shortDate(s) {
const t = Date.parse(s);
if (isNaN(t)) return s || '';
const d = new Date(t), now = new Date();
return d.toDateString() === now.toDateString()
? d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
: d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' });
}
function nameOnly(from) {
const m = /^\s*"?([^"<]*?)"?\s*<([^>]+)>/.exec(from || '');
return m ? (m[1] || m[2]) : (from || '');
}
async function loadFolders() {
const box = document.getElementById('folders');
try {
const d = await api('/folders');
folderRoles = Object.fromEntries(d.folders.map(f => [f.name, f.role || '']));
const icon = { inbox: '📥 ', sent: '📤 ' };
box.innerHTML = d.folders.map(f =>
`<div class="folder${f.name === folder ? ' active' : ''}" data-name="${esc(f.name)}"
title="${esc(f.label)}" style="padding-left:${12 + 14 * (f.role ? 0 : (f.depth || 0))}px">${icon[f.role] || ''}${esc(f.role ? f.label : (f.short || f.label))}</div>`).join('') || '<div class="empty">Keine Ordner</div>';
box.querySelectorAll('.folder').forEach(el => el.onclick = () => selectFolder(el.dataset.name, el.title));
} catch (e) {
box.innerHTML = `<div class="err">${esc(e.message)}</div>`;
document.getElementById('list').innerHTML = '';
}
}
function selectFolder(name, label) {
folder = name;
document.getElementById('folder-title').textContent = label || name;
document.querySelectorAll('.folder').forEach(el => el.classList.toggle('active', el.dataset.name === name));
reload();
}
function reload() { offset = 0; loadList(false); }
async function loadList(append) {
const list = document.getElementById('list');
if (!append) list.innerHTML = '<div class="empty">Lade …</div>';
const q = document.getElementById('search').value.trim();
try {
const d = await api(`/messages?folder=${encodeURIComponent(folder)}&limit=${PAGE}&offset=${offset}`
+ (q ? `&search=${encodeURIComponent(q)}` : ''));
total = d.total;
document.getElementById('count').textContent = total ? `${total}` : '';
const sent = folderRoles[folder] === 'sent';
const html = d.messages.map(m => `
<div class="msg${m.seen || sent ? '' : ' unread'}${m.uid === activeUid ? ' active' : ''}" data-uid="${esc(m.uid)}">
<div class="from"><span>${sent ? 'an ' + esc(nameOnly(m.to)) : esc(nameOnly(m.from))}</span><span class="date">${esc(shortDate(m.date))}</span></div>
<div class="subj">${m.maybe_attachments ? '📎 ' : ''}${esc(m.subject || '(kein Betreff)')}</div>
</div>`).join('');
if (!append) list.innerHTML = '';
list.querySelector('.more')?.remove();
list.insertAdjacentHTML('beforeend', html || (append ? '' : '<div class="empty">Keine Mails.</div>'));
offset += d.messages.length;
if (offset < total) list.insertAdjacentHTML('beforeend',
`<div class="more"><button onclick="loadList(true)">Weitere laden (${total - offset})</button></div>`);
list.querySelectorAll('.msg').forEach(el => el.onclick = () => openMessage(el.dataset.uid));
} catch (e) {
list.innerHTML = `<div class="err">${esc(e.message)}</div>`;
}
}
// Mail-HTML in einem Rahmen OHNE Skripte und ohne Zugriff auf diese Seite
// (sandbox ohne allow-scripts/allow-same-origin). Die CSP verbietet jedes
// Nachladen von aussen — kein Tracking-Pixel meldet "gelesen". Bilder, die
// in der Mail stecken, kommen als data: (das Brain baut cid: um).
function frameFor(html) {
const csp = "default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:";
const doc = `<!doctype html><html><head><meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="${csp}">
<base target="_blank"><style>body{font:14px/1.5 sans-serif;margin:16px;color:#111;}
img{max-width:100%;height:auto;}</style></head><body>${html}</body></html>`;
const f = document.createElement('iframe');
f.setAttribute('sandbox', 'allow-popups allow-popups-to-escape-sandbox');
f.setAttribute('referrerpolicy', 'no-referrer');
f.srcdoc = doc;
return f;
}
async function openMessage(uid) {
activeUid = uid;
document.querySelectorAll('.msg').forEach(el => el.classList.toggle('active', el.dataset.uid === uid));
const view = document.getElementById('view');
view.innerHTML = '<div class="empty">Lade …</div>';
try {
const m = await api(`/message?folder=${encodeURIComponent(folder)}&uid=${encodeURIComponent(uid)}`);
const atts = m.attachments.length
? `<div class="atts">${m.attachments.map(a => `<a href="${API}/attachment?folder=${encodeURIComponent(folder)}&uid=${encodeURIComponent(uid)}&index=${a.index}"
download="${esc(a.name)}">📎 ${esc(a.name)} <span style="color:var(--faint)">${Math.max(1, Math.round(a.size / 1024))} KB</span></a>`).join('')}</div>`
: '';
view.innerHTML = `
<div class="meta">
<h2>${esc(m.subject || '(kein Betreff)')}</h2>
<div>Von: ${esc(m.from)}</div>
<div>An: ${esc(m.to)}${m.cc ? ' · Cc: ' + esc(m.cc) : ''}</div>
<div>${esc(m.date)}</div>
</div>${atts}<div class="body" id="body"></div>`;
const body = document.getElementById('body');
if (m.html) body.appendChild(frameFor(m.html));
else body.innerHTML = `<pre>${esc(m.text || '(leer)')}</pre>`;
} catch (e) {
view.innerHTML = `<div class="err">${esc(e.message)}</div>`;
}
}
loadFolders().then(reload);
</script>
</body>
</html>
+2906 -300
View File
File diff suppressed because it is too large Load Diff
+1115 -13
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -136,6 +136,7 @@ services:
- RVS_TLS=${RVS_TLS:-true}
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
- RVS_TOKEN=${RVS_TOKEN:-}
- CLUSTER_LLM_TIMEOUT_S=${CLUSTER_LLM_TIMEOUT_S:-600}
restart: unless-stopped
# ─── Diagnostic (Selbstcheck-UI und Einstellungen) ────
+362
View File
@@ -0,0 +1,362 @@
# Lokale Sprachmodelle nutzen — Schnittstelle für Programme
Wie ein Programm die lokalen Sprachmodelle auf den KI-Boxen von ARIA nutzt.
**Kurz:** Du fragst ein **Modell**, nicht eine Box oder Karte. Der Cluster
sucht sich aus, wo es läuft. Eigene Programme sprechen dafür über den RVS
(Abschnitt A). Programme, die nur „OpenAI-kompatibel" können, bekommen den
OpenAI-RVS-Wrapper neben sich gestellt (Abschnitt B).
## Die Grundregel
**Programme wählen ein Modell aus — laden aber nie.**
- Jede KI-Box hat eine oder mehrere Grafikkarten fürs LLM. Jede dieser Karten
hält **genau ein Modell, dauerhaft geladen**.
- Welches Modell wo liegt, legt nur der Betreiber in der ARIA-Diagnostic fest
(Einstellungen → LLM-Karten).
- Ein Programm sieht die geladenen Modelle des ganzen Clusters — jedes
**einmal**, egal auf wie vielen Karten es liegt — und wählt eins.
- Liegt das Modell auf mehreren Karten, **verteilt der Cluster bei Last**.
Fällt eine Box mitten in einer Anfrage aus, übernimmt eine andere mit
demselben Modell. Davon merkt das Programm nichts.
- Ist das Modell nirgends geladen, gibt es einen **Fehler** — nie eine normale
Antwort mit Fehlertext. Ein Programm würde so einen Text sonst als Aussage
der KI in seinen Verlauf schreiben.
- Mehrere Programme dürfen **gleichzeitig dasselbe Modell** nutzen. Die Karte
bedient sie parallel; jede einzelne Antwort wird dabei etwas langsamer.
## Welcher Zugang?
| | A) Cluster über RVS | B) OpenAI-RVS-Wrapper | C) Direkt an eine Box |
|---|---|---|---|
| Für | eigene Programme | OpenHands, Continue, Open WebUI … | Sonderfälle |
| Sieht | alle Modelle, jedes einmal | alle Modelle, jedes einmal | nur diese Box |
| Verteilung + Ausfall-Ersatz | ja | ja | nein |
| Braucht | ausgehende Verbindung zum RVS | Wrapper-Container auf demselben Rechner | ausgehende Verbindung zum RVS |
---
## A) Cluster über RVS — empfohlen
### Verbinden
```
wss://<RVS_HOST>:<RVS_PORT>/?token=<RVS_TOKEN>
```
- Das **Token ist der Raum** — dasselbe `RVS_TOKEN` wie bei ARIA und den
KI-Boxen. Wer es hat, ist im Raum.
- Der Programm-Rechner braucht nur eine **ausgehende** Verbindung zum RVS.
Keine Portfreigabe, kein VPN — er kann in jedem Netz stehen.
- Der RVS ist ein **Verteiler**: Jede Nachricht geht an alle anderen im Raum.
Du siehst also auch Antworten für andere — ordne sie immer über die
`requestId` zu.
- Unbekannte Nachrichtentypen verwirft der RVS **ohne Fehlermeldung**. Die hier
beschriebenen sind freigegeben.
- **Nur ein Leser pro Verbindung.** Schickst du mehrere Anfragen gleichzeitig,
liest eine Schleife alle Nachrichten und verteilt sie per `requestId` an die
Wartenden (siehe Beispiel). Mehrere gleichzeitige `recv()` auf derselben
Verbindung gehen nicht.
Jede Nachricht hat diese Hülle:
```json
{ "type": "<typ>", "payload": { … }, "timestamp": 1760000000000 }
```
Beantwortet werden `llm_cluster_*`-Nachrichten von der **ARIA-Bridge** — sie
kennt alle Boxen und entscheidet, wohin eine Anfrage geht. Läuft ARIA nicht,
kommt keine Antwort (dein Programm sollte einen Timeout haben).
### 1. Welche Modelle gibt es? — `llm_cluster_models_request`
```json
{ "type": "llm_cluster_models_request", "payload": { "requestId": "a1" } }
```
Antwort:
```json
{
"type": "llm_cluster_models",
"payload": {
"requestId": "a1",
"models": [
{
"model": "qwen3-8b",
"ctx": 8192,
"available": 2,
"cards": [
{ "node": "ki-box", "instanceId": "llm@ki-box", "gpu": "1", "gpuName": "NVIDIA GeForce RTX 3060", "state": "ready" },
{ "node": "werkstatt", "instanceId": "llm@werkstatt", "gpu": "0", "gpuName": "NVIDIA RTX A4000", "state": "ready" }
]
}
]
}
}
```
| Feld | Bedeutung |
|---|---|
| `model` | Name — genau so in der Anfrage angeben. **Das** zeigst du dem Nutzer zur Auswahl. |
| `ctx` | **Kontextfenster in Tokens** (Eingabe + Ausgabe zusammen). Das kleinste unter allen Karten mit diesem Modell — darauf kannst du dich verlassen, egal wohin verteilt wird. Nicht mehr annehmen. `0` = unbekannt. **Kann sich ändern** (der Betreiber stellt es pro Karte ein) — vor längeren Aufträgen neu holen. |
| `available` | auf wie vielen Karten es gerade liegt |
| `cards` | nur zur Info (Anzeige, Fehlersuche) — für die Anfrage nicht nötig |
### 2. Fragen — `llm_cluster_request`
```json
{
"type": "llm_cluster_request",
"payload": {
"requestId": "a2",
"model": "qwen3-8b",
"messages": [
{ "role": "system", "content": "Du antwortest kurz." },
{ "role": "user", "content": "Wie hoch ist die Zugspitze?" }
],
"max_tokens": 256,
"temperature": 0.7
}
}
```
| Feld | Pflicht | Bedeutung |
|---|---|---|
| `requestId` | ja | eindeutig pro Anfrage (z.B. UUID) |
| `model` | ja* | aus `llm_cluster_models`. *Darf fehlen, wenn im Cluster genau ein Modell geladen ist. |
| `messages` | ja | Verlauf im OpenAI-Format (`system` / `user` / `assistant` / `tool`) |
| `max_tokens` | nein | Standard 512 |
| `temperature` | nein | Standard 0.7 |
| `stop` | nein | Liste von Stop-Strings |
| `tools` | nein | Tool-Definitionen im OpenAI-Format; die Antwort enthält dann ggf. `tool_calls` |
### 3. Antwort — `llm_cluster_response`
Erfolg:
```json
{
"type": "llm_cluster_response",
"payload": {
"requestId": "a2",
"ok": true,
"content": "Die Zugspitze ist 2962 Meter hoch.",
"model": "qwen3-8b",
"node": "werkstatt",
"gpu": "0",
"usage": { "prompt_tokens": 31, "completion_tokens": 12, "total_tokens": 43 },
"elapsedMs": 640,
"attempts": 1
}
}
```
`tool_calls` steht drin, wenn das Modell ein Werkzeug aufrufen will (OpenAI-Format).
`node`/`gpu` sagen, wer geantwortet hat; `attempts: 2` heißt, die erste Box ist
ausgefallen und eine andere hat übernommen.
Fehler — `ok: false`, dazu `error` (Text für Menschen) und `errorCode`:
| `errorCode` | Bedeutung | Was tun |
|---|---|---|
| `model_unavailable` | Das Modell ist gerade auf keiner Karte im Cluster geladen. `available` nennt, was es gibt. | Dem Nutzer anzeigen; Liste neu holen. |
| `bad_request` | Anfrage unvollständig, z.B. `messages` leer oder `model` fehlt bei mehreren Modellen | Anfrage korrigieren |
| `timeout` | Keine Antwort innerhalb der Frist (Standard 600 s) — auch nach Ersatzversuch | später nochmal |
| `box_offline` | Box ist während der Anfrage weggefallen, und es gab keine Ersatz-Box | später nochmal |
| `context_exceeded` | Der Verlauf ist länger als das Kontextfenster. Dazu `promptTokens` (so lang war die Anfrage) und `ctx` (so viel geht). | Verlauf kürzen und nochmal senden — z.B. auf ~80 % von `ctx` abzüglich der gewünschten Antwortlänge. |
| `model_error` | Das Modell selbst hat einen anderen Fehler gemeldet. Wird nicht anderswo wiederholt — wäre dort genauso. | `error` lesen |
**Fehler nie als Antwort der KI behandeln.** Zeig `error` dem Nutzer als
Fehlermeldung an, aber schreib ihn nicht als Assistant-Nachricht in den
Verlauf.
### Beispiel (Python)
Braucht Python 3.11+ und `websockets` (`pip install websockets`). Ein Leser
pro Verbindung, mehrere Anfragen gleichzeitig möglich.
```python
import asyncio, json, uuid
import websockets
RVS = "wss://rvs.example.org:444/?token=DEIN_RVS_TOKEN"
class AriaCluster:
"""Eine RVS-Verbindung, ein Leser. Antworten gehen per requestId an die
wartende Anfrage — der Raum ist ein Verteiler, Fremdes wird ignoriert."""
def __init__(self, ws):
self.ws, self.wait = ws, {}
self.reader = asyncio.create_task(self._read())
async def _read(self):
async for raw in self.ws:
msg = json.loads(raw)
p = msg.get("payload") or {}
fut = self.wait.get((msg.get("type"), p.get("requestId")))
if fut and not fut.done():
fut.set_result(p)
async def _call(self, mtype, answer, payload, timeout):
rid = str(uuid.uuid4())
fut = asyncio.get_running_loop().create_future()
self.wait[(answer, rid)] = fut
try:
await self.ws.send(json.dumps({"type": mtype, "payload": dict(payload, requestId=rid)}))
return await asyncio.wait_for(fut, timeout)
finally:
self.wait.pop((answer, rid), None)
async def models(self):
res = await self._call("llm_cluster_models_request", "llm_cluster_models", {}, 6)
return res["models"]
async def chat(self, model, messages, timeout=660, **opts):
res = await self._call("llm_cluster_request", "llm_cluster_response",
dict(opts, model=model, messages=messages), timeout)
if not res["ok"]:
raise RuntimeError(f"{res.get('errorCode')}: {res.get('error')}")
return res
async def main():
async with websockets.connect(RVS, max_size=2**24) as ws:
cluster = AriaCluster(ws)
models = await cluster.models()
for m in models:
print(f"{m['model']} (Kontext {m['ctx']}, auf {m['available']} Karte(n))")
if models:
res = await cluster.chat(models[0]["model"],
[{"role": "user", "content": "Sag Hallo in einem Satz."}],
max_tokens=64)
print(res["content"], f"— von {res['node']} / Karte {res['gpu']}")
asyncio.run(main())
```
### Umstellen von der direkten Box-Ansprache
Wer schon gegen Abschnitt C gebaut hat (`llm_models_request` /
`llm_request` mit `targetInstance`), stellt so um:
1. **Modelliste:** `llm_cluster_models_request` statt `llm_models_request`.
Statt einer Antwort pro Box kommt **eine** Antwort mit jedem Modell
einmal. Kein Einsammeln über Sekunden mehr.
2. **Auswahl:** Dem Nutzer nur den **Modellnamen** zeigen — nicht mehr
„Modell · Karte · Box". Wo es läuft, entscheidet der Cluster.
3. **Kontextfenster:** `ctx` aus der Liste übernehmen statt einen eigenen Wert
einzustellen — und **danach kürzen**, nicht nur anzeigen. Die Box gibt nur
so viel her, wie dort steht; rechnet ein Programm mit mehr, kommt
`context_exceeded`. Den Fehler zusätzlich abfangen: kürzen und nochmal
senden (`promptTokens`/`ctx` stehen dabei). Tokens grob schätzen reicht
nicht immer — Code und Deutsch brauchen mehr Tokens pro Zeichen als
englischer Fließtext.
4. **Anfrage:** `llm_cluster_request` statt `llm_request`; **kein**
`targetInstance` mehr. Die Antwort kommt als `llm_cluster_response`.
5. **Fehler:** `ok: false` als Fehler anzeigen, nicht als Antwort in den
Verlauf. Neue Codes siehe Tabelle oben.
6. **Lesen:** ein Leser pro Verbindung, Antworten per `requestId` zuordnen.
Felder und Format von `messages`, `tools`, `tool_calls` und `usage` bleiben
gleich.
---
## B) OpenAI-RVS-Wrapper — für Programme, die nur OpenAI können
Ein kleiner Container, der **auf demselben Rechner** wie das Programm läuft.
Für das Programm ist der Cluster dann ein ganz normaler OpenAI-Endpunkt:
```
Programm ──HTTP──▶ Wrapper (127.0.0.1:8099) ──RVS──▶ ARIA-Bridge ──▶ KI-Box / Karte
```
Liegt im Repo unter [`xtts/openai_rvs_wrapper/`](../xtts/openai_rvs_wrapper/):
```bash
cd xtts/openai_rvs_wrapper
cp .env.example .env # RVS_HOST, RVS_PORT, RVS_TOKEN eintragen
docker compose up -d --build
```
Im Browser zeigt **http://127.0.0.1:8099/** eine Hilfsseite: ob die Verbindung
steht und welche Modelle es gibt. Im Programm eintragen:
| Feld | Wert |
|---|---|
| Base URL | `http://127.0.0.1:8099/v1` |
| API-Key | beliebig — oder `WRAPPER_API_KEY`, falls gesetzt |
| Modell | ein Name von der Hilfsseite |
| Kontextfenster | der Wert von der Hilfsseite |
- `GET /v1/models` — jedes Modell einmal, mit `context_length`
- `POST /v1/chat/completions` — auch `stream: true` (kommt derzeit in einem
Rutsch) und `tools`
- Fehler als HTTP-Fehler mit OpenAI-Fehlerobjekt: nicht geladenes Modell →
404 `model_not_found`, Cluster nicht erreichbar → 503, Zeitüberschreitung → 504
- Antwort-Header `X-Aria-Node` / `X-Aria-Gpu` sagen, wer geantwortet hat
- **Läuft das Programm selbst in Docker**, ist `127.0.0.1` dort der eigene
Container. Dann das Docker-Netz `aria-llm` anhängen und
`http://openai-rvs-wrapper:8099/v1` nehmen (Details in der README des Wrappers).
Mit dem offiziellen OpenAI-Python-SDK getestet (`models.list`,
`chat.completions.create` mit und ohne Stream, Fehlerklassen).
---
## C) Direkt an eine Box — Sonderfall
Ohne den Cluster, eine bestimmte Box gezielt. Keine Verteilung, kein
Ausfall-Ersatz. Nur nötig, wenn ein Programm bewusst eine bestimmte Box will
oder ARIA nicht läuft.
**Modelle der Boxen:** `llm_models_request` `{requestId}` → **jede** Box
antwortet einzeln mit `llm_models` `{requestId, instanceId, node, models:
[{model, gpu, gpuName, ctx, state}]}`. Ein paar Sekunden einsammeln.
**Anfrage:** `llm_request` `{requestId, targetInstance, model, messages, …}`
→ `llm_response` `{requestId, ok, content, tool_calls, model, instanceId,
node, gpu, usage, elapsedMs}`. Bei Fehler `errorCode`:
`model_not_loaded` (mit `loadedModels`) oder `no_model_loaded`.
Ohne `targetInstance` antworten nur Boxen, die das Modell geladen haben — die
anderen schweigen. Hat keine es, kommt **keine** Antwort. Darum hier immer
`targetInstance` setzen.
**OpenAI-Port einer Box:** Jede Box hat zusätzlich einen eigenen
OpenAI-Endpunkt (`http://<IP der Box>:8081/v1`, Standard nur `localhost` der
Box). Er sieht nur diese Box; nicht geladene Modelle → HTTP 404. Für mehrere
Boxen den Wrapper (B) nehmen.
---
## Was Programme nicht senden
`llm_provision_model`, `llm_unload_model` und `llm_remove_model` sind der
ARIA-Diagnostic vorbehalten. Technisch könnte jeder im Raum sie senden — der
RVS unterscheidet die Teilnehmer nicht. Ein Programm, das sich nicht daran
hält, tauscht anderen das Modell unter den Füßen weg.
## Gleichzeitige Nutzung und Geschwindigkeit
- Mehrere Anfragen an dasselbe Modell laufen auf einer Karte **parallel**.
Jede einzelne wird etwas langsamer, in Summe schafft die Karte mehr.
- Liegt ein Modell auf mehreren Karten, schickt der Cluster neue Anfragen an
die Karte mit den wenigsten laufenden.
- Der Kontext (`ctx`) ist bei aktuellen llama.cpp-Versionen ein **gemeinsamer
Vorrat** aller gleichzeitigen Anfragen auf einer Karte. Schicken mehrere
Programme gleichzeitig sehr lange Verläufe, wird es dort eng.
## Wenn sich etwas ändert
- Lädt der Betreiber ein anderes Modell in eine Karte, ist das alte dort weg.
Liegt es noch auf einer anderen Karte, merkt ein Programm nichts; sonst kommt
`model_unavailable` — dann die Liste neu holen.
- Beim Laden oder Entladen startet die betroffene Box **alle** ihre Modelle
kurz neu (wenige Sekunden). Anfragen in diesem Moment warten oder gehen an
eine andere Box.
- Diese Schnittstelle wird erweitert, nicht umgebaut: Neue Felder können
dazukommen. Unbekannte Felder ignorieren.
+2 -2
View File
@@ -1,7 +1,7 @@
# FLUX.1-dev Bildgenerierung — Architektur & Stand
Ergaenzung des ARIA-Agent-Stacks um native Text-to-Image-Generierung via
FLUX.1-dev auf der Gamebox. Folgt dem **gleichen Pattern wie f5tts / whisper**:
FLUX.1-dev auf der AI-Box. Folgt dem **gleichen Pattern wie f5tts / whisper**:
ein eigener Container auf dem Gaming-PC, der sich selbst per WebSocket zum
RVS verbindet und auf seinen Request-Typ lauscht.
@@ -23,7 +23,7 @@ aria-bridge ── send_to_core ──▶ aria-brain
RVS
│ fanout
▼
flux-bridge (Gamebox)
flux-bridge (AI-Box)
│ FluxPipeline.from_pretrained(...)
│ pipeline(prompt, width, height, steps, guidance).images[0]
│ PIL → PNG → base64
+9 -9
View File
@@ -1,4 +1,4 @@
# Plan B — Lokaler LLM-Router (Gamebox) neben Claude
# Plan B — Lokaler LLM-Router (AI-Box) neben Claude
**Ziel:** „Gemini-Feeling" für den Alltag, ohne die Claude-Max-Subscription
aufzugeben. Ein schnelles lokales LLM beantwortet die einfachen ~80 % der Turns
@@ -11,7 +11,7 @@ Gemessen (10.07.2026): CLI-Round-trip über den Claude-Max-Proxy hat einen
**harten Boden von ~3,5 s** (Subprozess-Start pro Turn). Streaming-API würde das
brechen, kostet aber API-Geld → verliert die Max-Subscription. Ein lokales
LLM für die einfachen Turns umgeht den 3,5-s-Boden komplett und ist **gratis**
(läuft auf vorhandener Gamebox-GPU). Echtes Speech-to-Speech-Duplex (Gemini
(läuft auf vorhandener AI-Box-GPU). Echtes Speech-to-Speech-Duplex (Gemini
Live nativ) ist mit einem Text-Modell als Hirn prinzipiell nicht drin.
## Modell & Serving (entschieden)
@@ -19,17 +19,17 @@ Live nativ) ist mit einem Text-Modell als Hirn prinzipiell nicht drin.
- **Modell:** Qwen3 8B, GGUF **Q4_K_M** (~6 GB). Bestes Tool-Calling der 7/8B-
Klasse, solides Deutsch, Apache-2.0. Alt.: Mistral Small 3 7B (schneller,
weniger Tool-Calling).
- **Serving:** **llama.cpp `llama-server`** im Docker-Container auf der Gamebox
- **Serving:** **llama.cpp `llama-server`** im Docker-Container auf der AI-Box
(kein Ollama nötig — nativer OpenAI-kompatibler `/v1/chat/completions`).
- **VRAM-Budget:** 12-GB-Karte, Whisper-small (~1–2 GB) + F5-TTS (~1–2 GB) →
~8–9 GB frei → passt. (FLUX ist auf 12 GB eh raus.)
## Anbindung: über den RVS, wie TTS/STT (kein IP-Pflegen)
Die Gamebox ist ein anderer Host als das Brain. Statt direktem HTTP (IP/Port/
Die AI-Box ist ein anderer Host als das Brain. Statt direktem HTTP (IP/Port/
Firewall) läuft das LLM **über den RVS-Token-Room**, exakt wie Whisper/F5-TTS:
- llama.cpp hört nur auf localhost der Gamebox.
- llama.cpp hört nur auf localhost der AI-Box.
- Ein **dünner RVS-Adapter** daneben (Vorbild: whisper-/xtts-Bridge) verbindet
sich mit dem RVS-Token, lauscht auf `llm_request`, ruft lokal llama-server,
schickt `llm_response` (korreliert per requestId) zurück.
@@ -115,7 +115,7 @@ mit Ziel lokal.
## Phasen
- **B0 — Infra:** llama.cpp-Container + RVS-Adapter auf der Gamebox,
- **B0 — Infra:** llama.cpp-Container + RVS-Adapter auf der AI-Box,
`ALLOWED_TYPES`, `local_llm_chat()` im Brain. Isoliert testen („sag hallo").
- **B1 — Router + lokale Tools:** Heuristik Tier-1/2 + Escalation, schlanke
Persona lokal, **kuratierte Tool-Auswahl lokal** (Adapter/Bridge/Brain-Tool-
@@ -207,8 +207,8 @@ Zerfaellt in zwei Teile:
(Docker-Socket) + Controller mit Placement-Policy + Reconciliation +
Broadcast-Kollisions-Vermeidung (nicht 2× dieselbe Faehigkeit). = Mini-Nomad.
**Empfehlung:** Fuer 2 Gameboxen NICHT bauen — statische Platzierung reicht
(Gamebox1=LLM, Gamebox2=Voice). Dynamisches Laden/Entladen zum VRAM-Freimachen
**Empfehlung:** Fuer 2 AI-Boxen NICHT bauen — statische Platzierung reicht
(AI-Box1=LLM, AI-Box2=Voice). Dynamisches Laden/Entladen zum VRAM-Freimachen
deckt `llama-swap` innerhalb eines Hosts (B0.5). Waechst die Flotte: erst den
billigen Heartbeat-Teil; fuer echte Orchestrierung Docker Swarm / Nomad nehmen
statt selbst einen Scheduler zu bauen.
@@ -227,7 +227,7 @@ lohnt nicht):
einen Heartbeat via RVS (Host, GPU-Util, VRAM frei/belegt, laufende
GPU-Container). Diagnostic zeigt pro Host VRAM-Balken + Dienste + „Host X hat
N GB frei". Kein Start/Stop, nur Sicht + Hinweis wohin verschiebbar.
- **Zukunft (Gamebox3, 4×3060 = 48 GB):** neuer Host, eigenes Profil, `up` →
- **Zukunft (AI-Box3, 4×3060 = 48 GB):** neuer Host, eigenes Profil, `up` →
erscheint im Dashboard; grosses lokales LLM oder FLUX-Vollausbau dorthin.
Ohne Orchestrator.
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
ARIA FLUX-Bridge — laeuft auf der Gamebox (RTX 3060).
ARIA FLUX-Bridge — laeuft auf der AI-Box (RTX 3060).
Empfaengt flux_request via RVS → FLUX.1-dev/-schnell auf GPU → sendet
flux_response mit base64-PNG zurueck an die aria-bridge. Diese speichert
+38
View File
@@ -0,0 +1,38 @@
# ─── ARIA Host-Agent — Konfiguration ───────────────────────────────
# Kopiere diese Datei nach .env (neben die Binary) und passe sie an.
# RVS-Zugang (identisch zum Haupt-Stack — gleicher Raum/Token, damit ARIA
# diesen Rechner erreicht). Werte aus der Haupt-.env.
RVS_HOST=rvs.example.de
RVS_PORT=443
RVS_TLS=true
RVS_TLS_FALLBACK=true # bei TLS-Fehlschlag einmal auf ws:// zurueckfallen
RVS_TOKEN=
# RVS_SNI: nur noetig, wenn RVS_HOST eine IP ist (Agent im selben Netz wie der
# RVS, direkt auf die interne IP). Dann hier den Zertifikats-/Hostnamen angeben,
# damit der TLS-Handshake (SNI) passt. Sonst leer lassen.
# RVS_HOST=10.0.0.2
# RVS_SNI=example.com
RVS_SNI=
# ─── Identitaet dieses Rechners ────────────────────────────────────
# HOST_ID = technisch eindeutig (a-z0-9-_), Default = Hostname-Slug.
# HOST_NAME = menschlicher Name, so spricht ARIA den Rechner an ("Stefans Laptop").
HOST_ID=
HOST_NAME=
# ─── Steuerung (Sicherheit!) ───────────────────────────────────────
# MUSS auf true, sonst fuehrt der Agent nichts aus (reiner Idle-Client).
CONTROL_ENABLED=true
# ─── sudo ──────────────────────────────────────────────────────────
# Reihenfolge: 1) Agent laeuft als root -> braucht kein sudo. 2) SUDO_PASSWORD
# gesetzt -> sudo -S mit Passwort. 3) SUDO_NOPASSWD=true -> sudo -n (Live-ISO /
# passwortloses sudo, z.B. Linux Mint vom Stick). 4) sonst schlaegt sudo fehl.
SUDO_PASSWORD=
SUDO_NOPASSWD=false
# ─── Limits (optional) ─────────────────────────────────────────────
EXEC_TIMEOUT=60 # max. Laufzeit eines Kommandos (s)
OUT_MAX_CHARS=20000 # stdout-Ausschnitt (offset/max_chars pro Request)
FILE_MAX_BYTES=10485760 # max. Datei-Transfer (10 MB)
+6
View File
@@ -0,0 +1,6 @@
.env
build/
dist/
.buildenv/
*.spec
__pycache__/
+24
View File
@@ -0,0 +1,24 @@
# Baut die Host-Agent-Binary mit PyInstaller in einem Container mit ALTEM glibc
# (bullseye, glibc 2.31), damit die Onefile-Binary auf moeglichst vielen Linux-
# Distributionen laeuft (glibc ist abwaerts-, nicht aufwaertskompatibel).
FROM python:3.11-slim-bullseye
WORKDIR /build
# PyInstaller braucht objdump aus binutils (im slim-Image nicht enthalten).
RUN apt-get update \
&& apt-get install -y --no-install-recommends binutils \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir pyinstaller
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY host_agent.py .
# Onefile-Binary; psutil-Hidden-Imports werden von PyInstaller erkannt.
RUN pyinstaller --onefile --name aria-host-agent \
--collect-all psutil \
host_agent.py
# Ergebnis liegt in /build/dist/aria-host-agent
CMD ["sh", "-c", "cp /build/dist/aria-host-agent /out/ && echo 'Binary -> /out/aria-host-agent'"]
+36
View File
@@ -0,0 +1,36 @@
# Baut die Windows-.exe des Host-Agents AUF LINUX — via Wine + Windows-Python +
# PyInstaller. tobix/pywine bringt Wine + Windows-Python 3.11 mit (PyInstaller
# kann NICHT cross-compilen, deshalb der Wine-Umweg).
#
# Zusaetzlich baut NSIS ein setup.exe, das die Agent-.exe installiert, eine .env
# in C:\ProgramData\ARIA-Host-Agent anlegt (falls keine da ist) und den Agent als
# automatisch startenden Windows-Dienst (via nssm) einrichtet.
FROM tobix/pywine:3.11
ARG VERSION=0.0.0
# NSIS (Installer-Compiler, laeuft nativ auf Linux) + Tools fuer nssm.
RUN apt-get update \
&& apt-get install -y --no-install-recommends nsis curl unzip ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /work
# nssm — Non-Sucking Service Manager (public domain): macht aus der Konsolen-.exe
# einen sauberen Windows-Dienst (die .exe selbst spricht das SCM nicht).
RUN curl -fsSL https://nssm.cc/release/nssm-2.24.zip -o /tmp/nssm.zip \
&& unzip -q /tmp/nssm.zip -d /tmp \
&& cp /tmp/nssm-2.24/win64/nssm.exe ./nssm.exe \
&& rm -rf /tmp/nssm*
COPY requirements.txt host_agent.py ./
COPY windows/installer.nsi ./
# Windows-Python-Deps + PyInstaller, dann die Onefile-.exe bauen.
RUN wine pip install --no-cache-dir -r requirements.txt pyinstaller
RUN wine pyinstaller --onefile --name aria-host-agent --collect-all psutil host_agent.py \
&& cp dist/aria-host-agent.exe ./aria-host-agent.exe \
&& makensis -DVERSION=${VERSION} installer.nsi
# Beide Artefakte rausreichen (dist/ wird vom build-win.sh als Volume gemountet).
CMD ["bash","-lc","cp dist/aria-host-agent.exe /out/ && cp aria-host-agent-setup.exe /out/ && echo 'OK -> /out/aria-host-agent.exe + /out/aria-host-agent-setup.exe'"]
+197
View File
@@ -0,0 +1,197 @@
# ARIA Host-Agent
Ein schlanker Agent, der **direkt auf einem Rechner** läuft und ARIA erlaubt,
diesen Rechner zu steuern — auch wenn er sonst aus dem Netz **nicht erreichbar**
ist (hinter NAT/Firewall, kein offener Port). Der Agent verbindet sich
**ausgehend** zum RVS (gleicher Token wie der Rest von ARIA).
Unterschied zum **Satelliten**: der Satellit entdeckt und steuert *andere*
Geräte in einem LAN; der Host-Agent steuert *den Rechner, auf dem er läuft*.
## Fähigkeiten
| Aktion | Was |
|--------------|-----|
| `exec` | Shell-Kommando ausführen (optional `sudo`), stdout/stderr/exit |
| `read` | Datei lesen (Base64, mit Offset/Limit) |
| `write` | Datei schreiben/anhängen (Base64 oder Text) |
| `info` | OS, CPU/RAM/Disk-Auslastung, Uptime, IP, `gui`-Methode |
| `screenshot` | Bildschirmfoto (X11: scrot/maim · Wayland: grim) |
| `ui_tap` | Mausklick auf x,y (button/double optional) *(nur mit GUI)* |
| `ui_text` | Text ins fokussierte Feld tippen *(nur mit GUI)* |
| `ui_key` | Taste/Kombi (Return, Escape, ctrl+c …) *(nur mit GUI)* |
| `ui_swipe` | Maus ziehen x1,y1 → x2,y2 *(nur mit GUI)* |
| `app_launch` | App/Programm starten (`app`/`query` oder `command`) *(nur mit GUI)* |
ARIA nutzt diese über die Brain-Tools `host_list`/`host_exec`/`host_read`/
`host_write`/`host_info`/`host_screenshot` sowie `host_ui_tap`/`host_ui_text`/
`host_ui_key`/`host_ui_swipe`/`host_app_launch` (dieselben Tools wie beim
Android-Agenten — ein Werkzeugset für Handy **und** Rechner).
> **GUI-Steuerung ist capability-gated.** Der Agent meldet die `ui_*`/`app_launch`-
> Fähigkeiten in seinen `caps` nur, wenn eine grafische Session da ist — ein reiner
> Terminal-/Server-Linux (kein `DISPLAY`/`WAYLAND_DISPLAY`) bekommt sie **nicht**
> (dort bleibt es bei `exec` & Co.). ARIA arbeitet mit dem **Screenshot** als Augen
> (voller Auflösung = echte Pixel; auf dem Desktop gibt es kein `ui_dump`).
>
> Nötige Helfer je System: **Linux/X11** → `xdotool`; **Linux/Wayland** → `ydotool`
> (Daemon, meist root); **macOS** → Text/Tasten via Bordmittel (`osascript`), Maus
> via `cliclick` (`brew install cliclick`); **Windows** → Bordmittel (PowerShell).
> HiDPI/Retina-Hinweis: Screenshot-Pixel können 2× der Klick-Koordinaten sein — bei
> Fehlklicks Koordinaten halbieren.
## Plattformen
Eine Codebasis, läuft auf **Linux, macOS und Windows** (der Agent wählt Shell,
Screenshot-Methode und Root/Admin-Check je OS automatisch):
| | exec | Root/Admin | Screenshot | GUI-Steuerung (`ui_*`) |
|---|---|---|---|---|
| **Linux** | `bash -lc` | sudo (`SUDO_PASSWORD`/`SUDO_NOPASSWD`) / root | grim (Wayland) · scrot/maim (X11) | `xdotool` (X11) · `ydotool` (Wayland) |
| **macOS** | `bash -lc` | sudo (wie Linux) | `screencapture` (Bordmittel) | `osascript` (Text/Tasten) · `cliclick` (Maus) |
| **Windows** | PowerShell | Agent **als Administrator** starten (kein sudo) | PowerShell/System.Drawing (Bordmittel) | PowerShell (Bordmittel) |
PyInstaller kann **nicht cross-kompilieren** — jede Binary wird auf ihrem OS gebaut.
## Bauen
**Linux (portabel, empfohlen)** — Docker-Container mit altem glibc:
```bash
./build.sh # -> dist/aria-host-agent (~15 MB, läuft auf vielen Distros)
```
**Linux/macOS ohne Docker** — PyInstaller direkt (linkt gegen lokales glibc/OS):
```bash
./build-native.sh # -> dist/aria-host-agent
```
**Windows — nativ** (auf einem Windows-Rechner, Python 3 im PATH nötig):
```bat
build-native.bat REM -> dist\aria-host-agent.exe
```
**Windows — aus Docker heraus (auf Linux!), inkl. Installer** — Wine baut die
`.exe`, NSIS packt ein `setup.exe`, das den Agent als Windows-Dienst einrichtet:
```bash
./build-win.sh [version]
# -> dist/aria-host-agent.exe (Konsolen-Binary)
# -> dist/aria-host-agent-setup.exe (Installer: Dienst + .env in ProgramData)
```
Der erste Lauf zieht das `tobix/pywine`-Image (~1–2 GB) und richtet die Wine-
Python-Umgebung ein — das dauert; Folge-Builds sind schnell. PyInstaller kann
nicht cross-compilen, deshalb der Wine-Umweg. **macOS geht so NICHT** (Apple
lässt sich nicht legal aus Docker bauen) — dort `./build-native.sh` auf einem Mac.
### Docker scheitert? (Live-ISO / overlayfs-Root)
Wenn `build.sh` mit `failed to mount … overlayfs … invalid argument` abbricht,
läufst du wahrscheinlich auf einem **Live-System** (Live-ISO). Dessen Root ist
selbst ein overlayfs, und Dockers `overlay2`-Treiber kann kein Overlay-auf-
Overlay stapeln. Zwei Auswege:
- **Empfohlen:** Binary auf einem normal installierten Linux bauen (`./build.sh`)
und nur die fertige `dist/aria-host-agent` aufs Live-System kopieren. Die
Binary ist portabel — Ziel braucht weder Docker noch Python.
- **Nativ bauen (ohne Docker):**
```bash
sudo apt install -y python3-pip python3-venv
./build-native.sh
```
Achtung: nativ gebaut linkt die Binary gegen das glibc **dieser** Maschine —
sie läuft dann nur auf Systemen mit gleichem oder neuerem glibc.
## Installieren
1. `dist/aria-host-agent` auf den Ziel-Rechner kopieren.
2. `.env.example` → `.env` daneben, RVS-Zugang + `CONTROL_ENABLED=true` eintragen.
3. Starten: `chmod +x aria-host-agent && ./aria-host-agent`
### Als systemd-Dienst (empfohlen für Dauerbetrieb)
Der Installer kopiert Binary + `.env` an ihre Plätze und richtet den Dienst ein:
```bash
# .env-Pfad direkt übergeben:
sudo ./install-service.sh /pfad/zur/.env
# ODER ohne Argument -> ncurses-Dateidialog (dialog) zum Auswählen der .env:
sudo ./install-service.sh
```
Er legt ab:
- Binary → `/usr/local/bin/aria-host-agent`
- `.env` → `/etc/aria-host-agent/.env` (Rechte `0600`, enthält Token/Passwörter)
- Unit → `/etc/systemd/system/aria-host-agent.service`, dann `enable --now`.
Danach: `systemctl status aria-host-agent` · `journalctl -u aria-host-agent -f`.
Die Binary sucht er unter `dist/aria-host-agent` bzw. `./aria-host-agent` (oder
2. Argument). Braucht `dialog` für den Dateibrowser (bietet die Installation an).
### Windows-Dienst (setup.exe)
`aria-host-agent-setup.exe` (aus `build-win.sh` oder dem Gitea-Release) als
Administrator ausführen. Der Installer:
- kopiert die `.exe` nach `%ProgramFiles%\ARIA Host-Agent`,
- legt `%ProgramData%\ARIA-Host-Agent\.env` an (nur falls noch keine da ist),
- richtet über **nssm** den Dienst **ARIA Host-Agent** ein (Autostart) und startet ihn.
Danach die `.env` unter `%ProgramData%\ARIA-Host-Agent\` mit RVS-Zugang +
`CONTROL_ENABLED=true` füllen und den Dienst neu starten (`services.msc` →
*ARIA Host-Agent*, oder `nssm restart ARIAHostAgent`). Deinstallation über
*Apps & Features* → *ARIA Host-Agent* (die `.env` in ProgramData bleibt erhalten).
## Release (Binaries als Gitea-Assets)
`release_agent.sh <version>` baut alles Docker-Baubare und hängt es als
**Release-Asset** an den Tag `agent-v<version>` — nichts landet im Git-Tree:
```bash
./release_agent.sh 0.2.0 # Linux + Android + Windows (Wine)
SKIP_WINDOWS=1 ./release_agent.sh 0.2.0 # ohne Windows (schneller)
```
Assets: `aria-host-agent-linux-x64`, `aria-host-agent-android-agent-v<v>.apk`,
`aria-host-agent-windows.exe`, `aria-host-agent-windows-setup.exe`. **macOS** ist
nicht Docker-baubar — auf einem Mac `./build-native.sh` laufen lassen und das
Ergebnis vor dem Release nach `dist/aria-host-agent-macos` legen, dann nimmt das
Skript es automatisch mit. Gitea-Zugang via `.env`/Umgebung (`GITEA_URL`,
`GITEA_REPO`, `GITEA_USER`), Kennwort wird abgefragt.
## TLS / SNI — Agent im selben Netz wie der RVS
Steht der Rechner im **selben Netz wie der RVS** (z.B. Rechenzentrum) und soll
direkt auf dessen **interne IP** verbinden (kein NAT-Hairpin über den externen
Hostnamen), scheitert der TLS-Handshake sonst an `tlsv1 alert internal error`
(Caddy hat kein Zertifikat für die IP). Lösung — in der `.env`:
```
RVS_HOST=10.0.0.2 # interne RVS-IP
RVS_SNI=example.com # Name, für den das Caddy-Zert gilt
```
Der Agent verbindet dann auf die IP, präsentiert aber den Namen im TLS-SNI.
Zuhause / im Normalfall `RVS_SNI` leer lassen und den Hostnamen als `RVS_HOST`.
## sudo
Vier Fälle, der Agent wählt automatisch:
1. **Agent läuft als root** (z.B. systemd `User=root`) → volle Rechte, kein sudo nötig.
2. `SUDO_PASSWORD=…` in der `.env` → `sudo -S` mit Passwort.
3. `SUDO_NOPASSWD=true` → `sudo -n` (Live-ISO / passwortloses sudo, z.B. Linux
Mint vom Stick).
4. sonst → sudo-Kommandos scheitern mit klarer Meldung.
## Sicherheit
- Reagiert **nur** auf den eigenen RVS-Raum (Token) und **nur**, wenn
`CONTROL_ENABLED=true`.
- Keine offenen Ports (reiner ausgehender Client).
- Alle Kommandos werden geloggt.
- Der Agent gibt **vollen** Zugriff auf den Rechner — nur auf Maschinen
einsetzen, denen du ARIA anvertraust.
## Hinweis Screenshot
Als Systemdienst fehlt die grafische Session. Für `screenshot` den Agent in der
Desktop-Session starten (Autostart) oder `DISPLAY`/`XAUTHORITY` in der Unit setzen.
+11
View File
@@ -0,0 +1,11 @@
build/
.gradle/
dist/
*.apk
local.properties
.idea/
*.iml
captures/
# Signaturschluessel — NUR lokal, niemals ins oeffentliche Repo (Backup machen!)
aria-agent.keystore
+32
View File
@@ -0,0 +1,32 @@
# Baut die Android-Agent-APK (Debug, auto-signiert -> direkt installierbar).
# APK-Builds gehen nur unter Linux — deshalb im Container.
FROM eclipse-temurin:17-jdk-jammy
ARG GRADLE_VERSION=8.5
ARG CMDLINE_TOOLS=11076708
ENV ANDROID_SDK_ROOT=/opt/android-sdk
ENV ANDROID_HOME=/opt/android-sdk
RUN apt-get update && apt-get install -y --no-install-recommends \
unzip wget ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Gradle
RUN wget -q https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip -O /tmp/g.zip \
&& unzip -q /tmp/g.zip -d /opt && rm /tmp/g.zip
# Android cmdline-tools + SDK
RUN mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools \
&& wget -q https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip -O /tmp/c.zip \
&& unzip -q /tmp/c.zip -d ${ANDROID_SDK_ROOT}/cmdline-tools && rm /tmp/c.zip \
&& mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest
ENV PATH="/opt/gradle-${GRADLE_VERSION}/bin:${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools:${PATH}"
RUN yes | sdkmanager --licenses >/dev/null 2>&1 || true
RUN sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0" >/dev/null 2>&1
WORKDIR /project
COPY . /project
CMD ["bash","-lc","gradle --no-daemon assembleDebug && cp app/build/outputs/apk/debug/app-debug.apk /out/aria-android-agent.apk && echo 'OK -> /out/aria-android-agent.apk'"]
+270
View File
@@ -0,0 +1,270 @@
# ARIA Android-Agent
Ein **nativer Android-Agent** (eigene APK), der ARIA erlaubt, ein Smartphone
**fernzusteuern** — inkl. Bedienen fremder App-UIs (z.B. „ARIA, richte auf dem
Handy ein E-Mail-Konto ein"). Gegenstück zum Desktop-`host-agent` (Linux/macOS/
Windows), aber Android ist kein Unix-Shell-System — deshalb ein **anderes
Action-Set** (UI-Automation statt beliebiger Shell-Kommandos).
Verbindet sich wie die ARIA-App **ausgehend** zum RVS (gleicher Token/Raum),
Verbindungs-Setup per **QR-Scan oder manueller Eingabe**. Taucht in der
Diagnostic unter **Satelliten → Host-Agenten 💻** auf (`host_hello` mit
`os="Android …"` + Android-Caps).
## Warum nativ (Kotlin), nicht Termux/RN
- **UI-Automation** (fremde Apps bedienen) geht auf Android nur über einen
**AccessibilityService** — den kann nur eine native App bereitstellen.
- **Screenshots** einer laufenden Session: **MediaProjection** (native).
- **Dauerbetrieb**: Foreground-Service mit Notification (native).
- Termux gäbe nur Shell + `termux-api` (SMS/Anruf/Standort …), **kein** Bedienen
anderer App-UIs. Für „E-Mail-Konto durchklicken" reicht das nicht.
Tech: **Kotlin**, OkHttp-WebSocket (RVS-Client), CameraX/ML-Kit (QR),
AccessibilityService (Input), MediaProjection (Screenshot). Build via Docker
(Android-SDK + Gradle) → APK. Nur Linux baut APKs (Docker), Deploy manuell.
## Action-Set (host_command → host_result)
Android-spezifisch (statt exec/read/write des Desktop-Agents):
| Action | Was |
|---|---|
| `screenshot` | Bildschirmfoto (MediaProjection) — ARIA *sieht* den Schirm |
| `ui_dump` | Sichtbare UI als Baum (Texte, Buttons, Felder + Koordinaten) — ARIAs „Augen" für gezieltes Tippen |
| `ui_tap` | Tippen (x,y ODER auf ein Element aus ui_dump) |
| `ui_text` | Text in das fokussierte/angegebene Feld schreiben |
| `ui_swipe` | Wischen/Scrollen |
| `ui_key` | Systemtasten (BACK, HOME, ENTER …) |
| `app_launch` | App per Paketname starten (z.B. E-Mail-App) |
| `app_list` | installierte Apps auflisten |
| `info` | Gerät: Modell, Android-Version, Akku, Netz, IP |
| `notify` | Benachrichtigung anzeigen |
| *(später)* | `sms_send`, `call`, `location`, `clipboard` (je nach Bedarf + Berechtigung) |
ARIA-Flow „E-Mail einrichten": `app_launch` (Mail-App) → `screenshot`/`ui_dump`
(sehen, was da ist) → `ui_tap`/`ui_text` (durchklicken) → wieder `ui_dump` prüfen,
bis fertig. Genau das agentische Muster wie beim Endian-Fix, nur mit Handy-UI.
### Fire TV / Android TV
**Dieselbe APK** läuft auf Handy und Stick — die TV-Fähigkeiten schalten sich
automatisch frei (`caps` enthalten dann `tv`). Zusätzlich auf dem TV:
| Action | Was |
|---|---|
| `notify` | **Einblendung über dem laufenden Bild** (Text/Bild) — ohne Netflix & Co. zu stoppen; oder als Sprache, je nach Einstellung (`mode` überschreibt) |
| `speak` | Sprachausgabe über die TV-Lautsprecher (ARIAs echte Stimme, sonst Geräte-TTS) |
| `volume` | Lautstärke setzen (`percent`) |
| `listen` / `listen_stop` | Mikrofon öffnen/schließen — ARIA-Sprachdialog auf dem Gerät |
| `ui_key` | zusätzlich D-Pad: `up/down/left/right/ok`, `play_pause/next/prev`, `lauter/leiser/stumm` |
**Sprachdialog:** Die App spricht das gleiche Protokoll wie die ARIA-App
(`stt_stream_*` rein, `chat` + `audio_pcm` raus) — **serverseitig ist nichts
anzupassen**. In den App-Einstellungen wählst Du, wie ARIA hier antwortet:
**Einblendung** (Standard) oder **Sprache**; sie kann es pro Anfrage überschreiben.
> **Turn-Ownership:** Der RVS verteilt jede Antwort an *alle* Clients im Raum.
> Damit nicht Handy **und** Stick gleichzeitig losreden, gibt dieses Gerät nur
> aus, was aus **seiner eigenen** Frage entstanden ist.
> **Mikrofon der Fire-TV-Fernbedienung:** Die Mikro-Taste ist systemseitig mit
> Alexa belegt; ob Fremd-Apps an das Remote-Mikro kommen, ist je nach Stick-
> Generation unterschiedlich und **muss am Gerät ausprobiert werden**. Sicher
> funktioniert der **Sprech-Knopf in der App** (OK-Taste) — und falls das Remote-
> Mikro nicht hergibt, tut es ein USB-Mikro am Stick.
Berechtigungen einmalig in der App: **„Einblendung erlauben"** (Über anderen Apps
anzeigen) und beim ersten Sprechen das **Mikrofon**.
### Ausrollen per Satellit (ohne Datei-Kopieren)
Statt die APK von Hand aufs Gerät zu schaufeln, kann ein **Satellit** sie per ADB
über das Netz installieren:
1. Am Fire TV: **Einstellungen › Mein Fire TV › Entwickleroptionen › ADB-Debugging** an.
2. Diagnostic → Satelliten → scannen. ADB-fähige Geräte zeigen ein **ADB**-Tag
und einen **📦**-Button.
3. 📦 → APK-URL (aus dem Gitea-Release) → **Jetzt ausrollen**. Beim ersten Mal
erscheint auf dem TV „USB-Debugging zulassen?" → bestätigen (Haken „Immer erlauben").
Der Rollout **wartet darauf** und macht dann automatisch weiter.
**Einrichtung ohne Tippen:** Am Fire TV gibt es keine Kamera (kein QR-Scan) und
Host/Token mit der Fernbedienung einzugeben ist eine Qual. Deshalb ist im Rollout-
Dialog **„Gleich einrichten"** vorausgewählt: die Diagnostic kennt den RVS-Zugang
selbst und gibt ihn dem Agenten über dieselbe ADB-Verbindung mit
(`adb.provision` → `am start` mit Intent-Extras). Der Agent speichert die Daten,
startet den Dienst und ist **sofort online** — null Eingaben am Fernseher.
Das Token geht dabei **serverseitig** raus, es steht nie im Browser-Formular.
Mit **„Status prüfen"** (`adb.status`) siehst Du jederzeit, ob der Agent auf dem
Gerät installiert ist, welche Version läuft und ob der Dienst aktiv ist.
> **Sicherheitshinweis:** Die Einrichtung per Intent geht an die (exportierte)
> Haupt-Activity. Wer ADB-Zugriff auf das Gerät hat, kann den Agenten damit
> umkonfigurieren — wer ADB hat, kontrolliert das Gerät ohnehin. Die App zeigt
> beim Empfang einen Hinweis („Von ARIA eingerichtet"), damit es nicht unbemerkt
> passiert. Auf einem dedizierten Stick ist das unkritisch; schalte ADB-Debugging
> nach dem Ausrollen ab, wenn Du ganz sicher gehen willst.
ARIA kann dasselbe: `satellite_command` mit `action='adb.install'`,
`params={'ip':'…','url':'…'}`. Der Satellit lädt die APK **einmal** und kann sie
auf beliebig viele Sticks installieren.
> **⚠️ Gerät muss ENTSPERRT sein, damit ARIA es steuern kann.** Gesten und
> Systemtasten (`ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch`) greifen bei
> gesperrtem Bildschirm nicht — Android-Sicherheit. Der Agent erkennt das und
> meldet „Gerät ist gesperrt — bitte erst entsperren", statt still „ok" zu sagen.
> **Sehen** (`screenshot`, `ui_dump`, `info`) funktioniert dagegen auch bei
> gesperrtem Gerät.
## Dauerbetrieb — Foreground-Service vs. Push (FCM)
Der Agent muss **immer erreichbar** sein, obwohl Android Hintergrundprozesse
aggressiv killt (Doze, App-Standby, OEM-Batterie-Manager wie Xiaomi/Huawei).
Zwei Wege, deine WhatsApp-Intuition trifft ins Schwarze:
**A) Foreground-Service (persistente WebSocket)** — der einfache Start:
- Dauerhafte RVS-Verbindung + Foreground-Notification („Agent aktiv").
- Braucht: `FOREGROUND_SERVICE`, **Akku-Optimierung ausnehmen**
(`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` — User whitelistet die App),
`RECEIVE_BOOT_COMPLETED` + BootReceiver (Neustart nach Reboot), Auto-Reconnect
(haben wir im Protokoll schon).
- **Reutzt unser bestehendes `host_hello`/`host_command`/`host_result` 1:1.**
- Nachteil: etwas Akku; manche OEMs killen trotzdem → „Autostart" manuell erlauben.
**B) Self-hosted Push (KEIN Google!)** — genau wie WhatsApp, aber auf eigenem Server:
- **UnifiedPush + self-hosted ntfy**: Auf dem ARIA-Server läuft **ntfy** (freier,
self-hostbarer Push-Server). Der Agent nutzt **UnifiedPush** (offener Standard,
de-Google-Welt/F-Droid) mit dem ntfy-Distributor auf dem Handy. Will ARIA etwas,
POSTet die Bridge/RVS an ntfy → weckt die App → sie holt den Befehl vom RVS,
arbeitet, antwortet. **Läuft auch auf Custom-ROMs OHNE Google Play Services.**
- Akkuschonend wie FCM, aber ohne jede Google-Abhängigkeit. Nur ein Dienst mehr
(ntfy) im Stack + der Push-Auslöser serverseitig.
**C) FCM (Google) — optional:** Wer ein Stock-Android mit Play Services hat und
Googles Push-Kanal will, kann FCM statt ntfy nehmen (bester Akku auf GMS-Geräten).
Braucht Firebase-Projekt + Play Services. **Nur eine Option, keine Pflicht.**
**Custom-ROM ohne Google:** → Weg **A** (eigener Socket) oder **B** (self-hosted
ntfy). Beide brauchen KEIN Google. Für Stefans dediziertes Ziel-Handy ist **A**
sogar oft die einfachste Dauerlösung (unser eigener „Push" über den RVS-Socket).
**Hybrid (ideal, End-Ausbau):** Im Leerlauf nur Push (max. Akku). Ein Push weckt
die App → sie öffnet die RVS-Verbindung, hält sich per Wakelock für die Interaktion
wach (mehrere Befehle flüssig, z.B. E-Mail-Setup durchklicken) → schläft nach ein
paar Sekunden Ruhe wieder ein. So WhatsApp-Akku UND schnelle Multi-Befehl-Sessions.
Der Push kommt dabei von **B (self-hosted ntfy)** oder C (FCM) — freie Wahl.
Erste Push-Latenz aus tiefem Doze ~1–3 s; danach bleibt der Socket die Session offen.
**Empfehlung:** Meilenstein 1–3 mit **A (Foreground-Service)** — läuft sofort und
nutzt alles Vorhandene, damit wir schnell einen funktionierenden Agenten haben, ganz
ohne externe Dienste. Dann **Push-Hybrid mit self-hosted ntfy (B)** als Akku-Ausbau —
KEIN Google. FCM (C) nur optional für Stock-Android. Action-Set/Protokoll bleiben
identisch, nur der Wecker ändert sich.
## Sicherheit
- Reagiert nur auf den eigenen RVS-Raum (Token); Setup per QR/manuell.
- **CONTROL_ENABLED**-Schalter in der App (Default AUS) — erst wenn Stefan es
bewusst aktiviert, führt der Agent Aktionen aus.
- AccessibilityService + MediaProjection müssen vom User **explizit** in den
Android-Einstellungen freigegeben werden (kein stiller Zugriff möglich).
- Alle Aktionen werden protokolliert (In-App-Log + optional an ARIA).
- Voller Gerätezugriff — nur auf eigenen/anvertrauten Geräten nutzen.
## Meilensteine
1. **✅ Verbinden + sichtbar** — Gradle-Projekt, AndroidManifest, RVS-WS-Client,
Foreground-Service, Connect-UI (QR-Scan + manuell), `host_hello`/`host_ping`.
→ Agent erscheint in der Diagnostic. `info` funktioniert.
2. **✅ Sehen** — MediaProjection-Screenshot (`ScreenCapturer`, gleicher
`{format,bytes,base64}`-Vertrag wie der Desktop-Agent → `host_screenshot`) +
`ui_dump` (`AriaAccessibilityService`, nur lesend → Brain-Tool `host_ui_dump`).
Freigabe einmalig in der App: „Bildschirm-Zugriff erlauben" + „Bedienungshilfe
öffnen". → ARIA sieht den Schirm und liest die UI-Elemente mit Koordinaten.
3. **✅ Steuern** — `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` über den
AccessibilityService (`canPerformGestures`, `dispatchGesture`, `ACTION_SET_TEXT`,
`performGlobalAction`) → Brain-Tools `host_ui_tap`/`_text`/`_swipe`/`_key`/
`host_app_launch`. Tap-Koordinaten = die x/y aus `ui_dump` (echte Pixel), NICHT
aus dem (skalierten) Screenshot. → ARIA bedient Apps (E-Mail-Setup).
4. **Feinschliff** — `app_list`/`notify`, Build-Härtung, `release_agent.sh`.
## Bauen
APK-Builds laufen **nur unter Linux** — deshalb im Docker-Container. Du brauchst
nichts Android-spezifisches installiert, **nur Docker**. Android-SDK, Gradle und
Build-Tools zieht der Container selbst (`Dockerfile.build`).
```bash
cd host-agent/android
./build.sh
```
`build.sh` baut das Image `aria-android-agent-build` und lässt darin
`gradle assembleDebug` laufen. Ergebnis:
```
host-agent/android/dist/aria-android-agent.apk
```
Das ist ein **Debug-APK**: auto-signiert mit dem Android-Debug-Key, also direkt
installierbar — ohne eigenen Keystore, ohne Play Store.
### Auf dem Handy installieren
1. `dist/aria-android-agent.apk` aufs Zielgerät kopieren (USB, Cloud, `adb install
dist/aria-android-agent.apk`, …).
2. Antippen → Android fragt nach **„Unbekannte Quellen / Aus dieser Quelle
installieren erlauben"** → erlauben.
3. App öffnen → verbinden (QR/manuell), „Steuerung erlauben" an, für M2 zusätzlich
„Bildschirm-Zugriff erlauben" + „Bedienungshilfe öffnen".
### Was der erste Build kostet
Der **erste** Lauf lädt viel (JDK-Image, Gradle, Android-SDK, Dependencies) und
dauert entsprechend — mehrere Minuten. Folge-Builds sind schnell (Docker-Layer +
Gradle-Cache im Image). Häufige Stolperer:
- **Docker fehlt / kein Zugriff** → `docker`-Rechte prüfen (`docker ps`).
- **Overlay-on-Overlay auf einem Live-ISO** („invalid argument" beim Image-Bau) —
gleiches Problem wie beim Desktop-Agent auf dem Mint-Live-System; von einer
installierten Linux-Kiste bauen.
- **Kotlin-/Manifest-Fehler** beim allerersten Bau eines neuen Meilensteins: den
Gradle-Fehler posten, das glätten wir schnell.
### Version setzen
Bis es `release.sh` gibt, wird die Version in `app/build.gradle` gepflegt
(`versionCode` / `versionName`). Aktuell `1` / `0.2.0` (M1+M2).
## Release
Das APK ist **kein** Teil des Git-Trees (blaeht sonst die History dauerhaft auf) —
es wird als **Release-Asset** an einen Tag gehaengt. Das macht `release_agent.sh`
(liegt eine Ebene hoeher, in `host-agent/`):
```bash
cd host-agent
./release_agent.sh 0.2.0
```
Das Skript (wie die `release.sh` der Haupt-App, Version als Parameter):
- setzt die Version (`host_agent.py` → `AGENT_VERSION`, `app/build.gradle` →
`versionName`/`versionCode`),
- baut **Linux-Binary + Android-APK** per Docker,
- committet den Version-Bump, taggt **`agent-v<version>`** (eigener Namespace,
kollidiert nicht mit den App-Tags `v<version>`) und pusht,
- legt ein Gitea-Release an und laedt die Assets hoch:
`aria-host-agent-linux-x64`, `aria-host-agent-android-agent-v<version>.apk`,
optional `-macos` / `-windows.exe` (falls in `host-agent/dist/` vorgebaut).
Gitea-Zugang (`GITEA_URL`, `GITEA_REPO`, `GITEA_USER`) kommt aus der Umgebung oder
einer `.env`; das Kennwort wird interaktiv abgefragt. **Binaries landen unter
„Releases", nie im Tree.**
> Status: **M1–M3 fertig** — verbinden, `info`, `screenshot`, `ui_dump` (sehen)
> und `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` (steuern);
> `release_agent.sh` vorhanden. Damit läuft der volle Ablauf sehen→steuern
> (z.B. E-Mail-Konto einrichten). Nächstes: Feinschliff (M4).
+57
View File
@@ -0,0 +1,57 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'de.hackersoft.ariaagent'
compileSdk 34
defaultConfig {
applicationId 'de.hackersoft.ariaagent'
minSdk 26
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
versionCode 105
versionName '0.0.1.5'
}
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
// Android neue APKs als Update derselben App akzeptiert (sonst "Konflikt mit
// bestehendem Paket"). Die Keystore-Datei liegt NUR lokal (gitignored, nicht
// im oeffentlichen Repo) — UNBEDINGT sichern, sonst brechen kuenftige Updates.
def ariaKeystore = rootProject.file('aria-agent.keystore')
signingConfigs {
aria {
if (ariaKeystore.exists()) {
storeFile ariaKeystore
storePassword 'ariaagent'
keyAlias 'aria'
keyPassword 'ariaagent'
}
}
}
buildTypes {
debug {
if (ariaKeystore.exists()) signingConfig signingConfigs.aria
}
release {
minifyEnabled false
if (ariaKeystore.exists()) signingConfig signingConfigs.aria
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.squareup.okhttp3:okhttp:4.12.0' // RVS-WebSocket
implementation 'com.journeyapps:zxing-android-embedded:4.3.0' // QR-Scan (FOSS, kein Google-Dienst)
}
@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- app_launch: Start-Intents/App-Labels sind ab Android 11 sonst unsichtbar. -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<!-- Overlay: ARIA blendet Text/Bild UEBER das laufende Programm ein (Fire TV).
Muss vom Nutzer einmalig erlaubt werden (Spezialberechtigung). -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- Sprache: Mikrofon (Fire-TV-Fernbedienung / USB-Mikro) fuer ARIA. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- Fire TV / Android TV: dieselbe APK laeuft auf Stick UND Handy.
touchscreen/faketouch NICHT erforderlich (Fire TV hat keinen), und
leanback ebenfalls optional (sonst laeuft sie nicht mehr aufs Handy). -->
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.hardware.faketouch" android:required="false" />
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:banner="@drawable/banner_tv"
android:label="@string/app_name"
android:usesCleartextTraffic="true"
android:supportsRtl="true"
android:theme="@style/Theme.AppCompat.DayNight">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- Ohne LEANBACK_LAUNCHER taucht die App im Fire-TV-Menue NICHT
auf (sie waere installiert, aber nicht startbar). -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<!-- Unsichtbarer Helfer: schaltet die Bildschirm-Aufnahme nach einem
Neustart automatisch wieder scharf (die Sitzung ist nicht speicherbar,
nur die Berechtigung). Durchsichtig, ohne Animation -> man sieht nichts. -->
<activity
android:name=".CaptureActivity"
android:exported="false"
android:excludeFromRecents="true"
android:noHistory="true"
android:taskAffinity=""
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<service
android:name=".AgentService"
android:exported="false"
android:foregroundServiceType="dataSync|mediaProjection" />
<receiver
android:name=".BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<service
android:name=".AriaAccessibilityService"
android:exported="false"
android:label="ARIA Host-Agent"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_config" />
</service>
</application>
</manifest>
@@ -0,0 +1,64 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.os.Build
/** Verbindungs-/Agent-Einstellungen (in SharedPreferences persistiert). */
data class AgentConfig(
var host: String = "",
var port: Int = 443,
var tls: Boolean = true,
var token: String = "",
var name: String = "",
var controlEnabled: Boolean = false,
/** Wie antwortet ARIA auf diesem Geraet standardmaessig: "overlay" oder "speech".
* Pro Anfrage ueberschreibbar ("...bitte als Sprache"). */
var replyMode: String = "overlay",
/** Soll die Bildschirm-Aufnahme nach einem Neustart automatisch wieder scharf
* geschaltet werden? Wird gesetzt, sobald sie einmal erlaubt wurde — die
* SITZUNG ist naemlich nicht speicherbar, nur die Berechtigung. */
var captureEnabled: Boolean = false,
) {
companion object {
private const val PREFS = "aria_agent"
fun load(ctx: Context): AgentConfig {
val p = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
return AgentConfig(
host = p.getString("host", "") ?: "",
port = p.getInt("port", 443),
tls = p.getBoolean("tls", true),
token = p.getString("token", "") ?: "",
name = p.getString("name", "") ?: "",
controlEnabled = p.getBoolean("control", false),
replyMode = p.getString("replyMode", "overlay") ?: "overlay",
captureEnabled = p.getBoolean("capture", false),
)
}
}
fun save(ctx: Context) {
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().apply {
putString("host", host)
putInt("port", port)
putBoolean("tls", tls)
putString("token", token)
putString("name", name)
putBoolean("control", controlEnabled)
putString("replyMode", replyMode)
putBoolean("capture", captureEnabled)
apply()
}
}
fun isValid(): Boolean = host.isNotBlank() && token.isNotBlank()
fun displayName(): String = if (name.isNotBlank()) name else Build.MODEL
/** Stabile, technische Host-ID (a-z0-9_-), wie beim Desktop-Agent. */
fun hostId(): String {
val base = displayName().lowercase()
.replace(Regex("[^a-z0-9_-]+"), "-").trim('-')
return base.ifBlank { "android" }
}
}
@@ -0,0 +1,186 @@
package de.hackersoft.ariaagent
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
/**
* Foreground-Service (Weg A): haelt die RVS-Verbindung dauerhaft, damit ARIA
* den Agenten jederzeit erreicht. Persistente Notification + Auto-Reconnect.
*/
class AgentService : Service() {
private var rvs: RvsClient? = null
companion object {
private const val CH = "aria_agent"
private const val NOTIF_ID = 1
const val ACTION_STATUS = "de.hackersoft.ariaagent.STATUS"
const val ACTION_PROJECTION = "de.hackersoft.ariaagent.PROJECTION"
const val ACTION_TALK = "de.hackersoft.ariaagent.TALK"
const val EXTRA_RESULT_CODE = "resultCode"
const val EXTRA_RESULT_DATA = "resultData"
@Volatile var status: String = "gestoppt"
@Volatile var connected: Boolean = false
fun start(ctx: Context) {
val i = Intent(ctx, AgentService::class.java)
if (Build.VERSION.SDK_INT >= 26) ctx.startForegroundService(i) else ctx.startService(i)
}
fun stop(ctx: Context) {
ctx.stopService(Intent(ctx, AgentService::class.java))
}
}
override fun onCreate() {
super.onCreate()
createChannel()
startForegroundDataSync("startet …")
}
/** Normaler Vordergrund-Start (Typ dataSync). mediaProjection wird NUR beim
* Projizieren gesetzt — auf Android 14 darf man diesen Typ sonst nicht ohne
* Projection-Token verwenden. */
private fun startForegroundDataSync(text: String) {
if (Build.VERSION.SDK_INT >= 29) {
startForeground(NOTIF_ID, buildNotification(text),
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIF_ID, buildNotification(text))
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// WICHTIG: Jeder startForegroundService()-Aufruf MUSS binnen ~5s mit
// startForeground() beantwortet werden — sonst crasht Android den Prozess
// (ForegroundServiceDidNotStartInTimeException). Der Projection-Intent kommt
// per startForegroundService, obwohl der Dienst schon laeuft -> hier IMMER
// zuerst startForeground aufrufen (idempotent).
startForegroundDataSync(status)
if (intent?.action == ACTION_TALK) {
// Sprech-Taste: laeuft gerade eine Aufnahme -> beenden, sonst starten.
val v = rvs?.voice
if (v == null) startRvs()
else if (v.isRecording) v.stopListening("user") else v.startListening()
return START_STICKY
}
if (intent?.action == ACTION_PROJECTION) {
val code = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
@Suppress("DEPRECATION")
val data = intent.getParcelableExtra<Intent>(EXTRA_RESULT_DATA)
if (code != 0 && data != null) {
try {
// Android verlangt beim Projizieren einen Vordergrund-Dienst vom
// Typ mediaProjection — VOR getMediaProjection() setzen, sonst
// SecurityException (FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION).
if (Build.VERSION.SDK_INT >= 29) {
startForeground(
NOTIF_ID, buildNotification("Bildschirm-Zugriff aktiv"),
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION or
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
}
ScreenCapturer.start(applicationContext, code, data)
if (ScreenCapturer.active) {
// Merken: nach einem Neustart automatisch wieder scharf
// schalten (die Sitzung selbst ist nicht speicherbar).
AgentConfig.load(this).takeIf { !it.captureEnabled }?.let {
it.captureEnabled = true; it.save(this)
}
updateNotification("$status · Bildschirm-Zugriff aktiv")
} else {
updateNotification("Bildschirm-Fehler: ${ScreenCapturer.lastError}")
}
} catch (e: Throwable) {
ScreenCapturer.lastError = "${e.javaClass.simpleName}: ${e.message}"
updateNotification("Bildschirm-Fehler: ${ScreenCapturer.lastError}")
}
}
if (rvs == null) startRvs() // Dienst war frisch -> Verbindung nachziehen
return START_STICKY
}
return startRvs()
}
/** (Re-)Startet die RVS-Verbindung anhand der gespeicherten Config. */
private fun startRvs(): Int {
val cfg = AgentConfig.load(this)
if (!cfg.isValid()) {
stopSelf()
return START_NOT_STICKY
}
rvs?.stop()
rvs = RvsClient(applicationContext, cfg) { conn, msg ->
connected = conn
status = msg
updateNotification(msg)
sendBroadcast(Intent(ACTION_STATUS).setPackage(packageName))
}
rvs?.start()
// Bildschirm-Aufnahme nach Geraete-/App-Neustart automatisch wieder scharf
// schalten. Die Berechtigung (AppOp PROJECT_MEDIA) bleibt erhalten, die
// SITZUNG nicht — ohne das muesste man am Fernseher jedes Mal von Hand
// "Bildschirm-Zugriff erlauben" druecken. Ist der AppOp gesetzt, laeuft
// die Anfrage ohne Dialog durch; sonst fragt Android einmal nach.
if (cfg.captureEnabled && !ScreenCapturer.active) {
android.os.Handler(mainLooper).postDelayed({
if (!ScreenCapturer.active) {
try { CaptureActivity.request(applicationContext) } catch (_: Throwable) {}
}
}, 2500)
}
return START_STICKY
}
override fun onDestroy() {
try { rvs?.voice?.shutdown() } catch (_: Exception) {}
rvs?.stop()
ScreenCapturer.stop()
connected = false
status = "gestoppt"
sendBroadcast(Intent(ACTION_STATUS).setPackage(packageName))
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createChannel() {
if (Build.VERSION.SDK_INT >= 26) {
val ch = NotificationChannel(CH, "ARIA Agent", NotificationManager.IMPORTANCE_LOW)
ch.setShowBadge(false)
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.createNotificationChannel(ch)
}
}
private fun buildNotification(text: String): Notification {
val pi = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, CH)
.setContentTitle("ARIA Host-Agent")
.setContentText(text)
.setSmallIcon(R.drawable.ic_launcher)
.setOngoing(true)
.setContentIntent(pi)
.build()
}
private fun updateNotification(text: String) {
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.notify(NOTIF_ID, buildNotification(text))
}
}
@@ -0,0 +1,298 @@
package de.hackersoft.ariaagent
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.graphics.Path
import android.graphics.Rect
import android.os.Bundle
import android.view.View
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
/**
* Bedienungshilfe-Dienst (Meilenstein 2 — nur LESEND).
*
* Liefert einen strukturierten Baum der sichtbaren Bildschirm-Elemente: Text,
* Beschriftung (contentDescription), Klasse, Bildschirm-Position (Mittelpunkt +
* Rahmen) und Flags (klickbar/editierbar/ankreuzbar). ARIA nutzt das ergaenzend
* zum Screenshot, um Ziele exakt zu benennen. Tippen/Text folgt in M3.
*
* Der Nutzer schaltet den Dienst einmalig unter Einstellungen > Bedienungshilfen
* frei. Er fuehrt hier nichts autonom aus — reagiert nur auf `dump()`.
*/
class AriaAccessibilityService : AccessibilityService() {
override fun onServiceConnected() {
instance = this
}
override fun onUnbind(intent: android.content.Intent?): Boolean {
if (instance === this) instance = null
return super.onUnbind(intent)
}
override fun onDestroy() {
if (instance === this) instance = null
super.onDestroy()
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) { /* passiv */ }
override fun onInterrupt() { /* passiv */ }
/**
* Steht eine Einblendung offen, raeumt sie der naechste Tastendruck weg.
*
* Der Trick: wir geben IMMER `false` zurueck. Damit laeuft die Taste ganz
* normal weiter an den Film/die App — die Einblendung schluckt nichts. Sie
* muss dafuer auch nicht fokussierbar sein, kann also weiterhin ueber einem
* laufenden Video stehen, ohne die Fernbedienung zu blockieren.
*
* Deshalb dieser Weg und nicht "Overlay faengt Tasten selbst ab": ein
* fokussierbares Fenster wuerde der App darunter JEDE Eingabe nehmen,
* solange es steht.
*
* Eine feste Anzeigedauer ist naemlich immer falsch — mal zu kurz zum
* Lesen, mal steht sie ewig im Bild.
*/
override fun onKeyEvent(event: android.view.KeyEvent?): Boolean {
if (event != null && event.action == android.view.KeyEvent.ACTION_DOWN
&& OverlayView.isShowing()) {
OverlayView.hide(applicationContext)
}
return false // Taste NICHT verbrauchen
}
/** Strukturierter Baum des aktiven Fensters. Shape: {ok, result:{package,count,nodes[]}}. */
fun dump(): JSONObject {
val root = rootInActiveWindow
?: return JSONObject().put("ok", false)
.put("error", "Kein aktives Fenster lesbar (Bildschirm evtl. aus oder gesperrt).")
val nodes = JSONArray()
try {
walk(root, nodes, 0)
} finally {
@Suppress("DEPRECATION") try { root.recycle() } catch (_: Exception) {}
}
val result = JSONObject()
.put("package", root.packageName?.toString() ?: "")
.put("count", nodes.length())
.put("nodes", nodes)
return JSONObject().put("ok", true).put("result", result)
}
private fun walk(node: AccessibilityNodeInfo?, out: JSONArray, depth: Int) {
if (node == null || depth > 40 || out.length() >= 400) return
val text = node.text?.toString()?.trim()
val desc = node.contentDescription?.toString()?.trim()
val cls = node.className?.toString()?.substringAfterLast('.')
val interesting = !text.isNullOrBlank() || !desc.isNullOrBlank() ||
node.isClickable || node.isEditable || node.isCheckable
if (interesting) {
val r = Rect()
node.getBoundsInScreen(r)
val o = JSONObject()
if (!text.isNullOrBlank()) o.put("text", text)
if (!desc.isNullOrBlank()) o.put("desc", desc)
if (cls != null) o.put("cls", cls)
if (node.isClickable) o.put("clickable", true)
if (node.isEditable) o.put("editable", true)
if (node.isCheckable) o.put("checked", node.isChecked)
o.put("x", r.centerX())
o.put("y", r.centerY())
o.put("bounds", "${r.left},${r.top},${r.right},${r.bottom}")
out.put(o)
}
for (i in 0 until node.childCount) {
walk(node.getChild(i), out, depth + 1)
}
}
// ─── Steuern (Meilenstein 3) ────────────────────────────────────
/** Tippt auf Bildschirm-Koordinaten (Pixel wie in ui_dump x/y). */
fun tap(x: Int, y: Int): JSONObject {
val path = Path().apply { moveTo(x.toFloat(), y.toFloat()) }
return gesture(path, 0, 60, "Tippen ($x,$y)")
}
/** Wischt von (x1,y1) nach (x2,y2) ueber dauerMs (Scrollen/Swipen). */
fun swipe(x1: Int, y1: Int, x2: Int, y2: Int, durationMs: Int): JSONObject {
val path = Path().apply {
moveTo(x1.toFloat(), y1.toFloat())
lineTo(x2.toFloat(), y2.toFloat())
}
return gesture(path, 0, durationMs.coerceIn(50, 5000).toLong(),
"Wischen ($x1,$y1 -> $x2,$y2)")
}
private fun gesture(path: Path, startMs: Long, durationMs: Long, desc: String): JSONObject {
val g = GestureDescription.Builder()
.addStroke(GestureDescription.StrokeDescription(path, startMs, durationMs))
.build()
val latch = CountDownLatch(1)
val ok = AtomicBoolean(false)
val dispatched = dispatchGesture(g, object : GestureResultCallback() {
override fun onCompleted(d: GestureDescription?) { ok.set(true); latch.countDown() }
override fun onCancelled(d: GestureDescription?) { latch.countDown() }
}, null)
if (!dispatched) return errMsg("Geste konnte nicht ausgeloest werden ($desc)")
try { latch.await(6, TimeUnit.SECONDS) } catch (_: InterruptedException) {}
return if (ok.get()) okMsg("$desc ausgefuehrt") else errMsg("$desc abgebrochen/timeout")
}
/** Schreibt Text in ein Eingabefeld an (x,y) — oder in das fokussierte Feld. */
fun setText(x: Int, y: Int, text: String): JSONObject {
val root = rootInActiveWindow ?: return errMsg("kein aktives Fenster")
val node = editableAt(root, x, y)
?: root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)?.takeIf { it.isEditable }
?: return errMsg("kein Textfeld an ($x,$y) gefunden — vorher ui_tap aufs Feld?")
node.performAction(AccessibilityNodeInfo.ACTION_FOCUS)
val args = Bundle().apply {
putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text)
}
val done = node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
return if (done) okMsg("Text gesetzt (${text.length} Zeichen)")
else errMsg("Text setzen fehlgeschlagen (Feld nicht editierbar?)")
}
/** Tiefste editierbare Node, deren Rahmen (x,y) enthaelt. */
private fun editableAt(node: AccessibilityNodeInfo?, x: Int, y: Int): AccessibilityNodeInfo? {
if (node == null) return null
var found: AccessibilityNodeInfo? = null
for (i in 0 until node.childCount) {
editableAt(node.getChild(i), x, y)?.let { found = it }
}
if (found != null) return found
val r = Rect(); node.getBoundsInScreen(r)
return if (node.isEditable && r.contains(x, y)) node else null
}
/** Globale Taste: back/home/recents/notifications — plus D-Pad (Fire TV). */
fun globalKey(name: String): JSONObject {
val key = name.trim().lowercase()
// D-Pad/Medien: auf dem Fernseher wird damit navigiert (kein Touch).
DPAD_KEYS[key]?.let { return dpad(it, key) }
val action = when (key) {
"back", "zurueck", "zurück" -> GLOBAL_ACTION_BACK
"home", "start", "startseite" -> GLOBAL_ACTION_HOME
"recents", "letzte", "uebersicht", "übersicht" -> GLOBAL_ACTION_RECENTS
"notifications", "benachrichtigungen" -> GLOBAL_ACTION_NOTIFICATIONS
else -> return errMsg("Taste '$name' unbekannt. Verfuegbar: back, home, " +
"recents, notifications, up, down, left, right, ok/center, " +
"play_pause, next, prev, volume_up, volume_down, mute")
}
return if (performGlobalAction(action)) okMsg("Taste '$name' ausgefuehrt")
else errMsg("Taste '$name' fehlgeschlagen")
}
/** D-Pad/Medien-Taste — via Fokus-Navigation bzw. Medien-Session. */
private fun dpad(keyCode: Int, label: String): JSONObject {
// Lautstaerke/Stumm gehen ueber den AudioManager (zuverlaessiger als KeyEvent).
when (keyCode) {
android.view.KeyEvent.KEYCODE_VOLUME_UP,
android.view.KeyEvent.KEYCODE_VOLUME_DOWN,
android.view.KeyEvent.KEYCODE_VOLUME_MUTE -> {
val am = getSystemService(android.content.Context.AUDIO_SERVICE)
as android.media.AudioManager
val dir = when (keyCode) {
android.view.KeyEvent.KEYCODE_VOLUME_UP -> android.media.AudioManager.ADJUST_RAISE
android.view.KeyEvent.KEYCODE_VOLUME_DOWN -> android.media.AudioManager.ADJUST_LOWER
else -> android.media.AudioManager.ADJUST_TOGGLE_MUTE
}
am.adjustStreamVolume(android.media.AudioManager.STREAM_MUSIC, dir,
android.media.AudioManager.FLAG_SHOW_UI)
return okMsg("Taste '$label' ausgefuehrt")
}
}
// Medientasten an die aktive Wiedergabe-Session.
if (keyCode in MEDIA_KEYS) {
val am = getSystemService(android.content.Context.AUDIO_SERVICE)
as android.media.AudioManager
val now = android.os.SystemClock.uptimeMillis()
am.dispatchMediaKeyEvent(android.view.KeyEvent(now, now,
android.view.KeyEvent.ACTION_DOWN, keyCode, 0))
am.dispatchMediaKeyEvent(android.view.KeyEvent(now, now,
android.view.KeyEvent.ACTION_UP, keyCode, 0))
return okMsg("Taste '$label' ausgefuehrt")
}
// Richtungen/OK: ueber die Bedienungshilfe-Fokus-Navigation.
val root = rootInActiveWindow ?: return errMsg("kein aktives Fenster")
val focus = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)
if (keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER) {
val target = focus ?: return errMsg("nichts fokussiert — vorher navigieren")
return if (target.performAction(AccessibilityNodeInfo.ACTION_CLICK))
okMsg("OK/Auswahl ausgefuehrt") else errMsg("Auswahl fehlgeschlagen")
}
val dir = when (keyCode) {
android.view.KeyEvent.KEYCODE_DPAD_UP -> View.FOCUS_UP
android.view.KeyEvent.KEYCODE_DPAD_DOWN -> View.FOCUS_DOWN
android.view.KeyEvent.KEYCODE_DPAD_LEFT -> View.FOCUS_LEFT
else -> View.FOCUS_RIGHT
}
val from = focus ?: root
val next = from.focusSearch(dir)
?: return errMsg("kein Element in Richtung '$label' gefunden")
return if (next.performAction(AccessibilityNodeInfo.ACTION_FOCUS))
okMsg("Taste '$label' ausgefuehrt") else errMsg("Taste '$label' fehlgeschlagen")
}
private fun okMsg(m: String): JSONObject =
JSONObject().put("ok", true).put("result", JSONObject().put("message", m))
private fun errMsg(m: String): JSONObject =
JSONObject().put("ok", false).put("error", m)
companion object {
@Volatile
var instance: AriaAccessibilityService? = null
val available: Boolean get() = instance != null
/** Fernseher-Navigation (Fire TV): D-Pad, Medien- und Lautstaerke-Tasten. */
private val DPAD_KEYS: Map<String, Int> = mapOf(
"up" to android.view.KeyEvent.KEYCODE_DPAD_UP,
"hoch" to android.view.KeyEvent.KEYCODE_DPAD_UP,
"down" to android.view.KeyEvent.KEYCODE_DPAD_DOWN,
"runter" to android.view.KeyEvent.KEYCODE_DPAD_DOWN,
"left" to android.view.KeyEvent.KEYCODE_DPAD_LEFT,
"links" to android.view.KeyEvent.KEYCODE_DPAD_LEFT,
"right" to android.view.KeyEvent.KEYCODE_DPAD_RIGHT,
"rechts" to android.view.KeyEvent.KEYCODE_DPAD_RIGHT,
"ok" to android.view.KeyEvent.KEYCODE_DPAD_CENTER,
"center" to android.view.KeyEvent.KEYCODE_DPAD_CENTER,
"select" to android.view.KeyEvent.KEYCODE_DPAD_CENTER,
"enter" to android.view.KeyEvent.KEYCODE_DPAD_CENTER,
"play_pause" to android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
"play" to android.view.KeyEvent.KEYCODE_MEDIA_PLAY,
"pause" to android.view.KeyEvent.KEYCODE_MEDIA_PAUSE,
"stop" to android.view.KeyEvent.KEYCODE_MEDIA_STOP,
"next" to android.view.KeyEvent.KEYCODE_MEDIA_NEXT,
"prev" to android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS,
"forward" to android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD,
"rewind" to android.view.KeyEvent.KEYCODE_MEDIA_REWIND,
"volume_up" to android.view.KeyEvent.KEYCODE_VOLUME_UP,
"lauter" to android.view.KeyEvent.KEYCODE_VOLUME_UP,
"volume_down" to android.view.KeyEvent.KEYCODE_VOLUME_DOWN,
"leiser" to android.view.KeyEvent.KEYCODE_VOLUME_DOWN,
"mute" to android.view.KeyEvent.KEYCODE_VOLUME_MUTE,
"stumm" to android.view.KeyEvent.KEYCODE_VOLUME_MUTE,
)
private val MEDIA_KEYS: Set<Int> = setOf(
android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
android.view.KeyEvent.KEYCODE_MEDIA_PLAY,
android.view.KeyEvent.KEYCODE_MEDIA_PAUSE,
android.view.KeyEvent.KEYCODE_MEDIA_STOP,
android.view.KeyEvent.KEYCODE_MEDIA_NEXT,
android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS,
android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD,
android.view.KeyEvent.KEYCODE_MEDIA_REWIND,
)
}
}
@@ -0,0 +1,69 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
/**
* Audio-Fokus — damit ARIA nicht einfach ueber den laufenden Film redet.
*
* Ohne Fokus mischt Android beide Tonspuren: Netflix/Jellyfin/Spotify laufen in
* voller Lautstaerke weiter und ARIA ist kaum zu verstehen. Mit Fokus verhaelt
* sie sich wie ein normaler Sprachassistent:
*
* "duck" (Standard) laufende Medien werden LEISER, ARIA spricht darueber,
* danach geht die Lautstaerke von selbst wieder hoch.
* "pause" laufende Medien werden PAUSIERT (Player reagieren auf
* den Fokusverlust) — sinnvoll beim Zuhoeren, damit das
* Mikrofon nicht den Filmton aufnimmt.
* "none" kein Fokus (alte Mischung).
*/
object AudioFocusHelper {
private var request: AudioFocusRequest? = null
private var holder: String = ""
/** Fordert den Fokus an. [mode] = duck | pause | none. */
@Synchronized
fun request(ctx: Context, mode: String, who: String): Boolean {
if (mode == "none") return true
abandon(ctx)
val gain = if (mode == "pause") AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
else AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
val usage = if (who == "listen") AudioAttributes.USAGE_ASSISTANCE_SONIFICATION
else AudioAttributes.USAGE_ASSISTANT
return try {
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val req = AudioFocusRequest.Builder(gain)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(usage)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
// Kurze Unterbrechungen nicht als "verloren" behandeln.
.setWillPauseWhenDucked(false)
.setOnAudioFocusChangeListener { }
.build()
request = req
holder = who
am.requestAudioFocus(req) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
} catch (_: Throwable) {
false
}
}
/** Gibt den Fokus zurueck — die Medien werden wieder lauter / laufen weiter. */
@Synchronized
fun abandon(ctx: Context) {
val req = request ?: return
request = null
holder = ""
try {
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.abandonAudioFocusRequest(req)
} catch (_: Throwable) {
}
}
}
@@ -0,0 +1,16 @@
package de.hackersoft.ariaagent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/** Startet den Agent-Service nach dem Booten wieder (wenn konfiguriert). */
class BootReceiver : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
if (AgentConfig.load(ctx).isValid()) {
AgentService.start(ctx)
}
}
}
}
@@ -0,0 +1,65 @@
package de.hackersoft.ariaagent
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.media.projection.MediaProjectionManager
import android.os.Bundle
/**
* Unsichtbarer Helfer, der die Bildschirm-Aufnahme (neu) scharf schaltet.
*
* Hintergrund: Die Berechtigung (AppOp PROJECT_MEDIA) bleibt dauerhaft erhalten,
* die MediaProjection-SITZUNG aber nicht — Android laesst sie grundsaetzlich
* nicht speichern, sie stirbt mit dem Prozess. Nach einem Neustart des Geraets
* oder der App muesste man sonst jedes Mal von Hand "Bildschirm-Zugriff erlauben"
* druecken (auf einem Fire TV mit der Fernbedienung besonders laestig).
*
* Diese Activity loest die Anfrage automatisch aus und reicht das Ergebnis an den
* Dienst weiter. Ist PROJECT_MEDIA per ADB erlaubt, laeuft das OHNE Dialog durch —
* der Nutzer sieht nichts (durchsichtiges Theme, kein Fenster-Inhalt).
*/
class CaptureActivity : Activity() {
companion object {
private const val REQ = 4711
/** Startet die (Neu-)Scharfschaltung aus dem Dienst heraus. */
fun request(ctx: Context) {
val i = Intent(ctx, CaptureActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS or
Intent.FLAG_ACTIVITY_NO_ANIMATION)
ctx.startActivity(i)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
try {
val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
startActivityForResult(mpm.createScreenCaptureIntent(), REQ)
} catch (e: Throwable) {
ScreenCapturer.lastError = "Anfrage fehlgeschlagen: ${e.message}"
finish()
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQ && resultCode == RESULT_OK && data != null) {
val i = Intent(this, AgentService::class.java)
.setAction(AgentService.ACTION_PROJECTION)
.putExtra(AgentService.EXTRA_RESULT_CODE, resultCode)
.putExtra(AgentService.EXTRA_RESULT_DATA, data)
androidx.core.content.ContextCompat.startForegroundService(this, i)
} else {
// Abgelehnt oder (ohne AppOp) Dialog weggeklickt — kein Drama, der
// Nutzer kann es in der App jederzeit nachholen.
ScreenCapturer.lastError =
"Bildschirm-Zugriff nicht erteilt (in der Agent-App nachholen)"
}
finish()
overridePendingTransition(0, 0)
}
}
@@ -0,0 +1,277 @@
package de.hackersoft.ariaagent
import android.Manifest
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.widget.Button
import android.widget.EditText
import android.widget.RadioButton
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SwitchCompat
import androidx.core.content.ContextCompat
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import org.json.JSONObject
class MainActivity : AppCompatActivity() {
private lateinit var host: EditText
private lateinit var port: EditText
private lateinit var token: EditText
private lateinit var name: EditText
private lateinit var tls: SwitchCompat
private lateinit var control: SwitchCompat
private lateinit var statusView: TextView
private val scan = registerForActivityResult(ScanContract()) { res ->
res.contents?.let { applyQr(it) }
}
private val camPerm = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) launchScan() else toast("Kamera-Berechtigung nötig für QR-Scan")
}
private val notifPerm = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
private val micPerm = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) talkToAria() else toast("Ohne Mikrofon-Berechtigung kein Sprachdialog")
}
private val projection = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { res ->
if (res.resultCode == Activity.RESULT_OK && res.data != null) {
val i = Intent(this, AgentService::class.java)
.setAction(AgentService.ACTION_PROJECTION)
.putExtra(AgentService.EXTRA_RESULT_CODE, res.resultCode)
.putExtra(AgentService.EXTRA_RESULT_DATA, res.data)
ContextCompat.startForegroundService(this, i)
toast("Bildschirm-Zugriff aktiv — ARIA kann jetzt Screenshots machen")
} else {
toast("Bildschirm-Zugriff abgelehnt")
}
}
private val statusReceiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, i: Intent?) = refreshStatus()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
host = findViewById(R.id.host)
port = findViewById(R.id.port)
token = findViewById(R.id.token)
name = findViewById(R.id.name)
tls = findViewById(R.id.tls)
control = findViewById(R.id.control)
statusView = findViewById(R.id.status)
findViewById<Button>(R.id.btnScan).setOnClickListener {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED) launchScan()
else camPerm.launch(Manifest.permission.CAMERA)
}
findViewById<Button>(R.id.btnConnect).setOnClickListener { saveAndConnect() }
findViewById<Button>(R.id.btnStop).setOnClickListener {
AgentService.stop(this)
refreshStatus()
}
findViewById<Button>(R.id.btnScreen).setOnClickListener {
if (!AgentService.connected && !AgentConfig.load(this).isValid()) {
toast("Erst verbinden, dann Bildschirm-Zugriff erlauben")
return@setOnClickListener
}
val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
projection.launch(mpm.createScreenCaptureIntent())
}
findViewById<Button>(R.id.btnTalk).setOnClickListener {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED) talkToAria()
else micPerm.launch(Manifest.permission.RECORD_AUDIO)
}
findViewById<Button>(R.id.btnOverlayPerm).setOnClickListener {
if (OverlayView.canDraw(this)) { toast("Einblendung ist bereits erlaubt"); return@setOnClickListener }
try {
startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
android.net.Uri.parse("package:$packageName")))
toast("'ARIA Host-Agent' erlauben")
} catch (_: Exception) {
try { startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION)) }
catch (_: Exception) { toast("Einstellung nicht gefunden") }
}
}
findViewById<Button>(R.id.btnAccessibility).setOnClickListener {
try {
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
toast("'ARIA Host-Agent' in der Liste einschalten")
} catch (_: Exception) {
toast("Bedienungshilfe-Einstellungen nicht gefunden")
}
}
// Fern-Einrichtung (z.B. Fire TV, wo QR-Scan und Tippen wegfallen): der
// Satellit startet die App per ADB mit den Zugangsdaten als Extras.
// Hat das geklappt, ist die UI schon befuellt — sonst normal laden.
if (!applyProvisioning(intent)) loadIntoUi(AgentConfig.load(this))
// Fire TV: kein Touchscreen — der QR-Scan ist dort sinnlos (keine Kamera),
// und der erste Fokus muss gesetzt sein, damit die Fernbedienung greift.
if (isTvDevice()) {
findViewById<Button>(R.id.btnScan).visibility = android.view.View.GONE
host.requestFocus()
}
if (Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
notifPerm.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
override fun onResume() {
super.onResume()
val filter = IntentFilter(AgentService.ACTION_STATUS)
ContextCompat.registerReceiver(this, statusReceiver, filter,
ContextCompat.RECEIVER_NOT_EXPORTED)
refreshStatus()
}
override fun onPause() {
super.onPause()
try { unregisterReceiver(statusReceiver) } catch (_: Exception) {}
}
private fun launchScan() {
val o = ScanOptions()
.setBeepEnabled(false)
.setOrientationLocked(false)
.setPrompt("ARIA-Verbindungs-QR scannen")
scan.launch(o)
}
private fun applyQr(content: String) {
try {
val j = JSONObject(content)
host.setText(j.optString("host"))
port.setText((if (j.has("port")) j.optInt("port", 443) else 443).toString())
token.setText(j.optString("token"))
if (j.has("tls")) tls.isChecked = j.optBoolean("tls", true)
toast("QR übernommen — jetzt 'Speichern & Verbinden'")
} catch (_: Exception) {
toast("Das ist kein ARIA-Verbindungs-QR")
}
}
private fun loadIntoUi(c: AgentConfig) {
host.setText(c.host)
port.setText(c.port.toString())
token.setText(c.token)
name.setText(c.name)
tls.isChecked = c.tls
control.isChecked = c.controlEnabled
findViewById<RadioButton>(
if (c.replyMode == "speech") R.id.modeSpeech else R.id.modeOverlay
).isChecked = true
}
private fun saveAndConnect() {
val c = AgentConfig(
host = host.text.toString().trim(),
port = port.text.toString().trim().toIntOrNull() ?: 443,
tls = tls.isChecked,
token = token.text.toString().trim(),
name = name.text.toString().trim(),
controlEnabled = control.isChecked,
replyMode = if (findViewById<RadioButton>(R.id.modeSpeech).isChecked) "speech" else "overlay",
)
if (!c.isValid()) {
toast("Host und Token sind Pflicht")
return
}
c.save(this)
AgentService.start(this)
toast("Agent gestartet")
refreshStatus()
}
private fun refreshStatus() {
val dot = if (AgentService.connected) "🟢" else "🔴"
statusView.text = "Status: ${AgentService.status} $dot"
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
if (intent == null) return
setIntent(intent)
applyProvisioning(intent)
}
/**
* Fern-Einrichtung per Intent-Extras (vom Satelliten via ADB gestartet).
*
* Auf einem Fire TV gibt es keine Kamera (kein QR) und Tippen mit der
* Fernbedienung ist muehsam — deshalb kann der Satellit die Zugangsdaten
* direkt mitgeben:
* adb shell am start -n de.hackersoft.ariaagent/.MainActivity \
* -a de.hackersoft.ariaagent.PROVISION \
* -e host rvs.example.com --ei port 443 --ez tls true -e token XYZ
*
* Gibt true zurueck, wenn eingerichtet wurde (dann startet der Dienst gleich).
*/
private fun applyProvisioning(intent: Intent): Boolean {
val host = intent.getStringExtra("host")?.trim().orEmpty()
val token = intent.getStringExtra("token")?.trim().orEmpty()
if (host.isEmpty() || token.isEmpty()) return false
val old = AgentConfig.load(this)
val cfg = AgentConfig(
host = host,
port = intent.getIntExtra("port", old.port.takeIf { it > 0 } ?: 443),
tls = intent.getBooleanExtra("tls", old.tls),
token = token,
name = intent.getStringExtra("name")?.trim().takeUnless { it.isNullOrEmpty() } ?: old.name,
controlEnabled = intent.getBooleanExtra("control", true),
replyMode = intent.getStringExtra("reply_mode")?.trim()
.takeUnless { it.isNullOrEmpty() } ?: old.replyMode,
// Der Satellit erteilt beim Einrichten PROJECT_MEDIA — dann darf sich
// die Bildschirm-Aufnahme nach jedem Neustart selbst scharf schalten.
captureEnabled = intent.getBooleanExtra("capture", old.captureEnabled),
)
cfg.save(this)
AgentService.start(this)
// Sichtbar machen, dass die Konfiguration von aussen kam (Transparenz).
toast("Von ARIA eingerichtet: ${cfg.host} — Agent gestartet")
loadIntoUi(cfg)
refreshStatus()
return true
}
/** Sprech-Taste: startet/stoppt die Aufnahme im laufenden Dienst. */
private fun talkToAria() {
if (!AgentConfig.load(this).isValid()) { toast("Erst verbinden"); return }
ContextCompat.startForegroundService(this,
Intent(this, AgentService::class.java).setAction(AgentService.ACTION_TALK))
toast("Sprich jetzt — nochmal drücken beendet die Aufnahme")
}
/** Fire TV / Android TV (kein Touchscreen, Bedienung per Fernbedienung). */
private fun isTvDevice(): Boolean = try {
packageManager.hasSystemFeature("android.software.leanback") ||
packageManager.hasSystemFeature("android.hardware.type.television")
} catch (_: Exception) { false }
private fun toast(m: String) = Toast.makeText(this, m, Toast.LENGTH_SHORT).show()
}
@@ -0,0 +1,173 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.Base64
import android.view.Gravity
import android.view.View
import android.view.WindowManager
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
/**
* Einblendung ueber dem laufenden Bild (Fire TV / Android TV).
*
* ARIA kann Text (und optional ein Bild) UEBER Netflix & Co. anzeigen, ohne die
* Wiedergabe zu stoppen — fuer Antworten, Hinweise, Tuerklingel-Schnappschuesse.
* Technisch ein TYPE_APPLICATION_OVERLAY-Fenster; der Nutzer muss die
* Spezialberechtigung "Ueber anderen Apps anzeigen" einmalig erteilen.
*
* Nicht fokussierbar (FLAG_NOT_FOCUSABLE) — die Fernbedienung steuert weiter die
* App darunter, die Einblendung schluckt keine Tastendruecke.
*
* WEGBEKOMMEN ohne feste Anzeigedauer (seconds = 0):
* - Fernbedienung: der Accessibility-Dienst sieht den naechsten Tastendruck und
* raeumt die Einblendung weg — er gibt die Taste trotzdem an den Film weiter
* (siehe AriaAccessibilityService.onKeyEvent).
* - Touch: FLAG_WATCH_OUTSIDE_TOUCH meldet uns eine Beruehrung AUSSERHALB der
* Einblendung. Die Beruehrung geht gleichzeitig an die App darunter — es
* geht also kein Tipp verloren.
* - Notbremse: nach HARD_CAP_SEC verschwindet sie in jedem Fall. Ohne das
* koennte eine vergessene Einblendung dauerhaft im Bild stehen.
*
* Eine feste Dauer ist naemlich immer falsch geraten — mal zu kurz zum Lesen,
* mal steht sie laengst im Weg.
*/
object OverlayView {
/** Auch eine "bleibt stehen"-Einblendung geht spaetestens danach weg. */
private const val HARD_CAP_SEC = 600
private val main = Handler(Looper.getMainLooper())
private var root: LinearLayout? = null
private var hideRunnable: Runnable? = null
/** Steht gerade eine Einblendung? Der Accessibility-Dienst fragt das, bevor
* er auf einen Tastendruck hin aufraeumt. */
fun isShowing(): Boolean = root != null
/** Hat der Nutzer "Ueber anderen Apps anzeigen" erlaubt? */
fun canDraw(ctx: Context): Boolean =
if (Build.VERSION.SDK_INT >= 23) Settings.canDrawOverlays(ctx) else true
/**
* Zeigt eine Einblendung. [seconds] = Anzeigedauer (0 = bis zum naechsten
* hide()). [imageB64] optional ein PNG/JPEG als Base64.
* Muss NICHT vom Main-Thread gerufen werden.
*/
fun show(ctx: Context, title: String, text: String, seconds: Int, imageB64: String?) {
main.post {
try {
hideNow(ctx)
val wm = ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val box = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
setPadding(36, 28, 36, 28)
background = GradientDrawable().apply {
cornerRadius = 22f
setColor(Color.parseColor("#E60D0D1A")) // dunkel, leicht transparent
setStroke(3, Color.parseColor("#3FFF9F"))
}
}
if (title.isNotBlank()) {
box.addView(TextView(ctx).apply {
setText(title)
setTextColor(Color.parseColor("#3FFF9F"))
textSize = 22f
setPadding(0, 0, 0, 10)
})
}
if (text.isNotBlank()) {
box.addView(TextView(ctx).apply {
setText(text)
setTextColor(Color.parseColor("#E0E0F0"))
textSize = 19f
maxLines = 8
})
}
imageB64?.let { b64 ->
try {
val bytes = Base64.decode(b64, Base64.DEFAULT)
val bmp = android.graphics.BitmapFactory
.decodeByteArray(bytes, 0, bytes.size)
if (bmp != null) {
box.addView(ImageView(ctx).apply {
setImageBitmap(bmp)
adjustViewBounds = true
maxHeight = 520
setPadding(0, 14, 0, 0)
})
}
} catch (_: Exception) { /* Bild optional */ }
}
val type = if (Build.VERSION.SDK_INT >= 26)
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
else
@Suppress("DEPRECATION") WindowManager.LayoutParams.TYPE_SYSTEM_ALERT
val lp = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
type,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
// Meldet uns Beruehrungen AUSSERHALB, ohne sie zu
// schlucken — damit raeumt ein Tipp aufs Video die
// Einblendung weg und steuert trotzdem den Player.
WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH or
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
android.graphics.PixelFormat.TRANSLUCENT,
).apply {
gravity = Gravity.TOP or Gravity.END
x = 48
y = 48
width = (ctx.resources.displayMetrics.widthPixels * 0.42f).toInt()
}
// Beruehrung ausserhalb -> wegraeumen. Die Beruehrung selbst
// geht dank FLAG_NOT_TOUCHABLE weiter an die App darunter.
box.setOnTouchListener { _, ev ->
if (ev.action == android.view.MotionEvent.ACTION_OUTSIDE) hideNow(ctx)
false
}
wm.addView(box, lp)
root = box
// seconds > 0 : feste Dauer wie gehabt.
// seconds == 0: bleibt stehen, bis der Nutzer etwas tut
// (Taste via Accessibility-Dienst / Tipp daneben)
// — mit Notbremse, damit sie nie haengen bleibt.
val cap = if (seconds > 0) seconds else HARD_CAP_SEC
hideRunnable = Runnable { hideNow(ctx) }
main.postDelayed(hideRunnable!!, cap * 1000L)
} catch (_: Exception) {
root = null
}
}
}
/** Blendet eine laufende Einblendung aus. */
fun hide(ctx: Context) {
main.post { hideNow(ctx) }
}
private fun hideNow(ctx: Context) {
hideRunnable?.let { main.removeCallbacks(it) }
hideRunnable = null
val v: View? = root
root = null
if (v != null) {
try {
(ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager).removeView(v)
} catch (_: Exception) { /* schon weg */ }
}
}
}
@@ -0,0 +1,447 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.os.BatteryManager
import android.os.Build
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.TimeUnit
/**
* RVS-WebSocket-Client (Meilenstein 1). Spricht dasselbe Protokoll wie der
* Desktop-Agent: meldet sich per host_hello, haelt sich per host_ping frisch,
* beantwortet host_command -> host_result.
*
* M1-Aktionen: nur `info`. screenshot/ui_* folgen in M2/M3.
*/
class RvsClient(
private val appCtx: Context,
private val config: AgentConfig,
private val onStatus: (connected: Boolean, msg: String) -> Unit,
) {
private val client = OkHttpClient.Builder()
.pingInterval(20, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS) // Server-Push: nie lesen-timeouten
.build()
private var ws: WebSocket? = null
@Volatile private var running = false
private var pingThread: Thread? = null
/** Sprachdialog mit ARIA (Mikro rein, Sprache/Einblendung raus). */
val voice: VoiceSession by lazy {
VoiceSession(appCtx, config,
send = { t, p -> ws?.let { send(it, t, p) } ?: false },
onEvent = { msg -> onStatus(true, msg) })
}
/** Fire TV / Android TV? Dann gibt es keinen Touchscreen, dafuer D-Pad. */
private val isTv: Boolean by lazy {
try {
val pm = appCtx.packageManager
pm.hasSystemFeature("android.software.leanback") ||
pm.hasSystemFeature("android.hardware.type.television") ||
(appCtx.getSystemService(Context.UI_MODE_SERVICE) as android.app.UiModeManager)
.currentModeType == android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
} catch (_: Exception) { false }
}
private val caps: List<String> by lazy {
val base = mutableListOf("info", "screenshot", "ui_dump",
"ui_tap", "ui_text", "ui_swipe", "ui_key", "app_launch",
// Ausgabe auf dem Geraet: Einblendung ueber dem Bild + Sprachausgabe.
"notify", "speak", "volume",
// Sprachdialog: Mikro oeffnen/schliessen (ARIA kann es auch anstossen).
"listen", "listen_stop")
if (isTv) base += "tv" // Kennzeichen: dieses Geraet ist ein Fernseher/Stick
base
}
fun start() {
running = true
connect()
}
fun stop() {
running = false
pingThread?.interrupt()
try { ws?.close(1000, "bye") } catch (_: Exception) {}
ws = null
}
private fun url(): String {
val proto = if (config.tls) "wss" else "ws"
return "$proto://${config.host}:${config.port}?token=${config.token}"
}
private fun connect() {
if (!running) return
onStatus(false, "verbinde …")
val req = Request.Builder().url(url()).build()
ws = client.newWebSocket(req, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
onStatus(true, "verbunden")
sendHello(webSocket)
startPing(webSocket)
}
override fun onMessage(webSocket: WebSocket, text: String) {
handle(webSocket, text)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
onStatus(false, "getrennt: ${t.message ?: "?"}")
reconnectLater()
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
onStatus(false, "geschlossen")
reconnectLater()
}
})
}
private fun reconnectLater() {
pingThread?.interrupt()
if (!running) return
Thread {
try { Thread.sleep(3000) } catch (_: InterruptedException) { return@Thread }
connect()
}.start()
}
/**
* Verschickt eine Nachricht. Gibt zurueck, ob der Socket sie ANGENOMMEN hat.
*
* Wichtig: OkHttps `send()` WIRFT NICHT, sondern gibt `false` zurueck, wenn
* die Verbindung zu ist oder der Ausgangspuffer voll ist. Dieser Rueckgabe-
* wert wurde vorher verworfen — ein Senden ins Leere sah damit aus wie ein
* Erfolg. Genau daran ist die Fire-TV-Sprachausgabe lautlos gescheitert und
* hat trotzdem "gesprochen" gemeldet.
*/
private fun send(webSocket: WebSocket, type: String, payload: JSONObject): Boolean {
val o = JSONObject()
o.put("type", type)
o.put("payload", payload)
o.put("timestamp", System.currentTimeMillis())
return try { webSocket.send(o.toString()) } catch (_: Exception) { false }
}
private fun sendHello(webSocket: WebSocket) {
val p = JSONObject()
p.put("hostId", config.hostId())
p.put("name", config.displayName())
val kind = if (isTv) "Fire TV / Android TV" else "Android"
p.put("os", "$kind ${Build.VERSION.RELEASE} (${Build.MODEL})")
// Eigene LAN-IP mitmelden: so kann die Diagnostic diesen Agenten dem im
// Netz entdeckten Geraet zuordnen (z.B. um beim Ausrollen den bereits
// vergebenen Namen vorzuschlagen statt der technischen Kennung).
localIp()?.let { p.put("ip", it) }
p.put("caps", JSONArray(caps))
p.put("control", config.controlEnabled)
send(webSocket, "host_hello", p)
}
/** Erste echte IPv4 dieses Geraets im LAN (ohne Loopback). */
private fun localIp(): String? = try {
java.net.NetworkInterface.getNetworkInterfaces().toList()
.filter { it.isUp && !it.isLoopback }
.flatMap { it.inetAddresses.toList() }
.firstOrNull { !it.isLoopbackAddress && it is java.net.Inet4Address }
?.hostAddress
} catch (_: Throwable) { null }
private fun startPing(webSocket: WebSocket) {
pingThread?.interrupt()
pingThread = Thread {
while (running && !Thread.currentThread().isInterrupted) {
try { Thread.sleep(25000) } catch (_: InterruptedException) { break }
val p = JSONObject().put("hostId", config.hostId())
send(webSocket, "host_ping", p)
sendHello(webSocket) // Re-announce (RVS replayt hellos nicht)
}
}.also { it.start() }
}
private fun handle(webSocket: WebSocket, text: String) {
val msg = try { JSONObject(text) } catch (_: Exception) { return }
val type = msg.optString("type")
// Sprachdialog: chat/audio_pcm/stt_* laufen ueber dieselbe Verbindung.
// (Der RVS broadcastet alles im Raum — VoiceSession filtert auf den
// eigenen Turn, damit nicht auch Handy und Stick gleichzeitig antworten.)
if (type != "host_command") {
if (type in VOICE_TYPES) {
try { voice.handle(type, msg.optJSONObject("payload") ?: JSONObject()) }
catch (_: Throwable) {}
}
return
}
val payload = msg.optJSONObject("payload") ?: JSONObject()
val target = payload.optString("host").ifBlank { payload.optString("hostId") }
if (target.isNotBlank()
&& !target.equals(config.hostId(), true)
&& !target.equals(config.displayName(), true)) return
val action = payload.optString("action")
val params = payload.optJSONObject("params") ?: JSONObject()
fun reply(r: JSONObject) {
r.put("requestId", payload.optString("requestId"))
r.put("hostId", config.hostId())
r.put("action", action)
send(webSocket, "host_result", r)
}
// Alles, was SPRECHEN kann, laeuft in einem EIGENEN Thread. Es muss
// abwarten, bis wirklich Ton kommt — und dieses handle() laeuft auf dem
// Lese-Thread des WebSockets. Wuerden wir hier warten, kaeme das
// erwartete audio_pcm nie an (wir blockieren ja selbst das Lesen) und
// jede Sprachausgabe liefe garantiert in den Timeout.
if (action in SPEAKING_ACTIONS && config.controlEnabled) {
Thread {
val r = try {
if (action == "speak") doSpeak(params) else doNotify(params)
} catch (t: Throwable) {
err("Fehler bei '$action': ${t.javaClass.simpleName}: ${t.message}")
}
reply(r)
}.also { it.isDaemon = true }.start()
return
}
// WICHTIG: JEDE Aktion muss ein host_result liefern — auch bei Exception
// ODER OutOfMemoryError (Throwable!). Sonst bekommt ARIA statt einer
// Fehlermeldung nur einen Timeout (kein Result kommt zurueck).
val result: JSONObject = try {
when {
!config.controlEnabled ->
err("Steuerung ist in der Agent-App deaktiviert (Schalter 'Steuerung erlauben').")
action == "info" -> doInfo()
action == "screenshot" -> doScreenshot()
action == "ui_dump" -> doUiDump()
// Steuern geht nur bei ENTSPERRTEM Gerät (Gesten/Tasten greifen sonst
// nicht — Android-Sicherheit). Sehen (screenshot/ui_dump) geht gesperrt.
action in CONTROL_ACTIONS && isLocked() ->
err("Gerät ist gesperrt — zum Steuern bitte erst entsperren. " +
"(Screenshot/Bildschirm lesen geht auch gesperrt.)")
action == "ui_tap" -> doUiTap(params)
action == "ui_text" -> doUiText(params)
action == "ui_swipe" -> doUiSwipe(params)
action == "ui_key" -> doUiKey(params)
action == "app_launch" -> doAppLaunch(params)
action == "notify" -> doNotify(params)
action == "speak" -> doSpeak(params)
action == "volume" -> doVolume(params)
action == "listen" -> voice.startListening()
action == "listen_stop" -> voice.stopListening("remote")
action == "app_list" ->
err("Aktion '$action' kommt spaeter (noch nicht implementiert).")
else -> err("Aktion '$action' unbekannt.")
}
} catch (t: Throwable) {
err("Fehler bei '$action': ${t.javaClass.simpleName}: ${t.message}")
}
reply(result)
}
/** Steuer-Aktionen, die ein entsperrtes Gerät brauchen. */
private val CONTROL_ACTIONS = setOf("ui_tap", "ui_text", "ui_swipe", "ui_key", "app_launch")
/** Aktionen, die sprechen koennen — laufen abseits des WS-Lese-Threads,
* weil sie auf das eintreffende audio_pcm warten (siehe handle()). */
private val SPEAKING_ACTIONS = setOf("speak", "notify")
/** Message-Typen des ARIA-Sprachdialogs (kein host_command). */
private val VOICE_TYPES = setOf(
"chat", "audio_pcm", "stt_endpoint", "stt_stream_done", "stt_partial")
private fun isLocked(): Boolean = try {
val km = appCtx.getSystemService(Context.KEYGUARD_SERVICE) as android.app.KeyguardManager
km.isKeyguardLocked
} catch (_: Exception) { false }
private fun err(m: String): JSONObject = JSONObject().put("ok", false).put("error", m)
/** Bildschirmfoto — selber Vertrag wie der Desktop-Agent: {format,bytes,base64}. */
private fun doScreenshot(): JSONObject {
if (!ScreenCapturer.active) {
val why = ScreenCapturer.lastError?.let { " (letzter Fehler: $it)" } ?: ""
return err("Bildschirm-Zugriff nicht erlaubt. In der Agent-App auf dem Handy " +
"einmalig 'Bildschirm-Zugriff erlauben' antippen.$why")
}
val png = ScreenCapturer.capture()
?: return err("Screenshot fehlgeschlagen: ${ScreenCapturer.lastError ?: "unbekannt"}")
val b64 = android.util.Base64.encodeToString(png, android.util.Base64.NO_WRAP)
val res = JSONObject().put("format", "png").put("bytes", png.size).put("base64", b64)
return JSONObject().put("ok", true).put("result", res)
}
/** Sichtbare Bedienelemente als Baum (Bedienungshilfe). */
private fun doUiDump(): JSONObject {
val svc = a11y() ?: return a11yMissing()
return svc.dump()
}
private fun a11y(): AriaAccessibilityService? = AriaAccessibilityService.instance
private fun a11yMissing(): JSONObject =
err("Bedienungshilfe nicht aktiv. In der Agent-App 'Bedienungshilfe öffnen' " +
"antippen und 'ARIA Host-Agent' einschalten.")
/** Tippen auf Koordinaten (Pixel wie in ui_dump x/y). */
private fun doUiTap(p: JSONObject): JSONObject {
val svc = a11y() ?: return a11yMissing()
if (!p.has("x") || !p.has("y")) return err("ui_tap braucht x und y (aus ui_dump).")
return svc.tap(p.optInt("x"), p.optInt("y"))
}
/** Text in Feld an (x,y) schreiben. */
private fun doUiText(p: JSONObject): JSONObject {
val svc = a11y() ?: return a11yMissing()
val text = p.optString("text")
if (!p.has("x") || !p.has("y")) return err("ui_text braucht x, y und text.")
return svc.setText(p.optInt("x"), p.optInt("y"), text)
}
/** Wischen/Scrollen von (x1,y1) nach (x2,y2). */
private fun doUiSwipe(p: JSONObject): JSONObject {
val svc = a11y() ?: return a11yMissing()
if (!p.has("x1") || !p.has("y1") || !p.has("x2") || !p.has("y2"))
return err("ui_swipe braucht x1,y1,x2,y2 (optional duration_ms).")
return svc.swipe(p.optInt("x1"), p.optInt("y1"), p.optInt("x2"), p.optInt("y2"),
p.optInt("duration_ms", 300))
}
/** Globale Taste: back/home/recents/notifications. */
private fun doUiKey(p: JSONObject): JSONObject {
val svc = a11y() ?: return a11yMissing()
return svc.globalKey(p.optString("key"))
}
/** App starten (per Paketname oder Namens-Suche). */
private fun doAppLaunch(p: JSONObject): JSONObject {
val pm = appCtx.packageManager
var pkg = p.optString("package").trim()
val query = p.optString("query").trim()
if (pkg.isBlank() && query.isNotBlank()) {
pkg = resolvePackage(query) ?: return err("Keine App zu '$query' gefunden.")
}
if (pkg.isBlank()) return err("app_launch braucht 'package' ODER 'query' (App-Name).")
val intent = pm.getLaunchIntentForPackage(pkg)
?: return err("App '$pkg' nicht installiert oder ohne Start-Symbol.")
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
appCtx.startActivity(intent)
return JSONObject().put("ok", true)
.put("result", JSONObject().put("message", "App '$pkg' gestartet"))
}
/**
* ARIAs Antwort auf dem Geraet ausgeben: Einblendung (Overlay) und/oder Sprache.
* `mode` optional ("overlay" | "speech" | "both") — ohne Angabe gilt die
* Einstellung aus der App (config.replyMode). So kann ARIA pro Anfrage
* ueberschreiben ("...bitte als Sprache"), ohne die Voreinstellung zu aendern.
*/
private fun doNotify(p: JSONObject): JSONObject {
val text = p.optString("text").ifBlank { p.optString("message") }
val title = p.optString("title", "ARIA")
if (text.isBlank() && !p.has("image_base64")) return err("notify braucht 'text'.")
val mode = p.optString("mode").ifBlank { config.replyMode }
val done = mutableListOf<String>()
val problems = mutableListOf<String>()
if (mode == "overlay" || mode == "both") {
if (!OverlayView.canDraw(appCtx)) {
problems += "Einblendung nicht erlaubt — in der Agent-App auf dem Gerät " +
"'Einblendung erlauben' antippen (Berechtigung 'Über anderen Apps anzeigen')"
} else {
OverlayView.show(appCtx, title, text,
p.optInt("seconds", 12),
if (p.has("image_base64")) p.optString("image_base64") else null)
done += "eingeblendet"
}
}
if (mode == "speech" || mode == "both") {
val r = speakOut(p, text)
r.exceptionOrNull()?.let { problems += "Sprachausgabe: ${it.message}" }
?: run { done += "gesprochen (ARIAs Stimme)" }
}
if (done.isEmpty()) return err(problems.joinToString("; ").ifBlank { "nichts ausgegeben" })
val msg = done.joinToString(" + ") + (if (problems.isEmpty()) "" else " (${problems.joinToString("; ")})")
return JSONObject().put("ok", true).put("result", JSONObject().put("message", msg))
}
/** Nur Sprachausgabe (ARIAs Audio, falls mitgeliefert — sonst Geraete-TTS). */
private fun doSpeak(p: JSONObject): JSONObject {
val text = p.optString("text")
if (text.isBlank() && !p.has("audio_base64")) return err("speak braucht 'text' oder 'audio_base64'.")
val how = if (p.optString("audio_base64").isNotBlank()) "Audio abgespielt"
else if (p.optString("engine") == "device") "über die Geräte-Stimme gesprochen"
else "wird mit ARIAs Stimme gesprochen (Audio kommt vom Server)"
val r = speakOut(p, text)
return r.fold(
{ JSONObject().put("ok", true).put("result", JSONObject().put("message", how)) },
{ err("Sprachausgabe fehlgeschlagen: ${it.message}") },
)
}
/**
* Sprachausgabe. Reihenfolge:
* 1. mitgeliefertes Audio abspielen,
* 2. ARIAs ECHTE Stimme ueber den Server anfordern (Standard) — Fire TV
* Sticks haben oft gar keine (deutsche) TTS-Engine,
* 3. geraeteeigene Sprachausgabe nur auf ausdrueckliche Anforderung
* (engine='device') oder wenn kein Server-Audio moeglich ist.
* focus: duck (Medien leiser, Standard) | pause (Medien anhalten) | none.
*/
private fun speakOut(p: JSONObject, text: String): Result<Unit> {
val audio = p.optString("audio_base64")
val focus = p.optString("focus").ifBlank { "duck" }
if (audio.isNotBlank())
return Speaker.play(appCtx, audio, p.optString("audio_format", "wav"), focus)
if (p.optString("engine") == "device")
return Speaker.speak(appCtx, text, focus)
// Nicht nur abschicken, sondern abwarten bis wirklich Ton kommt —
// sonst meldet der Agent Erfolg und der Fernseher bleibt stumm.
return runCatching {
val mid = voice.requestTts(text)
voice.awaitSpeechStart(mid)
}
}
private fun doVolume(p: JSONObject): JSONObject {
if (!p.has("percent")) return err("volume braucht 'percent' (0-100).")
val v = p.optInt("percent")
Speaker.setVolume(appCtx, v)
return JSONObject().put("ok", true)
.put("result", JSONObject().put("message", "Lautstärke auf $v% gesetzt"))
}
/** Paketname per Label-Teilstring finden (case-insensitive). */
private fun resolvePackage(query: String): String? {
val pm = appCtx.packageManager
val q = query.lowercase()
val launch = android.content.Intent(android.content.Intent.ACTION_MAIN)
.addCategory(android.content.Intent.CATEGORY_LAUNCHER)
return pm.queryIntentActivities(launch, 0)
.mapNotNull { it.activityInfo }
.firstOrNull { pm.getApplicationLabel(it.applicationInfo).toString().lowercase().contains(q) }
?.packageName
}
private fun doInfo(): JSONObject {
val res = JSONObject()
res.put("host", config.displayName())
res.put("model", Build.MODEL)
res.put("manufacturer", Build.MANUFACTURER)
res.put("android", Build.VERSION.RELEASE)
res.put("sdk", Build.VERSION.SDK_INT)
try {
val bm = appCtx.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
res.put("battery_percent", bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY))
} catch (_: Exception) {}
return JSONObject().put("ok", true).put("result", res)
}
}
@@ -0,0 +1,149 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.Image
import android.media.ImageReader
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Handler
import android.os.HandlerThread
import android.util.DisplayMetrics
import android.view.WindowManager
import java.io.ByteArrayOutputStream
/**
* Bildschirm-Aufnahme via MediaProjection (Meilenstein 2 — "sehen").
*
* Der Nutzer erlaubt den Zugriff EINMALIG in der Agent-App (System-Dialog).
* Danach laeuft ein stiller VirtualDisplay -> ImageReader, aus dem `capture()`
* bei Bedarf das aktuelle Bild als PNG zieht. Kein Google-Dienst.
*
* Aufgenommen wird direkt in GEKAPPTER Aufloesung (max. 1280 lange Seite): die
* MediaProjection skaliert den Bildschirminhalt auf die VirtualDisplay-Groesse.
* Das haelt Speicher/Zeit klein (kein 10-MB-Vollbild-Bitmap -> kein OOM/Timeout).
*
* Der Zugriff geht bei App-Kill / Neustart verloren und muss neu erlaubt werden
* (Android-Sicherheit — Projection-Token ist nicht persistierbar).
*/
object ScreenCapturer {
private const val MAX_SIDE = 1280
private var projection: MediaProjection? = null
private var reader: ImageReader? = null
private var vdisplay: VirtualDisplay? = null
private var handlerThread: HandlerThread? = null
private var handler: Handler? = null
private var w = 0
private var h = 0
private var dpi = 0
/** Letzter Init-/Capture-Fehler (fuer die Fehlermeldung an ARIA). */
@Volatile
var lastError: String? = null
val active: Boolean
@Synchronized get() = projection != null
@Synchronized
fun start(ctx: Context, resultCode: Int, data: Intent) {
stop()
val mpm = ctx.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val mp = mpm.getMediaProjection(resultCode, data) ?: run {
lastError = "getMediaProjection lieferte null"
return
}
val metrics = DisplayMetrics()
val wm = ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("DEPRECATION")
wm.defaultDisplay.getRealMetrics(metrics)
dpi = metrics.densityDpi
// Direkt gekappt aufnehmen (lange Seite <= MAX_SIDE), Seitenverhaeltnis wahren.
val longSide = maxOf(metrics.widthPixels, metrics.heightPixels)
val scale = if (longSide > MAX_SIDE) MAX_SIDE.toFloat() / longSide else 1f
w = (metrics.widthPixels * scale).toInt().coerceAtLeast(1)
h = (metrics.heightPixels * scale).toInt().coerceAtLeast(1)
handlerThread = HandlerThread("aria-capture").also { it.start() }
handler = Handler(handlerThread!!.looper)
// Ab Android 14 Pflicht VOR createVirtualDisplay; frueher unschaedlich.
mp.registerCallback(object : MediaProjection.Callback() {
override fun onStop() { stop() }
}, handler)
val ir = ImageReader.newInstance(w, h, PixelFormat.RGBA_8888, 2)
reader = ir
// AUTO_MIRROR = Standard-Flag fuer MediaProjection-Capture (die Projection
// selbst autorisiert die Aufnahme, kein Sonderrecht noetig).
vdisplay = mp.createVirtualDisplay(
"aria-screen", w, h, dpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
ir.surface, null, handler,
)
projection = mp
lastError = null
}
@Synchronized
fun stop() {
try { vdisplay?.release() } catch (_: Exception) {}
try { reader?.close() } catch (_: Exception) {}
try { projection?.stop() } catch (_: Exception) {}
try { handlerThread?.quitSafely() } catch (_: Exception) {}
vdisplay = null
reader = null
projection = null
handlerThread = null
handler = null
}
/** PNG-Bytes des aktuellen Bildschirms, oder null (Grund in lastError). */
fun capture(): ByteArray? {
val r = reader ?: run { lastError = "kein aktiver Bildschirm-Reader"; return null }
var image: Image? = null
var tries = 0
// Bis ~3s auf den ersten Frame warten (frischer VirtualDisplay braucht evtl. kurz).
while (tries < 37) {
image = r.acquireLatestImage()
if (image != null) break
try { Thread.sleep(80) } catch (_: InterruptedException) {}
tries++
}
if (image == null) {
lastError = "kein Frame erhalten (Bildschirm an? evtl. DRM-geschuetzter Inhalt)"
return null
}
return try {
val plane = image.planes[0]
val buffer = plane.buffer
val pixelStride = plane.pixelStride
val rowStride = plane.rowStride
val rowPadding = rowStride - pixelStride * w
val bmpW = w + (if (pixelStride > 0) rowPadding / pixelStride else 0)
val bmp = Bitmap.createBitmap(bmpW, h, Bitmap.Config.ARGB_8888)
bmp.copyPixelsFromBuffer(buffer)
val out = ByteArrayOutputStream()
if (rowPadding == 0) {
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
} else {
val cropped = Bitmap.createBitmap(bmp, 0, 0, w, h)
cropped.compress(Bitmap.CompressFormat.PNG, 100, out)
cropped.recycle()
}
bmp.recycle()
lastError = null
out.toByteArray()
} catch (t: Throwable) {
lastError = "Encode-Fehler: ${t.javaClass.simpleName}: ${t.message}"
null
} finally {
try { image?.close() } catch (_: Exception) {}
}
}
}
@@ -0,0 +1,105 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.MediaPlayer
import android.speech.tts.TextToSpeech
import android.util.Base64
import java.io.File
import java.util.Locale
/**
* Sprachausgabe auf dem Geraet (v.a. Fire TV: ARIA spricht ueber die TV-Lautsprecher).
*
* Zwei Wege:
* 1) `play(audioBase64)` — ARIAs ECHTE Stimme: fertiges Audio (WAV/MP3) aus dem
* bestehenden TTS-Pfad (F5/XTTS) wird einfach abgespielt. Bevorzugt.
* 2) `speak(text)` — geraeteeigene TextToSpeech als Rueckfallebene, falls kein
* Audio mitgeliefert wurde (klingt nach Standard-Stimme, nicht nach ARIA).
*/
object Speaker {
private var tts: TextToSpeech? = null
private var ttsReady = false
private var player: MediaPlayer? = null
/** Spielt fertiges Audio (Base64, z.B. WAV aus dem ARIA-TTS) ueber die Lautsprecher.
* [focus] = duck (Medien leiser, Standard) | pause (Medien anhalten) | none. */
fun play(ctx: Context, audioB64: String, format: String = "wav",
focus: String = "duck"): Result<Unit> = runCatching {
stop()
val bytes = Base64.decode(audioB64, Base64.DEFAULT)
val f = File(ctx.cacheDir, "aria_speak.${format.ifBlank { "wav" }}")
f.writeBytes(bytes)
// Erst Fokus holen, damit Netflix & Co. leiser werden (sonst redet ARIA
// einfach mittendrin mit und man versteht beides nicht).
AudioFocusHelper.request(ctx, focus, "speak")
player = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
setDataSource(f.absolutePath)
setOnCompletionListener { mp ->
try { mp.release() } catch (_: Exception) {}
player = null
AudioFocusHelper.abandon(ctx) // Medien wieder laut / weiterlaufen
}
setOnErrorListener { _, _, _ -> AudioFocusHelper.abandon(ctx); false }
prepare()
start()
}
}
/** Rueckfallebene: geraeteeigene Sprachausgabe. */
fun speak(ctx: Context, text: String, focus: String = "duck"): Result<Unit> = runCatching {
if (tts == null) {
tts = TextToSpeech(ctx.applicationContext) { status ->
ttsReady = status == TextToSpeech.SUCCESS
if (ttsReady) tts?.language = Locale.GERMAN
}
// Kurz auf die Initialisierung warten (TTS meldet sich asynchron).
var waited = 0
while (!ttsReady && waited < 3000) { Thread.sleep(100); waited += 100 }
}
if (!ttsReady) throw IllegalStateException(
"Geraet hat keine Sprachausgabe (TTS-Engine fehlt — auf Fire TV ueblich)")
// Sprache pruefen: eine Engine ohne deutsche Daten wuerde sonst STILL
// nichts sagen und wir wuerden faelschlich 'gesprochen' melden.
val lang = tts?.setLanguage(Locale.GERMAN) ?: TextToSpeech.LANG_NOT_SUPPORTED
if (lang == TextToSpeech.LANG_MISSING_DATA || lang == TextToSpeech.LANG_NOT_SUPPORTED)
throw IllegalStateException(
"Geraete-Sprachausgabe kann kein Deutsch (Sprachdaten fehlen)")
AudioFocusHelper.request(ctx, focus, "speak")
tts?.setOnUtteranceProgressListener(object : android.speech.tts.UtteranceProgressListener() {
override fun onStart(id: String?) {}
override fun onDone(id: String?) { AudioFocusHelper.abandon(ctx) }
@Deprecated("Deprecated in Java")
override fun onError(id: String?) { AudioFocusHelper.abandon(ctx) }
})
val rc = tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "aria")
if (rc != TextToSpeech.SUCCESS) {
AudioFocusHelper.abandon(ctx)
throw IllegalStateException("Sprachausgabe abgelehnt (Code $rc)")
}
Unit
}
/** Lautstaerke 0..100 (Medien-Kanal). */
fun setVolume(ctx: Context, percent: Int) {
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val max = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
val v = (max * percent.coerceIn(0, 100) / 100.0).toInt()
am.setStreamVolume(AudioManager.STREAM_MUSIC, v, AudioManager.FLAG_SHOW_UI)
}
fun stop(ctx: Context? = null) {
try { player?.stop(); player?.release() } catch (_: Exception) {}
player = null
try { tts?.stop() } catch (_: Exception) {}
ctx?.let { AudioFocusHelper.abandon(it) }
}
}
@@ -0,0 +1,351 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.NoiseSuppressor
import android.util.Base64
import okhttp3.WebSocket
import org.json.JSONObject
import kotlin.random.Random
/**
* Sprachdialog mit ARIA direkt auf dem Geraet (v.a. Fire TV: reden ueber die
* Fernbedienung, Antwort ueber die TV-Lautsprecher bzw. als Einblendung).
*
* Nutzt GENAU das Protokoll der ARIA-App, damit serverseitig nichts angepasst
* werden muss:
* rein : stt_stream_start -> stt_audio_chunk (Feld 'pcm', 16 kHz mono s16le,
* 6400-Byte-Bloecke) -> stt_stream_end
* raus : chat (sender=aria, messageId/speak/converse) + audio_pcm (Feld
* 'base64', 24 kHz mono s16le, chunkweise bis final=true)
*
* WICHTIG — Turn-Ownership: Der RVS ist ein Broadcast-Relay ohne Adressierung.
* Jeder Client im Raum bekommt JEDE Antwort. Ohne Filter wuerden Handy-App und
* Fire TV dieselbe Antwort gleichzeitig vorlesen. Deshalb gibt dieses Geraet nur
* aus, was aus SEINER eigenen Frage entstanden ist (audioRequestId -> messageId).
*/
class VoiceSession(
private val appCtx: Context,
private val config: AgentConfig,
/** Gibt zurueck, ob der Socket die Nachricht angenommen hat (false = ins Leere). */
private val send: (type: String, payload: JSONObject) -> Boolean,
private val onEvent: (String) -> Unit = {},
) {
companion object {
// Aufnahme — muss exakt zur STT-Bridge passen.
private const val IN_RATE = 16000
private const val CHUNK_BYTES = 6400 // 3200 Samples = 200 ms
// Wiedergabe — F5-TTS liefert 24 kHz (sampleRate steht aber im Payload).
private const val OUT_RATE_DEFAULT = 24000
private const val MAX_RECORD_MS = 60000
}
@Volatile private var recording = false
@Volatile private var recordThread: Thread? = null
private var rec: AudioRecord? = null
private var aec: AcousticEchoCanceler? = null
private var ns: NoiseSuppressor? = null
private var track: AudioTrack? = null
@Volatile private var playingMessageId: String? = null
/** Eigener Turn: gesetzt beim Aufnehmen, wandert ueber stt_endpoint zur messageId. */
@Volatile private var myAudioRequestId: String? = null
@Volatile private var myMessageId: String? = null
@Volatile private var sessionId: String? = null
/** Text der letzten ARIA-Antwort auf UNSERE Frage (fuer die Einblendung). */
@Volatile private var lastReplyText: String = ""
/** Angeforderte TTS-Ausgaben (messageId) — deren Audio spielen wir ab, auch
* wenn es nicht aus einem eigenen Sprach-Turn stammt. */
private val expectedAudio = java.util.Collections.synchronizedSet(mutableSetOf<String>())
val isRecording: Boolean get() = recording
// ── Aufnehmen + streamen ────────────────────────────────────────
/** Startet die Aufnahme und streamt live an die STT. */
fun startListening(): JSONObject {
if (recording) return JSONObject().put("ok", false).put("error", "Nimmt bereits auf.")
val stamp = System.currentTimeMillis()
val rnd = Random.nextInt(100000, 999999)
val sid = "sttstr_${stamp}_$rnd"
val aid = "audio_${stamp}_$rnd"
sessionId = sid
myAudioRequestId = aid
myMessageId = null
val minBuf = AudioRecord.getMinBufferSize(
IN_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
if (minBuf <= 0) return JSONObject().put("ok", false)
.put("error", "Kein Mikrofon verfuegbar (Fire-TV-Fernbedienung aktiv? USB-Mikro?)")
val r = try {
AudioRecord(MediaRecorder.AudioSource.VOICE_COMMUNICATION, IN_RATE,
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
maxOf(minBuf, CHUNK_BYTES * 4))
} catch (e: Throwable) {
return JSONObject().put("ok", false)
.put("error", "Mikrofon-Zugriff fehlgeschlagen: ${e.message}")
}
if (r.state != AudioRecord.STATE_INITIALIZED) {
try { r.release() } catch (_: Exception) {}
return JSONObject().put("ok", false)
.put("error", "Mikrofon nicht initialisiert (Berechtigung erteilt? Mikro vorhanden?)")
}
// Echo/Rauschen daempfen, damit der Stick nicht seine eigene Ausgabe hoert.
try { if (AcousticEchoCanceler.isAvailable()) aec = AcousticEchoCanceler.create(r.audioSessionId)?.apply { enabled = true } } catch (_: Exception) {}
try { if (NoiseSuppressor.isAvailable()) ns = NoiseSuppressor.create(r.audioSessionId)?.apply { enabled = true } } catch (_: Exception) {}
rec = r
recording = true
// Beim Zuhoeren laufende Medien ANHALTEN — sonst nimmt das Mikrofon den
// Filmton mit auf und die Spracherkennung versteht nur Kauderwelsch.
AudioFocusHelper.request(appCtx, "pause", "listen")
send("stt_stream_start", JSONObject().apply {
put("requestId", sid)
put("audioRequestId", aid)
put("voice", "")
put("speed", 1.0)
put("interrupted", false)
put("endpointMs", 2400)
put("hardCapMs", MAX_RECORD_MS)
put("sampleRate", IN_RATE)
put("projectId", "")
put("targetInstance", "")
put("language", "de")
})
onEvent("hoert zu …")
recordThread = Thread {
val buf = ByteArray(CHUNK_BYTES)
var seq = 0
try {
r.startRecording()
val until = System.currentTimeMillis() + MAX_RECORD_MS
while (recording && System.currentTimeMillis() < until) {
val n = r.read(buf, 0, buf.size)
if (n <= 0) continue
val slice = if (n == buf.size) buf else buf.copyOf(n)
send("stt_audio_chunk", JSONObject().apply {
put("requestId", sid)
put("pcm", Base64.encodeToString(slice, Base64.NO_WRAP))
put("seq", seq++)
put("targetInstance", "")
})
}
} catch (_: Throwable) {
// Aufnahme abgebrochen — stopListening raeumt auf.
} finally {
stopListening("auto")
}
}.also { it.isDaemon = true; it.start() }
return JSONObject().put("ok", true)
.put("result", JSONObject().put("message", "Hoere zu …"))
}
/** Beendet die Aufnahme (die STT antwortet dann per stt_endpoint). */
fun stopListening(reason: String = "user"): JSONObject {
if (!recording) return JSONObject().put("ok", true)
.put("result", JSONObject().put("message", "Nahm nicht auf"))
recording = false
try { rec?.stop() } catch (_: Exception) {}
try { rec?.release() } catch (_: Exception) {}
try { aec?.release() } catch (_: Exception) {}
try { ns?.release() } catch (_: Exception) {}
rec = null; aec = null; ns = null
// Aufnahme vorbei -> Medien laufen weiter (die Antwort holt sich gleich
// selbst wieder Fokus, sobald ARIAs Audio kommt).
AudioFocusHelper.abandon(appCtx)
sessionId?.let {
send("stt_stream_end", JSONObject().apply {
put("requestId", it); put("reason", reason); put("targetInstance", "")
})
}
onEvent("verarbeite …")
return JSONObject().put("ok", true)
.put("result", JSONObject().put("message", "Aufnahme beendet"))
}
/**
* Laesst ARIAs ECHTE Stimme etwas sagen: fordert das TTS-Audio ueber den
* bestehenden Server-Pfad an (tts_request -> Bridge -> F5/XTTS -> audio_pcm)
* und spielt es ab, sobald es hereinkommt.
*
* Besser als die geraeteeigene Sprachausgabe: Fire TV Sticks haben oft gar
* keine (deutsche) TTS-Engine — und es klingt nach ARIA, nicht nach Roboter.
*/
fun requestTts(text: String): String {
val mid = "spk_${System.currentTimeMillis()}_${Random.nextInt(100000, 999999)}"
expectedAudio.add(mid)
// Nicht unbegrenzt wachsen lassen, falls mal keine Antwort kommt.
if (expectedAudio.size > 20) {
synchronized(expectedAudio) {
expectedAudio.iterator().let { if (it.hasNext()) { it.next(); it.remove() } }
}
}
val accepted = send("tts_request", JSONObject().apply {
put("text", text)
put("messageId", mid)
put("voice", "")
put("speed", 1.0)
})
if (!accepted) {
expectedAudio.remove(mid)
throw IllegalStateException(
"Keine Verbindung zum Server — TTS-Anfrage ging nicht raus")
}
return mid
}
/**
* Wartet, bis fuer [mid] wirklich der erste Ton kommt.
*
* Ohne das meldete `host_speak` Erfolg, sobald die Anfrage im Socket lag —
* ARIA sagte "erledigt", und der Fernseher blieb still. Hier wird erst
* geantwortet, wenn tatsaechlich Audio laeuft, sonst mit klarem Grund.
*/
fun awaitSpeechStart(mid: String, timeoutMs: Long = 20000) {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (playingMessageId == mid) return // Ton laeuft
if (!expectedAudio.contains(mid)) return // schon fertig gesprochen
try { Thread.sleep(100) } catch (_: InterruptedException) { return }
}
expectedAudio.remove(mid)
throw IllegalStateException(
"Server hat kein Audio geliefert (TTS-Dienst erreichbar? Stimme gesetzt?)")
}
/** Text direkt an ARIA schicken (ohne Mikrofon) — Antwort landet hier genauso. */
fun ask(text: String): JSONObject {
if (text.isBlank()) return JSONObject().put("ok", false).put("error", "leerer Text")
val stamp = System.currentTimeMillis()
myAudioRequestId = "audio_${stamp}_${Random.nextInt(100000, 999999)}"
myMessageId = null
send("chat", JSONObject().apply {
put("text", text)
put("voice", ""); put("speed", 1.0); put("projectId", "")
put("clientMsgId", "cmsg_${stamp}_${Random.nextInt(100000, 999999)}")
})
return JSONObject().put("ok", true)
.put("result", JSONObject().put("message", "Frage gesendet"))
}
// ── Eingehende Nachrichten ──────────────────────────────────────
/** Verarbeitet Sprach-/Chat-Nachrichten. true = war fuer uns relevant. */
fun handle(type: String, p: JSONObject): Boolean = when (type) {
"stt_endpoint", "stt_stream_done" -> {
if (p.optString("requestId") == sessionId) {
val text = p.optString("text")
if (type == "stt_endpoint" && text.isNotBlank()) onEvent("verstanden: $text")
if (type == "stt_stream_done") { recording = false; sessionId = null }
}
true
}
"chat" -> {
if (p.optString("sender") == "aria" && isMyTurn(p)) {
myMessageId = p.optString("messageId")
lastReplyText = p.optString("text")
deliverReply(p)
}
true
}
"audio_pcm" -> {
// Abspielen, wenn (a) wir dieses Audio selbst angefordert haben
// (host_speak/notify) oder (b) es die Antwort auf UNSERE Frage ist und
// Sprachausgabe gewuenscht war. Sonst wuerde auch das Handy mitreden.
val mid = p.optString("messageId")
if (mid.isNotBlank() &&
(expectedAudio.contains(mid) || (mid == myMessageId && wantSpeech()))) {
feedAudio(p)
if (p.optBoolean("final", false)) expectedAudio.remove(mid)
}
true
}
else -> false
}
/** Gehoert diese Antwort zu unserer Frage? */
private fun isMyTurn(p: JSONObject): Boolean {
val aid = myAudioRequestId ?: return false
val pa = p.optString("audioRequestId")
// Sprach-Turn: audioRequestId wird durchgereicht. Text-Turn (ask): die
// naechste ARIA-Antwort nach unserer Frage gehoert uns.
return if (pa.isNotBlank()) pa == aid else myMessageId == null
}
private fun wantSpeech(): Boolean = config.replyMode == "speech"
/** Antwort ausgeben: Einblendung und/oder Sprache (je nach Einstellung). */
private fun deliverReply(p: JSONObject) {
val text = p.optString("text")
if (text.isBlank()) return
if (config.replyMode == "overlay" && OverlayView.canDraw(appCtx)) {
OverlayView.show(appCtx, "ARIA", text, 15, null)
} else if (config.replyMode == "overlay") {
onEvent("Antwort (Einblendung nicht erlaubt): $text")
}
// Bei "speech" kommt das Audio ueber audio_pcm; falls ARIA nicht spricht
// (speak=false), trotzdem einblenden, damit nichts verloren geht.
if (wantSpeech() && !p.optBoolean("speak", true) && OverlayView.canDraw(appCtx)) {
OverlayView.show(appCtx, "ARIA", text, 15, null)
}
}
// ── TTS-Wiedergabe (chunkweises PCM) ────────────────────────────
private fun feedAudio(p: JSONObject) {
val mid = p.optString("messageId")
val rate = p.optInt("sampleRate", OUT_RATE_DEFAULT)
if (p.optBoolean("final", false)) {
try { track?.stop() } catch (_: Exception) {}
try { track?.release() } catch (_: Exception) {}
track = null; playingMessageId = null
// Fertig gesprochen -> Medien wieder hochfahren/weiterlaufen lassen.
AudioFocusHelper.abandon(appCtx)
onEvent("bereit")
return
}
val b64 = p.optString("base64")
if (b64.isBlank()) return
val pcm = try { Base64.decode(b64, Base64.DEFAULT) } catch (_: Exception) { return }
if (track == null || playingMessageId != mid) {
try { track?.release() } catch (_: Exception) {}
// ARIA antwortet -> laufende Medien leiser (duck), bis sie fertig ist.
AudioFocusHelper.request(appCtx, "duck", "speak")
val min = AudioTrack.getMinBufferSize(rate, AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT)
track = AudioTrack.Builder()
.setAudioAttributes(AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build())
.setAudioFormat(AudioFormat.Builder()
.setSampleRate(rate)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT).build())
.setBufferSizeInBytes(maxOf(min, 16384))
.setTransferMode(AudioTrack.MODE_STREAM)
.build().also { it.play() }
playingMessageId = mid
onEvent("spricht …")
}
try { track?.write(pcm, 0, pcm.size) } catch (_: Exception) {}
}
fun shutdown() {
stopListening("shutdown")
try { track?.stop(); track?.release() } catch (_: Exception) {}
track = null
AudioFocusHelper.abandon(appCtx)
}
}
@@ -0,0 +1,32 @@
<!-- Banner-Kachel fuer den Fire-TV-/Android-TV-Launcher (320x180 dp).
TV-Launcher zeigen Banner statt Icons — ohne das fehlt die Kachel. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<path
android:fillColor="#0D0D1A"
android:pathData="M0,0h320v180h-320z" />
<!-- Ring (ARIA-Auge) -->
<path
android:fillColor="#3FFF9F"
android:pathData="M74,90 m-34,0 a34,34 0 1,0 68,0 a34,34 0 1,0 -68,0 Z M74,90 m-17,0 a17,17 0 1,1 34,0 a17,17 0 1,1 -34,0 Z" />
<!-- Schriftzug "ARIA" als Pfade (keine Font-Abhaengigkeit im Vector) -->
<path
android:fillColor="#E0E0F0"
android:pathData="M140,110 L152,64 L162,64 L174,110 L164,110 L161,99 L153,99 L150,110 Z M155,91 L159,91 L157,82 Z" />
<path
android:fillColor="#E0E0F0"
android:pathData="M180,110 L180,64 L196,64 A13,13 0 0,1 196,90 L200,110 L190,110 L187,92 L189,92 L189,110 Z M189,72 L189,84 L195,84 A6,6 0 0,0 195,72 Z" />
<path
android:fillColor="#E0E0F0"
android:pathData="M208,64 L217,64 L217,110 L208,110 Z" />
<path
android:fillColor="#E0E0F0"
android:pathData="M224,110 L236,64 L246,64 L258,110 L248,110 L245,99 L237,99 L234,110 Z M239,91 L243,91 L241,82 Z" />
<!-- Untertitel-Balken -->
<path
android:fillColor="#3FFF9F"
android:pathData="M140,122 h118 v4 h-118 z" />
</vector>
@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#0D0D1A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#3FFF9F"
android:pathData="M54,26 m-26,0 a26,26 0 1,0 52,0 a26,26 0 1,0 -52,0 Z M54,26 m-13,0 a13,13 0 1,1 26,0 a13,13 0 1,1 -26,0 Z" />
<path
android:fillColor="#3FFF9F"
android:pathData="M52,58 h4 v24 h-4 z" />
</vector>
@@ -0,0 +1,181 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textSize="22sp"
android:textStyle="bold"
android:paddingBottom="4dp" />
<TextView
android:id="@+id/status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Status: —"
android:paddingBottom="16dp" />
<Button
android:id="@+id/btnScan"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="true"
android:focusableInTouchMode="false"
android:text="QR-Code scannen" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="— oder manuell —"
android:paddingTop="12dp"
android:paddingBottom="4dp" />
<EditText
android:id="@+id/host"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="RVS-Host (z.B. rvs.example.com)"
android:inputType="textUri" />
<EditText
android:id="@+id/port"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Port"
android:text="443"
android:inputType="number" />
<EditText
android:id="@+id/token"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="RVS-Token"
android:inputType="textNoSuggestions" />
<EditText
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Anzeigename (optional, z.B. 'Stefans Handy')"
android:inputType="text" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/tls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="TLS (wss)"
android:checked="true"
android:paddingTop="12dp" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/control"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Steuerung erlauben (Aktionen ausführen)"
android:paddingTop="8dp"
android:paddingBottom="16dp" />
<Button
android:id="@+id/btnConnect"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Speichern &amp; Verbinden" />
<Button
android:id="@+id/btnStop"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Agent stoppen"
android:paddingTop="8dp" />
<Button
android:id="@+id/btnTalk"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="true"
android:text="🎤 Mit ARIA sprechen"
android:paddingTop="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Sehen (Meilenstein 2)"
android:textStyle="bold"
android:paddingTop="24dp"
android:paddingBottom="4dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Damit ARIA Screenshots machen und die Oberfläche lesen kann. Beides einmalig freigeben."
android:textSize="13sp"
android:paddingBottom="8dp" />
<Button
android:id="@+id/btnScreen"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Bildschirm-Zugriff erlauben" />
<Button
android:id="@+id/btnAccessibility"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Bedienungshilfe öffnen"
android:paddingTop="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Antworten (Fernseher)"
android:textStyle="bold"
android:paddingTop="24dp"
android:paddingBottom="4dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Wie ARIA hier standardmäßig antwortet. Sie kann das pro Anfrage überschreiben (z.B. mit dem Zusatz: bitte als Sprache)."
android:textSize="13sp"
android:paddingBottom="8dp" />
<RadioGroup
android:id="@+id/replyMode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<RadioButton
android:id="@+id/modeOverlay"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="true"
android:text="Einblendung über dem Bild (Standard)" />
<RadioButton
android:id="@+id/modeSpeech"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="true"
android:text="Sprachausgabe über die Lautsprecher" />
</RadioGroup>
<Button
android:id="@+id/btnOverlayPerm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Einblendung erlauben"
android:paddingTop="8dp" />
</LinearLayout>
</ScrollView>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">ARIA Host-Agent</string>
<string name="accessibility_desc">Erlaubt ARIA, die sichtbaren Bildschirm-Elemente zu lesen (Text und Position), um Dich fernzusteuern. Nur aktiv, wenn Du \'Steuerung erlauben\' eingeschaltet hast.</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagRetrieveInteractiveWindows|flagReportViewIds|flagRequestFilterKeyEvents"
android:canRetrieveWindowContent="true"
android:canPerformGestures="true"
android:canRequestFilterKeyEvents="true"
android:notificationTimeout="100"
android:description="@string/accessibility_desc" />
+5
View File
@@ -0,0 +1,5 @@
// Root-Build. Plugin-Versionen zentral, in den Modulen nur angewandt.
plugins {
id 'com.android.application' version '8.2.2' apply false
id 'org.jetbrains.kotlin.android' version '1.9.22' apply false
}
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Baut die Android-Agent-APK per Docker (Android-SDK + Gradle).
# ./build.sh
# Ergebnis: dist/aria-android-agent.apk -> aufs Handy kopieren + installieren
# ("Unbekannte Quellen erlauben").
set -euo pipefail
cd "$(dirname "$0")"
mkdir -p dist
docker build -f Dockerfile.build -t aria-android-agent-build .
docker run --rm -v "$(pwd)/dist:/out" aria-android-agent-build
echo
echo "Fertig: dist/aria-android-agent.apk"
+5
View File
@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
org.gradle.caching=true
+16
View File
@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "aria-android-agent"
include(":app")
+30
View File
@@ -0,0 +1,30 @@
# systemd-Unit fuer den ARIA Host-Agent.
#
# Installation:
# sudo cp aria-host-agent /usr/local/bin/
# sudo mkdir -p /etc/aria-host-agent && sudo cp .env /etc/aria-host-agent/.env
# sudo cp aria-host-agent.service /etc/systemd/system/
# sudo systemctl enable --now aria-host-agent
#
# Als root (User=root): Kommandos haben volle Rechte, kein sudo/Passwort noetig.
# Fuer einen normalen User: User=<name> setzen und in der .env SUDO_PASSWORD
# oder SUDO_NOPASSWD konfigurieren.
#
# HINWEIS Screenshot: als Systemdienst fehlt die grafische Session (DISPLAY/
# WAYLAND_DISPLAY). Fuer host_screenshot den Agent stattdessen in der Desktop-
# Session starten (Autostart) oder DISPLAY/XAUTHORITY in der Unit setzen.
[Unit]
Description=ARIA Host-Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/etc/aria-host-agent
ExecStart=/usr/local/bin/aria-host-agent
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
+28
View File
@@ -0,0 +1,28 @@
@echo off
REM ARIA Host-Agent — Windows-Build mit PyInstaller (kein Docker).
REM Voraussetzung: Python 3 installiert und im PATH (python.org, "Add to PATH").
REM
REM build-native.bat
REM
REM Ergebnis: dist\aria-host-agent.exe (Onefile). Danach .env danebenlegen
REM (siehe .env.example) und starten. Fuer Admin-Rechte die .exe per Rechtsklick
REM "Als Administrator ausfuehren".
setlocal
cd /d "%~dp0"
python -m venv .buildenv || goto :err
call .buildenv\Scripts\activate.bat
python -m pip install --quiet --upgrade pip
python -m pip install --quiet pyinstaller -r requirements.txt || goto :err
pyinstaller --onefile --name aria-host-agent --collect-all psutil host_agent.py || goto :err
call deactivate
echo.
echo Fertig: dist\aria-host-agent.exe
echo .env danebenlegen (siehe .env.example), dann starten (ggf. als Administrator).
goto :eof
:err
echo.
echo FEHLER beim Bauen. Ist Python 3 installiert und im PATH? (python --version)
exit /b 1
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Baut die Host-Agent-Binary OHNE Docker — direkt mit PyInstaller.
# Nutze das, wenn Docker nicht geht (z.B. Live-ISO mit overlayfs-Root, wo
# Dockers overlay2-Treiber kein Overlay-auf-Overlay stapeln kann).
#
# sudo apt install -y python3-pip python3-venv # falls noch nicht da
# ./build-native.sh
#
# WICHTIG: Nativ gebaut linkt die Binary gegen das glibc DIESER Maschine. Sie
# laeuft dann nur auf Systemen mit glibc >= dem hier. Fuer breite Kompatibilitaet
# lieber ./build.sh (Docker/bullseye) auf einem normalen Rechner nutzen.
set -euo pipefail
cd "$(dirname "$0")"
python3 -m venv .buildenv
# shellcheck disable=SC1091
. .buildenv/bin/activate
pip install --quiet --upgrade pip
pip install --quiet pyinstaller -r requirements.txt
pyinstaller --onefile --name aria-host-agent --collect-all psutil host_agent.py
deactivate
echo
echo "Fertig: dist/aria-host-agent"
echo ".env danebenlegen (siehe .env.example), dann: chmod +x aria-host-agent && ./aria-host-agent"
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Baut die Windows-.exe + setup.exe des Host-Agents AUF LINUX (Wine im Docker).
# ./build-win.sh [version]
# Ergebnis:
# dist/aria-host-agent.exe (Konsolen-Binary)
# dist/aria-host-agent-setup.exe (Installer: richtet Windows-Dienst ein)
#
# Hinweis: Der erste Lauf zieht das tobix/pywine-Image (~1-2 GB) + baut die
# Wine-Umgebung — das dauert. Folge-Builds sind schnell.
set -euo pipefail
cd "$(dirname "$0")"
VERSION="${1:-0.0.0}"
mkdir -p dist
docker build -f Dockerfile.win --build-arg VERSION="$VERSION" -t aria-host-agent-win .
docker run --rm -v "$(pwd)/dist:/out" aria-host-agent-win
echo
echo "Fertig: dist/aria-host-agent.exe + dist/aria-host-agent-setup.exe"
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Baut die portable Host-Agent-Binary (Linux x86_64) via Docker + PyInstaller.
# Ergebnis: ./dist/aria-host-agent (Onefile, ~15 MB, keine Runtime noetig).
#
# ./build.sh
#
# Danach auf den Ziel-Rechner kopieren, .env danebenlegen und starten:
# ./aria-host-agent
set -euo pipefail
cd "$(dirname "$0")"
mkdir -p dist
docker build -f Dockerfile.build -t aria-host-agent-build .
docker run --rm -v "$(pwd)/dist:/out" aria-host-agent-build
echo
echo "Fertig: dist/aria-host-agent"
echo "Auf den Ziel-Rechner kopieren, .env danebenlegen (siehe .env.example), dann:"
echo " chmod +x aria-host-agent && ./aria-host-agent"
+739
View File
@@ -0,0 +1,739 @@
"""
ARIA Host-Agent — Direktzugriff auf EINEN Rechner.
Laeuft direkt auf dem Ziel-Rechner (Linux) und verbindet sich AUSGEHEND als
RVS-Client in Stefans Raum (gleicher Token). Damit kann ARIA diesen Rechner
direkt steuern, auch wenn er sonst aus dem Netz nicht erreichbar ist (hinter
NAT/Firewall, kein offener Port). Anders als der Satellit (der ein LAN
entdeckt/steuert) ist beim Agent das "Geraet" der Rechner selbst.
Als reine Binary verteilbar (PyInstaller onefile) + .env fuer die Zugangsdaten.
Faehigkeiten (host_command → host_result):
exec Shell-Kommando ausfuehren (optional sudo)
read/write Datei lesen/schreiben (Base64)
info OS / CPU / RAM / Disk / Uptime / Netz
screenshot Bildschirmfoto (X11/Wayland, wenn grafische Session da ist)
Sicherheit: reagiert nur auf den eigenen RVS-Raum (Token) und nur, wenn
CONTROL_ENABLED=true. Alles wird geloggt. Keine offenen Ports.
"""
from __future__ import annotations
import asyncio
import base64
import json
import logging
import os
import platform
import re
import shutil
import socket
import subprocess
import sys
import time
from pathlib import Path
import websockets
# ─── Plattform-Weichen (Linux / macOS / Windows) ────────────────────
IS_WINDOWS = os.name == "nt"
IS_MAC = sys.platform == "darwin"
def _is_admin() -> bool:
"""root (Unix) bzw. Administrator (Windows)."""
try:
return os.geteuid() == 0 # Unix (Linux/macOS)
except AttributeError:
try:
import ctypes
return ctypes.windll.shell32.IsUserAnAdmin() != 0 # Windows
except Exception:
return False
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [host-agent] %(levelname)s %(message)s",
)
logger = logging.getLogger("host-agent")
def _load_dotenv() -> None:
"""Laedt eine .env neben der Binary/dem Script (oder im CWD) in os.environ.
Bereits gesetzte Werte gewinnen. Kein python-dotenv noetig."""
here = os.path.dirname(os.path.abspath(__file__))
# PyInstaller-onefile: __file__ liegt im Temp-Extract-Dir, NICHT beim .exe/
# Binary — deshalb zusaetzlich sys.executable-Ordner (echter Binary-Ort) und
# das CWD (z.B. der ProgramData-Ordner, den der Windows-Dienst als AppDir nutzt).
exe_dir = os.path.dirname(os.path.abspath(sys.executable))
seen = set()
candidates = [os.path.join(d, ".env") for d in (exe_dir, here, os.getcwd())]
for path in [p for p in candidates if not (p in seen or seen.add(p))]:
if not os.path.isfile(path):
continue
try:
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
key = key.strip()
if key.startswith("export "):
key = key[len("export "):].strip()
val = val.strip()
if val[:1] in ("'", '"'):
q = val[0]
end = val.find(q, 1)
val = val[1:end] if end != -1 else val[1:]
else:
m = re.search(r"\s+#", val)
if m:
val = val[:m.start()]
val = val.strip()
if key and key not in os.environ:
os.environ[key] = val
except Exception as exc:
logger.warning(".env laden fehlgeschlagen (%s): %s", path, exc)
break
_load_dotenv()
# ─── Konfiguration ──────────────────────────────────────────────────
def _env_bool(name: str, default: bool) -> bool:
v = os.environ.get(name)
if v is None:
return default
return v.strip().lower() in ("1", "true", "yes", "on", "ja")
def _default_id() -> str:
host = socket.gethostname() or "host"
slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", host).strip("-").lower()
return slug or "host"
RVS_HOST = os.environ.get("RVS_HOST", "")
RVS_PORT = int(os.environ.get("RVS_PORT", "443") or "443")
RVS_TLS = _env_bool("RVS_TLS", True)
# Bei TLS-Fehlschlag einmal auf ws:// zurueckfallen (wie die Compute-Bridges).
# Hilft nur, wenn der RVS plaintext erreichbar ist; gegen Caddy-TLS bleibt wss.
RVS_TLS_FALLBACK = _env_bool("RVS_TLS_FALLBACK", True)
RVS_TOKEN = os.environ.get("RVS_TOKEN", "")
# TLS-Hostname (SNI + Zertifikatspruefung), falls RVS_HOST eine IP ist — z.B. der
# Agent laeuft im selben Netz wie der RVS und verbindet direkt auf die interne IP,
# das Caddy-Zertifikat gilt aber fuer den Namen. Dann: RVS_HOST=<interne-ip>,
# RVS_SNI=<zert-name>. Leer = SNI = RVS_HOST (Normalfall).
RVS_SNI = os.environ.get("RVS_SNI", "").strip()
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
if RVS_TLS and RVS_SNI:
import socket as _socket
_orig_getaddrinfo = _socket.getaddrinfo
def _sni_getaddrinfo(host, *a, **k):
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
_socket.getaddrinfo = _sni_getaddrinfo
HOST_ID = (os.environ.get("HOST_ID") or _default_id()).strip()
HOST_NAME = (os.environ.get("HOST_NAME") or HOST_ID).strip()
# Steuerung ist der Sinn des Agents — aber bewusst opt-in (Sicherheit).
CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False)
# sudo: 1) Agent laeuft als root -> direkt. 2) SUDO_PASSWORD gesetzt -> sudo -S.
# 3) SUDO_NOPASSWD=true (Live-ISO / NOPASSWD-sudoers) -> sudo -n. 4) sonst Fehler.
SUDO_PASSWORD = os.environ.get("SUDO_PASSWORD", "")
SUDO_NOPASSWD = _env_bool("SUDO_NOPASSWD", False)
EXEC_TIMEOUT = float(os.environ.get("EXEC_TIMEOUT", "60") or "60")
# Ausgabe-Fenster (wie beim Satelliten): grosse stdout klein halten fuers RVS.
OUT_MAX_CHARS = int(os.environ.get("OUT_MAX_CHARS", "20000") or "20000")
OUT_MAX_CHARS_HARD = int(os.environ.get("OUT_MAX_CHARS_HARD", "200000") or "200000")
# Datei-Transfer-Limit (Base64 durchs RVS).
FILE_MAX_BYTES = int(os.environ.get("FILE_MAX_BYTES", str(10 * 1024 * 1024)) or str(10 * 1024 * 1024))
# Version (wird von release_agent.sh beim Release gesetzt).
AGENT_VERSION = "0.0.1.5"
HEARTBEAT_SEC = 25
def _gui_input_method() -> str:
"""Welche Methode zur GUI-Steuerung (Maus/Tastatur) ist verfuegbar?
'' = keine (z.B. Linux-Server ohne X/Wayland -> nur Terminal/exec).
windows -> PowerShell (Bordmittel)
macos -> osascript (Text/Tasten) + cliclick (Maus, falls installiert)
xdotool -> Linux/X11
ydotool -> Linux/Wayland (Daemon noetig)"""
if IS_WINDOWS:
return "windows"
if IS_MAC:
return "macos"
if os.environ.get("DISPLAY") and shutil.which("xdotool"):
return "xdotool"
if os.environ.get("WAYLAND_DISPLAY") and shutil.which("ydotool"):
return "ydotool"
return ""
GUI_METHOD = _gui_input_method()
# macOS: Maus braucht cliclick; Text/Tasten/App gehen per osascript (Bordmittel).
_MAC_MOUSE = IS_MAC and shutil.which("cliclick") is not None
CAPS = ["exec", "read", "write", "info", "screenshot"]
if GUI_METHOD:
# Tastatur/Text/App gehen bei jeder GUI-Methode; Maus (tap/swipe) auf dem Mac
# nur mit cliclick.
CAPS += ["ui_text", "ui_key", "app_launch"]
if GUI_METHOD != "macos" or _MAC_MOUSE:
CAPS += ["ui_tap", "ui_swipe"]
# ─── Text-/Zahl-Helfer ──────────────────────────────────────────────
def _to_int(s):
try:
return int(str(s).strip())
except (TypeError, ValueError):
return None
def _to_float(s):
try:
return float(str(s).strip())
except (TypeError, ValueError):
return None
def _window_text(text: str, params: dict) -> dict:
"""contains-Zeilenfilter + offset/max_chars-Fenster. Gibt body + Metadaten."""
total = len(text)
contains = params.get("contains")
if contains:
terms = [contains] if isinstance(contains, str) else list(contains)
terms = [str(t).lower() for t in terms if str(t).strip()]
if terms:
text = "\n".join(ln for ln in text.splitlines()
if any(t in ln.lower() for t in terms))
offset = max(0, _to_int(params.get("offset")) or 0)
max_chars = _to_int(params.get("max_chars")) or OUT_MAX_CHARS
max_chars = max(1, min(max_chars, OUT_MAX_CHARS_HARD))
body = text[offset:offset + max_chars]
return {"body": body, "total_chars": total, "filtered": bool(contains),
"offset": offset, "returned_chars": len(body),
"truncated": offset + len(body) < len(text)}
# ─── Aktionen ───────────────────────────────────────────────────────
def _wrap_sudo(cmd: str, use_sudo: bool):
"""Baut die Argv (OS-abhaengige Shell) + optional Root/Admin-Rechte.
Gibt (argv, stdin_data) oder (None, fehlertext)."""
if IS_WINDOWS:
# PowerShell; kein sudo. Fuer Admin-Rechte muss der Agent SELBST als
# Administrator laufen (UAC) — dann hat 'sudo:true' bereits volle Rechte.
if use_sudo and not _is_admin():
return None, ("Windows kennt kein sudo. Starte den Agent als "
"Administrator ('Als Administrator ausfuehren'), dann "
"laufen Kommandos mit vollen Rechten.")
return ["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd], None
# Unix: Linux + macOS (bash vorhanden; macOS-sudo verhaelt sich wie Linux)
if not use_sudo or _is_admin():
return ["bash", "-lc", cmd], None
if SUDO_PASSWORD:
return ["sudo", "-S", "-p", "", "bash", "-lc", cmd], SUDO_PASSWORD + "\n"
if SUDO_NOPASSWD:
return ["sudo", "-n", "bash", "-lc", cmd], None
return None, ("sudo verlangt ein Passwort. Setze SUDO_PASSWORD in der .env, "
"oder SUDO_NOPASSWD=true (Live-ISO / passwortloses sudo), oder "
"starte den Agent als root.")
def _do_exec(params: dict) -> dict:
cmd = params.get("cmd") or params.get("command") or ""
if not cmd:
return {"ok": False, "error": "cmd (Kommando) erforderlich."}
argv, stdin_data = _wrap_sudo(cmd, bool(params.get("sudo")))
if argv is None:
return {"ok": False, "error": stdin_data}
timeout = _to_float(params.get("timeout")) or EXEC_TIMEOUT
try:
r = subprocess.run(argv, input=stdin_data, capture_output=True,
text=True, timeout=timeout)
except subprocess.TimeoutExpired:
return {"ok": False, "error": f"Timeout nach {timeout:.0f}s."}
except Exception as exc:
return {"ok": False, "error": f"exec fehlgeschlagen: {exc}"}
win = _window_text(r.stdout, params)
return {"ok": True, "result": {"exit_code": r.returncode,
"stderr": (r.stderr or "")[:4000], **win}}
def _do_read(params: dict) -> dict:
path = params.get("path") or ""
if not path:
return {"ok": False, "error": "path erforderlich."}
p = Path(path).expanduser()
if not p.is_file():
return {"ok": False, "error": f"Datei nicht gefunden: {path}"}
size = p.stat().st_size
offset = max(0, _to_int(params.get("offset")) or 0)
max_bytes = _to_int(params.get("max_bytes")) or FILE_MAX_BYTES
max_bytes = max(1, min(max_bytes, FILE_MAX_BYTES))
try:
with p.open("rb") as f:
f.seek(offset)
data = f.read(max_bytes)
except Exception as exc:
return {"ok": False, "error": f"Lesen fehlgeschlagen: {exc}"}
return {"ok": True, "result": {
"path": str(p), "size": size, "offset": offset,
"returned_bytes": len(data), "truncated": offset + len(data) < size,
"base64": base64.b64encode(data).decode("ascii"),
}}
def _do_write(params: dict) -> dict:
path = params.get("path") or ""
if not path:
return {"ok": False, "error": "path erforderlich."}
b64 = params.get("base64")
text = params.get("text")
if b64 is None and text is None:
return {"ok": False, "error": "base64 ODER text erforderlich."}
try:
data = base64.b64decode(b64) if b64 is not None else str(text).encode("utf-8")
except Exception as exc:
return {"ok": False, "error": f"base64 ungueltig: {exc}"}
p = Path(path).expanduser()
try:
p.parent.mkdir(parents=True, exist_ok=True)
mode = "ab" if params.get("append") else "wb"
with p.open(mode) as f:
f.write(data)
if params.get("chmod"):
os.chmod(p, int(str(params["chmod"]), 8))
except Exception as exc:
return {"ok": False, "error": f"Schreiben fehlgeschlagen: {exc}"}
return {"ok": True, "result": {"path": str(p), "bytes": len(data)}}
def _do_info(params: dict) -> dict:
info = {
"host": HOST_NAME, "hostname": socket.gethostname(),
"agent_version": AGENT_VERSION,
"os": platform.platform(), "kernel": platform.release(),
"arch": platform.machine(), "python": platform.python_version(),
"user": os.environ.get("USER") or os.environ.get("USERNAME") or "",
"is_root": _is_admin(),
"gui": GUI_METHOD or "none", # '' => nur Terminal, keine GUI-Steuerung
}
try:
import psutil
info["cpu_percent"] = psutil.cpu_percent(interval=0.3)
info["cpu_count"] = psutil.cpu_count()
vm = psutil.virtual_memory()
info["ram_used_mb"] = round(vm.used / 1024 / 1024)
info["ram_total_mb"] = round(vm.total / 1024 / 1024)
info["ram_percent"] = vm.percent
du = psutil.disk_usage("/")
info["disk_used_gb"] = round(du.used / 1024 / 1024 / 1024, 1)
info["disk_total_gb"] = round(du.total / 1024 / 1024 / 1024, 1)
info["disk_percent"] = du.percent
info["uptime_s"] = round(time.time() - psutil.boot_time())
info["load_avg"] = list(os.getloadavg()) if hasattr(os, "getloadavg") else None
except Exception:
# Fallback ohne psutil: das Noetigste aus os/shutil.
try:
info["load_avg"] = list(os.getloadavg())
except Exception:
info["load_avg"] = None
try:
total, used, free = shutil.disk_usage("/")
info["disk_used_gb"] = round(used / 1024 ** 3, 1)
info["disk_total_gb"] = round(total / 1024 ** 3, 1)
except Exception:
pass
# Primaere IP (best effort).
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
info["primary_ip"] = s.getsockname()[0]
s.close()
except Exception:
info["primary_ip"] = ""
return {"ok": True, "result": info}
def _do_screenshot(params: dict) -> dict:
"""Bildschirmfoto — OS-abhaengig. Windows: PowerShell/System.Drawing;
macOS: screencapture; Linux: grim (Wayland) / scrot/maim/import (X11).
Braucht eine aktive grafische Session."""
import tempfile
tmp = os.path.join(tempfile.gettempdir(), f"aria_shot_{int(time.time())}.png")
candidates = []
if IS_WINDOWS:
ps = ("Add-Type -AssemblyName System.Windows.Forms,System.Drawing;"
"$b=[System.Windows.Forms.SystemInformation]::VirtualScreen;"
"$bmp=New-Object System.Drawing.Bitmap $b.Width,$b.Height;"
"$g=[System.Drawing.Graphics]::FromImage($bmp);"
"$g.CopyFromScreen($b.Location,[System.Drawing.Point]::Empty,$b.Size);"
f"$bmp.Save('{tmp}');$g.Dispose();$bmp.Dispose()")
candidates.append(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps])
elif IS_MAC:
candidates.append(["screencapture", "-x", tmp]) # -x = ohne Ton
else:
if os.environ.get("WAYLAND_DISPLAY") and shutil.which("grim"):
candidates.append(["grim", tmp])
for tool, argv in (("scrot", ["scrot", "-o", tmp]),
("maim", ["maim", tmp]),
("gnome-screenshot", ["gnome-screenshot", "-f", tmp]),
("import", ["import", "-window", "root", tmp])):
if shutil.which(tool):
candidates.append(argv)
if not candidates:
return {"ok": False, "error":
"Kein Screenshot-Tool gefunden. Linux: grim (Wayland) oder "
"scrot/maim (X11) installieren. (Windows/macOS nutzen Bordmittel.)"}
last_err = ""
for argv in candidates:
try:
r = subprocess.run(argv, capture_output=True, text=True, timeout=15)
if r.returncode == 0 and os.path.isfile(tmp) and os.path.getsize(tmp) > 0:
with open(tmp, "rb") as f:
b = f.read()
os.remove(tmp)
return {"ok": True, "result": {"format": "png", "bytes": len(b),
"base64": base64.b64encode(b).decode("ascii")}}
last_err = (r.stderr or r.stdout or "").strip()[:200]
except Exception as exc:
last_err = str(exc)[:200]
return {"ok": False, "error": f"Screenshot fehlgeschlagen ({last_err}). "
"Laeuft der Agent in derselben grafischen Session?"}
# ─── GUI-Steuerung (Maus/Tastatur/App) — nur mit grafischer Session ──
def _gui_unavailable() -> dict:
return {"ok": False, "error":
"Keine grafische Session zum Steuern. Linux: X11 (DISPLAY + xdotool) "
"oder Wayland (WAYLAND_DISPLAY + ydotool-Daemon). macOS: cliclick fuer "
"Maus. Auf einem reinen Terminal-/Server-System gibt es keine GUI."}
def _run_gui(argv: list, desc: str) -> dict:
try:
r = subprocess.run(argv, capture_output=True, text=True, timeout=15)
if r.returncode == 0:
return {"ok": True, "result": {"message": f"{desc} ausgefuehrt"}}
return {"ok": False, "error": f"{desc} fehlgeschlagen: "
+ (r.stderr or r.stdout or f"exit {r.returncode}").strip()[:200]}
except Exception as exc:
return {"ok": False, "error": f"{desc}: {exc}"}
def _ps(script: str, desc: str) -> dict:
return _run_gui(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], desc)
def _osa(script: str, desc: str) -> dict:
return _run_gui(["osascript", "-e", script], desc)
_WIN_MOUSE_DECL = (
"Add-Type -Name U -Namespace W -MemberDefinition '"
"[DllImport(\"user32.dll\")] public static extern bool SetCursorPos(int x,int y);"
"[DllImport(\"user32.dll\")] public static extern void mouse_event("
"uint f,uint x,uint y,uint d,int e);';"
)
def _do_ui_tap(params: dict) -> dict:
if not GUI_METHOD:
return _gui_unavailable()
x = _to_int(params.get("x")); y = _to_int(params.get("y"))
if x is None or y is None:
return {"ok": False, "error": "ui_tap braucht x,y (Bildschirm-Pixel aus dem Screenshot)."}
button = str(params.get("button", "left")).lower()
double = bool(params.get("double"))
desc = f"Klick ({x},{y})"
if GUI_METHOD == "xdotool":
btn = {"left": "1", "middle": "2", "right": "3"}.get(button, "1")
argv = ["xdotool", "mousemove", "--sync", str(x), str(y), "click"]
if double:
argv += ["--repeat", "2", "--delay", "120"]
return _run_gui(argv + [btn], desc)
if GUI_METHOD == "ydotool":
code = {"left": "0xC0", "right": "0xC1", "middle": "0xC2"}.get(button, "0xC0")
_run_gui(["ydotool", "mousemove", "-a", str(x), str(y)], "move")
r = _run_gui(["ydotool", "click", code], desc)
if double and r.get("ok"):
_run_gui(["ydotool", "click", code], desc)
return r
if GUI_METHOD == "macos":
if not _MAC_MOUSE:
return {"ok": False, "error": "Maus-Steuerung braucht cliclick (brew install cliclick)."}
cmd = ("dc:" if double else ("rc:" if button == "right" else "c:")) + f"{x},{y}"
return _run_gui(["cliclick", cmd], desc)
if GUI_METHOD == "windows":
down, up = ("0x0002", "0x0004") if button != "right" else ("0x0008", "0x0010")
click = f"[W.U]::mouse_event({down},0,0,0,0);[W.U]::mouse_event({up},0,0,0,0);"
script = _WIN_MOUSE_DECL + f"[W.U]::SetCursorPos({x},{y});" + click + (click if double else "")
return _ps(script, desc)
return _gui_unavailable()
def _do_ui_text(params: dict) -> dict:
if not GUI_METHOD:
return _gui_unavailable()
text = str(params.get("text", ""))
if not text:
return {"ok": False, "error": "ui_text braucht 'text'."}
desc = f"Text ({len(text)} Zeichen)"
if GUI_METHOD == "xdotool":
return _run_gui(["xdotool", "type", "--clearmodifiers", "--", text], desc)
if GUI_METHOD == "ydotool":
return _run_gui(["ydotool", "type", "--", text], desc)
if GUI_METHOD == "macos":
esc = text.replace("\\", "\\\\").replace('"', '\\"')
return _osa(f'tell application "System Events" to keystroke "{esc}"', desc)
if GUI_METHOD == "windows":
safe = re.sub(r"([+^%~(){}\[\]])", r"{\1}", text).replace('"', '`"')
return _ps("Add-Type -AssemblyName System.Windows.Forms;"
f"[System.Windows.Forms.SendKeys]::SendWait(\"{safe}\")", desc)
return _gui_unavailable()
def _do_ui_key(params: dict) -> dict:
if not GUI_METHOD:
return _gui_unavailable()
key = str(params.get("key", "")).strip()
if not key:
return {"ok": False, "error": "ui_key braucht 'key' (z.B. Return, Escape, ctrl+c)."}
desc = f"Taste '{key}'"
if GUI_METHOD == "xdotool":
return _run_gui(["xdotool", "key", "--clearmodifiers", key], desc)
if GUI_METHOD == "ydotool":
return _run_gui(["ydotool", "key", key], desc) # best effort (Keycodes)
if GUI_METHOD == "macos":
codes = {"return": 36, "enter": 36, "tab": 48, "space": 49, "delete": 51,
"escape": 53, "esc": 53, "left": 123, "right": 124, "down": 125, "up": 126,
"home": 115, "end": 119, "pageup": 116, "pagedown": 121}
k = key.lower()
if k in codes:
return _osa(f'tell application "System Events" to key code {codes[k]}', desc)
return _osa(f'tell application "System Events" to keystroke "{key}"', desc)
if GUI_METHOD == "windows":
m = {"return": "{ENTER}", "enter": "{ENTER}", "escape": "{ESC}", "esc": "{ESC}",
"tab": "{TAB}", "backspace": "{BACKSPACE}", "delete": "{DEL}",
"up": "{UP}", "down": "{DOWN}", "left": "{LEFT}", "right": "{RIGHT}",
"home": "{HOME}", "end": "{END}", "pageup": "{PGUP}", "pagedown": "{PGDN}"}
send = m.get(key.lower(), key)
return _ps("Add-Type -AssemblyName System.Windows.Forms;"
f"[System.Windows.Forms.SendKeys]::SendWait('{send}')", desc)
return _gui_unavailable()
def _do_ui_swipe(params: dict) -> dict:
"""Auf dem Desktop = Maus ziehen von (x1,y1) nach (x2,y2)."""
if not GUI_METHOD:
return _gui_unavailable()
x1 = _to_int(params.get("x1")); y1 = _to_int(params.get("y1"))
x2 = _to_int(params.get("x2")); y2 = _to_int(params.get("y2"))
if None in (x1, y1, x2, y2):
return {"ok": False, "error": "ui_swipe braucht x1,y1,x2,y2."}
desc = f"Ziehen ({x1},{y1} -> {x2},{y2})"
if GUI_METHOD == "xdotool":
return _run_gui(["xdotool", "mousemove", "--sync", str(x1), str(y1),
"mousedown", "1", "mousemove", "--sync", str(x2), str(y2),
"mouseup", "1"], desc)
if GUI_METHOD == "ydotool":
_run_gui(["ydotool", "mousemove", "-a", str(x1), str(y1)], "move")
_run_gui(["ydotool", "click", "0x40"], "down")
_run_gui(["ydotool", "mousemove", "-a", str(x2), str(y2)], "move")
return _run_gui(["ydotool", "click", "0x80"], desc)
if GUI_METHOD == "macos":
if not _MAC_MOUSE:
return {"ok": False, "error": "Ziehen braucht cliclick."}
return _run_gui(["cliclick", f"dd:{x1},{y1}", f"du:{x2},{y2}"], desc)
if GUI_METHOD == "windows":
script = (_WIN_MOUSE_DECL +
f"[W.U]::SetCursorPos({x1},{y1});[W.U]::mouse_event(0x0002,0,0,0,0);"
f"Start-Sleep -Milliseconds 80;[W.U]::SetCursorPos({x2},{y2});"
"[W.U]::mouse_event(0x0004,0,0,0,0);")
return _ps(script, desc)
return _gui_unavailable()
def _do_app_launch(params: dict) -> dict:
"""Startet eine App/ein Programm (nicht-blockierend). 'app' = Name/Pfad,
'command' = beliebiger Startbefehl."""
app = str(params.get("app") or params.get("query") or "").strip()
command = str(params.get("command") or "").strip()
if not app and not command:
return {"ok": False, "error": "app_launch braucht 'app' (Name) oder 'command'."}
try:
if command:
argv = command if IS_WINDOWS else ["/bin/sh", "-c", command]
subprocess.Popen(argv, shell=bool(IS_WINDOWS),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return {"ok": True, "result": {"message": f"Befehl gestartet: {command}"}}
if IS_MAC:
subprocess.Popen(["open", "-a", app])
elif IS_WINDOWS:
subprocess.Popen(["cmd", "/c", "start", "", app])
else:
launcher = shutil.which(app) or app
head = ["setsid"] if shutil.which("setsid") else []
subprocess.Popen(head + [launcher],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return {"ok": True, "result": {"message": f"App gestartet: {app}"}}
except Exception as exc:
return {"ok": False, "error": f"Start fehlgeschlagen: {exc}"}
ACTIONS = {
"exec": _do_exec, "read": _do_read, "write": _do_write,
"info": _do_info, "screenshot": _do_screenshot,
"ui_tap": _do_ui_tap, "ui_text": _do_ui_text, "ui_key": _do_ui_key,
"ui_swipe": _do_ui_swipe, "app_launch": _do_app_launch,
}
# ─── RVS-Client ─────────────────────────────────────────────────────
class HostAgent:
def __init__(self) -> None:
self.ws = None
def _for_me(self, payload: dict) -> bool:
"""Command gilt uns, wenn kein host-Feld gesetzt ist (Broadcast) oder es
auf unsere ID/Name passt."""
target = (payload.get("host") or payload.get("hostId") or "").strip()
if not target:
return True
return target.lower() in (HOST_ID.lower(), HOST_NAME.lower())
async def _send(self, message: dict) -> None:
if self.ws is None:
return
try:
await self.ws.send(json.dumps(message))
except Exception as exc:
logger.warning("Senden fehlgeschlagen: %s", exc)
async def _hello(self, log: bool = False) -> None:
if log:
logger.info("host_hello: id=%s name=%s caps=%s control=%s",
HOST_ID, HOST_NAME, ",".join(CAPS), CONTROL_ENABLED)
await self._send({"type": "host_hello", "payload": {
"hostId": HOST_ID, "name": HOST_NAME, "os": platform.platform(),
"version": AGENT_VERSION, "caps": CAPS, "control": CONTROL_ENABLED,
}, "timestamp": int(time.time() * 1000)})
async def _heartbeat(self) -> None:
while True:
await asyncio.sleep(HEARTBEAT_SEC)
await self._send({"type": "host_ping", "payload": {"hostId": HOST_ID},
"timestamp": int(time.time() * 1000)})
await self._hello()
async def _handle(self, raw: str) -> None:
try:
msg = json.loads(raw)
except Exception:
return
if msg.get("type") != "host_command":
return
payload = msg.get("payload") or {}
if not self._for_me(payload):
return
req_id = payload.get("requestId", "")
action = (payload.get("action") or "").strip()
params = payload.get("params") or {}
if not CONTROL_ENABLED:
result = {"ok": False, "error": "Steuerung ist deaktiviert (CONTROL_ENABLED=false)."}
elif action not in ACTIONS:
result = {"ok": False, "error": f"Aktion '{action}' unbekannt (bekannt: {', '.join(ACTIONS)})."}
else:
logger.info("[cmd] %s params=%s", action,
{k: str(v)[:60] for k, v in params.items() if k not in ("base64",)})
loop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(None, ACTIONS[action], params)
except Exception as exc:
result = {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
await self._send({"type": "host_result",
"payload": {"requestId": req_id, "hostId": HOST_ID,
"action": action, **result},
"timestamp": int(time.time() * 1000)})
async def run(self) -> None:
if not RVS_HOST or not RVS_TOKEN:
logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).")
return
backoff = 1
# use_tls kann bei Fehlschlag einmal auf ws:// fallen (RVS_TLS_FALLBACK),
# danach wieder zurueck auf RVS_TLS (kein Sticky-Fallback).
use_tls = RVS_TLS
tls_fallback_tried = False
connect_kwargs = {"max_size": 16 * 1024 * 1024,
"ping_interval": 20, "ping_timeout": 20}
while True:
proto = "wss" if use_tls else "ws"
# Bei TLS + RVS_SNI: URI nutzt den HOSTNAMEN (Host-Header/SNI/Cert),
# getaddrinfo mappt ihn auf die IP in RVS_HOST. Bei ws:// direkt die IP.
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
url = f"{proto}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
fallback = False
try:
logger.info("Verbinde mit RVS %s://%s:%s%s …", proto, uri_host, RVS_PORT,
f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
async with websockets.connect(url, **connect_kwargs) as ws:
self.ws = ws
backoff = 1
tls_fallback_tried = False
await self._hello(log=True)
hb = asyncio.create_task(self._heartbeat())
try:
async for raw in ws:
await self._handle(raw)
finally:
hb.cancel()
except Exception as exc:
logger.warning("RVS-Verbindung verloren: %s", exc)
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
logger.info("TLS fehlgeschlagen — Fallback auf ws://")
use_tls = False
tls_fallback_tried = True
fallback = True
finally:
self.ws = None
if fallback:
continue # sofort erneut mit ws://
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30)
use_tls = RVS_TLS # kein Sticky-Fallback
tls_fallback_tried = False
def main() -> None:
logger.info("ARIA Host-Agent startet — id=%s name=%s control=%s root=%s",
HOST_ID, HOST_NAME, CONTROL_ENABLED, _is_admin())
try:
asyncio.run(HostAgent().run())
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# ARIA Host-Agent — Service-Installer (systemd).
#
# Installiert die Binary nach /usr/local/bin, die .env nach /etc/aria-host-agent
# und richtet den systemd-Dienst ein (enable + start).
#
# Nutzung:
# sudo ./install-service.sh /pfad/zur/.env # .env-Pfad direkt uebergeben
# sudo ./install-service.sh # ncurses-Dateidialog (dialog)
#
# Die Binary wird unter ./dist/aria-host-agent oder ./aria-host-agent erwartet
# (vorher ./build.sh oder ./build-native.sh ausfuehren), oder als 2. Argument.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BIN_DST="/usr/local/bin/aria-host-agent"
ETC_DIR="/etc/aria-host-agent"
UNIT_DST="/etc/systemd/system/aria-host-agent.service"
die() { echo "FEHLER: $*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "Bitte mit sudo/root ausfuehren: sudo $0 $*"
# ── Binary finden (Arg 2 > dist/ > ./) ──────────────────────────────
BIN_SRC="${2:-}"
if [ -z "$BIN_SRC" ]; then
if [ -x "$SCRIPT_DIR/dist/aria-host-agent" ]; then BIN_SRC="$SCRIPT_DIR/dist/aria-host-agent"
elif [ -x "$SCRIPT_DIR/aria-host-agent" ]; then BIN_SRC="$SCRIPT_DIR/aria-host-agent"
fi
fi
[ -n "$BIN_SRC" ] && [ -f "$BIN_SRC" ] || die "Binary nicht gefunden. Erst bauen (./build.sh) oder als 2. Argument uebergeben."
# ── .env bestimmen: Arg 1, sonst ncurses-Dateidialog ────────────────
ENV_SRC="${1:-}"
if [ -z "$ENV_SRC" ]; then
if ! command -v dialog >/dev/null 2>&1; then
echo "Kein .env-Pfad uebergeben und 'dialog' ist nicht installiert."
read -r -p "dialog jetzt installieren (apt)? [j/N] " a
case "$a" in
j|J|y|Y) (apt-get update && apt-get install -y dialog) || die "dialog-Installation fehlgeschlagen — .env-Pfad bitte als Argument uebergeben." ;;
*) die "Ohne dialog bitte den .env-Pfad als Argument uebergeben: sudo $0 /pfad/zur/.env" ;;
esac
fi
# dialog --fselect: Dateibrowser; --stdout gibt die Auswahl auf stdout.
START_DIR="${SUDO_USER:+/home/$SUDO_USER/}"
[ -d "$START_DIR" ] || START_DIR="$SCRIPT_DIR/"
ENV_SRC="$(dialog --stdout --title 'ARIA Host-Agent — .env auswaehlen' \
--fselect "$START_DIR" 14 72)" || true
clear
[ -n "$ENV_SRC" ] || die "Abgebrochen — keine .env ausgewaehlt."
fi
[ -f "$ENV_SRC" ] || die ".env nicht gefunden: $ENV_SRC"
echo "Binary : $BIN_SRC"
echo ".env : $ENV_SRC"
# ── Installieren ────────────────────────────────────────────────────
install -m 0755 "$BIN_SRC" "$BIN_DST"
install -d -m 0755 "$ETC_DIR"
install -m 0600 "$ENV_SRC" "$ETC_DIR/.env" # 0600: enthaelt Token/Passwoerter
if [ -f "$SCRIPT_DIR/aria-host-agent.service" ]; then
install -m 0644 "$SCRIPT_DIR/aria-host-agent.service" "$UNIT_DST"
else
die "aria-host-agent.service nicht gefunden neben dem Installer."
fi
systemctl daemon-reload
systemctl enable --now aria-host-agent
echo
echo "✓ Installiert & gestartet."
echo " Status: systemctl status aria-host-agent"
echo " Log: journalctl -u aria-host-agent -f"
echo " .env: $ETC_DIR/.env (aendern -> systemctl restart aria-host-agent)"
+150
View File
@@ -0,0 +1,150 @@
#!/bin/bash
# ════════════════════════════════════════════════════════════════════
# ARIA Host-Agent — Release Script
# Baut die auf Linux+Docker moeglichen Artefakte und haengt sie als
# Gitea-Release-Assets an einen Tag. NICHTS wandert in den Git-Tree —
# Binaries leben nur unter "Releases" (blaeht clone/History nicht auf).
#
# Verwendung: ./release_agent.sh <version> (z.B. ./release_agent.sh 0.2.0)
#
# Artefakte:
# - Linux-x64-Binary (Docker, PyInstaller) -> immer
# - Android-APK (Docker, Gradle) -> immer
# - macOS / Windows (falls in dist/ vorgebaut) -> optional
# (mac/win koennen auf Linux nicht cross-gebaut werden -> build-native.*
# auf dem jeweiligen OS laufen lassen, Ergebnis nach host-agent/dist/ legen)
#
# Eigener Tag-Namespace agent-v<version> (kollidiert NICHT mit den
# App-Tags v<version>).
#
# Gitea-Zugang (GITEA_URL, GITEA_REPO, GITEA_USER) wird aus der Umgebung
# oder aus host-agent/.env bzw. der Repo-Wurzel-.env gelesen; das Kennwort
# wird interaktiv abgefragt.
# ════════════════════════════════════════════════════════════════════
set -e
cd "$(dirname "$0")" # host-agent/
SCRIPT_DIR="$(pwd)"
ROOT_DIR="$(cd .. && pwd)"
# ── Farben ───────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
# ── Parameter ────────────────────────────────────────────────────────
VERSION=${1:?"Usage: ./release_agent.sh <version> (z.B. 0.2.0)"}
TAG="agent-v$VERSION"
# ── Gitea-Konfiguration (env > host-agent/.env > Repo-Wurzel-.env) ────
[ -f "$SCRIPT_DIR/.env" ] && source "$SCRIPT_DIR/.env"
[ -f "$ROOT_DIR/.env" ] && source "$ROOT_DIR/.env"
GITEA_URL="${GITEA_URL:?"GITEA_URL nicht gesetzt (in .env oder als Umgebungsvariable)"}"
GITEA_REPO="${GITEA_REPO:?"GITEA_REPO nicht gesetzt (z.B. stefan/aria-agent)"}"
GITEA_USER="${GITEA_USER:-$(echo "$GITEA_REPO" | cut -d'/' -f1)}"
echo -e "${CYAN}╔═══════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ ARIA Host-Agent Release — ${TAG}$(printf '%*s' $((14 - ${#TAG})) '')║${NC}"
echo -e "${CYAN}╚═══════════════════════════════════════════╝${NC}\n"
# ── Kennwort ─────────────────────────────────────────────────────────
echo -e "${YELLOW}Gitea-Login: ${GITEA_USER}${NC}"
read -s -p "Gitea-Kennwort: " GITEA_PASS; echo ""
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -u "${GITEA_USER}:${GITEA_PASS}" "$GITEA_URL/api/v1/user")
if [ "$HTTP_CODE" != "200" ]; then
echo -e "${RED}Login fehlgeschlagen (HTTP $HTTP_CODE). Kennwort korrekt?${NC}"; exit 1
fi
echo -e " ${GREEN}✓${NC} Login erfolgreich\n"
# ── Versionsnummern setzen ───────────────────────────────────────────
echo -e "${GREEN}[1/5] Version auf $VERSION setzen...${NC}"
# Desktop-Agent
sed -i "s/^AGENT_VERSION = \"[^\"]*\"/AGENT_VERSION = \"$VERSION\"/" host_agent.py
echo -e " ${GREEN}✓${NC} host_agent.py → AGENT_VERSION $VERSION"
# Android: versionName + versionCode (aus Version berechnen; 3- oder 4-stellig)
IFS='.' read -ra VP <<< "$VERSION"
V1=${VP[0]:-0}; V2=${VP[1]:-0}; V3=${VP[2]:-0}; V4=${VP[3]:-0}
VERSION_CODE=$((V1 * 1000000 + V2 * 10000 + V3 * 100 + V4)); [ "$VERSION_CODE" -lt 1 ] && VERSION_CODE=1
sed -i "s/versionName '[^']*'/versionName '$VERSION'/" android/app/build.gradle
sed -i "s/versionCode [0-9]*/versionCode $VERSION_CODE/" android/app/build.gradle
echo -e " ${GREEN}✓${NC} android/app/build.gradle → versionName $VERSION, versionCode $VERSION_CODE\n"
# ── Bauen (Docker) ───────────────────────────────────────────────────
echo -e "${GREEN}[2/5] Linux-Binary + Android-APK bauen (Docker)...${NC}"
./build.sh >/dev/null
LINUX_BIN="$SCRIPT_DIR/dist/aria-host-agent"
[ -f "$LINUX_BIN" ] || { echo -e "${RED}Linux-Binary fehlt: $LINUX_BIN${NC}"; exit 1; }
echo -e " ${GREEN}✓${NC} Linux-Binary ($(du -h "$LINUX_BIN" | cut -f1))"
( cd android && ./build.sh >/dev/null )
APK="$SCRIPT_DIR/android/dist/aria-android-agent.apk"
[ -f "$APK" ] || { echo -e "${RED}APK fehlt: $APK${NC}"; exit 1; }
echo -e " ${GREEN}✓${NC} Android-APK ($(du -h "$APK" | cut -f1))"
# Windows (.exe + setup.exe) via Wine im Docker. Dauert (pywine-Image ~1-2 GB) —
# mit SKIP_WINDOWS=1 ./release_agent.sh <v> ueberspringbar.
if [ "${SKIP_WINDOWS:-0}" = "1" ]; then
echo -e " ${YELLOW}Windows-Build uebersprungen (SKIP_WINDOWS=1)${NC}"
else
echo -e " ${CYAN}…${NC} Windows-.exe + setup.exe bauen (Wine, kann dauern)"
if ./build-win.sh "$VERSION" >/dev/null 2>&1; then
echo -e " ${GREEN}✓${NC} Windows-.exe + setup.exe"
else
echo -e " ${YELLOW}Windows-Build fehlgeschlagen — Release laeuft ohne Windows weiter.${NC}"
echo -e " ${YELLOW}(Einzeln testen: ./build-win.sh $VERSION)${NC}"
fi
fi
echo ""
# Asset-Liste aufbauen: "lokaler_pfad::asset-name"
ASSETS=(
"$LINUX_BIN::aria-host-agent-linux-x64"
"$APK::aria-host-agent-android-$TAG.apk"
)
# Native Artefakte (nur wenn vorhanden): Windows aus build-win.sh, macOS
# vorgebaut (build-native.sh auf einem Mac -> dist/aria-host-agent-macos legen).
[ -f "$SCRIPT_DIR/dist/aria-host-agent-macos" ] && ASSETS+=("$SCRIPT_DIR/dist/aria-host-agent-macos::aria-host-agent-macos")
[ -f "$SCRIPT_DIR/dist/aria-host-agent.exe" ] && ASSETS+=("$SCRIPT_DIR/dist/aria-host-agent.exe::aria-host-agent-windows.exe")
[ -f "$SCRIPT_DIR/dist/aria-host-agent-setup.exe" ] && ASSETS+=("$SCRIPT_DIR/dist/aria-host-agent-setup.exe::aria-host-agent-windows-setup.exe")
# ── Git-Tag ──────────────────────────────────────────────────────────
echo -e "${GREEN}[3/5] Git-Tag $TAG...${NC}"
git add host_agent.py android/app/build.gradle
git commit -m "release(agent): bump to $VERSION" 2>/dev/null || echo -e " ${YELLOW}Keine Aenderungen zum Committen${NC}"
if git rev-parse "$TAG" &>/dev/null; then
echo -e " ${YELLOW}Tag $TAG existiert bereits — überspringe${NC}"
else
git tag "$TAG"; echo -e " ${GREEN}✓${NC} Tag $TAG erstellt"
fi
git push origin main "$TAG"
echo -e " ${GREEN}✓${NC} Tag gepusht\n"
# ── Gitea-Release ────────────────────────────────────────────────────
echo -e "${GREEN}[4/5] Gitea-Release anlegen...${NC}"
BODY=$(printf 'ARIA Host-Agent %s\n\nDesktop (Linux) + Android-APK. Auf das Zielgeraet kopieren, siehe README.' "$TAG")
BODY_JSON=$(printf '%s' "$BODY" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))' 2>/dev/null || printf '"%s"' "$BODY")
RESP=$(curl -s -X POST "$GITEA_URL/api/v1/repos/$GITEA_REPO/releases" \
-u "${GITEA_USER}:${GITEA_PASS}" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"Host-Agent $TAG\",\"body\":$BODY_JSON,\"draft\":false,\"prerelease\":false}")
RELEASE_ID=$(echo "$RESP" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
if [ -z "$RELEASE_ID" ]; then echo -e "${RED}Release fehlgeschlagen:${NC}\n$RESP"; exit 1; fi
echo -e " ${GREEN}✓${NC} Release #$RELEASE_ID erstellt\n"
# ── Assets hochladen ─────────────────────────────────────────────────
echo -e "${GREEN}[5/5] Assets hochladen (${#ASSETS[@]})...${NC}"
for entry in "${ASSETS[@]}"; do
path="${entry%%::*}"; name="${entry##*::}"
UP=$(curl -s -X POST \
"$GITEA_URL/api/v1/repos/$GITEA_REPO/releases/$RELEASE_ID/assets?name=$name" \
-u "${GITEA_USER}:${GITEA_PASS}" -F "attachment=@${path}")
if echo "$UP" | grep -q '"name"'; then
echo -e " ${GREEN}✓${NC} $name"
else
echo -e " ${RED}✗ $name fehlgeschlagen:${NC} $UP"
fi
done
echo ""
echo -e "${GREEN}╔═══════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Host-Agent $TAG ist live!${NC}"
echo -e "${GREEN}║${NC} $GITEA_URL/$GITEA_REPO/releases/tag/$TAG"
echo -e "${GREEN}╚═══════════════════════════════════════════════════╝${NC}"
+2
View File
@@ -0,0 +1,2 @@
websockets>=12.0
psutil>=5.9 # System-Info (CPU/RAM/Disk/Uptime) fuer host_info
+83
View File
@@ -0,0 +1,83 @@
; ARIA Host-Agent — Windows-Installer (NSIS, auf Linux mit makensis gebaut).
;
; Installiert die Agent-.exe nach %ProgramFiles%\ARIA Host-Agent, legt eine .env
; in %ProgramData%\ARIA-Host-Agent an (nur falls noch keine da ist — User-Config
; bleibt erhalten) und richtet einen automatisch startenden Windows-Dienst via
; nssm ein. Der Dienst laeuft mit AppDirectory = ProgramData-Ordner, damit der
; Agent die .env von dort (aus dem CWD) liest.
!ifndef VERSION
!define VERSION "0.0.0"
!endif
!define SVC "ARIAHostAgent"
Name "ARIA Host-Agent ${VERSION}"
OutFile "aria-host-agent-setup.exe"
InstallDir "$PROGRAMFILES64\ARIA Host-Agent"
RequestExecutionLevel admin
Unicode true
ShowInstDetails show
ShowUninstDetails show
Var DataDir
Page directory
Page instfiles
UninstPage uninstConfirm
UninstPage instfiles
Section "Install"
SetOutPath "$INSTDIR"
File "aria-host-agent.exe"
File "nssm.exe"
; ProgramData-Ordner fuer die .env bestimmen
ReadEnvStr $0 "ProgramData"
StrCmp $0 "" 0 +2
StrCpy $0 "$PROFILE" ; Fallback, falls %ProgramData% fehlt
StrCpy $DataDir "$0\ARIA-Host-Agent"
CreateDirectory "$DataDir"
; .env nur schreiben, wenn noch keine existiert (User-Config nicht ueberschreiben)
IfFileExists "$DataDir\.env" env_done 0
FileOpen $1 "$DataDir\.env" w
FileWrite $1 "# ARIA Host-Agent — Konfiguration (dieser Windows-Dienst liest diese Datei).$\r$\n"
FileWrite $1 "# Nach dem Aendern den Dienst neu starten: services.msc -> ARIA Host-Agent.$\r$\n"
FileWrite $1 "RVS_HOST=rvs.example.de$\r$\n"
FileWrite $1 "RVS_PORT=443$\r$\n"
FileWrite $1 "RVS_TLS=true$\r$\n"
FileWrite $1 "RVS_TLS_FALLBACK=true$\r$\n"
FileWrite $1 "RVS_TOKEN=$\r$\n"
FileWrite $1 "RVS_SNI=$\r$\n"
FileWrite $1 "HOST_NAME=$\r$\n"
FileWrite $1 "CONTROL_ENABLED=true$\r$\n"
FileClose $1
env_done:
; Dienst (neu) einrichten — evtl. alten sauber entfernen, dann installieren
nsExec::ExecToLog '"$INSTDIR\nssm.exe" stop ${SVC}'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" remove ${SVC} confirm'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" install ${SVC} "$INSTDIR\aria-host-agent.exe"'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} AppDirectory "$DataDir"'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} DisplayName "ARIA Host-Agent"'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} Description "ARIA-Fernsteuerung dieses Rechners (RVS-Agent)."'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} Start SERVICE_AUTO_START'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" start ${SVC}'
; Uninstaller + Eintrag unter "Apps & Features"
WriteUninstaller "$INSTDIR\uninstall.exe"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent" "DisplayName" "ARIA Host-Agent"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent" "DisplayVersion" "${VERSION}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent" "UninstallString" '"$INSTDIR\uninstall.exe"'
SectionEnd
Section "Uninstall"
nsExec::ExecToLog '"$INSTDIR\nssm.exe" stop ${SVC}'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" remove ${SVC} confirm'
Delete "$INSTDIR\aria-host-agent.exe"
Delete "$INSTDIR\nssm.exe"
Delete "$INSTDIR\uninstall.exe"
RMDir "$INSTDIR"
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent"
; Die .env in %ProgramData%\ARIA-Host-Agent bleibt bewusst erhalten (User-Config).
SectionEnd
+5 -5
View File
@@ -183,9 +183,9 @@ Wichtige Mechanismen:
- [x] Decimal-zu-Worte fuer TTS (0.1 → null komma eins, mit IP-Schutz-Lookahead)
- [x] Generic Acronym-Buchstabieren (XTTS → X T T S, USB → U S B, ueber expliziter Liste)
- [x] voice_preload/voice_ready: Stille Mini-Render bei Voice-Wechsel + Toast/Status "bereit"
- [x] Whisper STT auf die Gamebox ausgelagert (faster-whisper CUDA, float16) — neuer aria-whisper-bridge Container
- [x] aria-bridge: STT primaer remote (Gamebox), Fallback lokal nach 45s Timeout
- [x] Whisper-Modell hot-swap auf Gamebox via config-Broadcast aus Diagnostic
- [x] Whisper STT auf die AI-Box ausgelagert (faster-whisper CUDA, float16) — neuer aria-whisper-bridge Container
- [x] aria-bridge: STT primaer remote (AI-Box), Fallback lokal nach 45s Timeout
- [x] Whisper-Modell hot-swap auf AI-Box via config-Broadcast aus Diagnostic
- [x] **F5-TTS ersetzt XTTS komplett** — neuer aria-f5tts-bridge Container, Voice Cloning, satzweises Streaming
- [x] Voice-Upload mit Whisper-Auto-Transkription — User muss keinen Referenz-Text eintippen
- [x] Audio-Pause statt Ducking: Spotify/YouTube pausieren komplett waehrend TTS (TRANSIENT statt MAY_DUCK)
@@ -334,7 +334,7 @@ Skills mit Tool-Use.
- [x] Datei-Manager (Diagnostic + App-Modal): /shared/uploads/ verwalten, Multi-Select + Select-All + Bulk-Download als ZIP + Bulk-Delete
- [x] Wipe-All-Button (Memory + Stimmen + Settings)
- [x] Voice Export/Import pro Stimme (Diagnostic + XTTS-Bridge auf Gamebox)
- [x] Voice Export/Import pro Stimme (Diagnostic + XTTS-Bridge auf AI-Box)
- [x] F5/Whisper-Settings als JSON-Bundle Export/Import
- [x] App Chat-Suche umgebaut: Highlight + Next/Prev statt Filter
- [x] App Pinch-Zoom in Bildern rewriten (Multi-Touch-Race-Bugs)
@@ -399,7 +399,7 @@ Skills mit Tool-Use.
### Architektur
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
- [ ] RVS Zombie-Connections endgueltig loesen
- [ ] Gamebox: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push
- [ ] AI-Box: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push
- [ ] Erste Skills bauen lassen (yt-dlp, pdf-extract, image-resize, etc.) — durch normale Anfragen, ARIA legt sie selbst an
- [ ] Heartbeat (periodische Selbst-Checks)
- [ ] Lokales LLM als Waechter (Triage vor Claude-Call)
+31 -8
View File
@@ -100,9 +100,13 @@ function _parseToolCalls(text) {
}
export function cliResultToOpenai(result, requestId) {
const modelName = result.modelUsage
? Object.keys(result.modelUsage)[0]
: "claude-sonnet-4";
// modelUsage ist die Wahrheit: die CLI tragt dort den Namen ein, unter dem
// der Request wirklich lief — bei einem Alias also die aufgeloeste Version
// ("opus" -> "claude-opus-5-5"). Leer bleibt es, wenn gar kein Call
// zustande kam (z.B. unbekanntes Modell).
const usage = result.modelUsage || {};
const usedKey = Object.keys(usage)[0];
const modelName = (usedKey && (usage[usedKey]?.canonicalModel || usedKey)) || "";
const rawText = result.result || "";
const { tool_calls, rest } = _parseToolCalls(rawText);
@@ -134,13 +138,32 @@ export function cliResultToOpenai(result, requestId) {
total_tokens:
(result.usage?.input_tokens || 0) + (result.usage?.output_tokens || 0),
},
// ARIA-Zusatzfelder (ausserhalb des OpenAI-Schemas, Clients ignorieren
// sie). Damit kann die Diagnostic pruefen, ob ein Modell wirklich
// gelaufen ist: bei einem unbekannten Namen kommt die CLI gar nicht bis
// zum Call, dann ist modelUsage leer und is_error gesetzt. Ohne das
// koennte man einen Fehlschlag nicht von einer Antwort unterscheiden —
// die Fehlermeldung steht naemlich einfach im content.
aria_model: modelName,
aria_error: result.is_error === true,
aria_context_window: (usedKey && usage[usedKey]?.contextWindow) || 0,
aria_max_output: (usedKey && usage[usedKey]?.maxOutputTokens) || 0,
};
}
/**
* Modellname fuer die Antwort.
*
* Hier wurde der Name FRUEHER umgeschrieben: alles mit "opus" drin wurde zu
* "claude-opus-4", Sonnet zu "claude-sonnet-4" usw. Damit log das Feld — die
* CLI liefert in modelUsage den tatsaechlich benutzten Namen (z.B.
* "claude-opus-5-5"), und genau der wurde weggeworfen. Im OpenAI-Protokoll
* soll `model` das Modell nennen, das geantwortet HAT; ein erfundener Name
* macht jede Ueberpruefung unmoeglich ("welches Modell lief gerade?").
*
* Jetzt bleibt der echte Name stehen; nur wenn gar keiner da ist, gibt es
* einen neutralen Platzhalter.
*/
function normalizeModelName(model) {
const m = model || "claude-sonnet-4";
if (m.includes("opus")) return "claude-opus-4";
if (m.includes("sonnet")) return "claude-sonnet-4";
if (m.includes("haiku")) return "claude-haiku-4";
return m;
return model || "unknown";
}
+43 -1
View File
@@ -18,6 +18,9 @@
*/
import { spawn } from "child_process";
import { EventEmitter } from "events";
import fs from "fs";
import os from "os";
import path from "path";
import { isAssistantMessage, isResultMessage, isContentDelta } from "../types/claude-cli.js";
const DEFAULT_TIMEOUT = 86400000; // 24h — lange Agent-Loops (Pentests etc.)
export class ClaudeSubprocess extends EventEmitter {
@@ -26,6 +29,8 @@ export class ClaudeSubprocess extends EventEmitter {
timeoutId = null;
isKilled = false;
_stdinClosed = false;
_systemPromptFile = null;
_systemPromptDir = null;
/**
* Start the Claude CLI subprocess with the given prompt
*/
@@ -51,6 +56,7 @@ export class ClaudeSubprocess extends EventEmitter {
// Handle spawn errors (e.g., claude not found)
this.process.on("error", (err) => {
this.clearTimeout();
this._cleanupSystemPrompt();
if (err.message.includes("ENOENT")) {
reject(new Error("Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code"));
}
@@ -85,6 +91,7 @@ export class ClaudeSubprocess extends EventEmitter {
this.process.on("close", (code) => {
console.error(`[Subprocess] Process closed with code: ${code}`);
this.clearTimeout();
this._cleanupSystemPrompt();
// Process any remaining buffer
if (this.buffer.trim()) {
this.processBuffer();
@@ -96,6 +103,7 @@ export class ClaudeSubprocess extends EventEmitter {
}
catch (err) {
this.clearTimeout();
this._cleanupSystemPrompt();
reject(err);
}
});
@@ -114,13 +122,47 @@ export class ClaudeSubprocess extends EventEmitter {
"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",
"--no-session-persistence", "--dangerously-skip-permissions", "--safe-mode",
];
// ARIA: System-Prompt ueber eine DATEI statt als Kommandozeilen-Argument.
// Grund: execve() begrenzt argv+envp zusammen auf ARG_MAX — im Container
// nur 128 KiB. Ein gewachsener System-Prompt (gepinnte Erinnerungen,
// Skills, Projektkontext; real gesehen ~77k Zeichen) sprengte das und der
// Start brach mit "spawn E2BIG" ab: der Turn ging verloren, die App bekam
// 502 und die Nachricht blieb unbeantwortet. Ueber die Datei gibt es
// praktisch keine Groessengrenze mehr.
this._systemPromptFile = null;
const sysPrompt = options.systemPrompt || "";
if (sysPrompt) {
try {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aria-sysprompt-"));
const file = path.join(dir, "system-prompt.txt");
fs.writeFileSync(file, sysPrompt, "utf-8");
this._systemPromptFile = file;
this._systemPromptDir = dir;
args.push("--system-prompt-file", file);
}
catch (err) {
// Notfalls wie bisher (kleine Prompts funktionieren so weiterhin).
console.error(`[Subprocess] System-Prompt-Datei fehlgeschlagen (${err.message}) — nutze Argument`);
args.push("--system-prompt", sysPrompt);
}
}
if (options.sessionId) {
args.push("--session-id", options.sessionId);
}
return args;
}
/** Temporaere System-Prompt-Datei wieder entfernen. */
_cleanupSystemPrompt() {
try {
if (this._systemPromptFile)
fs.rmSync(this._systemPromptDir || this._systemPromptFile, { recursive: true, force: true });
}
catch (_) { }
this._systemPromptFile = null;
this._systemPromptDir = null;
}
/**
* Eine User-Message im stream-json-Input-Format an stdin schreiben.
* Genutzt fuer den initialen Prompt UND fuer Zwischenrufe (sendMessage).
+29 -11
View File
@@ -15,29 +15,47 @@
* (siehe docker-compose.yml proxy-Block).
*/
// NUR echte Tier-Aliase. Die CLI loest sie selbst auf die aktuelle Version des
// Tiers auf.
//
// Hier standen frueher auch volle Versionen (claude-opus-5 → "opus",
// claude-fable-5 → "fable", ...). Das war falsch: wer bewusst eine BESTIMMTE
// Version waehlte, bekam still das Tier-Default — also unter Umstaenden ein
// ganz anderes Modell als ausgewaehlt. Volle Namen gehen jetzt unveraendert an
// die CLI (siehe extractModel), die versteht sie.
const MODEL_MAP = {
"claude-opus-4": "opus",
"claude-sonnet-4": "sonnet",
"claude-haiku-4": "haiku",
"claude-code-cli/claude-opus-4": "opus",
"claude-code-cli/claude-sonnet-4": "sonnet",
"claude-code-cli/claude-haiku-4": "haiku",
"opus": "opus",
"sonnet": "sonnet",
"haiku": "haiku",
"fable": "fable",
"claude-fable-5": "fable",
"claude-code-cli/opus": "opus",
"claude-code-cli/sonnet": "sonnet",
"claude-code-cli/haiku": "haiku",
"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",
};
// Echte Claude-Modellnamen: claude-<familie>-<zahlen/suffixe>.
// Die CLI nimmt laut `claude --help` "an alias for the latest model (e.g.
// 'fable', 'opus', or 'sonnet') or a model's full name" — exakte Versionen
// darf man also direkt durchreichen.
const FULL_MODEL_RE = /^claude-[a-z]+-[0-9][0-9a-z.-]*$/;
export function extractModel(model) {
if (MODEL_MAP[model]) return MODEL_MAP[model];
const stripped = (model || "").replace(/^claude-code-cli\//, "");
if (MODEL_MAP[stripped]) return MODEL_MAP[stripped];
// Exakte Version durchreichen statt sie auf einen Tier einzuschmelzen.
//
// Vorher landete JEDER unbekannte Name still auf "opus". Damit war eine
// neuere Version faktisch nicht waehlbar: wer "claude-opus-5-5" setzte,
// bekam ohne jede Meldung das Tier-Default — die Einstellung sah gesetzt
// aus und tat nichts. Kennt die CLI den Namen nicht, sagt SIE das deutlich
// ("isn't described by this version's model catalog"), und das ist die
// ehrlichere Antwort als ein stilles Downgrade.
if (FULL_MODEL_RE.test(stripped)) return stripped;
if (stripped) {
console.error(`[aria-model] Unbekannter Modellname ${JSON.stringify(stripped)} — nutze 'opus'`);
}
return "opus";
}
+131 -6
View File
@@ -552,19 +552,144 @@ function _loadModels() {
return DEFAULT_MODELS;
}
export function handleModels(_req, res) {
// ── Echte Modell-Liste von Anthropic ────────────────────────────────
//
// Anthropic veroeffentlicht einen signierten Modell-Katalog, aus dem auch
// Claude Code selbst seinen Modell-Auswaehler fuellt. Oeffentlich erreichbar,
// KEIN API-Key noetig (die Models-API /v1/models braucht einen; mit dem
// Max-Abo gibt es keinen).
//
// Warum ueberhaupt: die handgepflegte Liste hier veraltete garantiert. Sie
// kannte Opus 5 — waehrend im Katalog laengst Opus 5.5, Sonnet 5.5 und
// Fable 5.1 standen. Ein "↻ Aktualisieren"-Knopf, der nur eine lokale Datei
// neu liest, aktualisiert nichts.
//
// Struktur: surfaces.<surface>.model_selector_config[].models[]
// = { id, name, short_name, description, section }
// 'cc' ist die Claude-Code-Oberflaeche — genau die, durch die ARIA laeuft.
// section 'main' = die vorgeschlagenen, 'overflow' = aeltere/weitere.
const CATALOG_URL = process.env.ARIA_MODEL_CATALOG_URL
|| "https://downloads.claude.ai/model-catalog/v1/catalog.json";
const CATALOG_CACHE_FILE = "/shared/config/model_catalog_cache.json";
const CATALOG_TTL_MS = 6 * 60 * 60 * 1000; // 6h — der Katalog gilt eine Woche
const CATALOG_SURFACE = "cc";
let _catalogMem = null; // { at, models }
function _parseCatalog(doc) {
const out = [];
const surfaces = (doc && doc.surfaces && doc.surfaces[CATALOG_SURFACE]) || null;
const configs = surfaces && surfaces.model_selector_config;
if (!Array.isArray(configs)) return out;
for (const cfg of configs) {
for (const m of (cfg.models || [])) {
if (!m || typeof m.id !== "string") continue;
out.push({
id: m.id,
tier: m.short_name ? String(m.short_name).toLowerCase() : m.id,
display_name: m.name || m.id,
description: m.description || "",
section: m.section || "main",
});
}
}
return out;
}
function _readCatalogCache() {
try {
const c = JSON.parse(fs.readFileSync(CATALOG_CACHE_FILE, "utf-8"));
if (Array.isArray(c.models) && c.models.length) return c;
} catch (_) { /* kein Cache */ }
return null;
}
/**
* Liefert die Katalog-Modelle. `force` umgeht den TTL (Diagnostic-Knopf).
* Schlaegt der Abruf fehl, gilt der Cache — auch ein abgelaufener, denn eine
* etwas alte echte Liste ist besser als gar keine.
*/
async function _catalogModels(force) {
const now = Date.now();
if (!force && _catalogMem && (now - _catalogMem.at) < CATALOG_TTL_MS) {
return _catalogMem.models;
}
if (!force) {
const cached = _readCatalogCache();
if (cached && (now - (cached.at || 0)) < CATALOG_TTL_MS) {
_catalogMem = { at: cached.at, models: cached.models };
return cached.models;
}
}
try {
const ctl = AbortSignal.timeout ? AbortSignal.timeout(10000) : undefined;
const r = await fetch(CATALOG_URL, { signal: ctl });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const doc = await r.json();
const models = _parseCatalog(doc);
if (!models.length) throw new Error("Katalog enthaelt keine Modelle fuer Surface " + CATALOG_SURFACE);
_catalogMem = { at: now, models };
try {
fs.mkdirSync("/shared/config", { recursive: true });
fs.writeFileSync(CATALOG_CACHE_FILE, JSON.stringify({
at: now, version: doc.version, expires_at: doc.expires_at, models,
}, null, 2));
} catch (e) {
console.error("[aria-models] Katalog-Cache schreiben fehlgeschlagen:", e && e.message);
}
console.error(`[aria-models] Katalog geladen: ${models.length} Modelle (Version ${doc.version})`);
return models;
} catch (e) {
console.error("[aria-models] Katalog-Abruf fehlgeschlagen:", e && e.message);
const cached = _readCatalogCache();
if (cached) {
console.error("[aria-models] nutze Cache vom " + new Date(cached.at || 0).toISOString());
_catalogMem = { at: cached.at, models: cached.models };
return cached.models;
}
return [];
}
}
export async function handleModels(req, res) {
const created = Math.floor(Date.now() / 1000);
const models = _loadModels();
const force = !!(req && req.query && (req.query.refresh === "1" || req.query.refresh === "true"));
// 1) Tier-Aliase zuerst: sie zeigen IMMER auf die aktuelle Version ihres
// Tiers, ganz ohne Pflege. Fuer den Alltag die richtige Wahl.
const tiers = _loadModels().map(m => ({
id: m.id,
tier: m.tier || m.id,
display_name: m.display_name || m.id,
description: m.description || "",
section: "tier",
}));
// 2) Danach die exakten Versionen aus dem Anthropic-Katalog.
const catalog = await _catalogModels(force);
const seen = new Set(tiers.map(t => t.id));
const exact = catalog.filter(m => !seen.has(m.id)).map(m => ({
id: m.id,
tier: m.tier,
display_name: m.display_name
+ (m.section === "main" ? "" : " (ältere Version)"),
description: m.description
|| (m.section === "main" ? "" : "Aeltere Version — nur waehlen wenn gewollt."),
section: m.section,
}));
res.json({
object: "list",
data: models.map(m => ({
catalog_ok: catalog.length > 0,
data: [...tiers, ...exact].map(m => ({
id: m.id,
object: "model",
owned_by: "anthropic",
created,
tier: m.tier || m.id,
display_name: m.display_name || m.id,
description: m.description || "",
tier: m.tier,
display_name: m.display_name,
description: m.description,
section: m.section,
})),
});
}

Some files were not shown because too many files have changed in this diff Show More