Compare commits

...
28 Commits
Author SHA1 Message Date
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
39 changed files with 2348 additions and 106 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20500
versionName "0.2.5.0"
versionCode 20504
versionName "0.2.5.4"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -49,6 +49,28 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Teilen-Ziel: ARIA erscheint im Teilen-Menue anderer Apps (WhatsApp,
Galerie, Browser ...). EINE Datei bzw. reiner Text. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="application/*" />
</intent-filter>
<!-- Mehrere Dateien auf einmal. -->
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="application/*" />
</intent-filter>
</activity>
<provider
@@ -1,5 +1,6 @@
package com.ariacockpit
import android.content.Intent
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
@@ -19,4 +20,26 @@ class MainActivity : ReactActivity() {
*/
override fun createReactActivityDelegate(): ReactActivityDelegate =
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
/**
* Teilen in die LAUFENDE App (launchMode=singleTask liefert das hier ab).
* setIntent() ist wichtig, damit getSharedData() denselben Inhalt sieht, und
* das Event weckt die JS-Seite sofort — ohne es wuerde der geteilte Inhalt
* erst beim naechsten Kaltstart auftauchen.
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
// Best effort: je nach RN-Architektur ist der Kontext anders erreichbar.
// Scheitert es, greift der zuverlaessige Zweitweg — die JS-Seite fragt beim
// Zurueckkehren in den Vordergrund ohnehin nochmal via getSharedData() nach
// (setIntent() oben sorgt dafuer, dass sie denselben Inhalt sieht).
try {
val ctx = (application as? com.facebook.react.ReactApplication)
?.reactNativeHost?.reactInstanceManager?.currentReactContext
ShareIntentModule.notifyNewIntent(
ctx as? com.facebook.react.bridge.ReactApplicationContext, intent)
} catch (_: Throwable) {
}
}
}
@@ -25,6 +25,7 @@ class MainApplication : Application(), ReactApplication {
add(OpenWakeWordPackage())
add(PhoneCallPackage())
add(BackgroundAudioPackage())
add(ShareIntentPackage())
}
override fun getJSMainModuleName(): String = "index"
@@ -0,0 +1,154 @@
package com.ariacockpit
import android.content.Intent
import android.net.Uri
import android.provider.OpenableColumns
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.WritableArray
import com.facebook.react.bridge.WritableMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import java.io.File
/**
* Teilen-Ziel: nimmt Inhalte entgegen, die aus anderen Apps (WhatsApp, Galerie,
* Browser ...) per "Teilen → ARIA" geschickt werden.
*
* Die geteilten Dateien kommen als content://-URI, auf die nur DIESE App und nur
* JETZT zugreifen darf. Deshalb kopieren wir sie sofort in den App-Cache und
* geben einen echten Dateipfad zurueck — den kann die JS-Seite wie eine normal
* ausgewaehlte Datei weiterverarbeiten (RNFS.readFile → Base64 → Upload).
*
* Ablauf:
* App geschlossen → Intent liegt beim Start an → getSharedData()
* App laeuft → onNewIntent (launchMode=singleTask) → Event 'ShareReceived'
*/
class ShareIntentModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = "ShareIntent"
companion object {
private const val EVENT = "ShareReceived"
/** Vom MainActivity.onNewIntent gerufen, wenn die App schon laeuft. */
@JvmStatic
fun notifyNewIntent(ctx: ReactApplicationContext?, intent: Intent?) {
if (ctx == null || intent == null || !isShare(intent)) return
try {
val data = extract(ctx, intent)
ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit(EVENT, data)
} catch (_: Throwable) {
}
}
private fun isShare(i: Intent): Boolean =
i.action == Intent.ACTION_SEND || i.action == Intent.ACTION_SEND_MULTIPLE
/** Baut {text, items:[{path,name,type,size}]} aus dem Intent. */
private fun extract(ctx: ReactApplicationContext, intent: Intent): WritableMap {
val out = Arguments.createMap()
out.putString("text", intent.getStringExtra(Intent.EXTRA_TEXT) ?: "")
val uris = mutableListOf<Uri>()
if (intent.action == Intent.ACTION_SEND) {
@Suppress("DEPRECATION")
(intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM))?.let { uris.add(it) }
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
?.forEach { uris.add(it) }
}
val items: WritableArray = Arguments.createArray()
for (uri in uris) {
copyToCache(ctx, uri)?.let { items.pushMap(it) }
}
out.putArray("items", items)
return out
}
/**
* Kopiert den geteilten Inhalt in den Cache. Noetig, weil die Leseerlaubnis
* fuer die content://-URI nur fuer diesen Intent gilt — spaeter (nach der
* Projektauswahl) waere sie womoeglich schon erloschen.
*/
private fun copyToCache(ctx: ReactApplicationContext, uri: Uri): WritableMap? {
return try {
val cr = ctx.contentResolver
var name = "geteilt"
var size = 0L
cr.query(uri, null, null, null, null)?.use { c ->
val ni = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val si = c.getColumnIndex(OpenableColumns.SIZE)
if (c.moveToFirst()) {
if (ni >= 0) c.getString(ni)?.let { name = it }
if (si >= 0) size = c.getLong(si)
}
}
val type = cr.getType(uri) ?: "application/octet-stream"
val dir = File(ctx.cacheDir, "shared").apply { mkdirs() }
// Eindeutiger Name, damit zwei gleichnamige Bilder sich nicht ueberschreiben.
val safe = name.replace(Regex("[^A-Za-z0-9._-]"), "_").take(80)
val dst = File(dir, "${System.currentTimeMillis()}_$safe")
cr.openInputStream(uri)?.use { input ->
dst.outputStream().use { output -> input.copyTo(output) }
} ?: return null
Arguments.createMap().apply {
putString("path", dst.absolutePath)
putString("name", name)
putString("type", type)
putDouble("size", (if (size > 0) size else dst.length()).toDouble())
}
} catch (_: Throwable) {
null
}
}
}
/**
* Holt den Inhalt, mit dem die App GESTARTET wurde (App war zu).
* Gibt {text, items:[]} zurueck — items leer, wenn nichts geteilt wurde.
*/
@ReactMethod
fun getSharedData(promise: Promise) {
try {
val activity = currentActivity
val intent = activity?.intent
if (intent == null || !isShare(intent)) {
promise.resolve(Arguments.createMap().apply {
putString("text", "")
putArray("items", Arguments.createArray())
})
return
}
val data = extract(reactContext, intent)
// Verbraucht markieren, damit ein Rotieren/Zurueckkehren den Dialog
// nicht erneut oeffnet.
activity.intent = Intent(Intent.ACTION_MAIN)
promise.resolve(data)
} catch (e: Throwable) {
promise.reject("SHARE_READ_FAILED", e.message, e)
}
}
/** Aufgeraeumte Kopien loeschen (nach erfolgreichem Senden). */
@ReactMethod
fun clearCache(promise: Promise) {
try {
File(reactContext.cacheDir, "shared").listFiles()?.forEach { it.delete() }
promise.resolve(true)
} catch (e: Throwable) {
promise.resolve(false)
}
}
// Fuer NativeEventEmitter (RN verlangt diese beiden Methoden).
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
}
@@ -0,0 +1,16 @@
package com.ariacockpit
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class ShareIntentPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(ShareIntentModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.5.0",
"version": "0.2.5.4",
"private": true,
"scripts": {
"android": "react-native run-android",
+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;
+167 -22
View File
@@ -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';
@@ -198,6 +200,28 @@ 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.
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';
@@ -348,6 +372,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.
@@ -419,6 +447,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;
@@ -854,13 +909,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) {
@@ -1303,6 +1371,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;
@@ -1802,9 +1874,20 @@ 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'))));
}
// Passiv-Lauschen: leeres Endpoint (no-speech / Silero-Musik / speaker_
// mismatch). NICHT sofort beenden — solange die Stille-Toleranz ab
@@ -2456,16 +2539,47 @@ const ChatScreen: React.FC = () => {
setPendingAttachments(prev => [...prev, { file: photo, isPhoto: true }]);
}, []);
// ── Teilen-Ziel ────────────────────────────────────────────────
// Beim Start pruefen (App war zu) und auf spaetere Shares lauschen
// (App laeuft schon → onNewIntent im MainActivity).
useEffect(() => {
let alive = true;
const open = (p: { text: string; items: SharedItem[] }) => {
if (!alive) return;
if (!p.items.length && !p.text) return; // nichts dabei
setShareItems(p.items);
setShareText(p.text);
setShareVisible(true);
};
shareIntent.getShared().then(open).catch(() => {});
const unsub = shareIntent.onShare(open);
// Zweitweg: Teilen holt die App in den Vordergrund. Beim Wechsel nach
// 'active' nochmal nachfragen — das greift auch, wenn das native Event
// (je nach RN-Architektur) nicht durchkommt. getSharedData() markiert den
// Intent als verbraucht, es kann also nicht doppelt aufpoppen.
const appSub = AppState.addEventListener('change', st => {
if (st === 'active') shareIntent.getShared().then(open).catch(() => {});
});
return () => { alive = false; unsub(); try { appSub.remove(); } catch (_) {} };
}, []);
// Alle Pending Anhaenge + Text senden
const sendPendingAttachments = useCallback(async (messageText: string) => {
if (pendingAttachments.length === 0) return;
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', pendingAttachments.length);
const sendPendingAttachments = useCallback(async (
messageText: string,
// Explizite Liste (Teilen-Weg): der State waere beim direkten Aufruf noch
// nicht aktualisiert. Ohne Angabe wie bisher die Pending-Liste.
explicitAttachments?: { file: any; isPhoto: boolean }[],
) => {
const atts = explicitAttachments ?? pendingAttachments;
if (atts.length === 0) return;
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', atts.length);
const location = await getCurrentLocation();
const msgId = nextId();
// Alle Attachments fuer die Chat-Nachricht sammeln
const attachments: Attachment[] = [];
for (const { file, isPhoto } of pendingAttachments) {
for (const { file, isPhoto } of atts) {
const isImage = isPhoto || (file.type && file.type.startsWith('image/'));
const name = isPhoto ? file.fileName : file.name;
const base64 = file.base64 || '';
@@ -2488,7 +2602,7 @@ const ChatScreen: React.FC = () => {
const userMsg: ChatMessage = {
id: msgId,
sender: 'user',
text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
text: messageText || `${atts.length} Anhang/Anhaenge`,
timestamp: Date.now(),
attachments,
projectId: activePid,
@@ -2501,7 +2615,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 || '';
@@ -2556,6 +2670,33 @@ const ChatScreen: React.FC = () => {
// sendPendingAttachments — beide stehen in seinem deps-Array, und wenn es
// davor deklariert waere, laendeten sie dort in der Temporal Dead Zone
// (tsc TS2448/2454; lief nur zufaellig, weil Babel const→var hebt).
/** Geteiltes senden: Ziel setzen, Dateien anhaengen, abschicken. */
const handleShareSend = useCallback(async (projectId: string, text: string) => {
try {
// Chat-Focus aufs gewaehlte Ziel — die Nachricht landet dort, und Stefan
// steht nach dem Senden gleich im richtigen Kontext.
setFocusedProjectId(projectId);
focusedProjectIdRef.current = projectId;
const files = await shareIntent.toFileData(shareItems);
if (files.length) {
// Liste explizit uebergeben: der State waere hier noch leer.
await sendPendingAttachments(text, files.map(f => ({ file: f, isPhoto: false })));
} else if (text) {
// Reiner Text (z.B. geteilter Link): ins Eingabefeld, Stefan schickt ab.
setInputText(text);
}
shareIntent.clearShareCache();
} catch (e) {
console.warn('[Share] Senden fehlgeschlagen:', e);
Alert.alert('Teilen fehlgeschlagen', String((e as any)?.message || e));
} finally {
setShareVisible(false);
setShareItems([]);
setShareText('');
}
}, [shareItems, sendPendingAttachments]);
const sendTextMessage = useCallback(async () => {
const text = inputText.trim();
@@ -2790,16 +2931,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' ? (
@@ -2836,11 +2976,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);
}
}}
>
@@ -3693,6 +3831,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)}
+54 -15
View File
@@ -1141,7 +1141,7 @@ class AudioService {
* Leer = Hauptchat. Ohne Prefix / Sticky landet die STT-Nachricht damit
* automatisch in dem Kontext den Stefan gerade sieht. */
projectId?: string;
}): Promise<{ requestId: string; ok: boolean }> {
}): Promise<{ requestId: string; ok: boolean; error?: string }> {
if (this.recordingState !== 'idle') {
console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState);
return { requestId: '', ok: false };
@@ -1155,12 +1155,32 @@ 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 →
@@ -1202,20 +1222,17 @@ class AudioService {
console.warn('[Audio] PcmStreamChunk-Subscription fehlgeschlagen:', err);
}
const started = await PcmStreamRecorder.start();
if (!started) {
throw new Error('PcmStreamRecorder.start returned false');
// 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' };
}
// AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad.
// Vorher merken ob Musik lief (fuer Resume am Dialog-Ende).
await this._captureMediaActive();
this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {});
this.setState('recording');
// stt_stream_start — der Whisper-Bridge mitteilen dass jetzt Chunks kommen.
// 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 || '',
@@ -1230,18 +1247,40 @@ class AudioService {
targetInstance: this.streamTargetInstance,
});
const started = await PcmStreamRecorder.start();
if (!started) {
throw new Error('PcmStreamRecorder.start returned false');
}
// AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad.
// Vorher merken ob Musik lief (fuer Resume am Dialog-Ende).
await this._captureMediaActive();
this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {});
// 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).
+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;
}
+35 -7
View File
@@ -104,11 +104,7 @@ class RVSConnection {
// Verbindungen + doppelte Events.
if (this.ws) {
this.log('info', 'Bestehende WS-Verbindung wird geschlossen vor Neu-Connect');
try {
this.ws.onclose = null; // verhindert dass scheduleReconnect doppelt feuert
this.ws.onerror = null;
this.ws.close();
} catch (_) {}
this.detachSocket(this.ws);
this.ws = null;
}
@@ -134,6 +130,15 @@ class RVSConnection {
this.setState('disconnected');
}
/**
* Ist die Verbindung WIRKLICH offen? Fragt den Socket selbst, nicht den
* gemerkten Zustand — der kann (z.B. nach einem Zombie) falsch sein. Wer
* davon abhaengig macht, ob ueberhaupt gesendet wird, muss das Echte wissen.
*/
isSocketOpen(): boolean {
return !!this.ws && this.ws.readyState === WebSocket.OPEN;
}
/** Nachricht an den RVS senden */
send(type: MessageType, payload: Record<string, unknown>): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
@@ -215,7 +220,12 @@ class RVSConnection {
this.startHeartbeat();
};
const socket = this.ws; // genau DIESE Verbindung (nicht 'this.ws' spaeter)
this.ws.onmessage = (event: WebSocketMessageEvent) => {
// Nur die AKTUELLE Verbindung darf Nachrichten einspeisen. Sonst
// koennte eine alte, laengst aufgegebene Verbindung weiter Daten
// liefern und vortaeuschen, es sei alles in Ordnung.
if (socket !== this.ws) return;
try {
const message: RVSMessage = JSON.parse(event.data as string);
this.notifyMessageListeners(message);
@@ -258,8 +268,7 @@ class RVSConnection {
this.log('warn', 'TLS fehlgeschlagen — Fallback auf ws:// (ohne TLS)');
this.clearTimers();
if (this.ws) {
this.ws.onclose = null; // onclose-Handler entfernen um Doppel-Reconnect zu verhindern
try { this.ws.close(); } catch (_) {}
this.detachSocket(this.ws); // inkl. onmessage — sonst bleibt ein Zombie, der weiter empfaengt
}
this.ws = null;
this.shouldReconnect = true;
@@ -302,6 +311,25 @@ class RVSConnection {
}, HEARTBEAT_INTERVAL_MS);
}
/**
* Klemmt eine Verbindung VOLLSTAENDIG ab, bevor sie verworfen wird.
*
* Wichtig: frueher wurden nur onclose/onerror entfernt — onmessage blieb
* haengen. Eine aufgegebene (Zombie-)Verbindung lieferte dadurch weiter
* Nachrichten an die App, waehrend gesendet wurde ueber die neue/tote.
* Symptom: die App zeigt "getrennt", ARIAs Sprachausgabe kommt aber trotzdem
* an — und nichts, was man schreibt oder spricht, erreicht den Server.
*/
private detachSocket(ws: WebSocket): void {
try {
ws.onopen = null;
ws.onmessage = null; // <- das fehlte
ws.onclose = null; // verhindert doppelten Reconnect
ws.onerror = null;
ws.close();
} catch (_) {}
}
private clearTimers(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
+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 };
+384 -1
View File
@@ -1501,6 +1501,155 @@ META_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "jellyfin_search",
"description": (
"Sucht Filme/Serien/Folgen auf einem Jellyfin-Server (Medien-Bibliothek). "
"Liefert Titel, Jahr, Typ und die itemId, die Du zum Abspielen brauchst. "
"NIMM DAS statt Dich durch die Fire-TV-Oberflaeche zu klicken — das ist "
"zuverlaessig und schnell. Server werden im Diagnostic unter "
"Integrationen gepflegt; ohne Angabe wird der Standard-Server genutzt."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Suchbegriff, z.B. 'Asterix Kleopatra'."},
"server": {"type": "string", "description": "Server-Name (optional, sonst Standard)."},
"limit": {"type": "integer", "description": "Max. Treffer (Default 10)."},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "jellyfin_devices",
"description": (
"Zeigt, auf welchen Geraeten gerade eine Jellyfin-App laeuft und damit "
"fernsteuerbar ist (Sitzungen) — z.B. der Fire TV im Schlafzimmer. "
"WICHTIG: Ein Geraet erscheint nur, wenn die Jellyfin-App dort GEOEFFNET "
"ist. Ist sie zu, starte sie zuerst mit host_app_launch (query='Jellyfin') "
"und frage dann erneut."
),
"parameters": {
"type": "object",
"properties": {
"server": {"type": "string", "description": "Server-Name (optional)."},
},
},
},
},
{
"type": "function",
"function": {
"name": "jellyfin_play",
"description": (
"Startet die Wiedergabe eines Titels auf einem Jellyfin-Geraet. Ablauf: "
"1. jellyfin_search -> itemId, 2. jellyfin_devices -> Geraet/Sitzung, "
"3. jellyfin_play. Prueft selbst nach, ob die Wiedergabe wirklich "
"angelaufen ist. GIB IMMER 'host' MIT (Name des Host-Agenten am "
"Fernseher, z.B. 'Fire-TV Schlafzimmer'): dann starte ich Jellyfin bei "
"einer eingeschlafenen Sitzung automatisch neu und lege nochmal nach, "
"statt faelschlich Erfolg zu melden."
),
"parameters": {
"type": "object",
"properties": {
"item_id": {"type": "string", "description": "itemId aus jellyfin_search."},
"device": {"type": "string", "description": "Geraete-/Sitzungsname aus jellyfin_devices (z.B. 'Fire TV')."},
"host": {"type": "string", "description": "Host-Agent am Geraet (fuer automatischen Neustart bei toter Sitzung)."},
"server": {"type": "string", "description": "Server-Name (optional)."},
"resume": {"type": "boolean", "description": "Wenn moeglich fortsetzen statt von vorn (Default true)."},
},
"required": ["item_id", "device"],
},
},
},
{
"type": "function",
"function": {
"name": "jellyfin_control",
"description": (
"Steuert die laufende Wiedergabe auf einem Jellyfin-Geraet: pause, "
"unpause, stop, nextTrack, previousTrack."
),
"parameters": {
"type": "object",
"properties": {
"device": {"type": "string", "description": "Geraete-/Sitzungsname."},
"command": {"type": "string", "description": "pause | unpause | stop | nextTrack | previousTrack"},
"server": {"type": "string", "description": "Server-Name (optional)."},
},
"required": ["device", "command"],
},
},
},
{
"type": "function",
"function": {
"name": "host_notify",
"description": (
"Zeigt eine Nachricht AUF einem Host-Agenten an — bei einem Fire TV / "
"Android TV als EINBLENDUNG ueber dem laufenden Bild (Netflix & Co. "
"laufen weiter), wahlweise als Sprachausgabe. NIMM DIESES TOOL, wenn "
"Stefan etwas 'auf dem Fernseher einblenden/anzeigen' will — versuche "
"das NICHT ueber host_exec/adb (dort gibt es kein passendes Kommando). "
"mode: 'overlay' (einblenden), 'speech' (vorlesen) oder 'both'; ohne "
"Angabe gilt die Einstellung am Geraet. Braucht dort einmalig die "
"Berechtigung 'Ueber anderen Apps anzeigen'."
),
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name (z.B. 'Fire-TV Schlafzimmer')."},
"text": {"type": "string", "description": "Der anzuzeigende Text."},
"title": {"type": "string", "description": "Ueberschrift (Default 'ARIA')."},
"seconds": {"type": "integer", "description": "Anzeigedauer (Default 12)."},
"mode": {"type": "string", "description": "overlay | speech | both"},
"focus": {"type": "string", "description": "Bei Sprachausgabe: 'duck' (laufende Medien werden leiser, Standard) | 'pause' (Film/Musik anhalten) | 'none'."},
},
"required": ["host", "text"],
},
},
},
{
"type": "function",
"function": {
"name": "host_speak",
"description": (
"Laesst einen Host-Agenten Text laut vorlesen (Fire TV: ueber die "
"Fernseher-Lautsprecher). Fuer reine Sprachansagen; soll es auch/nur "
"sichtbar sein, nimm host_notify."
),
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name."},
"text": {"type": "string", "description": "Was gesprochen werden soll."},
"focus": {"type": "string", "description": "'duck' (Medien leiser, Standard) | 'pause' (Film/Musik anhalten) | 'none'."},
},
"required": ["host", "text"],
},
},
},
{
"type": "function",
"function": {
"name": "host_volume",
"description": "Setzt die Medien-Lautstaerke eines Host-Agenten (Fire TV / Android).",
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name."},
"percent": {"type": "integer", "description": "0-100."},
},
"required": ["host", "percent"],
},
},
},
{
"type": "function",
"function": {
@@ -2686,6 +2835,232 @@ class Agent:
except Exception as exc:
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
# ── Jellyfin (Medien) ─────────────────────────────────────
_JELLYFIN_CONFIG = "/shared/config/jellyfin.json"
def _jellyfin_server(self, wanted: str = "") -> Optional[dict]:
"""Server aus der Diagnostic-Konfiguration waehlen (Name oder Standard)."""
try:
with open(self._JELLYFIN_CONFIG, "r", encoding="utf-8") as f:
servers = (json.load(f) or {}).get("servers") or []
except Exception:
return None
servers = [s for s in servers if s.get("url")]
if not servers:
return None
w = (wanted or "").strip().lower()
if w:
for s in servers:
if w in (s.get("name") or "").lower() or w in (s.get("url") or "").lower():
return s
for s in servers:
if s.get("isDefault"):
return s
return servers[0]
def _jellyfin_call(self, server: dict, path: str, method: str = "GET",
params: Optional[dict] = None, timeout: float = 15.0):
"""Ruft die Jellyfin-HTTP-API. Gibt (daten, fehlertext)."""
url = server["url"].rstrip("/") + path
if params:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params)
req = urllib.request.Request(url, method=method, headers={
"Authorization": f'MediaBrowser Token="{server.get("apiKey", "")}"',
"Content-Type": "application/json",
})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", "ignore")
return (json.loads(raw) if raw.strip() else {}), ""
except urllib.error.HTTPError as exc:
if exc.code in (401, 403):
return None, ("Jellyfin lehnt den API-Key ab (401/403) — im Diagnostic "
"unter Integrationen pruefen.")
return None, f"Jellyfin-Fehler HTTP {exc.code}"
except Exception as exc:
return None, f"Jellyfin nicht erreichbar: {exc}"
def _jellyfin_find_session(self, server: dict, device: str, fresh_sec: int = 600):
"""Sucht die Sitzung eines Geraets. Gibt (session, fehlertext).
`activeWithinSeconds` filtert eingeschlafene Sitzungen aus: Jellyfin
listet Clients noch eine Weile, nachdem die App laengst weg ist — ein
Play-Befehl an so eine Karteileiche verpufft wortlos.
"""
sessions, err = self._jellyfin_call(
server, "/Sessions", params={"activeWithinSeconds": fresh_sec})
if err:
return None, err
d = (device or "").strip().lower()
cands = [s for s in (sessions or []) if s.get("SupportsRemoteControl")]
for s in cands:
blob = f"{s.get('DeviceName', '')} {s.get('Client', '')}".lower()
if d and d in blob:
return s, ""
if not d and len(cands) == 1:
return cands[0], ""
names = ", ".join(f"{s.get('DeviceName')} ({s.get('Client')})" for s in cands) or "keine"
return None, (f"Kein steuerbares Jellyfin-Geraet fuer '{device}' gefunden. "
f"Aktuell offen: {names}. Tipp: Die Jellyfin-App muss auf dem "
f"Geraet GEOEFFNET sein — sonst vorher host_app_launch "
f"(query='Jellyfin') aufrufen.")
def _jellyfin_playing(self, server: dict, sess: dict, item_id: str,
tries: int = 4, wait: float = 1.5) -> bool:
"""Prueft, ob auf der Sitzung wirklich etwas abgespielt wird.
Jellyfin quittiert einen Play-Befehl auch dann mit 204, wenn die Sitzung
laengst tot ist — ohne diese Kontrolle meldet man faelschlich Erfolg.
"""
want = (item_id or "").strip()
for _ in range(max(1, tries)):
time.sleep(wait)
sessions, err = self._jellyfin_call(server, "/Sessions", timeout=10.0)
if err:
continue
for s in (sessions or []):
if s.get("Id") != sess.get("Id"):
continue
now = s.get("NowPlayingItem") or {}
if now and (not want or now.get("Id") == want):
return True
return False
def _dispatch_jellyfin(self, name: str, arguments: dict) -> str:
server = self._jellyfin_server(arguments.get("server") or "")
if not server:
return ("FEHLER: Kein Jellyfin-Server eingerichtet. Im Diagnostic unter "
"Einstellungen → Integrationen → Jellyfin einen Server mit URL und "
"API-Key anlegen.")
label = server.get("name") or server.get("url")
try:
if name == "jellyfin_search":
q = (arguments.get("query") or "").strip()
if not q:
return "FEHLER: query ist Pflicht."
data, err = self._jellyfin_call(server, "/Items", params={
"searchTerm": q, "Recursive": "true",
"IncludeItemTypes": "Movie,Series,Episode",
"Limit": int(arguments.get("limit") or 10),
"Fields": "ProductionYear,Overview",
})
if err:
return f"FEHLER: {err}"
items = (data or {}).get("Items") or []
if not items:
return f"Keine Treffer fuer '{q}' auf {label}."
lines = []
for it in items:
lines.append(f"- {it.get('Name')} ({it.get('ProductionYear') or '?'}) "
f"[{it.get('Type')}] itemId={it.get('Id')}")
return f"Treffer auf {label}:\n" + "\n".join(lines)
if name == "jellyfin_devices":
# Nur Sitzungen, die zuletzt aktiv waren — sonst listet Jellyfin
# auch laengst geschlossene Apps, an die kein Befehl mehr geht.
sessions, err = self._jellyfin_call(
server, "/Sessions", params={"activeWithinSeconds": 600})
if err:
return f"FEHLER: {err}"
out = []
for s in (sessions or []):
if not s.get("SupportsRemoteControl"):
continue
now = (s.get("NowPlayingItem") or {}).get("Name")
out.append(f"- {s.get('DeviceName')} ({s.get('Client')})"
+ (f" — laeuft gerade: {now}" if now else ""))
if not out:
return (f"Auf {label} ist gerade kein steuerbares Geraet offen. Die "
f"Jellyfin-App muss auf dem Zielgeraet laufen — starte sie "
f"mit host_app_launch (query='Jellyfin') und frag dann erneut.")
return f"Steuerbare Jellyfin-Geraete auf {label}:\n" + "\n".join(out)
if name == "jellyfin_play":
item_id = (arguments.get("item_id") or "").strip()
device = (arguments.get("device") or "").strip()
if not item_id or not device:
return "FEHLER: item_id und device sind Pflicht."
sess, err = self._jellyfin_find_session(server, device)
if err:
return f"FEHLER: {err}"
play_params = {"itemIds": item_id, "playCommand": "PlayNow"}
note = ""
if arguments.get("resume") is not False:
# Fortsetzen: gespeicherte Position des Titels mitgeben.
info, _e = self._jellyfin_call(server, "/Items",
params={"ids": item_id, "Fields": "UserData"})
items = (info or {}).get("Items") or []
ticks = 0
if items:
ticks = ((items[0].get("UserData") or {}).get("PlaybackPositionTicks") or 0)
if ticks > 0:
play_params["startPositionTicks"] = ticks
note = f" — fortgesetzt bei {int(ticks / 10_000_000 // 60)} min"
_, err = self._jellyfin_call(
server, f"/Sessions/{sess['Id']}/Playing", method="POST",
params=play_params)
if err:
return f"FEHLER: {err}"
# Nachsehen, ob wirklich etwas laeuft. Eine eingeschlafene Sitzung
# nimmt den Befehl wortlos an, ohne dass der Film startet — genau
# der Fall "beim zweiten Mal ging es nicht".
if self._jellyfin_playing(server, sess, item_id):
# Ausdruecklich vermerken, dass NICHTS repariert werden musste —
# sonst erzaehlt das Modell gern die Panne aus einem frueheren
# Turn nach ("Session war eingeschlafen"), obwohl alles glatt lief.
return (f"OK — Wiedergabe auf '{sess.get('DeviceName')}' laeuft"
f"{note} (Server {label}). Sitzung war aktiv, kein Neustart "
f"noetig — erwaehne also KEINEN Neustart und KEINE "
f"eingeschlafene Sitzung.")
# Sitzung war tot. Wenn wir den zugehoerigen Agenten kennen:
# Jellyfin dort neu starten, frische Sitzung holen, einmal
# nachlegen. Sonst ehrlich sagen, was zu tun ist.
host = (arguments.get("host") or "").strip()
if not host:
return ("FEHLER: Der Befehl wurde angenommen, aber es laeuft nichts — "
"die Jellyfin-Sitzung auf dem Geraet war eingeschlafen. "
"Starte die Jellyfin-App neu (host_app_launch mit "
"query='Jellyfin') und versuche es erneut. Tipp: Gib "
"jellyfin_play den Parameter 'host' mit (Name des "
"Host-Agenten), dann mache ich das automatisch.")
self._dispatch_host("host_app_launch", {"host": host, "query": "Jellyfin"})
time.sleep(6) # App braucht einen Moment, bis sie sich anmeldet
sess2, err2 = self._jellyfin_find_session(server, device, fresh_sec=120)
if err2:
return (f"FEHLER: Jellyfin auf '{host}' neu gestartet, aber es meldet "
f"sich keine Sitzung: {err2}")
self._jellyfin_call(server, f"/Sessions/{sess2['Id']}/Playing",
method="POST", params=play_params)
if self._jellyfin_playing(server, sess2, item_id):
return (f"OK — Wiedergabe laeuft auf '{sess2.get('DeviceName')}'"
f"{note}. (Die Sitzung war eingeschlafen, ich habe Jellyfin "
f"auf '{host}' neu gestartet.)")
return (f"FEHLER: Auch nach dem Neustart von Jellyfin auf '{host}' startet "
f"die Wiedergabe nicht. Laeuft die App auf dem Bildschirm? "
f"Ggf. dort einmal anmelden.")
if name == "jellyfin_control":
device = (arguments.get("device") or "").strip()
cmd = (arguments.get("command") or "").strip()
allowed = {"pause", "unpause", "stop", "nexttrack", "previoustrack",
"playpause", "seek"}
if cmd.lower() not in allowed:
return f"FEHLER: command '{cmd}' unbekannt ({', '.join(sorted(allowed))})."
sess, err = self._jellyfin_find_session(server, device)
if err:
return f"FEHLER: {err}"
_, err = self._jellyfin_call(
server, f"/Sessions/{sess['Id']}/Playing/{cmd}", method="POST")
if err:
return f"FEHLER: {err}"
return f"OK — '{cmd}' an '{sess.get('DeviceName')}' gesendet."
return f"FEHLER: unbekanntes Jellyfin-Tool {name}"
except Exception as exc:
return f"FEHLER: Jellyfin-Aufruf fehlgeschlagen: {exc}"
def _dispatch_host(self, name: str, arguments: dict) -> str:
"""Wrapper um _dispatch_host_inner: fuehrt die Host-Aktion aus und schreibt
danach einen Historie-Eintrag an die Bridge (fuer die Diagnostic-Ansicht
@@ -2864,6 +3239,9 @@ class Agent:
"host_ui_swipe": ("ui_swipe", ["x1", "y1", "x2", "y2", "duration_ms"]),
"host_ui_key": ("ui_key", ["key"]),
"host_app_launch": ("app_launch", ["package", "query", "command"]),
"host_notify": ("notify", ["text", "title", "seconds", "mode", "focus"]),
"host_speak": ("speak", ["text", "focus"]),
"host_volume": ("volume", ["percent"]),
}
if name in _UI_ACTIONS:
action, keys = _UI_ACTIONS[name]
@@ -3671,10 +4049,15 @@ class Agent:
return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat."
if name in ("satellite_list", "satellite_devices", "satellite_command"):
return self._dispatch_satellite(name, arguments)
if name in ("jellyfin_search", "jellyfin_devices", "jellyfin_play",
"jellyfin_control"):
return self._dispatch_jellyfin(name, arguments)
if name in ("host_list", "host_exec", "host_read", "host_write",
"host_info", "host_screenshot", "host_ui_dump",
"host_ui_tap", "host_ui_text", "host_ui_swipe",
"host_ui_key", "host_app_launch"):
"host_ui_key", "host_app_launch",
"host_notify", "host_speak", "host_volume"):
return self._dispatch_host(name, arguments)
if name == "vm_register":
pid = (project_id or "").strip()
+43 -13
View File
@@ -3407,11 +3407,18 @@ class ARIABridge:
# weiter auseinander (man hoert den Wechsel erst).
_norm_txt = " ".join(text.lower().split())
_now_t = asyncio.get_event_loop().time()
# Fenster bewusst KURZ (1.5s): es soll nur den Zwilling abfangen, den
# zwei STT-Worker fuer DIESELBE Aufnahme liefern. Mit den frueheren 5s
# fielen echte Wiederholungen weg — also genau der Fall "ARIA hat mich
# nicht gehoert, ich sag's nochmal" und kurze Kommandos wie "lauter",
# "nächstes", "stopp". Die eigentliche Absicherung ist ohnehin die
# Idempotenz ueber audioRequestId weiter oben.
_DUPE_WINDOW_SEC = 1.5
if (_norm_txt
and _norm_txt == getattr(self, "_last_endpoint_norm", "")
and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < 5.0):
logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <5s): %r",
text[:60])
and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < _DUPE_WINDOW_SEC):
logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <%.1fs): %r",
_DUPE_WINDOW_SEC, text[:60])
return
self._last_endpoint_norm = _norm_txt
self._last_endpoint_at = _now_t
@@ -3543,6 +3550,10 @@ class ARIABridge:
elif msg_type == "sat_hello":
sid = (payload.get("id") or "").strip()
if sid:
# sat_hello kommt alle ~25s erneut. Nur Neuanmeldungen und echte
# Aenderungen loggen — sonst ersaeuft das Bridge-Log in
# "Satellit online", und die interessanten Zeilen sind weg.
prev = self._satellites.get(sid)
self._satellites[sid] = {
"id": sid,
"location": payload.get("location") or sid,
@@ -3550,9 +3561,12 @@ class ARIABridge:
"control": bool(payload.get("control")),
"last_seen": time.time(),
}
logger.info("[sat] Satellit online: %s (%s) caps=%s control=%s",
sid, self._satellites[sid]["location"],
self._satellites[sid]["caps"], self._satellites[sid]["control"])
cur = self._satellites[sid]
if prev is None or any(prev.get(k) != cur.get(k)
for k in ("location", "caps", "control")):
logger.info("[sat] Satellit %s: %s (%s) caps=%s control=%s",
"online" if prev is None else "aktualisiert",
sid, cur["location"], cur["caps"], cur["control"])
return
elif msg_type == "host_hello":
@@ -3618,9 +3632,16 @@ class ARIABridge:
"busy": bool(prev.get("busy", False)),
"last_seen": time.time(),
}
logger.info("[worker] online: %s (service=%s node=%s gpus=%s model=%s)",
iid, self._workers[iid]["service"], self._workers[iid]["node"],
self._workers[iid]["gpus"] or "?", self._workers[iid]["model"] or "?")
# Wie bei sat_hello: worker_hello wiederholt sich im Takt von
# ~30s. Nur neue Boxen und echte Aenderungen (anderes Modell,
# andere GPUs) ins Log — der Rest ist Rauschen.
cur = self._workers[iid]
if not prev or any(prev.get(k) != cur.get(k)
for k in ("service", "node", "gpus", "model", "models")):
logger.info("[worker] %s: %s (service=%s node=%s gpus=%s model=%s)",
"online" if not prev else "aktualisiert",
iid, cur["service"], cur["node"],
cur["gpus"] or "?", cur["model"] or "?")
return
elif msg_type == "stt_lease_request":
@@ -3777,8 +3798,12 @@ class ARIABridge:
r")\s*[.!?]?\s*$",
re.IGNORECASE,
)
# Trenner bewusst nur ':' und '-' — NICHT das Komma. Whisper setzt gern
# Kommata, und damit matchten normale Saetze wie "In Ordnung, mach mal ..."
# als Projekt-Prefix: die Nachricht wanderte still in ein Projekt und war im
# Hauptchat unsichtbar ("wird gar nicht verarbeitet").
_VOICE_META_PROJECT_PREFIX = re.compile(
r"^\s*(?:aria[,.]?\s+)?(?:f(?:ü|ue)r|ins?)\s+([\w\-äöüßÄÖÜ]{2,40})[:\-,]\s*(.+?)\s*$",
r"^\s*(?:aria[,.]?\s+)?(?:f(?:ü|ue)r|ins?)\s+([\w\-äöüßÄÖÜ]{2,40})\s*[:\-]\s*(.+?)\s*$",
re.IGNORECASE | re.DOTALL,
)
@@ -3833,7 +3858,10 @@ class ARIABridge:
s = SequenceMatcher(None, q, pname).ratio()
if s > best_score:
best, best_score = p, s
if best and best_score >= 0.6:
# Schwelle bewusst hoch (0.82): bei 0.6 reichte schon eine vage
# Aehnlichkeit, und die Nachricht verschwand still in einem
# fremden Projekt. Lieber im Hauptchat lassen als falsch routen.
if best and best_score >= 0.82:
pid = best["id"]
self._voice_sticky_project_id = pid
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
@@ -3845,8 +3873,10 @@ class ARIABridge:
# Kein Match → als normale Nachricht weiter (Sticky wenn aktiv)
# 3) Kein Meta / Prefix → Sticky oder Default
if self._voice_sticky_project_id and now < self._voice_sticky_expires_at:
# Sticky refreshen
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
# Sticky NICHT verlaengern: frueher hat jede Folgenachricht die Frist
# erneuert — ein einziger Fehl-Match und danach landete dauerhaft
# alles in diesem Projekt. Jetzt laeuft er nach der urspruenglichen
# Zeit aus und der Hauptchat uebernimmt wieder.
return (True, stripped, self._voice_sticky_project_id, "sticky")
# Sticky abgelaufen — zurücksetzen
self._voice_sticky_project_id = ""
+226 -6
View File
@@ -961,6 +961,33 @@
</div>
</div>
<!-- Jellyfin (Medien) -->
<div class="settings-section">
<h2>Jellyfin (Filme &amp; Serien)</h2>
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
Damit steuert ARIA die Wiedergabe direkt über die Jellyfin-Schnittstelle statt
sich durch die Fire-TV-Oberfläche zu klicken: Film suchen → Abspielgerät finden →
starten. Mehrere Server möglich (eigener, vom Kumpel …) — einer davon ist der
Standard, wenn ARIA keinen nennt.
<br>API-Key: Jellyfin → <em>Dashboard → API-Schlüssel → +</em>.
<strong>ARIAs Brain muss den Server erreichen können</strong> (URL aus Sicht des Servers,
nicht des Browsers).
</div>
<div class="card" style="max-width:780px;">
<div id="jellyfin-list" style="display:flex;flex-direction:column;gap:10px;">
<div style="color:#555570;font-style:italic;">Lade …</div>
</div>
<div style="margin-top:14px;display:flex;gap:8px;align-items:center;">
<button class="btn secondary" onclick="addJellyfinServer()" style="padding:6px 14px;font-size:12px;">+ Server</button>
<button class="btn" onclick="saveJellyfinConfig()" style="padding:6px 14px;font-size:12px;">Speichern</button>
<span id="jellyfin-status" style="color:#8888AA;font-size:11px;"></span>
</div>
<div style="color:#666680;font-size:10px;margin-top:8px;">
API-Keys werden mit Datei-Rechten 0600 gespeichert (/shared/config/jellyfin.json).
</div>
</div>
</div>
<!-- FLUX Bildgenerierung -->
<div class="settings-section">
<h2>FLUX Bildgenerierung</h2>
@@ -2251,6 +2278,7 @@
if (msg.type === 'host_update') { hosts = msg.hosts || []; renderHosts(); return; }
if (msg.type === 'rooms_info') { rooms = (msg.payload && msg.payload.rooms) || []; renderRooms(); return; }
if (msg.type === 'sat_result') { onSatResult(msg.payload || {}); return; }
if (msg.type === 'llm_openai_config_result') { onOpenAiConfigResult(msg.payload || {}); return; }
if (msg.type === 'agent_history_list') {
if (agentHistoryHost && msg.payload && msg.payload.host === agentHistoryHost) {
renderAgentHistory(msg.payload.events || []);
@@ -4537,6 +4565,7 @@
loadRuntimeConfig();
loadOnboardingQR();
loadOAuthServices();
loadJellyfinConfig();
refreshVoiceIdStatus();
} else if (tab === 'brain') {
loadBrainStatus();
@@ -4593,7 +4622,9 @@
'<span style="color:#8888AA;">' + escapeHtml(modelText) + '</span>' +
(w.gpus ? '<span style="color:#8888AA;">GPU ' + escapeHtml(w.gpus) + '</span>' : '') +
'<span style="margin-left:auto;color:' + dot + ';">' + stat + '</span>' +
'</div>';
'</div>' +
// Nur LLM-Boxen: OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.)
(w.service === 'llm' ? renderOpenAiEndpoint(w) : '');
}).join('');
// Auslastungs-Button pro Node — Ziel bevorzugt die llm-Instanz (sieht alle
// GPUs + Tokens), sonst irgendein online Worker des Node.
@@ -4606,6 +4637,62 @@
}).join('');
}
// ── OpenAI-Endpunkt einer LLM-Box (fuer OpenHands, Continue, ...) ──
// Der PORT kommt immer aus der .env der Box (Docker-Port-Mapping, steht beim
// Container-Start fest) und wird hier nur angezeigt. Der API-Key ist hier
// setzbar — ausser er steht in der .env, dann hat die Vorrang und das Feld
// ist schreibgeschuetzt.
function renderOpenAiEndpoint(w) {
const id = 'oa-' + (w.instanceId || '').replace(/[^a-zA-Z0-9]/g, '-');
const port = w.openaiPort || '8081';
const bind = w.openaiBind || '127.0.0.1';
const lanOpen = bind === '0.0.0.0';
const locked = !!w.openaiEnvLocked;
const keyState = w.openaiKeySet
? '<span style="color:#34C759;">Key gesetzt</span>'
: '<span style="color:#FF9500;">kein Key — Endpunkt offen</span>';
const warn = (lanOpen && !w.openaiKeySet)
? '<div style="color:#FF6E6E;margin-top:4px;">⚠ Im LAN erreichbar OHNE Key — jeder im Netz kann das Modell nutzen.</div>'
: '';
const field = locked
? '<input type="text" value="aus .env übernommen" disabled ' +
'style="flex:1;background:#15151F;color:#666;border:1px solid #1E1E2E;border-radius:4px;padding:4px 8px;font-size:11px;">' +
'<span style="color:#8888AA;font-size:10px;">🔒 .env hat Vorrang</span>'
: '<input type="password" id="' + id + '" placeholder="API-Key setzen (leer = entfernen)" ' +
'style="flex:1;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;border-radius:4px;padding:4px 8px;font-size:11px;">' +
'<button class="btn secondary" onclick="saveOpenAiKey(\'' + escapeHtml(w.instanceId) + '\',\'' + id + '\')" ' +
'style="padding:2px 8px;font-size:10px;">Speichern</button>';
return '<div style="margin:2px 0 8px 18px;padding:6px 8px;background:#0D0D1A;border-radius:6px;font-size:11px;">' +
'<div style="color:#8888AA;">OpenAI-Endpunkt (OpenHands &amp; Co.): ' +
'<code style="color:#FFD60A;">http://' + escapeHtml(bind === '0.0.0.0' ? '&lt;box-ip&gt;' : '127.0.0.1') +
':' + escapeHtml(port) + '/v1</code> · ' + keyState + '</div>' +
'<div style="display:flex;gap:6px;align-items:center;margin-top:5px;">' + field + '</div>' +
'<div id="' + id + '-msg" style="color:#8888AA;margin-top:4px;"></div>' +
'<div style="color:#666;margin-top:3px;">Port und Erreichbarkeit kommen aus der <code>.env</code> der Box ' +
'(<code>LLM_OPENAI_PORT</code>, <code>LLM_OPENAI_BIND</code>) — sie stehen beim Container-Start fest.</div>' +
warn + '</div>';
}
function saveOpenAiKey(instanceId, fieldId) {
const el = document.getElementById(fieldId);
const msg = document.getElementById(fieldId + '-msg');
if (!el) return;
openAiReqId = 'oa_' + Date.now() + '_' + Math.floor(Math.random() * 100000);
if (msg) msg.textContent = 'speichere …';
send({ action: 'llm_openai_config', instanceId, apiKey: el.value, requestId: openAiReqId });
}
let openAiReqId = null;
function onOpenAiConfigResult(p) {
if (!p || (openAiReqId && p.requestId && p.requestId !== openAiReqId)) return;
const id = 'oa-' + String(p.instanceId || '').replace(/[^a-zA-Z0-9]/g, '-');
const msg = document.getElementById(id + '-msg');
if (!msg) return;
msg.innerHTML = p.ok
? '<span style="color:#34C759;">✓ ' + escapeHtml(p.message || 'gespeichert') + '</span>'
: '<span style="color:#FF6E6E;">✗ ' + escapeHtml(p.error || 'fehlgeschlagen') + '</span>';
}
// Sprung von den Einstellungen zur Compute-Flotte (dort sitzt der
// Auslastung-Button pro Node → Live nvidia-smi + Graphen).
function gotoComputeFleet() {
@@ -4743,6 +4830,120 @@
send({ action: 'host_forget', hostId });
}
// ── Jellyfin-Server (Medien-Integration) ─────────────────────────
let jellyfinServers = [];
async function loadJellyfinConfig() {
try {
const r = await fetch('/api/jellyfin-config');
const d = await r.json();
jellyfinServers = d.servers || [];
} catch (e) {
jellyfinServers = [];
}
renderJellyfin();
}
function addJellyfinServer() {
jellyfinServers.push({
id: 'jf_' + Date.now().toString(36),
name: '', url: '', apiKey: '',
isDefault: jellyfinServers.length === 0,
});
renderJellyfin();
}
function removeJellyfinServer(i) {
const s = jellyfinServers[i];
if (!confirm('Server "' + (s.name || s.url || 'neu') + '" entfernen?')) return;
jellyfinServers.splice(i, 1);
if (jellyfinServers.length && !jellyfinServers.some(x => x.isDefault)) {
jellyfinServers[0].isDefault = true;
}
renderJellyfin();
}
function setJellyfinDefault(i) {
jellyfinServers.forEach((s, k) => { s.isDefault = (k === i); });
renderJellyfin();
}
function updateJellyfinField(i, field, value) {
if (jellyfinServers[i]) jellyfinServers[i][field] = value;
}
function renderJellyfin() {
const box = document.getElementById('jellyfin-list');
if (!box) return;
if (!jellyfinServers.length) {
box.innerHTML = '<div style="color:#555570;font-style:italic;">' +
'Noch kein Server. Mit „+ Server" einen anlegen.</div>';
return;
}
box.innerHTML = jellyfinServers.map((s, i) =>
'<div style="border:1px solid #1E1E2E;border-radius:8px;padding:10px;">' +
'<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">' +
'<input type="radio" name="jf-default" ' + (s.isDefault ? 'checked' : '') +
' onchange="setJellyfinDefault(' + i + ')" title="Standard-Server">' +
'<span style="color:#8888AA;font-size:11px;">Standard</span>' +
'<input type="text" value="' + escapeHtml(s.name || '') + '" placeholder="Name (z.B. Zuhause, Kumpel Tim)"' +
' oninput="updateJellyfinField(' + i + ',\'name\',this.value)"' +
' style="flex:1;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:5px 9px;font-size:12px;">' +
'<button class="btn secondary" onclick="testJellyfinServer(' + i + ')" style="padding:4px 10px;font-size:11px;">Test</button>' +
'<button class="btn secondary" onclick="removeJellyfinServer(' + i + ')" style="padding:4px 9px;font-size:11px;">✕</button>' +
'</div>' +
'<div style="display:flex;gap:8px;">' +
'<input type="text" value="' + escapeHtml(s.url || '') + '" placeholder="http://192.168.0.10:8096"' +
' oninput="updateJellyfinField(' + i + ',\'url\',this.value)"' +
' style="flex:2;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:5px 9px;font-size:12px;">' +
'<input type="password" value="' + escapeHtml(s.apiKey || '') + '" placeholder="API-Key"' +
' oninput="updateJellyfinField(' + i + ',\'apiKey\',this.value)"' +
' style="flex:1;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:5px 9px;font-size:12px;">' +
'</div>' +
'<div id="jf-test-' + i + '" style="font-size:11px;color:#8888AA;margin-top:6px;"></div>' +
'</div>'
).join('');
}
async function testJellyfinServer(i) {
const s = jellyfinServers[i];
const out = document.getElementById('jf-test-' + i);
if (!s || !out) return;
out.textContent = 'prüfe …';
try {
const r = await fetch('/api/jellyfin-test', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: s.url, apiKey: s.apiKey }),
});
const d = await r.json();
out.innerHTML = d.ok
? '<span style="color:#34C759;">✓ erreichbar — ' + escapeHtml(d.name || '') +
' (Version ' + escapeHtml(d.version || '?') + ')</span>'
: '<span style="color:#FF6E6E;">✗ ' + escapeHtml(d.error || 'nicht erreichbar') + '</span>';
} catch (e) {
out.innerHTML = '<span style="color:#FF6E6E;">✗ ' + escapeHtml(e.message) + '</span>';
}
}
async function saveJellyfinConfig() {
const el = document.getElementById('jellyfin-status');
el.textContent = 'speichere …';
try {
const r = await fetch('/api/jellyfin-config', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ servers: jellyfinServers }),
});
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'Fehler');
jellyfinServers = d.config.servers || [];
renderJellyfin();
el.innerHTML = '<span style="color:#34C759;">✓ gespeichert (' + jellyfinServers.length + ')</span>';
setTimeout(() => { el.textContent = ''; }, 4000);
} catch (e) {
el.innerHTML = '<span style="color:#FF6E6E;">✗ ' + escapeHtml(e.message) + '</span>';
}
}
// ── APK-Rollout auf Android/Fire TV (per Satellit + ADB) ─────────
let apkTarget = null; // {satId, ip, name}
let apkRequestId = null;
@@ -4753,11 +4954,20 @@
document.getElementById('apk-status').textContent = '';
const log = document.getElementById('apk-log');
log.style.display = 'none'; log.textContent = '';
// Namensfeld vorbelegen — aber nicht mit Seriennummern/IP-Platzhaltern,
// die die Netz-Erkennung bei Fire TV oft liefert (z.B. 0d94400fa5b63301).
// Namensfeld vorbelegen. Reihenfolge:
// 1. Name, den der Agent auf DIESEM Gerät schon trägt (per IP zugeordnet)
// 2. zuletzt hier eingegebener Name (im Browser gemerkt)
// 3. der Name aus der Netz-Erkennung — aber nur, wenn er menschenlesbar ist
// (Fire TV meldet oft Seriennummern wie F8FCE10B4E6C@AS-AFTKA[AirPlay])
const known = (hosts || []).find(h => h.ip && h.ip === ip);
const remembered = (function () {
try { return localStorage.getItem('apkName:' + satId + ':' + ip) || ''; }
catch (e) { return ''; }
})();
const looksTechnical = !name || /^[0-9a-f]{8,}$/i.test(name) ||
/^\d{1,3}(\.\d{1,3}){3}$/.test(name);
document.getElementById('apk-name').value = looksTechnical ? '' : name;
/^\d{1,3}(\.\d{1,3}){3}$/.test(name) || /[@\[\]:]/.test(name);
document.getElementById('apk-name').value =
(known && known.name) || remembered || (looksTechnical ? '' : name);
document.getElementById('apk-modal').classList.add('open');
}
function closeApkDeploy() {
@@ -4785,6 +4995,7 @@
// Am PC getippter Name gewinnt; sonst der aus der Netz-Erkennung (der
// ist bei Fire TV oft nur die Seriennummer).
params.name = document.getElementById('apk-name').value.trim() || apkTarget.name;
rememberApkName(params.name);
}
send({ action: 'sat_command', satellite: apkTarget.satId, cmd: 'adb.install',
requestId: apkRequestId, params });
@@ -4803,7 +5014,16 @@
send({ action: 'sat_command', satellite: apkTarget.satId, cmd: 'adb.provision',
requestId: apkRequestId,
params: { ip: apkTarget.ip, provision: true,
name: document.getElementById('apk-name').value.trim() || apkTarget.name } });
name: rememberApkName(document.getElementById('apk-name').value.trim()
|| apkTarget.name) } });
}
/** Merkt sich den vergebenen Namen pro Gerät (Vorschlag beim naechsten Mal). */
function rememberApkName(n) {
try {
if (apkTarget && n) localStorage.setItem('apkName:' + apkTarget.satId + ':' + apkTarget.ip, n);
} catch (e) { /* privater Modus o.ae. */ }
return n;
}
/** Prüft, ob der Agent auf dem Gerät installiert ist und läuft. */
+137 -2
View File
@@ -328,6 +328,87 @@ function writeLocalLlmConfig(patch) {
return cur;
}
// ── Jellyfin-Server (Medien-Integration) ─────────────────────
// /shared/config/jellyfin.json — mehrere Server moeglich (eigener + z.B. vom
// Kumpel). ARIAs Brain liest dieselbe Datei und spricht die HTTP-API direkt an:
// Film suchen -> Sitzung des Abspielgeraets finden -> abspielen. Das ist
// deutlich zuverlaessiger, als sich durch die Fire-TV-Oberflaeche zu klicken.
const JELLYFIN_CONFIG_FILE = "/shared/config/jellyfin.json";
function readJellyfinConfig() {
try {
const p = JSON.parse(fs.readFileSync(JELLYFIN_CONFIG_FILE, "utf-8"));
const servers = Array.isArray(p.servers) ? p.servers : [];
return {
servers: servers.map(s => ({
id: String(s.id || ""),
name: String(s.name || ""),
url: String(s.url || ""),
apiKey: String(s.apiKey || ""),
isDefault: !!s.isDefault,
})).filter(s => s.id && s.url),
};
} catch {
return { servers: [] };
}
}
function writeJellyfinConfig(cfg) {
const servers = (Array.isArray(cfg.servers) ? cfg.servers : [])
.map(s => ({
id: String(s.id || "").trim() || Math.random().toString(36).slice(2, 10),
name: String(s.name || "").trim(),
url: String(s.url || "").trim().replace(/\/+$/, ""), // ohne Slash am Ende
apiKey: String(s.apiKey || "").trim(),
isDefault: !!s.isDefault,
}))
.filter(s => s.url);
// Genau einen Standard-Server markieren (der erste, falls keiner gesetzt ist).
if (servers.length && !servers.some(s => s.isDefault)) servers[0].isDefault = true;
let seen = false;
for (const s of servers) {
if (s.isDefault && seen) s.isDefault = false;
if (s.isDefault) seen = true;
}
fs.mkdirSync("/shared/config", { recursive: true });
const tmp = JELLYFIN_CONFIG_FILE + ".tmp";
fs.writeFileSync(tmp, JSON.stringify({ servers }, null, 2), { mode: 0o600 });
fs.renameSync(tmp, JELLYFIN_CONFIG_FILE);
try { fs.chmodSync(JELLYFIN_CONFIG_FILE, 0o600); } catch (_) {} // API-Keys!
return { servers };
}
/** Erreichbarkeit + Version eines Jellyfin-Servers pruefen. */
function testJellyfin(url, apiKey) {
return new Promise((resolve) => {
let u;
try { u = new URL(url.replace(/\/+$/, "") + "/System/Info"); }
catch (e) { return resolve({ ok: false, error: "Ungültige URL" }); }
const mod = u.protocol === "https:" ? require("https") : require("http");
const req = mod.request(u, {
method: "GET",
timeout: 8000,
headers: { "Authorization": `MediaBrowser Token="${apiKey}"` },
}, (res) => {
let body = "";
res.on("data", c => { body += c; if (body.length > 65536) req.destroy(); });
res.on("end", () => {
if (res.statusCode === 401 || res.statusCode === 403) {
return resolve({ ok: false, error: "API-Key abgelehnt (401/403)" });
}
if (res.statusCode !== 200) {
return resolve({ ok: false, error: `HTTP ${res.statusCode}` });
}
try {
const j = JSON.parse(body);
resolve({ ok: true, name: j.ServerName || "", version: j.Version || "" });
} catch (e) { resolve({ ok: false, error: "Antwort nicht lesbar" }); }
});
});
req.on("timeout", () => { req.destroy(); resolve({ ok: false, error: "Zeitüberschreitung" }); });
req.on("error", (e) => resolve({ ok: false, error: e.message }));
req.end();
});
}
// ── Lokale Modell-Liste (Diagnostic-Dropdown) ────────────────
// /shared/config/local_models.json — kuratierte Liste; muss zu den KEYS in
// xtts/llama-swap/config.yaml passen. Wird bei Bedarf mit Defaults seeded.
@@ -557,7 +638,7 @@ function hostList() {
const now = Date.now();
return Array.from(hosts.values()).map(h => ({
hostId: h.hostId, name: h.name, os: h.os, caps: h.caps, control: h.control,
online: (now - (h.last_seen || 0)) < 300000,
ip: h.ip || "", online: (now - (h.last_seen || 0)) < 300000,
}));
}
@@ -575,6 +656,8 @@ function workerList() {
return Array.from(workers.values()).map(w => ({
instanceId: w.instanceId, service: w.service, node: w.node,
gpus: w.gpus, model: w.model, models: w.models || [], busy: !!w.busy,
openaiPort: w.openaiPort || "", openaiBind: w.openaiBind || "",
openaiKeySet: !!w.openaiKeySet, openaiEnvLocked: !!w.openaiEnvLocked,
online: (now - (w.last_seen || 0)) < WORKER_OFFLINE_MS,
}));
}
@@ -1087,7 +1170,8 @@ function connectRVS(forcePlain) {
const wasKnown = hosts.has(p.hostId);
hosts.set(p.hostId, {
hostId: p.hostId, name: p.name || p.hostId, os: p.os || "",
caps: p.caps || [], control: !!p.control, last_seen: Date.now(),
caps: p.caps || [], control: !!p.control, ip: p.ip || "",
last_seen: Date.now(),
});
if (!wasKnown) broadcastHosts();
else hosts.get(p.hostId).last_seen = Date.now();
@@ -1098,6 +1182,8 @@ function connectRVS(forcePlain) {
} else if (msg.type === "rooms_info") {
// Antwort des RVS auf rooms_query → an den Browser (Raum-Diagnose).
broadcast({ type: "rooms_info", payload: msg.payload || {} });
} else if (msg.type === "llm_openai_config_result") {
broadcast({ type: "llm_openai_config_result", payload: msg.payload || {} });
} else if (msg.type === "sat_result") {
// Ergebnis einer Satelliten-Aktion → an den Browser (z.B. APK-Rollout).
broadcast({ type: "sat_result", payload: msg.payload || {} });
@@ -1127,6 +1213,11 @@ function connectRVS(forcePlain) {
instanceId: p.instanceId, service: p.service || "",
node: p.node || "", gpus: p.gpus || "", model: p.model || "",
models: Array.isArray(p.models) ? p.models : (p.model ? [p.model] : []),
// OpenAI-Endpunkt (nur llm-Worker): Port/Bind kommen aus der .env der
// Box, der Key wird NIE uebertragen — nur ob einer gesetzt ist und ob
// die .env ihn vorgibt (dann ist das Feld hier schreibgeschuetzt).
openaiPort: p.openaiPort || "", openaiBind: p.openaiBind || "",
openaiKeySet: !!p.openaiKeySet, openaiEnvLocked: !!p.openaiEnvLocked,
busy: !!prev.busy, last_seen: Date.now(),
});
broadcastWorkers();
@@ -2025,6 +2116,39 @@ const server = http.createServer((req, res) => {
}
});
return;
} else if (req.url === "/api/jellyfin-config" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(readJellyfinConfig()));
} else if (req.url === "/api/jellyfin-config" && req.method === "POST") {
let body = "";
req.on("data", chunk => { body += chunk; if (body.length > 65536) req.destroy(); });
req.on("end", () => {
try {
const cfg = writeJellyfinConfig(JSON.parse(body));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, config: cfg }));
log("info", "server", `Jellyfin-Config: ${cfg.servers.length} Server gespeichert`);
} catch (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
}
});
return;
} else if (req.url === "/api/jellyfin-test" && req.method === "POST") {
let body = "";
req.on("data", chunk => { body += chunk; if (body.length > 8192) req.destroy(); });
req.on("end", async () => {
try {
const { url, apiKey } = JSON.parse(body);
const r = await testJellyfin(String(url || ""), String(apiKey || ""));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(r));
} catch (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
}
});
return;
} else if (req.url === "/api/onboarding") {
// RVS-Credentials fuer QR-Code App-Onboarding
res.writeHead(200, { "Content-Type": "application/json" });
@@ -2849,6 +2973,17 @@ wss.on("connection", (ws) => {
},
timestamp: Date.now(),
});
} else if (msg.action === "llm_openai_config") {
// Browser setzt den API-Key des OpenAI-Endpunkts einer Box.
sendToRVS_raw({
type: "llm_openai_config",
payload: {
instanceId: String(msg.instanceId || ""),
apiKey: String(msg.apiKey || ""),
requestId: String(msg.requestId || ""),
},
timestamp: Date.now(),
});
} else if (msg.action === "agent_history_query") {
// Browser will die gespeicherte Historie eines Agenten — die Bridge
// antwortet mit agent_history_list (auf derselben rvsWs).
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId 'de.hackersoft.ariaagent'
minSdk 26
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
versionCode 101
versionName '0.0.1.1'
versionCode 102
versionName '0.0.1.2'
}
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
@@ -49,6 +49,17 @@
</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"
@@ -14,6 +14,10 @@ data class AgentConfig(
/** 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"
@@ -28,6 +32,7 @@ data class AgentConfig(
name = p.getString("name", "") ?: "",
controlEnabled = p.getBoolean("control", false),
replyMode = p.getString("replyMode", "overlay") ?: "overlay",
captureEnabled = p.getBoolean("capture", false),
)
}
}
@@ -41,6 +46,7 @@ data class AgentConfig(
putString("name", name)
putBoolean("control", controlEnabled)
putString("replyMode", replyMode)
putBoolean("capture", captureEnabled)
apply()
}
}
@@ -93,6 +93,11 @@ class AgentService : Service() {
}
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}")
@@ -123,6 +128,19 @@ class AgentService : Service() {
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
}
@@ -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,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)
}
}
@@ -246,6 +246,9 @@ class MainActivity : AppCompatActivity() {
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)
@@ -126,11 +126,24 @@ class RvsClient(
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 {
@@ -318,7 +331,7 @@ class RvsClient(
if (mode == "speech" || mode == "both") {
val r = speakOut(p, text)
r.exceptionOrNull()?.let { problems += "Sprachausgabe: ${it.message}" }
?: run { done += "gesprochen" }
?: 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("; ")})")
@@ -329,19 +342,33 @@ class RvsClient(
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", "gesprochen")) },
{ JSONObject().put("ok", true).put("result", JSONObject().put("message", how)) },
{ err("Sprachausgabe fehlgeschlagen: ${it.message}") },
)
}
/** Audio bevorzugt (ARIAs echte Stimme), sonst Geraete-TTS. */
/**
* 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")
return if (audio.isNotBlank())
Speaker.play(appCtx, audio, p.optString("audio_format", "wav"))
else Speaker.speak(appCtx, text)
val focus = p.optString("focus").ifBlank { "duck" }
if (audio.isNotBlank())
return Speaker.play(appCtx, audio, p.optString("audio_format", "wav"), focus)
if (p.optString("engine") == "device")
return Speaker.speak(appCtx, text, focus)
return runCatching { voice.requestTts(text); Unit }
}
private fun doVolume(p: JSONObject): JSONObject {
@@ -24,28 +24,38 @@ object Speaker {
private var ttsReady = false
private var player: MediaPlayer? = null
/** Spielt fertiges Audio (Base64, z.B. WAV aus dem ARIA-TTS) ueber die Lautsprecher. */
fun play(ctx: Context, audioB64: String, format: String = "wav"): Result<Unit> = runCatching {
/** 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_MEDIA)
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
setDataSource(f.absolutePath)
setOnCompletionListener { mp -> try { mp.release() } catch (_: Exception) {}; player = null }
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): Result<Unit> = runCatching {
fun speak(ctx: Context, text: String, focus: String = "duck"): Result<Unit> = runCatching {
if (tts == null) {
tts = TextToSpeech(ctx.applicationContext) { status ->
ttsReady = status == TextToSpeech.SUCCESS
@@ -55,8 +65,26 @@ object Speaker {
var waited = 0
while (!ttsReady && waited < 3000) { Thread.sleep(100); waited += 100 }
}
if (!ttsReady) throw IllegalStateException("Keine Sprachausgabe verfuegbar (TTS-Engine fehlt)")
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "aria")
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
}
@@ -68,9 +96,10 @@ object Speaker {
am.setStreamVolume(AudioManager.STREAM_MUSIC, v, AudioManager.FLAG_SHOW_UI)
}
fun stop() {
fun stop(ctx: Context? = null) {
try { player?.stop(); player?.release() } catch (_: Exception) {}
player = null
try { tts?.stop() } catch (_: Exception) {}
ctx?.let { AudioFocusHelper.abandon(it) }
}
}
@@ -61,6 +61,10 @@ class VoiceSession(
/** 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 ────────────────────────────────────────
@@ -100,6 +104,9 @@ class VoiceSession(
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)
@@ -154,6 +161,9 @@ class VoiceSession(
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", "")
@@ -164,6 +174,32 @@ class VoiceSession(
.put("result", JSONObject().put("message", "Aufnahme beendet"))
}
/**
* Laesst ARIAs ECHTE Stimme etwas sagen: fordert das TTS-Audio ueber den
* bestehenden Server-Pfad an (tts_request -> Bridge -> F5/XTTS -> audio_pcm)
* und spielt es ab, sobald es hereinkommt.
*
* Besser als die geraeteeigene Sprachausgabe: Fire TV Sticks haben oft gar
* keine (deutsche) TTS-Engine — und es klingt nach ARIA, nicht nach Roboter.
*/
fun requestTts(text: String): String {
val mid = "spk_${System.currentTimeMillis()}_${Random.nextInt(100000, 999999)}"
expectedAudio.add(mid)
// Nicht unbegrenzt wachsen lassen, falls mal keine Antwort kommt.
if (expectedAudio.size > 20) {
synchronized(expectedAudio) {
expectedAudio.iterator().let { if (it.hasNext()) { it.next(); it.remove() } }
}
}
send("tts_request", JSONObject().apply {
put("text", text)
put("messageId", mid)
put("voice", "")
put("speed", 1.0)
})
return mid
}
/** Text direkt an ARIA schicken (ohne Mikrofon) — Antwort landet hier genauso. */
fun ask(text: String): JSONObject {
if (text.isBlank()) return JSONObject().put("ok", false).put("error", "leerer Text")
@@ -200,10 +236,15 @@ class VoiceSession(
true
}
"audio_pcm" -> {
// Nur abspielen, wenn es die Antwort auf UNSERE Frage ist und
// Sprachausgabe gewuenscht war (sonst redet auch das Handy mit).
// 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() && mid == myMessageId && wantSpeech()) feedAudio(p)
if (mid.isNotBlank() &&
(expectedAudio.contains(mid) || (mid == myMessageId && wantSpeech()))) {
feedAudio(p)
if (p.optBoolean("final", false)) expectedAudio.remove(mid)
}
true
}
else -> false
@@ -245,6 +286,8 @@ class VoiceSession(
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
}
@@ -253,6 +296,8 @@ class VoiceSession(
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()
@@ -276,5 +321,6 @@ class VoiceSession(
stopListening("shutdown")
try { track?.stop(); track?.release() } catch (_: Exception) {}
track = null
AudioFocusHelper.abandon(appCtx)
}
}
+1 -1
View File
@@ -162,7 +162,7 @@ OUT_MAX_CHARS_HARD = int(os.environ.get("OUT_MAX_CHARS_HARD", "200000") or "2000
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.1"
AGENT_VERSION = "0.0.1.2"
HEARTBEAT_SEC = 25
+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).
+118
View File
@@ -93,6 +93,15 @@ const ALLOWED_TYPES = new Set([
// ab, die Bridge antwortet mit agent_history_list. OHNE diese Typen verwirft
// der RVS sie still an der Allow-List und die Historie bleibt leer.
"agent_history", "agent_history_query", "agent_history_list",
// STT-Lease: die App fragt vor einer Aufnahme, WELCHE STT-Instanz sie nehmen
// soll (stt_lease_request -> stt_lease). Fehlten diese Typen hier, lief die
// Anfrage IMMER in den 250ms-Timeout: jede Aufnahme startete verspaetet (erste
// Silben weg) und ohne targetInstance nahmen ALLE STT-Worker dieselbe Session
// an -> doppelte stt_endpoint. chat_ack quittiert getippte Nachrichten; ohne
// ihn liefen sie in der App nach 3 Versuchen auf "fehlgeschlagen", obwohl sie
// laengst verarbeitet waren. stt_transcribe_* ist die Wake-Wort-Bestaetigung.
"stt_lease", "stt_lease_request", "stt_transcribe_blob", "stt_transcribe_result",
"chat_ack",
// Raum-Diagnose: Diagnostic fragt die aktuellen RVS-Raeume ab (rooms_query),
// RVS antwortet direkt mit rooms_info (Fingerprint + Laenge + Client-Zahl je
// Raum). Deckt Token-Prefix-Kollisionen auf (2 Raeume, gleicher 8-Zeichen-Log).
@@ -104,6 +113,9 @@ const ALLOWED_TYPES = new Set([
// OHNE diese Typen verwirft der RVS die Meldungen an der Allow-List (Z. 312),
// und die Box bleibt in der Flotte unsichtbar, obwohl sie sendet.
"worker_hello", "worker_ping", "worker_update", "worker_list",
// OpenAI-Endpunkt einer LLM-Box: Diagnostic setzt den API-Key (der PORT kommt
// immer aus der .env der Box — er ist ein Docker-Port-Mapping).
"llm_openai_config", "llm_openai_config_result",
"node_stats", "node_stats_stream_start", "node_stats_stream_stop",
"node_stats_history_request", "node_stats_history",
"node_stats_reset", "node_stats_reset_done",
@@ -112,6 +124,86 @@ const ALLOWED_TYPES = new Set([
// Token-Raum: token -> { clients: Set<ws> }
const rooms = new Map();
// ── Punkt-zu-Punkt-Antworten ────────────────────────────────────────
//
// Der RVS ist ein Broadcast-Relay: normalerweise bekommt JEDER Client im Raum
// jede Nachricht. Bei Datei-Antworten ist das fatal. Ein 150-MB-Video wird als
// base64 ~200 MB gross und ging bisher an ALLE Clients im Raum — auch an die
// STT/TTS-Worker, die daran mit Code 1009 (message too big) gestorben sind und
// die gerade laufende Sprachaufnahme mitgerissen haben ("erste Sprachnachricht
// kommt nicht durch"). Die Worker-max_size-Angleichung verhindert den Absturz,
// aber 200 MB an zehn Clients zu schicken bleibt Unsinn: nur EINER hat gefragt.
//
// Darum: wer eine Anfrage mit requestId stellt, wird gemerkt — die Antwort mit
// derselben requestId geht nur an ihn zurueck. Unbekannte requestId → wie
// bisher broadcasten (nichts geht verloren, falls ein Client mithoeren muss,
// dessen Anfrage der RVS nie gesehen hat).
const TARGETED = new Map([
["file_request", "file_response"],
["file_zip_request", "file_zip_response"],
["file_version_download_request", "file_version_download_response"],
["brain_request", "brain_response"],
]);
const TARGETED_REPLIES = new Set(TARGETED.values());
// `antwortTyp \0 requestId` -> { clients: Set<ws>, at }
// Mehrere Clients koennen dieselbe requestId benutzen (die App nimmt die
// Nachrichten-ID) — darum ein Set, sonst bekaeme nur der letzte die Antwort.
const pendingReplies = new Map();
const PENDING_TTL_MS = 180_000;
function pendingKey(replyType, requestId) {
return `${replyType}${requestId}`;
}
/** Merkt sich den Fragenden, wenn die Nachricht eine gezielte Anfrage ist. */
function rememberRequester(ws, msg) {
const replyType = TARGETED.get(msg.type);
if (!replyType) return;
const rid = msg.payload && msg.payload.requestId;
if (!rid || typeof rid !== "string") return;
const key = pendingKey(replyType, rid);
let entry = pendingReplies.get(key);
if (!entry) {
entry = { clients: new Set(), at: Date.now() };
pendingReplies.set(key, entry);
}
entry.clients.add(ws);
entry.at = Date.now();
}
/**
* Empfaenger fuer eine Antwort-Nachricht.
* null = keine gezielte Antwort → normal broadcasten.
* [] = der Fragende ist weg → verwerfen (NICHT broadcasten, sonst waere
* der ganze Sinn weg und die 200 MB gingen doch an alle).
*/
function targetedRecipients(msg) {
if (!TARGETED_REPLIES.has(msg.type)) return null;
const rid = msg.payload && msg.payload.requestId;
if (!rid || typeof rid !== "string") return null;
const key = pendingKey(msg.type, rid);
const entry = pendingReplies.get(key);
if (!entry) return null;
pendingReplies.delete(key);
return [...entry.clients].filter((c) => c.readyState === 1);
}
/** Abgelaufene/tote Eintraege wegwerfen (laeuft mit cleanupRooms). */
function cleanupPendingReplies() {
const now = Date.now();
for (const [key, entry] of pendingReplies) {
if (now - entry.at > PENDING_TTL_MS) {
pendingReplies.delete(key);
continue;
}
for (const c of entry.clients) {
if (c.readyState > 1) entry.clients.delete(c);
}
if (entry.clients.size === 0) pendingReplies.delete(key);
}
}
// ── Hilfsfunktionen ─────────────────────────────────────────────────
function timestamp() {
@@ -135,6 +227,7 @@ function cleanupRooms() {
log(`Leerer Raum entfernt: ${token.slice(0, 8)}...`);
}
}
cleanupPendingReplies();
}
// ── HTTP + WebSocket Server (hybrid) ────────────────────────────────
@@ -409,6 +502,28 @@ function registerClient(ws, token) {
return;
}
// Anfrage merken, damit die Antwort gleich gezielt zurueckgehen kann
rememberRequester(ws, msg);
// Gezielte Antwort? Dann NUR an den Fragenden — nicht an den ganzen Raum.
const targets = targetedRecipients(msg);
if (targets) {
const data = raw.toString();
let sent = 0;
for (const client of targets) {
if (client !== ws) { client.send(data); sent++; }
}
// Grosse Payloads mitloggen: hier spart der RVS am meisten ein, und
// wenn mal eine Antwort verschwindet will man genau das hier sehen.
if (raw.length > 4 * 1024 * 1024 || sent === 0) {
const mb = (raw.length / 1024 / 1024).toFixed(1);
log(sent === 0
? `${msg.type}: Fragender nicht mehr verbunden — ${mb}MB verworfen`
: `${msg.type}: ${mb}MB gezielt an ${sent} Client(s) statt an ${room.clients.size - 1}`);
}
return;
}
// An alle anderen Clients im Raum weiterleiten
for (const client of room.clients) {
if (client !== ws && client.readyState === 1) {
@@ -419,6 +534,9 @@ function registerClient(ws, token) {
ws.on("close", () => {
room.clients.delete(ws);
for (const [key, entry] of pendingReplies) {
if (entry.clients.delete(ws) && entry.clients.size === 0) pendingReplies.delete(key);
}
log(`Client getrennt: ${token.slice(0, 8)}... (${room.clients.size} verbleibend)`);
if (room.clients.size === 0) {
rooms.delete(token);
+3 -1
View File
@@ -48,9 +48,11 @@ CONTROL_ENABLED=true
# adb.provision Agent FERN einrichten: RVS-Zugang uebergeben, statt ihn mit der
# Fernbedienung abzutippen (params: ip, rvs_host, rvs_token, ...)
# adb.status laeuft/installiert der Agent auf dem Geraet? (params: ip)
# adb.permissions Sonderrechte per ADB erteilen (Bildschirmaufnahme/Overlay/
# Mikro) — spart das Zusammenklicken mit der Fernbedienung
# ACHTUNG: Diese Liste ueberschreibt den Code-Default. Wird eine neue Aktion
# ergaenzt, MUSS sie hier nachgetragen werden — sonst lehnt der Satellit sie ab.
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec,adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec,adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status,adb.permissions
# ─── ADB / Android-Rollout (optional) ──────────────────────────────
# Fire TV Sticks & Android-Geraete im LAN: APK ausrollen ohne Datei-Kopieren.
+53 -3
View File
@@ -637,6 +637,8 @@ async def _control(action: str, params: dict, devices: list[dict]) -> dict:
return await loop.run_in_executor(None, _do_adb_provision, params)
if action == "adb.status":
return await loop.run_in_executor(None, _do_adb_status, params)
if action == "adb.permissions":
return await loop.run_in_executor(None, _do_adb_permissions, params)
return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."}
except Exception as exc:
return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
@@ -1267,6 +1269,42 @@ def _do_adb_status(params: dict) -> dict:
"nicht installiert")}}
def _adb_grant_permissions(target: str, package: str) -> dict:
"""Erteilt dem Agenten die Sonderrechte per ADB — das spart das Antippen mit
der Fernbedienung (auf einem Fire TV besonders muehsam):
PROJECT_MEDIA Bildschirmaufnahme ohne Nachfrage-Dialog (Screenshots)
SYSTEM_ALERT_WINDOW Einblendung ueber dem laufenden Bild (Overlay)
RECORD_AUDIO Mikrofon (Sprachdialog)
Best effort: was nicht geht, wird gemeldet, bricht aber nichts ab."""
granted, failed = [], []
ops = [("appops", ["appops", "set", package, "PROJECT_MEDIA", "allow"], "Bildschirmaufnahme"),
("appops", ["appops", "set", package, "SYSTEM_ALERT_WINDOW", "allow"], "Einblendung")]
perms = [("android.permission.RECORD_AUDIO", "Mikrofon")]
for _, cmd, label in ops:
rc, out, err = _adb_run(["-s", target, "shell"] + cmd, timeout=30)
(granted if rc == 0 and "error" not in f"{out}{err}".lower() else failed).append(label)
for perm, label in perms:
rc, out, err = _adb_run(["-s", target, "shell", "pm", "grant", package, perm], timeout=30)
(granted if rc == 0 and "error" not in f"{out}{err}".lower() else failed).append(label)
return {"granted": granted, "failed": failed}
def _do_adb_permissions(params: dict) -> dict:
"""Nur die Berechtigungen setzen (ohne Neuinstallation/Einrichtung)."""
target = _adb_target(params)
if not target:
return {"ok": False, "error": "ip/host erforderlich."}
package = (params.get("package") or AGENT_PACKAGE).strip()
ok, msg = _adb_connect(target)
if not ok:
return {"ok": False, "error": msg}
r = _adb_grant_permissions(target, package)
msg = "erteilt: " + (", ".join(r["granted"]) or "—")
if r["failed"]:
msg += " · nicht moeglich: " + ", ".join(r["failed"])
return {"ok": True, "result": {"target": target, "message": msg, **r}}
def _do_adb_provision(params: dict) -> dict:
"""Richtet den ARIA-Agent auf dem Geraet FERN ein (RVS-Zugang uebergeben).
@@ -1301,6 +1339,11 @@ def _do_adb_provision(params: dict) -> dict:
return {"ok": False, "error": f"'{package}' ist nicht installiert — "
"erst adb.install ausfuehren."}
# Sonderrechte gleich mit erteilen (Bildschirm/Overlay/Mikro) — sonst muesste
# man sie am Fernseher mit der Fernbedienung zusammenklicken.
perms = ({"granted": [], "failed": []} if params.get("skip_permissions")
else _adb_grant_permissions(target, package))
# WICHTIG: 'adb shell' haengt die Argumente zu EINER Kommandozeile zusammen,
# die auf dem Geraet nochmal von der Shell zerlegt wird. Werte mit Leerzeichen
# ("Wohnzimmer Fire TV") wuerden dabei abgeschnitten -> jeden Wert quoten.
@@ -1319,7 +1362,10 @@ def _do_adb_provision(params: dict) -> dict:
"-e", "token", q(rvs_token),
"--ei", "port", str(int(params.get("rvs_port") or 443)),
"--ez", "tls", "true" if _as_bool(params.get("rvs_tls"), True) else "false",
"--ez", "control", "true" if _as_bool(params.get("control"), True) else "false"]
"--ez", "control", "true" if _as_bool(params.get("control"), True) else "false",
# Nur wenn die Bildschirm-Berechtigung wirklich sitzt: dann darf sich
# die Aufnahme nach jedem Neustart selbst scharf schalten (dialogfrei).
"--ez", "capture", "true" if "Bildschirmaufnahme" in perms.get("granted", []) else "false"]
if params.get("name"):
args += ["-e", "name", q(str(params["name"]))]
if params.get("reply_mode"):
@@ -1336,9 +1382,13 @@ def _do_adb_provision(params: dict) -> dict:
"Android hat den Einrichtungs-Intent nicht ausgeliefert (Activity "
"war schon offen). Auf dem Geraet die Agent-App einmal schliessen "
"und erneut 'Nur einrichten' druecken. Details: " + txt[:200]}
msg = f"Agent eingerichtet (RVS {rvs_host}) und gestartet"
if perms.get("granted"):
msg += " · Rechte erteilt: " + ", ".join(perms["granted"])
if perms.get("failed"):
msg += " · manuell noetig: " + ", ".join(perms["failed"])
return {"ok": True, "result": {
"target": target, "package": package,
"message": f"Agent eingerichtet (RVS {rvs_host}) und gestartet"}}
"target": target, "package": package, "permissions": perms, "message": msg}}
def _as_bool(v, default: bool) -> bool:
+38 -1
View File
@@ -4,7 +4,7 @@
# ════════════════════════════════════════════════
# ─── Welche Dienste startet DIESER Node? ──────────
# Komma-getrennt aus: voxtral, whisper, f5tts, llm
# Komma-getrennt aus: voxtral, whisper, f5tts, llm, openhands
# Nur die aufgefuehrten Dienste starten bei `docker compose up -d`.
# nur STT-Box (Voxtral) → COMPOSE_PROFILES=voxtral
# nur STT-Box (Whisper) → COMPOSE_PROFILES=whisper
@@ -13,6 +13,12 @@
# Kleine Karte (wenig VRAM): Whisper + F5-TTS auf EINER GPU
# → COMPOSE_PROFILES=whisper,f5tts
# All-in-One, grosse Karte → COMPOSE_PROFILES=voxtral,f5tts,llm
# LLM + OpenHands-Oberflaeche → COMPOSE_PROFILES=llm,openhands
# 'openhands' ist REIN OPTIONAL (Standalone-Entwickler-Agent, s.u.). Wer es nicht
# auffuehrt, bei dem wird der Container gar nicht erst angelegt — das Image wird
# nicht einmal geladen. Fuer den normalen ARIA-Betrieb also einfach weglassen.
# ACHTUNG: Profile brauchen compose v2 (`docker compose`, mit Leerzeichen). Das
# alte `docker-compose` 1.x behandelt sie unzuverlaessig.
# Hinweis: voxtral UND whisper zusammen NICHT sinnvoll — beide sind STT und
# wuerden dieselbe Anfrage doppelt beantworten. Pro Node genau EINEN STT waehlen:
# Voxtral-3B (~9 GB, beste Qualitaet) ODER Whisper (klein, passt neben F5-TTS).
@@ -56,3 +62,34 @@ RVS_SNI=
# WHISPER_MODEL=small # tiny|base|small|medium|large-v3 (Hot-Swap via Diagnostic)
# WHISPER_LANGUAGE=de
# LLM_MODEL=qwen3-8b # Key aus llama-swap/config.yaml
# ─── OpenAI-Endpunkt des lokalen LLM (optional) ───
# llama-swap ist OpenAI-kompatibel (/v1/chat/completions, /v1/models). Damit
# koennen Fremd-Tools direkt auf das lokale Modell: OpenHands, Continue,
# Open WebUI, Aider ... Base-URL dann: http://<box>:<LLM_OPENAI_PORT>/v1
#
# LLM_OPENAI_BIND steuert, WER drankommt:
# 127.0.0.1 nur diese Maschine (Default, sicher)
# 0.0.0.0 das ganze LAN → dann UNBEDINGT LLM_OPENAI_KEY setzen!
LLM_OPENAI_BIND=127.0.0.1
LLM_OPENAI_PORT=8081
# API-Key. Leer = Endpunkt ohne Authentifizierung (nur mit BIND=127.0.0.1 ok).
# WICHTIG: Steht hier ein Wert, hat er VORRANG — das Feld ist dann in der ARIA-
# Diagnostic schreibgeschuetzt. Leer lassen, wenn Du den Key lieber dort pro Box
# setzen willst (er wird dann auf der Box unter /models/openai_key.json abgelegt).
# Der Key wirkt, sobald ein Modell das naechste Mal geladen wird.
LLM_OPENAI_KEY=
# ─── OpenHands (optional, eigenes Profil) ─────────
# Standalone-Entwickler-Agent, der das lokale LLM nutzt. Start:
# COMPOSE_PROFILES=llm,openhands docker compose up -d
# Dann http://<box>:<OPENHANDS_PORT> aufrufen.
# ACHTUNG: OpenHands startet eigene Arbeits-Container und bekommt dafuer den
# Docker-Socket — das ist faktisch Root-Zugriff auf diese Maschine. Deshalb
# BIND auf 127.0.0.1 lassen und von aussen per SSH-Tunnel zugreifen:
# ssh -L 3000:127.0.0.1:3000 user@box -> lokal http://localhost:3000
OPENHANDS_BIND=127.0.0.1
OPENHANDS_PORT=3000
# Wie OpenHands das lokale Modell anspricht (Container-intern, daher :8080):
OPENHANDS_LLM_BASE_URL=http://llama-swap:8080/v1
OPENHANDS_LLM_MODEL=openai/qwen3-8b
+80
View File
@@ -70,3 +70,83 @@ 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
```
## Lokales LLM von außen nutzen (OpenAI-Endpunkt)
`llama-swap` spricht **OpenAI-kompatibel** (`/v1/chat/completions`, `/v1/models`).
Damit können Fremd-Tools direkt auf das lokale Modell: **OpenHands**, Continue,
Open WebUI, Aider …
```
Base-URL: http://<box>:<LLM_OPENAI_PORT>/v1
Modell: qwen3-8b (oder ein anderer Key aus llama-swap/config.yaml)
API-Key: LLM_OPENAI_KEY (falls gesetzt)
```
In der `.env`:
| Variable | Bedeutung |
|---|---|
| `LLM_OPENAI_BIND` | `127.0.0.1` = nur diese Maschine (**Default, sicher**) · `0.0.0.0` = ganzes LAN |
| `LLM_OPENAI_PORT` | Host-Port (Default `8081`) |
| `LLM_OPENAI_KEY` | API-Key; leer = **ohne Authentifizierung** |
> ⚠️ `LLM_OPENAI_BIND=0.0.0.0` **ohne** `LLM_OPENAI_KEY` heißt: jeder im Netz kann
> das Modell benutzen. Entweder Key setzen oder auf localhost gebunden lassen.
**Wo wird was konfiguriert?**
- **Port und Erreichbarkeit** immer in der `.env`. Beides ist ein Docker-Port-Mapping
und steht beim Container-Start fest — es lässt sich nicht aus der Ferne ändern.
- **API-Key**: entweder in der `.env` **oder** — wenn dort leer — in der ARIA-Diagnostic
unter *Compute-Flotte* bei der jeweiligen Box. Dort abgelegt landet er auf der Box
unter `/models/openai_key.json` (Rechte 0600).
- **Die `.env` hat immer Vorrang.** Steht `LLM_OPENAI_KEY` dort, ist das Feld in der
Diagnostic **schreibgeschützt** (🔒-Hinweis).
- Ein geänderter Key greift, sobald ein Modell das **nächste Mal geladen** wird
(llama-server bekommt ihn per `--api-key`). Laufendes Modell also einmal entladen
lassen (TTL) oder ein anderes Modell anfragen.
Prüfen:
```bash
curl -s http://localhost:8081/v1/models # ohne Key
curl -s -H "Authorization: Bearer $LLM_OPENAI_KEY" \
http://localhost:8081/v1/models # mit Key
```
## OpenHands (optionales Profil)
OpenHands ist **rein optional** und liegt in einem eigenen Profil. Wer es nicht in
`COMPOSE_PROFILES` aufführt, bei dem wird der Container **gar nicht erst angelegt** —
das Image wird nicht einmal geladen. Für den normalen ARIA-Betrieb also einfach weglassen:
```bash
COMPOSE_PROFILES=voxtral,f5tts,llm docker compose up -d # ohne OpenHands
COMPOSE_PROFILES=llm,openhands docker compose up -d # mit OpenHands
```
Dann `http://<box>:<OPENHANDS_PORT>` (Default `3000`) öffnen. Das lokale Modell ist
schon vorbelegt (`OPENHANDS_LLM_BASE_URL`, `OPENHANDS_LLM_MODEL`) und in der
Oberfläche änderbar.
| Variable | Bedeutung |
|---|---|
| `OPENHANDS_BIND` | `127.0.0.1` (Default) oder `0.0.0.0` fürs LAN |
| `OPENHANDS_PORT` | Host-Port (Default `3000`) |
| `OPENHANDS_LLM_BASE_URL` | Default `http://llama-swap:8080/v1` (Container-intern — **nicht** der Host-Port) |
| `OPENHANDS_LLM_MODEL` | Default `openai/qwen3-8b` |
> ⚠️ OpenHands startet eigene Arbeits-Container und bekommt dafür den **Docker-Socket**.
> Das ist faktisch Root-Zugriff auf diese Maschine — wer die Oberfläche erreicht,
> kann im Zweifel alles auf der Box.
>
> **Deshalb bleibt `OPENHANDS_BIND` auf `127.0.0.1`.** Von einem anderen Rechner
> greifst Du per SSH-Tunnel zu, statt den Port ins LAN zu öffnen:
> ```bash
> ssh -L 3000:127.0.0.1:3000 user@box # dann lokal http://localhost:3000
> ```
> Das kostet nichts und ist die wirksamste Absicherung, solange der Socket
> durchgereicht wird. Wer echte Isolation braucht: OpenHands in eine eigene VM,
> oder einen rootless Docker-Daemon als Beiwagen statt des Host-Sockets.
Ohne das Profil ändert sich nichts: Wer nur ARIAs Box fahren will, startet OpenHands gar nicht.
+37
View File
@@ -139,6 +139,13 @@ services:
# Liest die vom llm-adapter generierte Config. Beim allerersten Boot faengt
# restart: unless-stopped die Reihenfolge ab, bis der Adapter sie geschrieben hat.
command: ["--config", "/models/llama-swap.config.yaml", "--listen", "0.0.0.0:8080"]
# OpenAI-kompatibler Endpunkt (/v1/chat/completions, /v1/models) — damit
# koennen Fremd-Tools wie OpenHands, Continue oder Open WebUI direkt auf das
# lokale LLM. Standardmaessig NUR auf localhost gebunden (sicher); fuer
# Zugriff aus dem LAN LLM_OPENAI_BIND=0.0.0.0 setzen — dann aber unbedingt
# LLM_OPENAI_KEY vergeben, sonst kann jeder im Netz das Modell nutzen.
ports:
- "${LLM_OPENAI_BIND:-127.0.0.1}:${LLM_OPENAI_PORT:-8081}:8080"
restart: unless-stopped
# ─── Local-LLM-Adapter — RVS <-> llama.cpp ────
@@ -172,3 +179,33 @@ services:
# Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig.
- LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600}
restart: unless-stopped
# ─── OpenHands (optional, Standalone) ────────────────
# Eigenstaendige Entwickler-Agenten-Oberflaeche, die das LOKALE LLM ueber den
# OpenAI-Endpunkt von llama-swap nutzt. Bewusst ein eigenes Profil: wer nur
# ARIAs Box fahren will, startet es gar nicht erst.
# COMPOSE_PROFILES=llm,openhands docker compose up -d
# Danach http://<box>:${OPENHANDS_PORT:-3000} und dort als Provider
# "OpenAI-kompatibel" mit Base-URL http://llama-swap:8080/v1 eintragen
# (aus dem Container heraus erreichbar), Modell z.B. qwen3-8b.
openhands:
image: docker.all-hands.dev/all-hands-ai/openhands:latest
container_name: aria-openhands
profiles: ["openhands"]
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:latest
- LOG_ALL_EVENTS=true
# Vorbelegung fuer das lokale Modell — in der Oberflaeche aenderbar.
- LLM_BASE_URL=${OPENHANDS_LLM_BASE_URL:-http://llama-swap:8080/v1}
- LLM_API_KEY=${LLM_OPENAI_KEY:-local}
- LLM_MODEL=${OPENHANDS_LLM_MODEL:-openai/qwen3-8b}
volumes:
# OpenHands startet seine Arbeits-Container selbst -> braucht den Socket.
# ACHTUNG: das gibt dem Container Kontrolle ueber den Docker-Host.
- /var/run/docker.sock:/var/run/docker.sock
- ./openhands-state:/.openhands
ports:
- "${OPENHANDS_BIND:-127.0.0.1}:${OPENHANDS_PORT:-3000}:3000"
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
+8 -1
View File
@@ -896,7 +896,14 @@ async def run_loop(runner: F5Runner) -> None:
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
# max_size MUSS zum RVS/zur Bridge passen (1500 MB). Der RVS ist ein
# BROADCAST-Relay: eine grosse Nachricht (Datei-Transfer, Voice-Upload,
# APK-Update) geht an JEDEN Client im Raum. Wer ein kleineres Limit hat,
# schliesst die Verbindung mit Code 1009 ("message too big") und landet in
# einer Reconnect-Schleife — real gesehen mit einem 209-MB-Frame, der
# whisper und f5tts im Minutentakt neu starten liess.
_MAX_WS_BYTES = int(os.getenv("RVS_MAX_SIZE_MB", "1500")) * 1024 * 1024
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": _MAX_WS_BYTES}
try:
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
async with websockets.connect(url, **connect_kwargs) as ws:
+110 -1
View File
@@ -27,6 +27,8 @@ import asyncio
import json
import logging
import os
import re
import shlex
import time
import httpx
@@ -94,6 +96,44 @@ BASE_CONFIG_PATH = os.getenv("LLAMA_BASE_CONFIG", "/llamaswap/config.yaml")
GEN_CONFIG_PATH = os.getenv("LLAMA_GEN_CONFIG", "/models/llama-swap.config.yaml")
REGISTRY_PATH = os.getenv("LLM_REGISTRY", "/models/aria_models.json")
# ── OpenAI-kompatibler Endpunkt (fuer OpenHands & Co.) ─────
# Der Port ist ein Docker-Port-Mapping und steht beim Container-Start fest —
# er kommt IMMER aus der .env. Der API-Key kann zusaetzlich zur Laufzeit aus der
# Diagnostic gesetzt werden, ABER: steht er in der .env, hat die .env Vorrang
# und das Feld ist in der Diagnostic schreibgeschuetzt.
OPENAI_PORT = os.getenv("LLM_OPENAI_PORT", "8081").strip() or "8081"
OPENAI_BIND = os.getenv("LLM_OPENAI_BIND", "127.0.0.1").strip() or "127.0.0.1"
_OPENAI_KEY_ENV = os.getenv("LLM_OPENAI_KEY", "").strip()
OPENAI_KEY_FILE = os.getenv("LLM_OPENAI_KEY_FILE", "/models/openai_key.json")
def _openai_key() -> str:
"""Gueltiger API-Key: .env gewinnt, sonst der in der Diagnostic gesetzte."""
if _OPENAI_KEY_ENV:
return _OPENAI_KEY_ENV
try:
with open(OPENAI_KEY_FILE) as f:
return str((json.load(f) or {}).get("key") or "").strip()
except Exception:
return ""
def _openai_key_save(key: str) -> bool:
"""Speichert den in der Diagnostic gesetzten Key. Bei .env-Vorrang: nein."""
if _OPENAI_KEY_ENV:
return False
try:
os.makedirs(os.path.dirname(OPENAI_KEY_FILE), exist_ok=True)
tmp = OPENAI_KEY_FILE + ".tmp"
with open(tmp, "w") as f:
json.dump({"key": key.strip()}, f)
os.replace(tmp, OPENAI_KEY_FILE)
os.chmod(OPENAI_KEY_FILE, 0o600)
return True
except Exception as exc:
logger.error("OpenAI-Key speichern fehlgeschlagen: %s", exc)
return False
# ── Auslastungs-Monitor (Stage E) ──────────────────────────
import node_stats
STATS_PATH = os.getenv("STATS_PATH", "/models/aria_stats.json")
@@ -144,6 +184,19 @@ def _generate_config() -> int:
f"-hf {repo}:{quant}\n-ngl {ngl} -c {ctx} --jinja"),
"ttl": 3600,
}
# API-Key auf JEDES Modell anwenden (auch die aus dem Basis-Template) —
# llama-server prueft dann den Authorization-Header. Ohne Key bleibt der
# Endpunkt offen; deshalb ist er per Default nur an localhost gebunden.
key = _openai_key()
for k, spec in list(models.items()):
cmd = str((spec or {}).get("cmd") or "")
if not cmd:
continue
cmd = re.sub(r"\s*--api-key\s+\S+", "", cmd) # alten Key entfernen
if key:
cmd = cmd.rstrip() + f" --api-key {shlex.quote(key)}"
models[k] = dict(spec, cmd=cmd)
out = dict(base)
out["models"] = models
try:
@@ -270,6 +323,13 @@ async def _announce(ws) -> None:
"instanceId": INSTANCE_ID, "service": WORKER_SERVICE,
"node": NODE_NAME, "gpus": GPU_IDS, "model": LLM_MODEL,
"models": models, # welche Modelle diese Box fahren kann (llama-swap-Keys)
# OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.). Der Key selbst wird
# NICHT gesendet (Broadcast!) — nur ob einer gesetzt ist und ob die .env
# ihn vorgibt (dann ist das Feld in der Diagnostic schreibgeschuetzt).
"openaiPort": OPENAI_PORT,
"openaiBind": OPENAI_BIND,
"openaiKeySet": bool(_openai_key()),
"openaiEnvLocked": bool(_OPENAI_KEY_ENV),
})
logger.info("worker_hello: models=%s", models)
@@ -355,6 +415,45 @@ async def _do_llm_request(ws, payload: dict) -> None:
})
async def _handle_openai_config(ws, payload: dict) -> None:
"""Setzt den API-Key des OpenAI-Endpunkts (aus der Diagnostic).
Der PORT wird hier bewusst NICHT geaendert: er ist ein Docker-Port-Mapping
und steht beim Container-Start fest — dafuer ist die .env zustaendig.
Steht der Key in der .env, gewinnt sie und wir lehnen ab.
"""
if (payload.get("instanceId") or INSTANCE_ID) != INSTANCE_ID:
return
req_id = payload.get("requestId", "")
key = str(payload.get("apiKey") or "").strip()
if _OPENAI_KEY_ENV:
await _send(ws, "llm_openai_config_result", {
"requestId": req_id, "instanceId": INSTANCE_ID, "ok": False,
"error": "Der API-Key steht in der .env dieser Box — die hat Vorrang. "
"Zum Aendern dort anpassen und die Box neu starten.",
})
return
if not _openai_key_save(key):
await _send(ws, "llm_openai_config_result", {
"requestId": req_id, "instanceId": INSTANCE_ID, "ok": False,
"error": "Key konnte nicht gespeichert werden (Schreibrechte auf /models?)",
})
return
# Config neu schreiben, damit llama-server den Key beim naechsten Laden nutzt.
_generate_config()
logger.info("OpenAI-API-Key %s (wirkt beim naechsten Modell-Laden)",
"gesetzt" if key else "entfernt")
await _send(ws, "llm_openai_config_result", {
"requestId": req_id, "instanceId": INSTANCE_ID, "ok": True,
"keySet": bool(key),
"message": ("API-Key gesetzt. Er greift, sobald das Modell das naechste Mal "
"geladen wird — bereits laufende Modelle einmal entladen lassen "
"(TTL) oder ein anderes Modell anfragen."
if key else "API-Key entfernt — der Endpunkt ist jetzt offen."),
})
await _announce(ws)
async def _handle_provision(ws, payload: dict) -> None:
"""Fuegt ein Modell hinzu: Registry+Config schreiben, reload, dann Warmup
(zieht das GGUF via -hf beim ersten Load). Meldet die neue Modell-Liste."""
@@ -435,7 +534,14 @@ async def _run() -> None:
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 16 * 1024 * 1024}
# max_size MUSS zum RVS/zur Bridge passen (1500 MB). Der RVS ist ein
# BROADCAST-Relay: eine grosse Nachricht (Datei-Transfer, Voice-Upload,
# APK-Update) geht an JEDEN Client im Raum. Wer ein kleineres Limit hat,
# schliesst die Verbindung mit Code 1009 ("message too big") und landet in
# einer Reconnect-Schleife — real gesehen mit einem 209-MB-Frame, der
# whisper und f5tts im Minutentakt neu starten liess.
_MAX_WS_BYTES = int(os.getenv("RVS_MAX_SIZE_MB", "1500")) * 1024 * 1024
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": _MAX_WS_BYTES}
try:
logger.info("Verbinde zu RVS: %s%s (llama=%s)", masked,
f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "", LLAMA_URL)
@@ -464,6 +570,9 @@ async def _run() -> None:
# Auslastungs-Monitor (node_stats_*) abfangen.
if await _stats.handle(ws, mtype, payload, _send):
continue
if mtype == "llm_openai_config":
asyncio.create_task(_handle_openai_config(ws, payload))
continue
if mtype not in ("llm_request", "llm_provision_model", "llm_remove_model"):
continue
if mtype == "llm_provision_model":
+8 -1
View File
@@ -760,7 +760,14 @@ async def run_loop(sessions: SessionManager) -> None:
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
# max_size MUSS zum RVS/zur Bridge passen (1500 MB). Der RVS ist ein
# BROADCAST-Relay: eine grosse Nachricht (Datei-Transfer, Voice-Upload,
# APK-Update) geht an JEDEN Client im Raum. Wer ein kleineres Limit hat,
# schliesst die Verbindung mit Code 1009 ("message too big") und landet in
# einer Reconnect-Schleife — real gesehen mit einem 209-MB-Frame, der
# whisper und f5tts im Minutentakt neu starten liess.
_MAX_WS_BYTES = int(os.getenv("RVS_MAX_SIZE_MB", "1500")) * 1024 * 1024
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": _MAX_WS_BYTES}
try:
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
async with websockets.connect(url, **connect_kwargs) as ws:
+23 -7
View File
@@ -584,6 +584,12 @@ class SessionManager:
if ws is None:
return # disconnected — Endpointer pausiert bis Reconnect
# Modell laedt noch (Kaltstart): Audio weiter sammeln, aber NICHT
# transkribieren/finalisieren — sonst kaeme leerer Text heraus und die
# Aufnahme waere verloren. Sobald es geladen ist, laeuft es normal weiter.
if self.runner.model is None:
return
audio_ms = self._buffer_duration_ms(sess)
# Hard-Cap erreicht → wie Endpoint behandeln (egal ob neuer Text)
@@ -892,7 +898,14 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
# max_size MUSS zum RVS/zur Bridge passen (1500 MB). Der RVS ist ein
# BROADCAST-Relay: eine grosse Nachricht (Datei-Transfer, Voice-Upload,
# APK-Update) geht an JEDEN Client im Raum. Wer ein kleineres Limit hat,
# schliesst die Verbindung mit Code 1009 ("message too big") und landet in
# einer Reconnect-Schleife — real gesehen mit einem 209-MB-Frame, der
# whisper und f5tts im Minutentakt neu starten liess.
_MAX_WS_BYTES = int(os.getenv("RVS_MAX_SIZE_MB", "1500")) * 1024 * 1024
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": _MAX_WS_BYTES}
try:
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
async with websockets.connect(url, **connect_kwargs) as ws:
@@ -962,19 +975,22 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
# transcribe-Call mit Leerstring weil Model None.
target_model = payload.get("model") or runner.model_size or WHISPER_MODEL
needs_load = (runner.model is None) or (target_model != runner.model_size)
# WICHTIG: Session SOFORT anlegen — auch wenn das Modell
# noch laedt. Sonst laufen alle stt_audio_chunk der ersten
# Aufnahme ins Leere (feed_chunk findet keine Session) und
# der Take ist verloren; erst die zweite Aufnahme klappt,
# weil das Modell dann warm ist. Der Buffer fuellt sich
# jetzt waehrend des Ladens, das Transkribieren wartet.
sessions.start_session(payload)
if needs_load:
async def _load_then_start(p, target):
async def _load_only(target):
await _broadcast_status(ws, "loading", model=target)
try:
await runner.ensure_loaded(target)
await _broadcast_status(ws, "ready", model=runner.model_size)
except Exception as e:
await _broadcast_status(ws, "error", error=str(e)[:200])
return
sessions.start_session(p)
asyncio.create_task(_load_then_start(payload, target_model))
else:
sessions.start_session(payload)
asyncio.create_task(_load_only(target_model))
elif mtype == "stt_audio_chunk":
ok = sessions.feed_chunk(payload)