Compare commits

..

23 Commits

Author SHA1 Message Date
duffyduck 1ea614c26b fix(brain): CPU-only torch — verhindert 5 GB CUDA-Bloat im Brain-Image
sentence-transformers zieht torch als Dependency, und der Default-Wheel
auf x86_64-linux ist die CUDA-Variante mit allen NVIDIA-Libs
(nvidia-cudnn, nvidia-cublas, cuda-toolkit, triton, ...). ~5 GB pro
Build-Layer, frisst die 22-GB-VM auf.

Fix: torch CPU-Wheel explizit zuerst installieren. Damit ist die
torch-Dependency erfuellt wenn sentence-transformers spaeter kommt,
und die CUDA-Libs werden nie gezogen.

Brain laeuft eh komplett auf CPU (MiniLM-Embeddings ~120 MB), GPU-Bloat
war reine Disk-Verschwendung.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:45:51 +02:00
duffyduck acaa9fc3f2 feat(oauth): generische OAuth2-Pipeline ueber RVS-Callback (Spotify/Google/GitHub/Strava/MS)
Bisher musste Stefan bei OAuth-Flows manuell den Auth-Code aus der
Browser-URL kopieren (redirect_uri war localhost). Jetzt: RVS hat einen
HTTP-Listener auf demselben Port wie der WebSocket, Provider redirecten
nach Auth zu https://{RVS_HOST}/oauth/callback/{service}, RVS broadcastet,
aria-bridge forwarded, Brain matched state + tauscht code gegen Token.
Token-Refresh laeuft automatisch.

- rvs/server.js: hybrid http.createServer + WebSocketServer{noServer}.
  Route GET /oauth/callback/{service}, broadcast oauth_callback an alle
  Raeume, schoene Dark-Mode-HTML-Antwort an den Browser (Auto-Close 4s).
- bridge/aria_bridge.py: empfaengt oauth_callback, POSTet an Brain
  /internal/oauth-callback.
- aria-brain/oauth.py: neuer Manager. Pending-Store mit state+TTL,
  Token-Exchange (Basic-Auth oder Body je nach Provider), persistente
  Speicherung in /shared/config/oauth_tokens.json (mode 0600),
  Token-Refresh wenn <60s Restzeit. Vordefinierte Configs fuer Spotify,
  Google, GitHub, Strava, Microsoft.
- aria-brain/agent.py: META-Tools oauth_authorize / oauth_get_token /
  oauth_revoke.
- aria-brain/prompts.py: System-Prompt-Block zeigt ARIA die feste
  Callback-URL als Quelle der Wahrheit + aktuelle Service-States.
- aria-brain/main.py: HTTP-Endpoints /oauth/services, /oauth/apps,
  /oauth/authorize, /oauth/{service}/revoke, /internal/oauth-callback.
- diagnostic: neue Section "OAuth-Apps". Pro Service Karte mit Status,
  client_id + client_secret (Passwort-Toggle), Speichern + Autorisieren-
  Buttons. Authorize oeffnet Provider-Auth in neuem Tab.
- docker-compose.yml: brain-env um RVS_HOST + RVS_PORT_PUBLIC + RVS_TLS
  ergaenzt (Brain braucht die Werte zum Bau der Callback-URL).
- .env.example: RVS_PORT_PUBLIC + Brain-Timeout-Vars (PROXY_TIMEOUT_SEC
  + Connect/Write/Pool) dokumentiert.
- README.md: OAuth-Pipeline + ARIA-Live-Mirror in Diagnostic-Section,
  OAuth-Apps in Einstellungen-Tab erwaehnt.
- issue.md: OAuth-Pipeline + Brain-Timeout-Fix als erledigt dokumentiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:39:54 +02:00
duffyduck 0887674497 fix(brain): Proxy-Timeout 20min -> 24h Read, split httpx-Timeouts, Cleanup-Pfade
Brain timed bei langen Pentests nach exakt 20:00 min raus, obwohl ARIAs
Subprozess fleissig weiterarbeitete und der Live-View alles zeigte.
Root-Cause: proxy_client.py hatte einen 1200s httpx.Client-Timeout —
genau der Wert, den wir vor 5 Tagen am Proxy auf 24h hochgezogen hatten.
Schicht uebersehen.

- docker-compose.yml: PROXY_TIMEOUT_SEC=86400 als brain-env.
- proxy_client.py: httpx.Timeout split (connect=10, read=86400, write=30,
  pool=10). Toter Proxy wird in 10s erkannt, lange ARIA-Sessions duerfen
  24h laufen.
- routes.js handleNonStreamingResponse: res.on("close") + isComplete-Flag.
  Brain-Disconnect killt jetzt den Subprozess statt ihn verwaisen zu lassen.
- agent.py chat(): try/except — bei Exception nach dem User-Turn wird ein
  Assistant-Error-Marker geschrieben, damit Conversation user->assistant
  konsistent bleibt (kein Tool-Call-Loop-Fail in Folge-Calls).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:24:22 +02:00
duffyduck f5243b1abb fix(proxy): Idle-Watchdog statt Hard-Timeout fuer lange Agent-Sessions
Pentests u.ae. brauchen oft >20min — der bisherige 20-min Hard-Cutoff
in claude-max-api-proxy's subprocess/manager.js killte den Subprocess
mitten in der Arbeit, egal wie aktiv ARIA gerade war.

Loesung:
- Hard-Timeout via sed auf 24h hochgesetzt (Last-Resort gegen wirklich
  haengende Subprozesse).
- Eigener Idle-Watchdog in routes.js: Subprocess wird gekillt erst wenn
  ueber ARIA_IDLE_TIMEOUT_MS (Default 20min) keine message/content_delta
  Events ankommen. Jede Aktivitaet resettet den Timer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:02:04 +02:00
duffyduck eb5c178139 fix(proxy): tool_result Events ueber generic 'message' statt nicht-existentem 'user'
Der claude-max-api-proxy Subprocess-Manager emittiert nur 'message',
'assistant', 'content_delta', 'result', 'error', 'close', 'raw' —
KEIN 'user'. tool_result-Blocks landen daher ausschliesslich im
generischen 'message'-Event mit type==='user'. Filter darauf statt
auf einen Event-Namen der nicht existiert, sonst kam in der ARIA-Live-
View nichts an.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:56:17 +02:00
duffyduck 31b0bfaac1 feat(diagnostic): ARIA-Live (read-only Terminal-Mirror) + Not-Aus statt SSH-Tab
SSH-Tab raus — funktionierte eh nicht zuverlaessig und war konzeptionell
falsch. Stattdessen Live-Mirror der Claude-Code-Session:

- proxy-patches/routes.js: assistant + user Events parsed → POSTed Tool-
  Inputs (truncated 2 KB) + Tool-Results (truncated 4 KB) + Assistant-Text
  an aria-bridge:8090/internal/agent-stream. start/end Marker pro Session.
  Subprocess-Tracking (_activeSubprocesses Map) + interner Side-Channel
  auf Port 3457 mit POST /cancel-all fuer Hard-Kill.

- bridge: neuer /internal/agent-stream Endpoint pusht 1:1 als RVS
  agent_stream. cancel_request Handler nimmt optional 'hard'-Flag —
  triggert dann zusaetzlich _cancel_proxy_subprocesses() das den Proxy-
  Side-Channel ruft.

- rvs: agent_stream whitelisted.

- diagnostic: SSH-Tab → 'ARIA Live'. Monospace-Stream, farbcodiert
  (text=hell, tool_use=cyan, tool_result=gruen/rot, thinking=gelb-italic),
  Auto-Scroll, max 2000 Zeilen Backlog. Roter  Not-Aus-Button mit
  Confirm → aria_panic_stop action → diagnostic-server broadcastet
  cancel_request mit hard:true.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:23:13 +02:00
duffyduck 1d3c45fdda fix(flux): Torch 2.5.1 — 2.4 crasht in transformers MoE custom_op-Registrierung
transformers 4.50+ registriert in integrations/moe.py einen torch.library
.custom_op mit String-Forward-References als Type-Annotations. Torch 2.4's
infer_schema kann diese nicht aufloesen ("Parameter input has unsupported
type torch.Tensor"), erst 2.5+ macht typing.get_type_hints() draus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:37:15 +02:00
duffyduck 84a59d7b4f fix(flux): Torch 2.4 + torchvision — transformers braucht beides
Aktuelles transformers schaltet PyTorch ab wenn < 2.4
("Disabling PyTorch because PyTorch >= 2.4 is required, found 2.3.1").
Ohne PyTorch laed diffusers das FLUX-Modell nicht. torchvision wird
zusaetzlich von CLIPImageProcessor/SiglipImageProcessor gebraucht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:59:50 +02:00
duffyduck 8ad3e39453 release: bump version to 0.1.6.1 2026-05-16 23:29:54 +02:00
duffyduck afa96b1d44 feat(flux): HF-Token in Diagnostic statt .env
Passwort-Feld in der FLUX-Section, mit Show/Hide-Toggle und kurzem
Hinweis-Link zu den HuggingFace-Schritten (Lizenz-Agree + Token-Erzeugung).
Wert wird in voice_config.json persistiert und per config-Broadcast an
die flux-bridge gepusht; dort vor jedem from_pretrained als HF_TOKEN +
HUGGING_FACE_HUB_TOKEN env gesetzt.

HF_TOKEN aus .env.example + docker-compose.yml entfernt. Auch FLUX_MODEL
aus compose raus — Default-Modell kommt jetzt komplett aus Diagnostic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:25:55 +02:00
duffyduck 0407c5bc3c chore(diagnostic): FLUX-Einstellungen in eigene Section statt unter Sprachausgabe
Stand vorher in der Sprachausgabe-Card — falscher Ort, weil
Bildgenerierung eigene Domaene ist. Neue settings-section zwischen
Sprachausgabe und Whisper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:21:06 +02:00
duffyduck 2d348aeec7 feat(flux): Modell-Wahl per Diagnostic + raw/switch-Keywords + Download-Hinweis
Diagnostic-Einstellungen fuer FLUX:
- Default-Modell (dev | schnell) — wird via RVS gepusht, flux-bridge
  hot-swappt die Pipeline aus dem HF-Cache (~15-30s)
- Raw-Keyword (Default 'flux') — Pipe-Modus, Brain leitet Stefans Text
  1:1 als prompt durch, kein Rewriting/Beautify
- Switch-Keyword (Default 'fix') — zwingt das ANDERE Modell als Default

Brain-Tool flux_generate um model + raw erweitert, System-Prompt-Block
mit den aktuellen Diagnostic-Settings + Whisper-Toleranz-Hinweis.

Kein eager Bootstrap-Load: flux-bridge wartet auf config oder ersten
Request. Bei erstem HF-Download zeigt Banner "laedt erstmalig runter"
mit Pfeil-Icon, Toast in der App wenn fertig.

FLUX_MODEL aus der .env entfernt (Steuerung jetzt komplett ueber
Diagnostic). HF_TOKEN-Kommentar erklaert warum trotz lokaler Inference
noetig (HF Gate-Mechanismus fuer FLUX.1-dev).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:11:22 +02:00
duffyduck 7e53dcfed3 feat(flux): Bildgenerierung via FLUX.1-dev — flux-bridge auf Gamebox
Eigener Compose-Stack im /flux Verzeichnis (kann auf separater Maschine
laufen). aria-bridge routet flux_request via RVS, ARIA referenziert das
fertige PNG im Reply mit [FILE: ...]-Marker. Brain-Tool flux_generate
mit Caps fuer steps/dimension.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:33:48 +02:00
duffyduck 33d5be781f release: bump version to 0.1.6.0 2026-05-16 19:21:04 +02:00
duffyduck 785f5d0805 fix(bridge): grosse File-Re-Downloads zerreissen nicht mehr die WS
Symptom (aus Bridge-Log): bei chat_history_request triggert die App
file_request fuer alle fehlenden Anhaenge. Bei einem 40 MB MP4 wird das
base64-encoded ~53 MB, ueberschreitet das RVS-maxPayload (50 MB).
Server droppt mit Code 1009 'message too big', Bridge crasht im cleanup
mit AttributeError 'NoneType has no call_soon' (websockets-Lib-Bug bei
nested context-manager-cleanup nach abgerissener Verbindung).

Drei Layer:

(1) RVS-Server: maxPayload 50 → 100 MB — deckt ~70 MB binaer ab nach
    base64-inflate. Comment im server.js erklaert den Hintergrund.

(2) Bridge: max_size 50 → 100 MB synchron zum Server. PLUS pre-check
    im file_request-Handler — Dateien > 70 MB werden mit Fehler-Response
    abgewiesen statt blind base64-zu-encoden und die WS zu killen.
    Limit knapp unter Server-Limit damit Bridge proaktiv blockiert.

(3) App: file_response-Handler liest 'error'-Feld aus dem Payload und
    zeigt nen Toast 'Datei X: Datei zu gross fuer Transfer (40 MB,
    Limit 70 MB)'. Statt einfach zu schweigen oder endlos zu retryen.

Crash bei websockets-cleanup ist ein Lib-Bug (NoneType.call_soon) —
nicht direkt fixbar, aber tritt jetzt nicht mehr auf weil Bridge proaktiv
die zu grossen Files ablehnt und die WS nicht mehr abreisst.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:18:52 +02:00
duffyduck fac87474ec release: bump version to 0.1.5.9 2026-05-16 18:41:10 +02:00
duffyduck 8227266aea release: bump version to 0.1.5.8 2026-05-16 18:06:37 +02:00
duffyduck 5d24e01d4b release: bump version to 0.1.5.7 2026-05-16 16:39:35 +02:00
duffyduck 4fe72cc4a8 feat(chat): System-Hints in Bubbles ausblenden (Toggle in Settings)
Bridge fuegt User-Texten Praefixe in eckigen Klammern hinzu damit Brain
Kontext hat — z.B. '[Stefans aktuelle GPS-Position: 53.0, 8.5. Nutze die
nur wenn ...]' oder '[Hinweis: Stefan hat dich gerade unterbrochen...]'.
Die landeten via chat_backup auch in der App-Bubble — Stefan sieht jeden
Hint mit, hat nichts in der UI verloren.

Fix: App-side stripSystemHints() filtert aufeinanderfolgende `[...]`-
Bloecke am Textanfang inkl. Trennleerzeichen. Wird in renderMessage
angewendet, default an (Hints versteckt). Toggle in Settings →
Allgemein → 'Chat-Bubbles' kehrt's um falls Debug gewuenscht.

Brain bekommt weiterhin den vollen Text — Bridge-Side unveraendert.
Live-Toggle: Settings setzt aria_show_hints in AsyncStorage, ChatScreen
re-liest alle 2s (gleicher Mechanismus wie tts_enabled etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:21:12 +02:00
duffyduck eeeb1d43f5 chore(diagnostic): Gateway-Reste rauswerfen — Spam-Log weg
Diagnostic loggte konstant '[gateway] Nicht verbunden — kann nicht senden'
weil die UI bei jedem Send-Klick noch versuchte ueber den OpenClaw-
Gateway-Pfad zu schicken. Den gibt's seit Monaten nicht mehr — alles
laeuft via Diagnostic → RVS → Bridge → Brain (HTTP).

server.js:
- sendToGateway() loggt nichts mehr (No-Op, returnt false)
- sendToRVS() raeumt den 'gateway + RVS dual'-Pfad weg, geht direkt
  ueber RVS
- 'test_gateway'-Action vom Client wird umgeleitet auf RVS damit alte
  Browser-Sessions noch funktionieren

index.html:
- 'Gateway senden'-Buttons (Chat-Test + Vollbild) entfernt, 'Via RVS
  senden' umbenannt zu 'Senden'
- Gateway-Tab im Log-Viewer raus, mapSourceToTab leitet evtl. Reste
  in den server-Tab um
- testGateway() + testGatewayFS() JS-Funktionen entfernt
- btn-gw-Disable-Logik raus

connectGateway/handleGatewayMessage/gatewayWs/state.gateway im server.js
bleiben als deprecated stehen — kein aktiver Code zugreift mehr drauf,
aber rauswerfen wuerde viele Diffs erzeugen ohne Nutzen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:15:39 +02:00
duffyduck 0044e222db fix(phone): Anruf-Erkennung im Hintergrund + bei gesperrtem Display
Symptom: App bekommt im minimierten oder display-gesperrten Zustand
nicht mit ob ein Anruf angefangen oder beendet wurde — TTS spricht
weiter waehrend Telefon klingelt, oder bleibt stumm nach Auflegen.

Zwei Ursachen:

1) Kotlin: TelephonyCallback war auf reactApplicationContext.mainExecutor
   registriert. Wenn die Activity pausiert ist (display aus, App im
   Hintergrund), wird der mainExecutor verzoegert oder gar nicht
   abgearbeitet — Call-State-Events kommen nicht durch.
   Fix: eigener Executors.newSingleThreadExecutor() — laeuft unabhaengig
   vom UI-Thread solange der App-Prozess lebt (Foreground-Service
   garantiert das).

2) TS: TelephonyManager-Listener kann nach laengerer Hintergrund-Zeit
   verloren gehen (React-Bridge-Context recreated nach Resume).
   Fix: neue refresh()-Methode in phoneCallService, AppState-Resume
   ruft sie auf — wenn telephonyAttached=false ist, wird der Native-
   Listener neu attached.

Plus: Status-Property telephonyAttached macht in Logs sichtbar ob
Pfad 1 (TelephonyManager) wirklich greift. Pfad 2 (AudioFocus fuer
VoIP) war nie betroffen, der laeuft komplett im Native-Code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:59:55 +02:00
duffyduck 048d231b60 fix(wake): false-positive nach langer Hintergrund-Pause verwerfen
Symptom: Ohr aktiv, App im Hintergrund (jetzt mit Foreground-Service
permanent lebendig), nach laengerer Zeit oeffnet Stefan die App und sie
nimmt schon auf — angeblich Wake-Word getriggert. War aber TV/Husten/
sonstige Hintergrund-Geraeusche waehrend Stefan nicht da war.

Mit dem neuen Hintergrund-Modus laeuft openWakeWord jetzt permanent und
faengt jedes False-Positive im Hintergrund auf. Ohne dieser Fall war
das nicht moeglich weil die JS-Engine pausiert war.

Fix: Heuristik beim AppState-Resume in ChatScreen.tsx
- backgroundDauer wird gemerkt (lastBackgroundAt vs Resume-Zeit)
- Wenn >30s im Hintergrund UND state='conversing' UND letzter Wake-
  Trigger juenger als 15s: false-positive — Aufnahme abbrechen + zurueck
  zu armed
- Resume-Cooldown 1500 → 3000 ms (Audio-Spikes beim AppState-Switch
  haben gelegentlich nach 1.5s noch nicht verklungen)

Neue Methoden:
- wakeword.ts: lastTriggerAt-Tracking + discardIfFreshlyTriggered(maxAge)
- audio.ts: cancelRecording() — bricht recorder ab ohne Result zu
  emittieren, loescht die Audio-Datei

Setzt voraus dass Stefan nicht laenger als 30s im Hintergrund mit ARIA
spricht ueber Wake-Word. Falls doch: bei Resume waere die Aufnahme weg
und er muesste nochmal triggern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:54:07 +02:00
duffyduck 2bac9c26ca release: bump version to 0.1.5.6 2026-05-16 14:32:34 +02:00
31 changed files with 3358 additions and 236 deletions
+25
View File
@@ -16,11 +16,21 @@ ARIA_AUTH_TOKEN=change-me-to-a-long-random-string
# Alle muessen den gleichen Host, Port und Token nutzen. # Alle muessen den gleichen Host, Port und Token nutzen.
# Hostname des RVS-Servers (z.B. rvs.example.de oder mobil.hacker-net.de) # Hostname des RVS-Servers (z.B. rvs.example.de oder mobil.hacker-net.de)
# WICHTIG: muss oeffentlich aufloesbar sein (DNS), nicht nur intern.
# Wird auch fuer OAuth-Callback-URLs verwendet — Spotify/Google/etc.
# redirecten Stefan im Browser an https://{RVS_HOST}/oauth/callback/{service}.
RVS_HOST=rvs.example.de RVS_HOST=rvs.example.de
# Port auf dem der RVS laeuft (muss mit rvs/docker-compose.yml uebereinstimmen) # Port auf dem der RVS laeuft (muss mit rvs/docker-compose.yml uebereinstimmen)
RVS_PORT=443 RVS_PORT=443
# Oeffentlich erreichbarer TLS-Port — was Browser/Provider von aussen sehen.
# Meist identisch mit RVS_PORT, kann aber abweichen wenn ein TLS-Terminator
# (Caddy/Nginx) davor steht der z.B. 444 auf intern 3000 mappt. Wird fuer
# die OAuth-Callback-URL benutzt; muss zu dem Eintrag im Provider-Dashboard
# passen. Leer/ungesetzt = RVS_PORT wird verwendet.
RVS_PORT_PUBLIC=
# TLS (wss://) verwenden? true = verschluesselt, false = unverschluesselt (ws://) # TLS (wss://) verwenden? true = verschluesselt, false = unverschluesselt (ws://)
RVS_TLS=true RVS_TLS=true
@@ -35,6 +45,21 @@ RVS_TLS_FALLBACK=true
# Generieren: ./generate-token.sh (traegt den Token automatisch ein) # Generieren: ./generate-token.sh (traegt den Token automatisch ein)
RVS_TOKEN= RVS_TOKEN=
# ── Brain-Timeouts ───────────────────────────────
# Brain redet via HTTP mit dem Proxy-Container. Da der Proxy non-streaming
# antwortet (Response kommt erst nach subprocess-close), kann ein Brain-Call
# bei langen Agent-Sessions (Pentests, Multi-Step-Tasks) >1h dauern.
# PROXY_TIMEOUT_SEC ist der httpx-Read-Timeout im Brain — wir setzen ihn
# bewusst hoch (24h), der Proxy hat einen eigenen Idle-Watchdog
# (ARIA_IDLE_TIMEOUT_MS in der proxy-Logik, default 20min Inaktivitaet)
# der den Subprocess killt wenn wirklich was haengt.
# Connect/Write/Pool bleiben klein damit toter Proxy in 10s erkannt wird.
PROXY_TIMEOUT_SEC=86400
# Diese drei sind defensive Defaults — aendern nur wenn netzwerk-bedingt noetig.
# PROXY_CONNECT_TIMEOUT_SEC=10
# PROXY_WRITE_TIMEOUT_SEC=30
# PROXY_POOL_TIMEOUT_SEC=10
# ── Gitea — Release-Verwaltung ─────────────────── # ── Gitea — Release-Verwaltung ───────────────────
# Wird von release.sh genutzt um APKs auf Gitea zu veroeffentlichen. # Wird von release.sh genutzt um APKs auf Gitea zu veroeffentlichen.
# Kennwort wird beim Release interaktiv abgefragt (nicht in .env!). # Kennwort wird beim Release interaktiv abgefragt (nicht in .env!).
+3 -2
View File
@@ -332,7 +332,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
**Auflösung**: Background-Loop tickt alle 8s (vorher 30s — bei 100 km/h durch einen 300m-Radius war eine Vorbeifahrt nur ~22s drin und konnte verpasst werden). Plus event-getrieben: Bridge ruft nach jedem `location_update` von der App sofort einen `/triggers/check-now` im Brain — Watcher sehen die frische Position in Millisekunden statt im Polling-Takt. `near()`-Funktionen ignorieren GPS-Daten älter als 5 Minuten (verhindert Phantom-Fires bei abgeschaltetem Tracking). **Auflösung**: Background-Loop tickt alle 8s (vorher 30s — bei 100 km/h durch einen 300m-Radius war eine Vorbeifahrt nur ~22s drin und konnte verpasst werden). Plus event-getrieben: Bridge ruft nach jedem `location_update` von der App sofort einen `/triggers/check-now` im Brain — Watcher sehen die frische Position in Millisekunden statt im Polling-Takt. `near()`-Funktionen ignorieren GPS-Daten älter als 5 Minuten (verhindert Phantom-Fires bei abgeschaltetem Tracking).
- **Dateien**: Browser fuer `/shared/uploads/` mit Multi-Select + "Alle markieren" + Bulk-Download (ZIP bei 2+) + Bulk-Delete. Live-Update der Chat-Bubbles beim Delete. - **Dateien**: Browser fuer `/shared/uploads/` mit Multi-Select + "Alle markieren" + Bulk-Download (ZIP bei 2+) + Bulk-Delete. Live-Update der Chat-Bubbles beim Delete.
- **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning + Voice Export/Import, Whisper, Sprachmodell (brainModel), Onboarding-QR, App-Cleanup - **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning + Voice Export/Import, **FLUX Bildgenerierung** (Default-Modell + Raw/Switch-Keywords + HF-Token), **OAuth-Apps** (Spotify, Google, GitHub, Strava, Microsoft, ...) mit client_id+client_secret pro Service + One-Click-Autorisieren, Whisper, Sprachmodell (brainModel), Onboarding-QR, App-Cleanup
### Was zusaetzlich noch drin steckt ### Was zusaetzlich noch drin steckt
@@ -342,7 +342,8 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- **Voice Export/Import**: einzelne Stimmen als `.tar.gz` zwischen Gameboxen mitnehmen - **Voice Export/Import**: einzelne Stimmen als `.tar.gz` zwischen Gameboxen mitnehmen
- **Settings Export/Import**: `voice_config.json` + `highlight_triggers.json` als JSON-Bundle - **Settings Export/Import**: `voice_config.json` + `highlight_triggers.json` als JSON-Bundle
- **Claude Login**: Browser-Terminal zum Einloggen in den Proxy - **Claude Login**: Browser-Terminal zum Einloggen in den Proxy
- **SSH Terminal**: direkter SSH-Zugang zu aria-wohnung - **ARIA Live**: read-only Mirror der Claude-Code-Session — alle Tool-Calls + Inputs + Outputs live in einer Monospace-Liste, farbcodiert. Plus ⛔ **Not-Aus**-Button der per RVS einen `cancel_request` mit `hard:true` ausloest → aria-bridge ruft den proxy-internen `/cancel-all` Side-Channel → alle Claude-Subprocesses werden sofort gekillt
- **OAuth-Callback-Pipeline**: RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket. Provider (Spotify/Google/...) redirecten den User an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet als `oauth_callback`-WS-Message → aria-bridge forwarded an Brain → Brain matched `state`, tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json`. Token-Refresh laeuft automatisch. ARIA hat `oauth_authorize` / `oauth_get_token` / `oauth_revoke` als Brain-Tools
--- ---
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10505 versionCode 10601
versionName "0.1.5.5" versionName "0.1.6.1"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
@@ -15,6 +15,7 @@ import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReactMethod
import com.facebook.react.modules.core.DeviceEventManagerModule import com.facebook.react.modules.core.DeviceEventManagerModule
import java.util.concurrent.Executors
/** /**
* Lauscht auf Anruf-Statusaenderungen — wenn das Telefon klingelt oder ein * Lauscht auf Anruf-Statusaenderungen — wenn das Telefon klingelt oder ein
@@ -35,6 +36,11 @@ class PhoneCallModule(reactContext: ReactApplicationContext) : ReactContextBaseJ
private var legacyListener: PhoneStateListener? = null private var legacyListener: PhoneStateListener? = null
private var modernCallback: Any? = null // TelephonyCallback ab API 31 private var modernCallback: Any? = null // TelephonyCallback ab API 31
private var lastState: Int = TelephonyManager.CALL_STATE_IDLE private var lastState: Int = TelephonyManager.CALL_STATE_IDLE
// Eigener Single-Thread-Executor statt mainExecutor — der wird bei
// pausierter Activity verzoegert oder gar nicht abgearbeitet, der eigene
// Thread laeuft unabhaengig solange der App-Prozess lebt (was er ja tut,
// wir haben einen Foreground-Service der das garantiert).
private val callbackExecutor = Executors.newSingleThreadExecutor()
@ReactMethod @ReactMethod
fun start(promise: Promise) { fun start(promise: Promise) {
@@ -59,7 +65,7 @@ class PhoneCallModule(reactContext: ReactApplicationContext) : ReactContextBaseJ
handleStateChange(state) handleStateChange(state)
} }
} }
tm.registerTelephonyCallback(reactApplicationContext.mainExecutor, cb) tm.registerTelephonyCallback(callbackExecutor, cb)
modernCallback = cb modernCallback = cb
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.1.5.5", "version": "0.1.6.1",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+96 -14
View File
@@ -149,6 +149,22 @@ const MAX_THOUGHTS = 500;
// im Gespraechsmodus bei sehr vielen Nachrichten. // im Gespraechsmodus bei sehr vielen Nachrichten.
const capMessages = (msgs: ChatMessage[]): ChatMessage[] => const capMessages = (msgs: ChatMessage[]): ChatMessage[] =>
msgs.length > MAX_MEMORY_MESSAGES ? msgs.slice(-MAX_MEMORY_MESSAGES) : msgs; msgs.length > MAX_MEMORY_MESSAGES ? msgs.slice(-MAX_MEMORY_MESSAGES) : msgs;
// Bridge fuegt User-Texten Praefixe in eckigen Klammern hinzu damit Brain
// Kontext hat (GPS-Position, Barge-In-Hint etc.). Diese sollen nicht in der
// Bubble auftauchen — nur Brain sieht sie. Filtert alle aufeinanderfolgenden
// [...]-Bloecke am Textanfang weg, inkl. der Trennleerzeichen dahinter.
function stripSystemHints(text: string): string {
if (!text) return text;
let out = text;
// Mehrere Hints koennen aneinanderhaengen — "[A] [B] Hallo" → "Hallo"
while (true) {
const m = out.match(/^\s*\[[^\]]*\]\s*/);
if (!m) break;
out = out.slice(m[0].length);
}
return out;
}
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`; const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
const STORAGE_PATH_KEY = 'aria_attachment_storage_path'; const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
@@ -274,11 +290,17 @@ const ChatScreen: React.FC = () => {
// Stream zumuellen. Eigentlich seltener Fall, aber billig zu pruefen. // Stream zumuellen. Eigentlich seltener Fall, aber billig zu pruefen.
const lastThoughtKeyRef = useRef<string>(''); const lastThoughtKeyRef = useRef<string>('');
// Service-Status (Gamebox: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit // Service-Status (Gamebox: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string}>>({}); const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string, downloading?: boolean, freshlyDownloaded?: boolean}>>({});
const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false); const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false);
// Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button) // Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button)
const [ttsDeviceEnabled, setTtsDeviceEnabled] = useState(true); const [ttsDeviceEnabled, setTtsDeviceEnabled] = useState(true);
const [ttsMuted, setTtsMuted] = useState(false); const [ttsMuted, setTtsMuted] = useState(false);
// System-Hints in Bubble: Bridge fuegt User-Text Praefixe wie
// "[Stefans aktuelle GPS-Position: ...]" oder "[Hinweis: Stefan hat
// dich gerade unterbrochen...]" hinzu damit Brain Kontext hat. Die
// App soll sie standardmaessig NICHT anzeigen — Stefan sieht sonst
// jeden Hint mit. Toggle in Settings.
const [showSystemHints, setShowSystemHints] = useState(false);
// Gerätelokale XTTS-Voice-Wahl (bevorzugt gegenueber dem globalen Default) // Gerätelokale XTTS-Voice-Wahl (bevorzugt gegenueber dem globalen Default)
const localXttsVoiceRef = useRef<string>(''); const localXttsVoiceRef = useRef<string>('');
// Geraetelokale TTS-Wiedergabegeschwindigkeit (speed-Param an F5-TTS) // Geraetelokale TTS-Wiedergabegeschwindigkeit (speed-Param an F5-TTS)
@@ -446,6 +468,8 @@ const ChatScreen: React.FC = () => {
ttsSpeedRef.current = await loadTtsSpeed(); ttsSpeedRef.current = await loadTtsSpeed();
const gps = await AsyncStorage.getItem('aria_gps_enabled'); const gps = await AsyncStorage.getItem('aria_gps_enabled');
setGpsEnabled(gps === 'true'); setGpsEnabled(gps === 'true');
const hints = await AsyncStorage.getItem('aria_show_hints');
setShowSystemHints(hints === 'true'); // default false
}; };
loadSettings(); loadSettings();
const interval = setInterval(loadSettings, 2000); const interval = setInterval(loadSettings, 2000);
@@ -480,14 +504,40 @@ const ChatScreen: React.FC = () => {
return () => { phoneCallService.stop().catch(() => {}); }; return () => { phoneCallService.stop().catch(() => {}); };
}, []); }, []);
// App-Resume: kurzer Wake-Word-Cooldown — beim Wechsel Background→Foreground // App-Resume: drei Schutzmaßnahmen gegen verirrte Wake-Word-Trigger
// gibt's haeufig Audio-Pegel-Spikes (AudioFocus-Switch, AudioTrack re-route) // beim Wechsel Background→Foreground:
// die openWakeWord sonst faelschlich als Wake-Word interpretiert. // (a) Cooldown 3s — Audio-Pegel-Spikes (AudioFocus-Switch, AudioTrack
// re-route) sollen openWakeWord nicht faelschlich triggern
// (b) Wenn die App laenger im Hintergrund war und in 'conversing'
// zurueckkommt: vermutlich false-positive durch ein Hintergrund-
// Geraeusch (TV, Husten etc.) waehrend Stefan gar nicht da war.
// Wir verwerfen den Trigger und gehen zurueck zu 'armed'.
// (c) Aktuelle Aufnahme abbrechen falls sie aus dem false-positive
// gerade gestartet wurde.
useEffect(() => { useEffect(() => {
let lastState: string = AppState.currentState; let lastState: string = AppState.currentState;
let lastBackgroundAt = 0;
const sub = AppState.addEventListener('change', (next) => { const sub = AppState.addEventListener('change', (next) => {
if (lastState !== 'active' && next === 'active') { if (next === 'background' || next === 'inactive') {
wakeWordService.setResumeCooldown(1500); lastBackgroundAt = Date.now();
} else if (lastState !== 'active' && next === 'active') {
wakeWordService.setResumeCooldown(3000);
const bgDur = lastBackgroundAt > 0 ? Date.now() - lastBackgroundAt : 0;
// Bei laengerer Hintergrund-Zeit (>30s): pruefen ob ein frisches
// Wake-Word getriggert wurde wahrend die App weg war — wenn ja,
// verwerfen + laufende Aufnahme stoppen.
if (bgDur > 30_000) {
wakeWordService.discardIfFreshlyTriggered(15_000).then(discarded => {
if (discarded) {
try { audioService.cancelRecording(); } catch {}
}
}).catch(() => {});
}
// PhoneCall-Listener pruefen: kann passieren dass der nach laengerer
// Hintergrund-Zeit verloren geht (Bridge-Context recreated). Refresh
// versucht ihn neu zu attachen falls noetig — sonst kriegt die App
// bei display-aus / minimized keine Anruf-Events mit.
phoneCallService.refresh().catch(() => {});
} }
lastState = next; lastState = next;
}); });
@@ -838,6 +888,16 @@ const ChatScreen: React.FC = () => {
const b64 = (message.payload.base64 as string) || ''; const b64 = (message.payload.base64 as string) || '';
const serverPath = (message.payload.serverPath as string) || ''; const serverPath = (message.payload.serverPath as string) || '';
const mimeType = (message.payload.mimeType as string) || ''; const mimeType = (message.payload.mimeType as string) || '';
// Fehler-Response (z.B. Datei zu gross, nicht gefunden) → Toast,
// kein erneuter Versuch. Hauptverdacht: 40+ MB Videos die ueber
// den 70 MB Bridge-Limit gehen.
const fileErr = (message.payload as any).error as string | undefined;
if (fileErr) {
const fname = (message.payload.name as string) || serverPath.split('/').pop() || 'Datei';
console.warn('[Chat] file_response Fehler fuer %s: %s', fname, fileErr);
ToastAndroid.show(`${fname}: ${fileErr}`, ToastAndroid.LONG);
return;
}
if (b64 && reqId) { if (b64 && reqId) {
const fileName = (message.payload.name as string) || 'download'; const fileName = (message.payload.name as string) || 'download';
persistAttachment(b64, reqId, fileName).then(filePath => { persistAttachment(b64, reqId, fileName).then(filePath => {
@@ -1111,22 +1171,39 @@ const ChatScreen: React.FC = () => {
} }
} }
// Gamebox-Bridges (f5tts/whisper) melden Lade-Status — Banner oben // Gamebox-Bridges (f5tts/whisper/flux) melden Lade-Status — Banner oben.
// Toast bei Download-Ende: erstmaliger HF-Download (mehrere GB) → User
// soll wissen dass er Bilder/Stimmen jetzt nutzen kann ohne in den
// Banner gucken zu muessen.
if (message.type === ('service_status' as any)) { if (message.type === ('service_status' as any)) {
const p = message.payload as any; const p = message.payload as any;
const svc = (p?.service as string) || ''; const svc = (p?.service as string) || '';
if (!svc) return; if (!svc) return;
const newState = (p?.state as string) || 'unknown';
const freshlyDownloaded = p?.freshlyDownloaded === true;
setServiceStatus(prev => ({ setServiceStatus(prev => ({
...prev, ...prev,
[svc]: { [svc]: {
state: (p?.state as string) || 'unknown', state: newState,
model: p?.model as string | undefined, model: p?.model as string | undefined,
loadSeconds: p?.loadSeconds as number | undefined, loadSeconds: p?.loadSeconds as number | undefined,
error: p?.error as string | undefined, error: p?.error as string | undefined,
downloading: p?.downloading === true,
freshlyDownloaded,
}, },
})); }));
// Bei neuer Loading-Phase Banner wieder aktivieren // Bei neuer Loading-Phase Banner wieder aktivieren
if (p?.state === 'loading') setServiceBannerDismissed(false); if (newState === 'loading') setServiceBannerDismissed(false);
// Download-Fertig-Toast: Bridge setzt freshlyDownloaded=true bei dem
// 'ready'-Broadcast direkt nach einem Cache-Miss-Load. Ein einziger
// Toast pro Modell-Download, kein State-Tracking auf App-Seite noetig.
if (newState === 'ready' && freshlyDownloaded) {
const niceName = svc === 'flux' ? 'FLUX' : svc === 'f5tts' ? 'F5-TTS' : svc === 'whisper' ? 'Whisper' : svc;
const model = p?.model ? ` (${p.model})` : '';
try {
ToastAndroid.show(`${niceName}-Modell heruntergeladen${model} — jetzt einsatzbereit`, ToastAndroid.LONG);
} catch {}
}
} }
}); });
@@ -2006,7 +2083,7 @@ const ChatScreen: React.FC = () => {
{/* Text (nicht anzeigen wenn nur "Anhang empfangen" und ein Bild da ist) */} {/* Text (nicht anzeigen wenn nur "Anhang empfangen" und ein Bild da ist) */}
{!(item.text === 'Anhang empfangen' && item.attachments?.some(a => a.type === 'image' && a.uri)) && ( {!(item.text === 'Anhang empfangen' && item.attachments?.some(a => a.type === 'image' && a.uri)) && (
<MessageText <MessageText
text={item.text} text={showSystemHints ? item.text : stripSystemHints(item.text)}
style={[styles.messageText, isUser ? styles.userText : styles.ariaText]} style={[styles.messageText, isUser ? styles.userText : styles.ariaText]}
/> />
)} )}
@@ -2136,7 +2213,7 @@ const ChatScreen: React.FC = () => {
const allReady = !anyLoading && !anyError && entries.every(([, v]) => v.state === 'ready'); const allReady = !anyLoading && !anyError && entries.every(([, v]) => v.state === 'ready');
const bg = anyError ? '#3A1F1F' : anyLoading ? '#3A331F' : '#1F3A2A'; const bg = anyError ? '#3A1F1F' : anyLoading ? '#3A331F' : '#1F3A2A';
const border = anyError ? '#FF3B30' : anyLoading ? '#FFD60A' : '#34C759'; const border = anyError ? '#FF3B30' : anyLoading ? '#FFD60A' : '#34C759';
const labels: Record<string, string> = { f5tts: 'F5-TTS', whisper: 'Whisper STT' }; const labels: Record<string, string> = { f5tts: 'F5-TTS', whisper: 'Whisper STT', flux: 'FLUX Image-Gen' };
return ( return (
<TouchableOpacity <TouchableOpacity
activeOpacity={allReady ? 0.6 : 1.0} activeOpacity={allReady ? 0.6 : 1.0}
@@ -2146,11 +2223,16 @@ const ChatScreen: React.FC = () => {
{entries.map(([svc, info]) => { {entries.map(([svc, info]) => {
let icon = '\u23F3', text = ''; let icon = '\u23F3', text = '';
if (info.state === 'loading') { if (info.state === 'loading') {
text = `${labels[svc] || svc}: laedt${info.model ? ' ' + info.model : ''}...`; icon = info.downloading ? '\u2B07' : '\u23F3'; // \u2B07 vs \u23F3
const action = info.downloading
? 'laedt erstmalig runter (mehrere GB, kann dauern)'
: 'laedt';
text = `${labels[svc] || svc}: ${action}${info.model ? ' ' + info.model : ''}...`;
} else if (info.state === 'ready') { } else if (info.state === 'ready') {
icon = '\u2705'; icon = info.freshlyDownloaded ? '\uD83C\uDF89' : '\u2705'; // \uD83C\uDF89 vs \u2705
const sec = info.loadSeconds ? ` (${info.loadSeconds.toFixed(1)}s)` : ''; const sec = info.loadSeconds ? ` (${info.loadSeconds.toFixed(1)}s)` : '';
text = `${labels[svc] || svc}: bereit${info.model ? ' ' + info.model : ''}${sec}`; const dl = info.freshlyDownloaded ? ' \u2014 Download fertig!' : '';
text = `${labels[svc] || svc}: bereit${info.model ? ' ' + info.model : ''}${sec}${dl}`;
} else if (info.state === 'error') { } else if (info.state === 'error') {
icon = '\u274C'; icon = '\u274C';
text = `${labels[svc] || svc}: Fehler ${info.error || ''}`; text = `${labels[svc] || svc}: Fehler ${info.error || ''}`;
+34
View File
@@ -131,6 +131,7 @@ const SettingsScreen: React.FC = () => {
const [gpsEnabled, setGpsEnabled] = useState(false); const [gpsEnabled, setGpsEnabled] = useState(false);
const [gpsTracking, setGpsTracking] = useState(gpsTrackingService.isActive()); const [gpsTracking, setGpsTracking] = useState(gpsTrackingService.isActive());
const [backgroundMode, setBackgroundMode] = useState(true); // Default an const [backgroundMode, setBackgroundMode] = useState(true); // Default an
const [showSystemHints, setShowSystemHints] = useState(false); // Default aus
const [scannerVisible, setScannerVisible] = useState(false); const [scannerVisible, setScannerVisible] = useState(false);
const [logTab, setLogTab] = useState<LogTab>('live'); const [logTab, setLogTab] = useState<LogTab>('live');
const [logs, setLogs] = useState<LogEntry[]>([]); const [logs, setLogs] = useState<LogEntry[]>([]);
@@ -202,6 +203,10 @@ const SettingsScreen: React.FC = () => {
// Default ist an — nur explicit 'false' deaktiviert // Default ist an — nur explicit 'false' deaktiviert
setBackgroundMode(saved !== 'false'); setBackgroundMode(saved !== 'false');
}); });
AsyncStorage.getItem('aria_show_hints').then(saved => {
// Default ist aus — nur explicit 'true' aktiviert
setShowSystemHints(saved === 'true');
});
// gpsTrackingService status syncen + auf Aenderungen lauschen // gpsTrackingService status syncen + auf Aenderungen lauschen
setGpsTracking(gpsTrackingService.isActive()); setGpsTracking(gpsTrackingService.isActive());
const offGps = gpsTrackingService.onChange(setGpsTracking); const offGps = gpsTrackingService.onChange(setGpsTracking);
@@ -616,6 +621,13 @@ const SettingsScreen: React.FC = () => {
} }
}, []); }, []);
// --- System-Hints Toggle ---
const handleShowSystemHintsToggle = useCallback((value: boolean) => {
setShowSystemHints(value);
AsyncStorage.setItem('aria_show_hints', String(value)).catch(() => {});
}, []);
// --- XTTS Voice --- // --- XTTS Voice ---
const selectVoice = useCallback((voiceName: string) => { const selectVoice = useCallback((voiceName: string) => {
@@ -1103,6 +1115,28 @@ const SettingsScreen: React.FC = () => {
</View> </View>
</View> </View>
{/* === Bubble-Anzeige === */}
<Text style={styles.sectionTitle}>Chat-Bubbles</Text>
<View style={styles.card}>
<View style={styles.toggleRow}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>System-Hints in Bubbles anzeigen</Text>
<Text style={styles.toggleHint}>
Wenn aktiviert: GPS-Position, Barge-In-Hinweise und andere
System-Praefixe in eckigen Klammern bleiben in der User-Bubble
sichtbar (Debug). Standardmaessig versteckt — Brain bekommt sie
trotzdem, sie sind nur fuer dich nicht relevant.
</Text>
</View>
<Switch
value={showSystemHints}
onValueChange={handleShowSystemHintsToggle}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={showSystemHints ? '#FFFFFF' : '#666680'}
/>
</View>
</View>
{/* === Hintergrund-Modus === */} {/* === Hintergrund-Modus === */}
<Text style={styles.sectionTitle}>Hintergrund-Modus</Text> <Text style={styles.sectionTitle}>Hintergrund-Modus</Text>
<View style={styles.card}> <View style={styles.card}>
+25
View File
@@ -727,6 +727,31 @@ class AudioService {
} }
} }
/** Aufnahme abbrechen ohne RecordingResult zu emittieren — z.B. bei
* Wake-Word-False-Positive beim App-Resume aus laengerem Hintergrund.
* Aufgenommene Datei wird sofort verworfen. */
async cancelRecording(): Promise<void> {
if (this.recordingState !== 'recording') return;
console.log('[Audio] Aufnahme abgebrochen (cancel)');
this.vadEnabled = false;
if (this.vadTimer) { clearInterval(this.vadTimer); this.vadTimer = null; }
if (this.maxDurationTimer) { clearTimeout(this.maxDurationTimer); this.maxDurationTimer = null; }
if (this.noSpeechTimer) { clearTimeout(this.noSpeechTimer); this.noSpeechTimer = null; }
try {
const path = await this.recorder.stopRecorder();
this.recorder.removeRecordBackListener();
// Datei loeschen wenn da
if (path && path !== 'Already stopped') {
const local = path.replace(/^file:\/\//, '');
try { await RNFS.unlink(local); } catch {}
}
} catch (err) {
console.warn('[Audio] cancelRecording stop fehlgeschlagen:', err);
}
this._releaseFocusDeferred();
this.setState('idle');
}
/** Aufnahme stoppen und Ergebnis zurueckgeben */ /** Aufnahme stoppen und Ergebnis zurueckgeben */
async stopRecording(): Promise<RecordingResult | null> { async stopRecording(): Promise<RecordingResult | null> {
if (this.recordingState !== 'recording') { if (this.recordingState !== 'recording') {
+40
View File
@@ -43,6 +43,42 @@ class PhoneCallService {
/** Damit Resume nach VoIP-Loss nicht doppelt feuert wenn auch /** Damit Resume nach VoIP-Loss nicht doppelt feuert wenn auch
* TelephonyManager-IDLE-Event kommt. */ * TelephonyManager-IDLE-Event kommt. */
private interruptedByFocus: boolean = false; private interruptedByFocus: boolean = false;
/** True wenn der TelephonyManager-Listener (Pfad 1) wirklich registriert
* ist. False wenn READ_PHONE_STATE abgelehnt wurde oder Native nicht ging. */
private telephonyAttached: boolean = false;
/** Status fuer Diagnose: laeuft die Anruf-Erkennung tatsaechlich? */
status(): { focusAttached: boolean; telephonyAttached: boolean } {
return {
focusAttached: this.focusSubscription !== null,
telephonyAttached: this.telephonyAttached,
};
}
/** Nach App-Resume: pruefen ob die Listener noch leben. Wenn der
* TelephonyManager-Listener verloren ging (kann passieren wenn der
* React-Bridge-Context recreated wurde), neu attachen. */
async refresh(): Promise<void> {
if (!this.started) return;
if (this.telephonyAttached) return; // alles ok
if (!PhoneCall) return;
try {
const ok = await PhoneCall.start();
if (ok) {
if (!this.subscription) {
const emitter = new NativeEventEmitter(NativeModules.PhoneCall as any);
this.subscription = emitter.addListener(
'PhoneCallStateChanged',
(e: { state: PhoneState }) => this._onStateChanged(e.state),
);
}
this.telephonyAttached = true;
console.log('[PhoneCall] refresh: TelephonyManager-Listener re-attached');
}
} catch (err: any) {
console.warn('[PhoneCall] refresh fehlgeschlagen:', err?.message || err);
}
}
async start(): Promise<boolean> { async start(): Promise<boolean> {
if (this.started || Platform.OS !== 'android') return false; if (this.started || Platform.OS !== 'android') return false;
@@ -82,7 +118,10 @@ class PhoneCallService {
'PhoneCallStateChanged', 'PhoneCallStateChanged',
(e: { state: PhoneState }) => this._onStateChanged(e.state), (e: { state: PhoneState }) => this._onStateChanged(e.state),
); );
this.telephonyAttached = true;
console.log('[PhoneCall] TelephonyManager-Listener aktiv'); console.log('[PhoneCall] TelephonyManager-Listener aktiv');
} else {
console.warn('[PhoneCall] PhoneCall.start() lieferte false — Native-Listener nicht aktiv');
} }
} else { } else {
console.warn('[PhoneCall] READ_PHONE_STATE abgelehnt — VoIP-Calls werden trotzdem ueber AudioFocus erkannt'); console.warn('[PhoneCall] READ_PHONE_STATE abgelehnt — VoIP-Calls werden trotzdem ueber AudioFocus erkannt');
@@ -108,6 +147,7 @@ class PhoneCallService {
this.started = false; this.started = false;
this.lastState = 'idle'; this.lastState = 'idle';
this.interruptedByFocus = false; this.interruptedByFocus = false;
this.telephonyAttached = false;
} }
private _onStateChanged(state: PhoneState): void { private _onStateChanged(state: PhoneState): void {
+33
View File
@@ -86,6 +86,11 @@ class WakeWordService {
* oft einen Audio-Pegel-Spike (AudioFocus-Switch, AudioTrack re-route), * oft einen Audio-Pegel-Spike (AudioFocus-Switch, AudioTrack re-route),
* der openWakeWord faelschlich triggern kann. */ * der openWakeWord faelschlich triggern kann. */
private cooldownUntilMs: number = 0; private cooldownUntilMs: number = 0;
/** Zeitpunkt des letzten echten Wake-Word-Triggers — gebraucht damit
* ChatScreen entscheiden kann ob ein 'conversing'-State bei App-Resume
* ein false-positive war (Wake-Word im Hintergrund getriggert waehrend
* Stefan gar nicht in der App war). */
private lastTriggerAt: number = 0;
private keyword: WakeKeyword = DEFAULT_KEYWORD; private keyword: WakeKeyword = DEFAULT_KEYWORD;
private nativeReady: boolean = false; private nativeReady: boolean = false;
@@ -231,6 +236,7 @@ class WakeWordService {
} }
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)', console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
this.keyword, this.state, this.bargeListening); this.keyword, this.state, this.bargeListening);
this.lastTriggerAt = now;
if (this.nativeReady && OpenWakeWord) { if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {} try { await OpenWakeWord.stop(); } catch {}
} }
@@ -341,6 +347,33 @@ class WakeWordService {
this.setState('off'); this.setState('off');
} }
/** Wenn ein conversing-State auf einem Wake-Word-Trigger juenger als
* maxAgeMs basiert: false-positive verwerfen, zurueck zu armed.
* Wird vom ChatScreen aufgerufen wenn die App aus laengerem Hintergrund
* zurueck kommt — dann ist ein „gerade getriggertes" Wake-Word sehr
* wahrscheinlich ein TV-Spike, Husten, ARIAs eigene TTS-Aufnahme etc.
* Returnt true wenn verworfen wurde. */
async discardIfFreshlyTriggered(maxAgeMs: number = 10_000): Promise<boolean> {
if (this.state !== 'conversing') return false;
if (this.lastTriggerAt === 0) return false;
const age = Date.now() - this.lastTriggerAt;
if (age > maxAgeMs) return false;
console.log('[WakeWord] Resume: verwerfe verdaechtiges conversing (age=%dms)', age);
this.lastTriggerAt = 0;
if (this.nativeReady && OpenWakeWord) {
try {
await OpenWakeWord.start();
ToastAndroid.show('Hintergrund-Trigger verworfen — lausche wieder', ToastAndroid.SHORT);
this.setState('armed');
return true;
} catch (err) {
console.warn('[WakeWord] re-arm nach discard fehlgeschlagen:', err);
}
}
this.setState('off');
return true;
}
/** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten */ /** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten */
async resume(): Promise<void> { async resume(): Promise<void> {
if (this.state !== 'conversing') return; if (this.state !== 'conversing') return;
+7
View File
@@ -21,6 +21,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app WORKDIR /app
# CPU-only torch zuerst — sonst zieht sentence-transformers den Default
# torch-Wheel der ~5 GB CUDA-Libs (nvidia-cudnn, nvidia-cublas, cuda-toolkit,
# triton, ...) als Dependencies einsaugt. Brain laeuft komplett auf CPU
# (MiniLM-Embeddings ~120 MB), wir brauchen das alles nicht.
RUN pip install --no-cache-dir torch==2.5.1 \
--index-url https://download.pytorch.org/whl/cpu
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
+327 -2
View File
@@ -18,6 +18,9 @@ from __future__ import annotations
import json import json
import logging import logging
import os
import urllib.error
import urllib.request
from typing import Optional from typing import Optional
from conversation import Conversation, Turn from conversation import Conversation, Turn
@@ -27,6 +30,34 @@ from proxy_client import ProxyClient, Message as ProxyMessage
import skills as skills_mod import skills as skills_mod
import triggers as triggers_mod import triggers as triggers_mod
import watcher as watcher_mod import watcher as watcher_mod
import oauth as oauth_mod
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
# laedt die flux-bridge zudem ~24 GB Modell von HF (~5-10 min). Brain wartet
# synchron — Stefan kuendigt es vorher an wenn er weiss dass es feuert.
FLUX_HTTP_TIMEOUT_SEC = 1200
# Diagnostic-Settings fuer FLUX (Default-Modell + User-Keywords) liegen im
# selben File wie F5-TTS/Whisper Config — von der aria-bridge geschrieben.
VOICE_CONFIG_PATH = "/shared/config/voice_config.json"
def _load_flux_config() -> dict:
"""Liest fluxXxx-Felder aus der Voice-Config. Default-Werte wenn nichts
persistiert ist — Stefan hat in Diagnostic vielleicht noch nichts gesetzt."""
try:
with open(VOICE_CONFIG_PATH, encoding="utf-8") as f:
data = json.load(f) or {}
except (FileNotFoundError, json.JSONDecodeError):
data = {}
except Exception as exc:
logger.debug("Voice-Config lesen fehlgeschlagen: %s", exc)
data = {}
return {
"fluxDefaultModel": data.get("fluxDefaultModel", "dev"),
"fluxKeywordRaw": data.get("fluxKeywordRaw", "flux"),
"fluxKeywordSwitch": data.get("fluxKeywordSwitch", "fix"),
}
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -215,6 +246,160 @@ META_TOOLS = [
}, },
}, },
}, },
{
"type": "function",
"function": {
"name": "oauth_authorize",
"description": (
"Startet einen OAuth2-Authorize-Flow fuer einen externen "
"Service (Spotify, Google, GitHub, Strava, Microsoft, ...). "
"Returnt eine URL die Stefan im Browser oeffnen muss — er "
"loggt sich beim Provider ein und stimmt den Scopes zu, der "
"Provider redirected zu unserem RVS-Callback, RVS forwarded "
"an Brain, Token wird automatisch gespeichert.\n\n"
"**Nutze das wenn:** Stefan moechte einen Service nutzen "
"(z.B. \"verbinde mich mit Spotify\", \"baue einen Spotify-"
"Skill\"), aber `oauth_get_token` wirft *Kein Token gespeichert*.\n\n"
"**Workflow:**\n"
"1. `oauth_authorize(service='spotify')` -> URL\n"
"2. Gib Stefan die URL als anklickbaren Link\n"
"3. Warte bis er sagt dass er autorisiert hat\n"
"4. `oauth_get_token('spotify')` -> access_token, kannst Du im API-Call nutzen\n\n"
"Voraussetzung: Stefan hat in Diagnostic > OAuth-Apps fuer den "
"Service `client_id` + `client_secret` eingetragen. Falls nicht, "
"wirft das Tool eine entsprechende Fehlermeldung — sage Stefan "
"er soll das machen, NICHT versuchen die Credentials selbst zu "
"raten oder zu generieren."
),
"parameters": {
"type": "object",
"properties": {
"service": {
"type": "string",
"description": "Service-Name. Vordefinierte: spotify, google, github, strava, microsoft. Custom-Services moeglich wenn Stefan sie in oauth_apps.json eingetragen hat (mit auth_url + token_url).",
},
"scopes": {
"type": "array",
"items": {"type": "string"},
"description": "Optional: Provider-spezifische Scopes (z.B. fuer Spotify ['user-read-playback-state','playlist-modify-public']). Wenn weggelassen, werden die Default-Scopes des Services genutzt.",
},
},
"required": ["service"],
},
},
},
{
"type": "function",
"function": {
"name": "oauth_get_token",
"description": (
"Liefert das aktuelle access_token fuer einen Service. "
"Refresht automatisch wenn abgelaufen (oder < 60s Restzeit) "
"und der Provider einen refresh_token mitgegeben hat.\n\n"
"**Nutze das in Skills** wenn Du Provider-APIs callen willst — "
"der token kommt als Bearer-Header in Deinen HTTP-Request, "
"z.B. `Authorization: Bearer <token>`.\n\n"
"Wirft wenn Service noch nicht authentifiziert ist oder der "
"Refresh fehlschlaegt → dann erst `oauth_authorize` aufrufen."
),
"parameters": {
"type": "object",
"properties": {
"service": {"type": "string", "description": "z.B. spotify, google, ..."},
},
"required": ["service"],
},
},
},
{
"type": "function",
"function": {
"name": "oauth_revoke",
"description": (
"Loescht das gespeicherte Token fuer einen Service (lokal). "
"Stefan muss danach via `oauth_authorize` neu autorisieren wenn "
"er den Service wieder nutzen will. Nutze das wenn Stefan sagt "
"\"melde mich bei X ab\" oder \"vergiss meine Spotify-Anmeldung\"."
),
"parameters": {
"type": "object",
"properties": {"service": {"type": "string"}},
"required": ["service"],
},
},
},
{
"type": "function",
"function": {
"name": "flux_generate",
"description": (
"Generiere ein Bild aus einem Text-Prompt via FLUX auf der Gamebox-GPU. "
"Brauchbar fuer 'mal mir ein X', 'wie sieht ein Y aus?', Mockups, "
"Konzept-Skizzen, Memes. Render dauert 20-90s — kuendige es Stefan "
"kurz an, dann ist er nicht ueberrascht.\n\n"
"**Schreibe deine Antwort wie immer auf Deutsch**, und referenziere das "
"fertige Bild MIT dem `[FILE: ...]`-Marker, GENAU im Pfad-Format das das "
"Tool zurueckgibt. Beispiel:\n"
" 'Hier dein Aquarell:\\n[FILE: /shared/uploads/aria_generated_1234.png]'\n\n"
"Der Marker wird beim App-Renderer ausgeblendet und das Bild stattdessen "
"inline als Anhang gezeigt.\n\n"
"**Prompt-Sprache: bevorzugt Englisch.** FLUX versteht zwar Deutsch, "
"liefert aber mit englischen Prompts deutlich konsistentere Ergebnisse. "
"Uebersetze Stefans deutsche Beschreibung selbststaendig — AUSSER `raw=true`.\n\n"
"**Modus `raw=true` (Pipe-Modus):** Wenn Stefan das Raw-Keyword aus dem "
"FLUX-Settings-Block im System-Prompt nutzt (typischerweise `flux`), "
"leite seinen Text 1:1 als prompt durch — KEIN Uebersetzen, KEIN "
"Beautify, KEINE Qualitaets-Keywords. Stefan formuliert dann selbst und "
"der Prompt geht roh an FLUX. Brauchbar wenn er den vollen Output ohne "
"ARIAs Filter haben will.\n\n"
"**Modell-Wahl (`model`):** \n"
"- `default` (oder weglassen): das in den Diagnostic-Settings eingestellte "
"Default-Modell (steht im FLUX-Block im System-Prompt).\n"
"- `dev`: hochqualitatives FLUX.1-dev, 20-90s, ~28 steps.\n"
"- `schnell`: FLUX.1-schnell, 4-step distillation, ~5-15s.\n"
"Wenn Stefan das Switch-Keyword (steht ebenfalls im FLUX-Block) im Prompt "
"verwendet → setze `model` auf das ANDERE Modell als das Default. Bei "
"'in hoher Qualitaet'/'detailliert' → `dev`. Bei 'schnell mal'/'fix' → `schnell`.\n\n"
"Modell-Switch kostet einmalig 15-30s (Pipeline-Reload aus HF-Cache). "
"Stefan sieht den Status im Diagnostic-Banner.\n\n"
"Caps:\n"
"- `width`/`height`: 256-1536, wird auf Vielfache von 64 gesnappt (Default 1024)\n"
"- `steps`: 1-50 (Default 28 fuer dev, 4 fuer schnell)\n"
"- `guidance_scale`: 0.0-20.0 (Default 3.5)\n"
"- `seed`: optional, gleicher seed + gleicher prompt → gleiches Bild"
),
"parameters": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": (
"Bei raw=false (Default): englischer Bild-Prompt, von dir aus Stefans Worten gebaut, "
"mit Stil/Licht/Kamera-Stichworten. Bei raw=true: Stefans Text 1:1 ohne Aenderung."
),
},
"raw": {
"type": "boolean",
"description": (
"true = Pipe-Modus, kein Rewriting. Setzen wenn Stefan das Raw-Keyword "
"(siehe FLUX-Block im System-Prompt) am Anfang seiner Nachricht verwendet."
),
},
"model": {
"type": "string",
"enum": ["default", "dev", "schnell"],
"description": "Default-Modell oder explizit dev/schnell. Default = Diagnostic-Setting.",
},
"width": {"type": "integer", "description": "Breite in px (Default 1024, max 1536)"},
"height": {"type": "integer", "description": "Hoehe in px (Default 1024, max 1536)"},
"steps": {"type": "integer", "description": "Inference-Steps (Default 28, max 50). Mehr = besser+langsamer."},
"guidance_scale": {"type": "number", "description": "Wie strikt am Prompt kleben (Default 3.5)"},
"seed": {"type": "integer", "description": "Reproduzierbarkeits-Seed (optional)"},
},
"required": ["prompt"],
},
},
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@@ -437,10 +622,25 @@ class Agent:
condition_funcs = watcher_mod.describe_functions() condition_funcs = watcher_mod.describe_functions()
# 5. System-Prompt + Window-Messages # 5. System-Prompt + Window-Messages
flux_config = _load_flux_config()
# OAuth-Block: aktuelle Service-States + Callback-URL fuer ARIA
try:
oauth_services = oauth_mod.list_services()
except Exception as exc:
logger.warning("oauth list_services fehlgeschlagen: %s", exc)
oauth_services = None
oauth_host = os.environ.get("RVS_HOST", "").strip()
oauth_port = os.environ.get("RVS_PORT_PUBLIC", os.environ.get("RVS_PORT", "443")).strip()
oauth_tls = os.environ.get("RVS_TLS", "true").strip().lower() != "false"
system_prompt = build_system_prompt(hot, cold, skills=all_skills, system_prompt = build_system_prompt(hot, cold, skills=all_skills,
triggers=all_triggers, triggers=all_triggers,
condition_vars=condition_vars, condition_vars=condition_vars,
condition_funcs=condition_funcs) condition_funcs=condition_funcs,
flux_config=flux_config,
oauth_services=oauth_services,
oauth_callback_host=oauth_host,
oauth_callback_port=oauth_port,
oauth_callback_tls=oauth_tls)
messages = [ProxyMessage(role="system", content=system_prompt)] messages = [ProxyMessage(role="system", content=system_prompt)]
for t in self.conversation.window(): for t in self.conversation.window():
messages.append(ProxyMessage(role=t.role, content=t.content)) messages.append(ProxyMessage(role=t.role, content=t.content))
@@ -449,8 +649,14 @@ class Agent:
len(hot), len(cold), len(active_skills), len(all_skills), len(hot), len(cold), len(active_skills), len(all_skills),
len(self.conversation.window()), len(system_prompt)) len(self.conversation.window()), len(system_prompt))
# 6. Tool-Use-Loop # 6. Tool-Use-Loop. Bei Exception (z.B. Proxy-Timeout) muss ein
# Assistant-Turn als Error-Marker geschrieben werden — der User-Turn
# ist bereits in der Conversation. Ohne Gegenpart wird die naechste
# Anfrage im Window an Claude geschickt mit user → user als letzten
# zwei Turns, was OpenAI/Anthropic verwirrt und bei strict tools-Aufrufen
# zu 400-Errors fuehren kann.
final_reply = "" final_reply = ""
try:
for iteration in range(self.MAX_TOOL_ITERATIONS): for iteration in range(self.MAX_TOOL_ITERATIONS):
result = self.proxy.chat_full(messages, tools=tools) result = self.proxy.chat_full(messages, tools=tools)
if result.tool_calls: if result.tool_calls:
@@ -484,6 +690,19 @@ class Agent:
if not final_reply: if not final_reply:
raise RuntimeError("Leerer Reply vom Proxy") raise RuntimeError("Leerer Reply vom Proxy")
except Exception as exc:
# Conversation-Konsistenz: User-Turn ist drin (Schritt 1), Assistant
# muss auch rein damit die Paarung stimmt. Wir schreiben einen
# Error-Marker statt zu rollback-en (rollback wuerde Race-Conditions
# mit der JSONL-Persistenz aufmachen).
err_text = f"[Fehler: {exc}]"
logger.error("chat() Exception — schreibe Error-Marker als Assistant-Turn: %s", exc)
try:
self.conversation.add("assistant", err_text)
except Exception as add_exc:
logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc)
raise
# 7. Assistant-Turn (final reply) in die Conversation # 7. Assistant-Turn (final reply) in die Conversation
self.conversation.add("assistant", final_reply) self.conversation.add("assistant", final_reply)
return final_reply return final_reply
@@ -607,6 +826,112 @@ class Agent:
else: else:
lines.append(f"- {t['name']} ({t['type']}, {state})") lines.append(f"- {t['name']} ({t['type']}, {state})")
return "\n".join(lines) return "\n".join(lines)
if name == "oauth_authorize":
svc = (arguments.get("service") or "").strip()
if not svc:
return "FEHLER: service ist Pflicht (z.B. 'spotify')."
scopes = arguments.get("scopes") if isinstance(arguments.get("scopes"), list) else None
try:
info = oauth_mod.build_authorize_url(svc, scopes=scopes)
except RuntimeError as exc:
return f"FEHLER: {exc}"
except Exception as exc:
logger.exception("oauth_authorize fehlgeschlagen")
return f"FEHLER: {exc}"
return (
f"OK — Authorize-URL fuer {svc} bereit.\n"
f"Sage Stefan: Klicke diesen Link um Dich bei {svc} anzumelden:\n\n"
f"{info['url']}\n\n"
f"Nach Zustimmung schickt Dich der Provider zu unserem Callback "
f"({info['redirect_uri']}); RVS schnappt sich den code automatisch, "
f"Brain tauscht ihn gegen ein Token. Du musst nichts copy-pasten.\n"
f"Falls beim Provider 'redirect_uri_mismatch' auftaucht, muss Stefan "
f"`{info['redirect_uri']}` einmalig im Provider-Dashboard als gueltige "
f"Redirect-URI eintragen."
)
if name == "oauth_get_token":
svc = (arguments.get("service") or "").strip()
if not svc:
return "FEHLER: service ist Pflicht."
try:
record = oauth_mod.get_token(svc)
except RuntimeError as exc:
return f"FEHLER: {exc}"
tok = record.get("access_token", "")
ttype = record.get("token_type", "Bearer")
exp = record.get("expires_at", 0)
remain = max(0, int(exp) - int(__import__("time").time()))
return (
f"OK — Token fuer {svc} (Typ: {ttype}, gueltig noch {remain}s).\n"
f"access_token: {tok}\n"
f"Nutze als HTTP-Header: Authorization: {ttype} {tok}"
)
if name == "oauth_revoke":
svc = (arguments.get("service") or "").strip()
if not svc:
return "FEHLER: service ist Pflicht."
ok = oauth_mod.revoke(svc)
return f"OK — Token fuer {svc} entfernt." if ok else f"Kein Token fuer {svc} vorhanden."
if name == "flux_generate":
prompt = (arguments.get("prompt") or "").strip()
if not prompt:
return "FEHLER: prompt ist Pflicht."
req: dict = {"prompt": prompt}
for key in ("width", "height", "steps", "seed"):
if key in arguments and arguments[key] is not None:
try:
req[key] = int(arguments[key])
except (TypeError, ValueError):
pass
if arguments.get("guidance_scale") is not None:
try:
req["guidance_scale"] = float(arguments["guidance_scale"])
except (TypeError, ValueError):
pass
# Modell-Wahl: 'default' (oder weglassen) → flux-bridge nimmt Diagnostic-Default.
# 'dev' / 'schnell' → expliziter Override.
model_arg = (arguments.get("model") or "").strip().lower()
if model_arg in ("dev", "schnell"):
req["model"] = model_arg
# `raw` ist Brain-Domain (kein Rewriting des prompt) und wird hier
# nicht durchgereicht — der prompt enthaelt bei raw=true bereits
# Stefans Originaltext.
try:
body = json.dumps(req).encode("utf-8")
http_req = urllib.request.Request(
f"{BRIDGE_URL}/internal/flux-generate", data=body, method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(http_req, timeout=FLUX_HTTP_TIMEOUT_SEC) as resp:
raw = resp.read()
result = json.loads(raw.decode("utf-8", "ignore"))
except urllib.error.HTTPError as exc:
try:
err_body = exc.read().decode("utf-8", "ignore")
err_data = json.loads(err_body)
err = err_data.get("error") or err_body
except Exception:
err = str(exc)
return f"FEHLER (flux-bridge): {err}"
except Exception as exc:
logger.exception("flux_generate HTTP-Call fehlgeschlagen")
return f"FEHLER: flux-bridge nicht erreichbar ({exc})"
if not result.get("ok"):
return f"FEHLER (flux-bridge): {result.get('error', 'unbekannt')}"
# Kompakte Rueckmeldung: Pfad + Render-Stats. Brain bettet den
# Pfad in ihre Antwort als [FILE: ...]-Marker ein (siehe Tool-Beschreibung).
return (
f"OK — Bild generiert.\n"
f"path: {result['path']}\n"
f"size: {result.get('width','?')}x{result.get('height','?')} "
f"({result.get('sizeBytes',0)//1024} KB)\n"
f"steps={result.get('steps','?')} guidance={result.get('guidance','?')} "
f"seed={result.get('seed','?')} model={result.get('model','?')}\n"
f"renderSeconds={result.get('renderSeconds','?')}\n\n"
f"WICHTIG: Schreibe in deiner Antwort an Stefan den Pfad EXAKT als "
f"Marker: [FILE: {result['path']}] — dann zeigt die App das Bild inline."
)
if name == "memory_search": if name == "memory_search":
query = (arguments.get("query") or "").strip() query = (arguments.get("query") or "").strip()
if not query: if not query:
+116
View File
@@ -36,6 +36,7 @@ import metrics as metrics_mod
import triggers as triggers_mod import triggers as triggers_mod
import watcher as watcher_mod import watcher as watcher_mod
import background as background_mod import background as background_mod
import oauth as oauth_mod
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("aria-brain") logger = logging.getLogger("aria-brain")
@@ -849,3 +850,118 @@ async def skills_import(request: Request, overwrite: bool = False):
except ValueError as exc: except ValueError as exc:
raise HTTPException(400, str(exc)) raise HTTPException(400, str(exc))
return {"imported": manifest} return {"imported": manifest}
# ── OAuth ─────────────────────────────────────────────────────────
@app.get("/oauth/services")
async def oauth_services_list():
"""Liste aller Services mit Status (configured/authenticated/expires)."""
return {"services": oauth_mod.list_services()}
@app.get("/oauth/apps")
async def oauth_apps_get():
"""Liefert die persistierte Provider-Config (client_id sichtbar, client_secret
NICHT — wer den Wert braucht muss ihn neu eintragen). Fuer Diagnostic-UI."""
apps = oauth_mod._load_json(oauth_mod.APPS_FILE)
safe = {}
for service, entry in apps.items():
if not isinstance(entry, dict):
continue
safe[service] = {
"client_id": entry.get("client_id", ""),
"has_client_secret": bool(entry.get("client_secret")),
"scopes": entry.get("scopes"),
"auth_url": entry.get("auth_url"),
"token_url": entry.get("token_url"),
}
return {"apps": safe, "defaults": list(oauth_mod.DEFAULT_PROVIDERS.keys())}
class OAuthAppIn(BaseModel):
service: str
client_id: str = ""
client_secret: str = ""
scopes: Optional[List[str]] = None
auth_url: Optional[str] = None
token_url: Optional[str] = None
@app.post("/oauth/apps")
async def oauth_apps_set(body: OAuthAppIn):
"""Speichert/aktualisiert eine Provider-Config. Leerer client_secret laesst
den bestehenden Wert stehen (damit man die Form ohne Re-Eingabe absenden
kann fuer reine scope-Aenderungen)."""
service = (body.service or "").strip()
if not service or not service.isidentifier() and not all(c.isalnum() or c in "_-" for c in service):
raise HTTPException(400, "Ungueltiger service-Name (a-z0-9_- erlaubt)")
apps = oauth_mod._load_json(oauth_mod.APPS_FILE)
entry = apps.get(service) or {}
if body.client_id:
entry["client_id"] = body.client_id.strip()
if body.client_secret:
entry["client_secret"] = body.client_secret.strip()
if body.scopes is not None:
entry["scopes"] = body.scopes
if body.auth_url:
entry["auth_url"] = body.auth_url.strip()
if body.token_url:
entry["token_url"] = body.token_url.strip()
apps[service] = entry
oauth_mod._save_json(oauth_mod.APPS_FILE, apps)
logger.info("OAuth-App %s gespeichert (client_id=%s, has_secret=%s)",
service, entry.get("client_id", ""), bool(entry.get("client_secret")))
return {"ok": True, "service": service}
@app.delete("/oauth/apps/{service}")
async def oauth_apps_delete(service: str):
apps = oauth_mod._load_json(oauth_mod.APPS_FILE)
if service in apps:
apps.pop(service)
oauth_mod._save_json(oauth_mod.APPS_FILE, apps)
# Token auch wegwerfen
oauth_mod.revoke(service)
return {"ok": True}
@app.post("/oauth/{service}/revoke")
async def oauth_revoke_endpoint(service: str):
return {"ok": oauth_mod.revoke(service)}
class OAuthAuthorizeIn(BaseModel):
service: str
scopes: Optional[List[str]] = None
@app.post("/oauth/authorize")
async def oauth_authorize_endpoint(body: OAuthAuthorizeIn):
"""Baut eine Authorize-URL fuer einen Service. Diagnostic kann das nutzen
um den Auth-Flow manuell anzustossen. ARIA selbst nutzt das Tool
`oauth_authorize` (in agent._dispatch_tool gemapped auf die gleiche Logik)."""
try:
return oauth_mod.build_authorize_url(body.service, scopes=body.scopes)
except RuntimeError as exc:
raise HTTPException(400, str(exc))
@app.post("/internal/oauth-callback")
async def oauth_callback_internal(request: Request):
"""Wird von aria-bridge gerufen wenn ein RVS oauth_callback ankommt.
Macht den state-Match + token-exchange und persistiert."""
try:
body = await request.json()
except Exception as exc:
raise HTTPException(400, f"bad json: {exc}")
service = (body.get("service") or "").strip()
code = (body.get("code") or "").strip()
state = (body.get("state") or "").strip()
err = body.get("error") or None
err_desc = body.get("errorDescription") or None
if not service:
raise HTTPException(400, "service erforderlich")
result = oauth_mod.handle_callback(service, code, state, error=err, error_description=err_desc)
return result
+425
View File
@@ -0,0 +1,425 @@
"""
OAuth-Manager fuer ARIA. Generischer OAuth2 Authorization-Code-Flow fuer
Spotify, Google, GitHub, Strava, Microsoft etc.
Architektur:
- Brain haelt einen Pending-Store: state-String → pending Auth-Request
(mit timeout). Wenn ein Callback ankommt (via aria-bridge ueber RVS),
matched der state und der code wird gegen access_token getauscht.
- Token-Storage: /shared/config/oauth_tokens.json (pro Service ein Eintrag
mit access_token, refresh_token, expires_at, scope).
- Provider-Configs: /shared/config/oauth_apps.json — pro Service
{client_id, client_secret, auth_url, token_url, scopes, ...}. Wird
typischerweise via Diagnostic-UI gefuellt.
- Token-Refresh: automatisch wenn access_token abgelaufen oder < 60s
bis Ablauf bei get_token() Aufruf.
OAuth-Callback-URL: https://{RVS_HOST}:{RVS_PORT_PUBLIC}/oauth/callback/{service}
RVS_PORT_PUBLIC ist nicht zwingend gleich RVS_PORT (port-mapping via TLS-Proxy).
ARIA setzt die URL beim Auth-Request automatisch — Stefan muss sie EINMAL pro
Service im Provider-Dashboard registrieren.
"""
from __future__ import annotations
import base64
import json
import logging
import os
import secrets
import time
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
CONFIG_DIR = Path("/shared/config")
APPS_FILE = CONFIG_DIR / "oauth_apps.json"
TOKENS_FILE = CONFIG_DIR / "oauth_tokens.json"
# Default-Provider-Configs. Werden von oauth_apps.json gemergt (User-Config
# uebersteuert). Stefan muss nur client_id + client_secret eintragen.
DEFAULT_PROVIDERS: dict[str, dict] = {
"spotify": {
"auth_url": "https://accounts.spotify.com/authorize",
"token_url": "https://accounts.spotify.com/api/token",
"scopes": ["user-read-playback-state", "user-modify-playback-state",
"user-read-currently-playing", "playlist-read-private",
"user-library-read"],
"client_auth": "basic", # client_id:client_secret als Basic-Auth-Header
},
"google": {
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"scopes": ["openid", "email", "profile"],
"client_auth": "body", # client_id+secret im Body
"extra_auth_params": {"access_type": "offline", "prompt": "consent"},
},
"github": {
"auth_url": "https://github.com/login/oauth/authorize",
"token_url": "https://github.com/login/oauth/access_token",
"scopes": ["read:user"],
"client_auth": "body",
"accept_header": "application/json", # GitHub returns form-urlencoded otherwise
},
"strava": {
"auth_url": "https://www.strava.com/oauth/authorize",
"token_url": "https://www.strava.com/oauth/token",
"scopes": ["read", "activity:read_all"],
"client_auth": "body",
"extra_auth_params": {"approval_prompt": "auto"},
},
"microsoft": {
"auth_url": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
"token_url": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
"scopes": ["User.Read", "offline_access"],
"client_auth": "body",
},
}
# Pending Auth-Requests: state → {service, scopes, redirect_uri, created_at}
_PENDING: dict[str, dict] = {}
PENDING_TTL_SEC = 600 # 10 min — laenger nicht sinnvoll, OAuth-Codes sind eh kurzlebig
# ── Helpers ─────────────────────────────────────────────────
def _callback_url(service: str) -> str:
"""Baut die Redirect-URL die wir bei der Provider-Auth angeben.
Liest RVS_HOST / RVS_PORT_PUBLIC / RVS_TLS aus env."""
host = os.environ.get("RVS_HOST", "").strip()
if not host:
raise RuntimeError("RVS_HOST nicht gesetzt — OAuth-Callbacks nicht moeglich")
port = os.environ.get("RVS_PORT_PUBLIC", os.environ.get("RVS_PORT", "443")).strip()
tls = os.environ.get("RVS_TLS", "true").strip().lower() != "false"
scheme = "https" if tls else "http"
# Default-Ports 443/80 nicht in URL anhaengen
if (tls and port == "443") or (not tls and port == "80"):
return f"{scheme}://{host}/oauth/callback/{service}"
return f"{scheme}://{host}:{port}/oauth/callback/{service}"
def _load_json(path: Path) -> dict:
try:
if path.exists():
return json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
logger.warning("OAuth-Datei %s lesen fehlgeschlagen: %s", path, exc)
return {}
def _save_json(path: Path, data: dict) -> None:
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
tmp.replace(path)
# 600 — enthaelt Secrets
try: os.chmod(path, 0o600)
except OSError: pass
except Exception as exc:
logger.error("OAuth-Datei %s speichern fehlgeschlagen: %s", path, exc)
def _provider_config(service: str) -> dict:
"""Mergt Default-Provider-Config mit User-Override aus oauth_apps.json."""
defaults = DEFAULT_PROVIDERS.get(service, {}).copy()
apps = _load_json(APPS_FILE)
user = (apps.get(service) or {}).copy()
# Tiefes Merge nicht noetig — die kollidierenden Felder sind alle scalar/list.
merged = {**defaults, **user}
return merged
def _provider_credentials(service: str) -> tuple[str, str]:
"""Liest client_id + client_secret aus oauth_apps.json. Wirft wenn nicht
konfiguriert — der OAuth-Flow kann ohne nicht starten."""
apps = _load_json(APPS_FILE)
entry = apps.get(service) or {}
cid = (entry.get("client_id") or "").strip()
sec = (entry.get("client_secret") or "").strip()
if not cid or not sec:
raise RuntimeError(
f"OAuth-App '{service}' nicht konfiguriert. Bitte in Diagnostic > "
f"OAuth-Apps client_id + client_secret eintragen."
)
return cid, sec
def _cleanup_pending() -> None:
"""Entfernt abgelaufene Pending-Auths."""
now = time.time()
for state, info in list(_PENDING.items()):
if now - info.get("created_at", 0) > PENDING_TTL_SEC:
_PENDING.pop(state, None)
# ── Authorize ───────────────────────────────────────────────
def build_authorize_url(service: str, scopes: Optional[list[str]] = None,
extra_params: Optional[dict] = None) -> dict:
"""Baut die Authorize-URL fuer einen Provider. Speichert den state
im Pending-Store. Returns {url, state, redirect_uri, service}.
Wird vom Brain-Tool oauth_authorize gerufen. ARIA gibt die url an Stefan,
der oeffnet sie im Browser, autorisiert, Provider redirected zur
redirect_uri (= RVS), RVS broadcasted, bridge forwarded, brain matched
state → exchange.
"""
_cleanup_pending()
cfg = _provider_config(service)
if not cfg.get("auth_url") or not cfg.get("token_url"):
raise RuntimeError(f"Provider '{service}' hat keine auth_url/token_url. "
f"In oauth_apps.json eintragen oder einen der "
f"vordefinierten Services nutzen ({', '.join(DEFAULT_PROVIDERS)}).")
cid, _ = _provider_credentials(service)
redirect_uri = _callback_url(service)
state = secrets.token_urlsafe(32)
use_scopes = scopes if scopes else cfg.get("scopes") or []
params = {
"client_id": cid,
"response_type": "code",
"redirect_uri": redirect_uri,
"state": state,
}
if use_scopes:
params["scope"] = " ".join(use_scopes)
params.update(cfg.get("extra_auth_params") or {})
if extra_params:
params.update(extra_params)
url = cfg["auth_url"] + "?" + urllib.parse.urlencode(params)
_PENDING[state] = {
"service": service,
"redirect_uri": redirect_uri,
"scopes": use_scopes,
"created_at": time.time(),
}
logger.info("[oauth] Authorize-URL fuer %s gebaut: state=%s redirect=%s",
service, state[:8] + "...", redirect_uri)
return {"url": url, "state": state, "redirect_uri": redirect_uri, "service": service}
# ── Token-Exchange ──────────────────────────────────────────
def _token_request(token_url: str, body_params: dict, cfg: dict,
client_id: str, client_secret: str) -> dict:
"""POST an provider /token endpoint. Returns parsed JSON oder wirft."""
data = urllib.parse.urlencode(body_params).encode("utf-8")
headers = {"Content-Type": "application/x-www-form-urlencoded"}
if cfg.get("accept_header"):
headers["Accept"] = cfg["accept_header"]
# Client-Auth: 'basic' (Header) oder 'body' (im Form-Body)
if cfg.get("client_auth") == "basic":
auth_str = f"{client_id}:{client_secret}"
b64 = base64.b64encode(auth_str.encode("utf-8")).decode("ascii")
headers["Authorization"] = f"Basic {b64}"
else:
# bereits im body_params drin (siehe Caller)
pass
req = urllib.request.Request(token_url, data=data, method="POST", headers=headers)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
raw = resp.read().decode("utf-8", "ignore")
try:
return json.loads(raw)
except json.JSONDecodeError:
# GitHub default ist form-urlencoded — accept_header sollte
# JSON anfordern, aber falls's doch mal kommt:
parsed = urllib.parse.parse_qs(raw)
return {k: v[0] if isinstance(v, list) and v else v for k, v in parsed.items()}
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", "ignore")[:500]
raise RuntimeError(f"Token-Request HTTP {e.code}: {body}") from e
def handle_callback(service: str, code: str, state: str,
error: Optional[str] = None,
error_description: Optional[str] = None) -> dict:
"""Verarbeitet einen OAuth-Callback. Validiert state, tauscht code gegen
Token, speichert. Returns {ok, service, message, ...}.
Wird von /internal/oauth-callback (HTTP, von aria-bridge) gerufen.
"""
_cleanup_pending()
if error:
# Provider hat User-Abbruch oder Fehler gemeldet
_PENDING.pop(state, None) if state else None
logger.warning("[oauth] Provider-Error %s/%s: %s%s",
service, state[:8] + "..." if state else "?", error, error_description)
return {"ok": False, "service": service, "error": error,
"errorDescription": error_description}
pending = _PENDING.pop(state, None)
if not pending:
logger.warning("[oauth] Unknown state %s fuer %s — abgelaufen oder CSRF?", state[:8] + "...", service)
return {"ok": False, "service": service,
"error": "invalid_state",
"errorDescription": "Unbekannter oder abgelaufener state (Auth-Anfrage muss erst per oauth_authorize neu gestartet werden)."}
if pending.get("service") != service:
logger.warning("[oauth] state-Service-Mismatch: pending=%s vs callback=%s",
pending.get("service"), service)
return {"ok": False, "service": service,
"error": "service_mismatch",
"errorDescription": "state gehoert zu einem anderen Service."}
if not code:
return {"ok": False, "service": service, "error": "no_code"}
cfg = _provider_config(service)
try:
client_id, client_secret = _provider_credentials(service)
except RuntimeError as exc:
return {"ok": False, "service": service, "error": "no_credentials",
"errorDescription": str(exc)}
body = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": pending["redirect_uri"],
}
if cfg.get("client_auth") != "basic":
body["client_id"] = client_id
body["client_secret"] = client_secret
try:
token_data = _token_request(cfg["token_url"], body, cfg, client_id, client_secret)
except Exception as exc:
logger.exception("[oauth] Token-Exchange fehlgeschlagen fuer %s", service)
return {"ok": False, "service": service, "error": "exchange_failed",
"errorDescription": str(exc)[:200]}
access = token_data.get("access_token")
if not access:
return {"ok": False, "service": service, "error": "no_access_token",
"errorDescription": str(token_data)[:200]}
expires_in = int(token_data.get("expires_in") or 3600)
refresh = token_data.get("refresh_token") or ""
scope = token_data.get("scope") or " ".join(pending.get("scopes") or [])
token_type = token_data.get("token_type") or "Bearer"
record = {
"service": service,
"access_token": access,
"refresh_token": refresh,
"token_type": token_type,
"scope": scope,
"expires_at": int(time.time()) + expires_in,
"obtained_at": int(time.time()),
}
_persist_token(service, record)
logger.info("[oauth] %s authentifiziert — expires in %ds, refresh=%s",
service, expires_in, "ja" if refresh else "nein")
return {"ok": True, "service": service, "expiresIn": expires_in,
"hasRefresh": bool(refresh), "scope": scope}
# ── Token-Storage / Refresh / Revoke ─────────────────────────
def _persist_token(service: str, record: dict) -> None:
tokens = _load_json(TOKENS_FILE)
tokens[service] = record
_save_json(TOKENS_FILE, tokens)
def _load_token(service: str) -> Optional[dict]:
return _load_json(TOKENS_FILE).get(service)
def get_token(service: str, refresh_threshold_sec: int = 60) -> dict:
"""Holt das aktuelle access_token fuer einen Service. Refresht automatisch
wenn weniger als refresh_threshold_sec Restzeit. Returns das ganze
record-dict — Caller nimmt sich access_token raus.
Wirft wenn nicht authentifiziert oder Refresh fehlschlaegt — Tool-Aufrufer
soll dann oauth_authorize anbieten."""
record = _load_token(service)
if not record:
raise RuntimeError(f"Kein Token fuer '{service}' gespeichert. Erst per "
f"oauth_authorize authentifizieren.")
exp = int(record.get("expires_at") or 0)
remaining = exp - int(time.time())
if remaining > refresh_threshold_sec:
return record
# Refresh noetig
refresh_tok = (record.get("refresh_token") or "").strip()
if not refresh_tok:
raise RuntimeError(f"Token fuer '{service}' abgelaufen und kein refresh_token "
f"vorhanden — bitte neu autorisieren mit oauth_authorize.")
cfg = _provider_config(service)
client_id, client_secret = _provider_credentials(service)
body = {
"grant_type": "refresh_token",
"refresh_token": refresh_tok,
}
if cfg.get("client_auth") != "basic":
body["client_id"] = client_id
body["client_secret"] = client_secret
try:
new_data = _token_request(cfg["token_url"], body, cfg, client_id, client_secret)
except Exception as exc:
raise RuntimeError(f"Token-Refresh fuer '{service}' fehlgeschlagen: {exc}") from exc
new_access = new_data.get("access_token")
if not new_access:
raise RuntimeError(f"Refresh-Antwort ohne access_token: {new_data}")
expires_in = int(new_data.get("expires_in") or 3600)
# refresh_token kann (manche Provider) bei jedem Refresh rotieren
new_refresh = (new_data.get("refresh_token") or refresh_tok).strip()
record.update({
"access_token": new_access,
"refresh_token": new_refresh,
"expires_at": int(time.time()) + expires_in,
"obtained_at": int(time.time()),
})
if new_data.get("scope"):
record["scope"] = new_data["scope"]
_persist_token(service, record)
logger.info("[oauth] %s Token refreshed — neue Restzeit %ds", service, expires_in)
return record
def revoke(service: str) -> bool:
"""Entfernt das Token aus dem Storage (Best-Effort, kein Provider-Revoke-Call)."""
tokens = _load_json(TOKENS_FILE)
if service not in tokens:
return False
tokens.pop(service, None)
_save_json(TOKENS_FILE, tokens)
logger.info("[oauth] %s Token geloescht (lokal).", service)
return True
def list_services() -> list[dict]:
"""Diagnostik: zeigt fuer jeden konfigurierten Service ob Token da ist
+ Ablaufzeit. Wird von Diagnostic genutzt."""
apps = _load_json(APPS_FILE)
tokens = _load_json(TOKENS_FILE)
out = []
services = set(apps.keys()) | set(tokens.keys()) | set(DEFAULT_PROVIDERS.keys())
now = int(time.time())
for s in sorted(services):
app = apps.get(s) or {}
tok = tokens.get(s) or {}
configured = bool(app.get("client_id") and app.get("client_secret"))
out.append({
"service": s,
"configured": configured,
"authenticated": bool(tok.get("access_token")),
"expiresAt": tok.get("expires_at"),
"expiresInSec": (tok.get("expires_at", 0) - now) if tok.get("expires_at") else None,
"hasRefresh": bool(tok.get("refresh_token")),
"scope": tok.get("scope", ""),
"isDefault": s in DEFAULT_PROVIDERS,
})
return out
+106 -1
View File
@@ -240,6 +240,94 @@ def build_triggers_section(
return "\n".join(lines) return "\n".join(lines)
def build_oauth_section(oauth_services: list[dict] | None,
callback_host: str = "",
callback_port: str = "443",
callback_tls: bool = True) -> str:
"""Block fuer den System-Prompt: zeigt ARIA welche externen Services
via OAuth verfuegbar sind, welche schon authentifiziert sind, und welche
Callback-URL beim Provider eingetragen werden muss."""
scheme = "https" if callback_tls else "http"
if callback_host:
if (callback_tls and callback_port == "443") or (not callback_tls and callback_port == "80"):
base = f"{scheme}://{callback_host}/oauth/callback/<SERVICE>"
else:
base = f"{scheme}://{callback_host}:{callback_port}/oauth/callback/<SERVICE>"
else:
base = "<nicht konfiguriert — RVS_HOST in brain env fehlt>"
lines = [
"## OAuth externe Services",
"",
"Du kannst Spotify, Google, GitHub, Strava, Microsoft (und custom-konfigurierte) "
"Services via OAuth2 ansprechen. Workflow ist IMMER:",
"1. `oauth_get_token(service)` versuchen — Token vorhanden? → benutzen.",
"2. Wirft 'Kein Token gespeichert'? → `oauth_authorize(service)` aufrufen, URL an Stefan, warten, dann nochmal `oauth_get_token`.",
"",
f"**Callback-URL (fest, NICHT raten):** `{base}`",
"Diese URL muss Stefan EINMAL pro Service im Provider-Dashboard als gueltige "
"Redirect-URI eintragen. Sie ist hardcoded an die RVS-Infrastruktur gebunden "
"und aendert sich nicht — auch nicht wenn Du als Brain neu aufgesetzt wirst.",
"",
"**NICHT** versuchen client_id / client_secret selbst zu generieren oder zu "
"raten. Wenn nicht eingetragen → Stefan sagen er soll es in Diagnostic > "
"OAuth-Apps machen.",
]
if oauth_services:
lines.append("")
lines.append("**Aktuelle Service-Status:**")
for s in oauth_services:
name = s.get("service", "?")
configured = s.get("configured", False)
auth = s.get("authenticated", False)
remain = s.get("expiresInSec")
parts = []
if not configured:
parts.append("Credentials fehlen")
elif not auth:
parts.append("nicht authentifiziert")
else:
if remain is None:
parts.append("authentifiziert")
elif remain > 0:
parts.append(f"authentifiziert, Token gueltig noch {remain}s")
else:
parts.append("Token abgelaufen (wird automatisch refresht)")
lines.append(f"- `{name}`: {' / '.join(parts)}")
return "\n".join(lines)
def build_flux_section(flux_config: dict) -> str:
"""Block fuer den System-Prompt: aktuelle Diagnostic-Settings fuer
Bildgenerierung (Default-Modell + User-konfigurierbare Keywords).
flux_config kommt aus /shared/config/voice_config.json:
fluxDefaultModel: "dev" | "schnell" (Default "dev")
fluxKeywordRaw: z.B. "flux" (Pipe-Modus, kein Rewriting)
fluxKeywordSwitch:z.B. "fix" (anderes Modell als Default)
"""
default_model = (flux_config or {}).get("fluxDefaultModel", "dev")
kw_raw = (flux_config or {}).get("fluxKeywordRaw", "flux")
kw_switch = (flux_config or {}).get("fluxKeywordSwitch", "fix")
other_model = "schnell" if default_model == "dev" else "dev"
lines = [
"## FLUX Bildgenerierung",
f"- Default-Modell: `{default_model}` (alternativ: `{other_model}`).",
f"- Raw-Keyword: `{kw_raw}` — wenn Stefans Nachricht damit beginnt "
f"oder das Wort als ersten echten Wortteil enthaelt, ruf "
f"`flux_generate(..., raw=true)` und leite seinen Text 1:1 als prompt "
f"durch. KEIN Uebersetzen, KEIN Beautify, KEINE Stil-Adds.",
f"- Switch-Keyword: `{kw_switch}` — taucht's in der Nachricht auf, "
f"setze `model=\"{other_model}\"` (das ANDERE Modell als das Default).",
"- Natuerliche Sprache funktioniert auch: 'mal eben fix' / 'schnell' → schnell, "
"'in hoher Qualitaet' / 'detailliert' → dev.",
"- Whisper-Erkennung des Raw-Keywords ist nicht perfekt — wenn Stefans "
"Sprachnachricht z.B. mit 'fluks', 'flocks', 'fluxx' anfaengt, behandle "
"das auch als Raw-Keyword.",
]
return "\n".join(lines)
def build_system_prompt( def build_system_prompt(
pinned: List[MemoryPoint], pinned: List[MemoryPoint],
cold: List[MemoryPoint] | None = None, cold: List[MemoryPoint] | None = None,
@@ -247,8 +335,13 @@ def build_system_prompt(
triggers: List[dict] | None = None, triggers: List[dict] | None = None,
condition_vars: List[dict] | None = None, condition_vars: List[dict] | None = None,
condition_funcs: List[dict] | None = None, condition_funcs: List[dict] | None = None,
flux_config: dict | None = None,
oauth_services: list[dict] | None = None,
oauth_callback_host: str = "",
oauth_callback_port: str = "443",
oauth_callback_tls: bool = True,
) -> str: ) -> str:
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers.""" """Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth."""
parts = [build_hot_memory_section(pinned), "", build_time_section()] parts = [build_hot_memory_section(pinned), "", build_time_section()]
if skills: if skills:
parts.append("") parts.append("")
@@ -256,6 +349,18 @@ def build_system_prompt(
if condition_vars: if condition_vars:
parts.append("") parts.append("")
parts.append(build_triggers_section(triggers or [], condition_vars, condition_funcs)) parts.append(build_triggers_section(triggers or [], condition_vars, condition_funcs))
if flux_config is not None:
parts.append("")
parts.append(build_flux_section(flux_config))
# OAuth-Block bauen wir nur wenn RVS_HOST konfiguriert ist (sonst hat
# die Callback-URL keinen Sinn). Sonst lassen wir den Block weg statt
# ARIA eine "<nicht konfiguriert>"-URL zu zeigen.
if oauth_callback_host:
parts.append("")
parts.append(build_oauth_section(oauth_services,
callback_host=oauth_callback_host,
callback_port=oauth_callback_port,
callback_tls=oauth_callback_tls))
if cold: if cold:
parts.append("") parts.append("")
parts.append(build_cold_memory_section(cold)) parts.append(build_cold_memory_section(cold))
+20 -3
View File
@@ -25,7 +25,17 @@ logger = logging.getLogger(__name__)
RUNTIME_CONFIG_FILE = Path("/shared/config/runtime.json") RUNTIME_CONFIG_FILE = Path("/shared/config/runtime.json")
ENV_MODEL = os.environ.get("BRAIN_MODEL", "claude-sonnet-4") ENV_MODEL = os.environ.get("BRAIN_MODEL", "claude-sonnet-4")
PROXY_URL = os.environ.get("PROXY_URL", "http://proxy:3456") PROXY_URL = os.environ.get("PROXY_URL", "http://proxy:3456")
PROXY_TIMEOUT_SEC = float(os.environ.get("PROXY_TIMEOUT_SEC", "1200")) # Read-Timeout: wie lange wir auf die HTTP-Antwort vom Proxy warten.
# Proxy ist non-streaming → erstes Byte kommt erst NACH subprocess close.
# Agent-Loops (Pentests etc.) koennen >1h dauern → muss hoch sein.
# Default 24h, kann via PROXY_TIMEOUT_SEC env ueberschrieben werden.
PROXY_TIMEOUT_SEC = float(os.environ.get("PROXY_TIMEOUT_SEC", "86400"))
# Connect/Write/Pool: klein damit toter Proxy schnell erkannt wird.
# Wenn der Proxy-Container nicht antwortet beim TCP-Connect oder waehrend
# wir den Request-Body schreiben, ist er kaputt — kein Grund 24h zu warten.
PROXY_CONNECT_TIMEOUT_SEC = float(os.environ.get("PROXY_CONNECT_TIMEOUT_SEC", "10"))
PROXY_WRITE_TIMEOUT_SEC = float(os.environ.get("PROXY_WRITE_TIMEOUT_SEC", "30"))
PROXY_POOL_TIMEOUT_SEC = float(os.environ.get("PROXY_POOL_TIMEOUT_SEC", "10"))
def _read_model_from_runtime() -> str: def _read_model_from_runtime() -> str:
@@ -62,8 +72,15 @@ class ProxyClient:
def __init__(self, base_url: str = PROXY_URL, model: str = DEFAULT_MODEL): def __init__(self, base_url: str = PROXY_URL, model: str = DEFAULT_MODEL):
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.model = model self.model = model
# Persistente Client-Connection — vermeidet TCP-Handshake bei jedem Call # Persistente Client-Connection — vermeidet TCP-Handshake bei jedem Call.
self._client = httpx.Client(timeout=PROXY_TIMEOUT_SEC) # Timeouts split nach Phase: connect/write/pool klein (toter Proxy → schnell
# ReadTimeout), read gross (ARIA darf ewig rechnen).
self._client = httpx.Client(timeout=httpx.Timeout(
connect=PROXY_CONNECT_TIMEOUT_SEC,
read=PROXY_TIMEOUT_SEC,
write=PROXY_WRITE_TIMEOUT_SEC,
pool=PROXY_POOL_TIMEOUT_SEC,
))
def chat(self, messages: List[Message], model: Optional[str] = None) -> str: def chat(self, messages: List[Message], model: Optional[str] = None) -> str:
"""Convenience: einfacher Chat ohne Tools. Gibt nur den Reply-String zurueck.""" """Convenience: einfacher Chat ohne Tools. Gibt nur den Reply-String zurueck."""
+302 -5
View File
@@ -487,6 +487,7 @@ class ARIABridge:
self.tts_enabled = True self.tts_enabled = True
self.xtts_voice = "" self.xtts_voice = ""
self._f5tts_config: dict = {} self._f5tts_config: dict = {}
self._flux_config: dict = {}
vc: dict = {} vc: dict = {}
# Gespeicherte Voice-Config laden # Gespeicherte Voice-Config laden
try: try:
@@ -503,9 +504,14 @@ class ARIABridge:
"f5ttsCfgStrength", "f5ttsNfeStep"): "f5ttsCfgStrength", "f5ttsNfeStep"):
if k in vc: if k in vc:
self._f5tts_config[k] = vc[k] self._f5tts_config[k] = vc[k]
logger.info("Voice-Config geladen: tts=%s voice=%s f5tts=%s", # FLUX-Felder (Default-Modell + Keywords) gleicher Mechanismus
for k in ("fluxDefaultModel", "fluxKeywordRaw", "fluxKeywordSwitch", "huggingfaceToken"):
if k in vc:
self._flux_config[k] = vc[k]
logger.info("Voice-Config geladen: tts=%s voice=%s f5tts=%s flux=%s",
self.tts_enabled, self.xtts_voice or "default", self.tts_enabled, self.xtts_voice or "default",
self._f5tts_config or "defaults") self._f5tts_config or "defaults",
self._flux_config or "defaults")
except Exception as e: except Exception as e:
logger.warning("Voice-Config laden fehlgeschlagen: %s", e) logger.warning("Voice-Config laden fehlgeschlagen: %s", e)
# Whisper-Modell: Config hat Vorrang, dann env/Default (medium) # Whisper-Modell: Config hat Vorrang, dann env/Default (medium)
@@ -541,6 +547,12 @@ class ARIABridge:
# Beeinflusst das Timeout fuer stt_request — bei "loading" warten wir laenger, # Beeinflusst das Timeout fuer stt_request — bei "loading" warten wir laenger,
# weil das Modell beim ersten Request noch ~1-2 Min runtergeladen werden kann. # weil das Modell beim ersten Request noch ~1-2 Min runtergeladen werden kann.
self._remote_stt_ready: bool = False self._remote_stt_ready: bool = False
# FLUX-Render-Requests die aktuell auf Antwort der flux-bridge (Gamebox) warten.
# requestId → Future mit dem flux_response-Payload (oder None bei Fehler).
self._pending_flux: dict[str, asyncio.Future] = {}
# flux-bridge service_status: True wenn ready. Render-Timeouts werden
# bei 'loading' deutlich grosszuegiger gesetzt (Modell-Download ~24 GB).
self._remote_flux_ready: bool = False
# User-Message-Counter fuer Auto-Compact. Bei zu langer Konversation # User-Message-Counter fuer Auto-Compact. Bei zu langer Konversation
# sprengt die argv-Liste beim Claude-Subprocess-Spawn (E2BIG). Bei # sprengt die argv-Liste beim Claude-Subprocess-Spawn (E2BIG). Bei
# COMPACT_AFTER erreicht → Sessions reset + Container restart. # COMPACT_AFTER erreicht → Sessions reset + Container restart.
@@ -1232,6 +1244,7 @@ class ARIABridge:
"whisperModel": self.stt_engine.model_size, "whisperModel": self.stt_engine.model_size,
} }
payload.update(getattr(self, "_f5tts_config", {}) or {}) payload.update(getattr(self, "_f5tts_config", {}) or {})
payload.update(getattr(self, "_flux_config", {}) or {})
await self._send_to_rvs({ await self._send_to_rvs({
"type": "config", "type": "config",
"payload": payload, "payload": payload,
@@ -1478,8 +1491,11 @@ class ARIABridge:
try: try:
url = f"{current_url}?token={self.rvs_token}" url = f"{current_url}?token={self.rvs_token}"
logger.info("[rvs] Verbinde: %s", current_url) logger.info("[rvs] Verbinde: %s", current_url)
# max_size=50MB (siehe core-Connect oben — gleicher Grund). # max_size=100MB synchron zum RVS-Server (siehe rvs/server.js).
async with websockets.connect(url, max_size=50 * 1024 * 1024) as ws: # File-Re-Download fuer Anhaenge braucht Platz fuer base64-
# inflate (~1.33×). Groessere Files lehnt der file_request-
# Handler proaktiv ab bevor's zur 1009-Disconnection kommt.
async with websockets.connect(url, max_size=100 * 1024 * 1024) as ws:
self.ws_rvs = ws self.ws_rvs = ws
retry_delay = 2 retry_delay = 2
logger.info("[rvs] Verbunden — warte auf App-Nachrichten") logger.info("[rvs] Verbunden — warte auf App-Nachrichten")
@@ -1661,6 +1677,12 @@ class ARIABridge:
return return
if msg_type == "cancel_request": if msg_type == "cancel_request":
hard = bool(payload.get("hard"))
if hard:
logger.warning("[rvs] NOT-AUS — hard cancel: Diagnostic /api/cancel + Proxy /cancel-all")
await self._cancel_via_diagnostic()
await self._cancel_proxy_subprocesses()
else:
logger.info("[rvs] Cancel-Request von App — rufe Diagnostic /api/cancel auf") logger.info("[rvs] Cancel-Request von App — rufe Diagnostic /api/cancel auf")
await self._cancel_via_diagnostic() await self._cancel_via_diagnostic()
await self._emit_activity("idle", "") await self._emit_activity("idle", "")
@@ -1767,6 +1789,15 @@ class ARIABridge:
self._f5tts_config = {} self._f5tts_config = {}
self._f5tts_config[k] = payload[k] self._f5tts_config[k] = payload[k]
changed = True changed = True
# FLUX-Felder: gleiche Logik wie F5-TTS. flux-bridge applied
# fluxDefaultModel selbst (Pipeline-Swap). Keywords nutzt Brain
# via /shared/config/voice_config.json.
for k in ("fluxDefaultModel", "fluxKeywordRaw", "fluxKeywordSwitch", "huggingfaceToken"):
if k in payload:
if not hasattr(self, "_flux_config"):
self._flux_config = {}
self._flux_config[k] = payload[k]
changed = True
# Persistent speichern in Shared Volume # Persistent speichern in Shared Volume
if changed: if changed:
try: try:
@@ -1777,6 +1808,7 @@ class ARIABridge:
"whisperModel": self.stt_engine.model_size, "whisperModel": self.stt_engine.model_size,
} }
config_data.update(getattr(self, "_f5tts_config", {})) config_data.update(getattr(self, "_f5tts_config", {}))
config_data.update(getattr(self, "_flux_config", {}))
with open("/shared/config/voice_config.json", "w") as f: with open("/shared/config/voice_config.json", "w") as f:
json.dump(config_data, f, indent=2) json.dump(config_data, f, indent=2)
logger.info("[rvs] Voice-Config gespeichert: %s", config_data) logger.info("[rvs] Voice-Config gespeichert: %s", config_data)
@@ -2204,6 +2236,33 @@ class ARIABridge:
"timestamp": int(asyncio.get_event_loop().time() * 1000), "timestamp": int(asyncio.get_event_loop().time() * 1000),
}) })
return return
# Groessen-Check VOR base64-Encode + Send. Sonst zerreisst's bei
# grossen Files (>~70 MB binaer) die WebSocket-Verbindung mit
# Code 1009 (message too big) — RVS-Server droppt, Bridge crasht
# im cleanup (websockets-Lib-Bug). Limit deckt typische Videos
# und Bilder ab; alles drueber soll der User per SSH abholen.
FILE_MAX_BYTES = 70 * 1024 * 1024
try:
file_size = os.path.getsize(server_path)
except OSError as exc:
logger.warning("[rvs] getsize fehlgeschlagen: %s", exc)
file_size = 0
if file_size > FILE_MAX_BYTES:
logger.warning("[rvs] Re-Download abgelehnt: %s zu gross (%dMB > %dMB)",
server_path, file_size // (1024 * 1024),
FILE_MAX_BYTES // (1024 * 1024))
await self._send_to_rvs({
"type": "file_response",
"payload": {
"requestId": req_id,
"serverPath": server_path,
"name": os.path.basename(server_path),
"error": f"Datei zu gross fuer Transfer ({file_size // (1024 * 1024)} MB, Limit {FILE_MAX_BYTES // (1024 * 1024)} MB)",
"sizeBytes": file_size,
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
return
with open(server_path, "rb") as f: with open(server_path, "rb") as f:
file_b64 = base64.b64encode(f.read()).decode("ascii") file_b64 = base64.b64encode(f.read()).decode("ascii")
mime, _ = mimetypes.guess_type(server_path) mime, _ = mimetypes.guess_type(server_path)
@@ -2279,8 +2338,43 @@ class ARIABridge:
future.set_result(text) future.set_result(text)
return return
elif msg_type == "oauth_callback":
# RVS hat einen OAuth-Provider-Callback empfangen (z.B. Spotify
# nach User-Authorize) und broadcastet ihn. Wir forwarden an Brain,
# das den state-Match macht + code gegen access_token tauscht.
asyncio.create_task(self._forward_oauth_callback(payload))
return
elif msg_type == "flux_response":
# Antwort der flux-bridge auf unseren flux_request. Erste Nachricht
# mit state='rendering' ist nur Progress-Ping — die echte Antwort
# kommt mit state='done' (oder error).
request_id = payload.get("requestId", "")
future = self._pending_flux.get(request_id)
if future is None or future.done():
return
error = payload.get("error", "")
if error:
logger.warning("[rvs] flux_response Fehler: %s", error)
future.set_result({"error": error})
return
state = payload.get("state", "")
if state == "rendering":
# Nur Progress-Info, future bleibt offen
logger.info("[rvs] flux: rendering %dx%d steps=%d ...",
payload.get("width", 0), payload.get("height", 0),
payload.get("steps", 0))
return
# state == "done" oder fehlt → final
logger.info("[rvs] flux fertig: %dx%d, %.1fs, %d KB",
payload.get("width", 0), payload.get("height", 0),
payload.get("renderSeconds", 0),
(payload.get("sizeBytes", 0)) // 1024)
future.set_result(payload)
return
elif msg_type == "service_status": elif msg_type == "service_status":
# Gamebox-Bridges (whisper / f5tts) melden ihren Lade-Status. # Gamebox-Bridges (whisper / f5tts / flux) melden ihren Lade-Status.
# Wir nutzen das fuer den dynamischen STT-Timeout: solange whisper # Wir nutzen das fuer den dynamischen STT-Timeout: solange whisper
# im 'loading' steckt, geben wir der Bridge mehr Zeit (Modell-Download # im 'loading' steckt, geben wir der Bridge mehr Zeit (Modell-Download
# kann 1-2 Min dauern), statt nach 45s lokal zu fallbacken. # kann 1-2 Min dauern), statt nach 45s lokal zu fallbacken.
@@ -2291,6 +2385,11 @@ class ARIABridge:
self._remote_stt_ready = (state == "ready") self._remote_stt_ready = (state == "ready")
if self._remote_stt_ready != was_ready: if self._remote_stt_ready != was_ready:
logger.info("[rvs] whisper-bridge -> %s", state) logger.info("[rvs] whisper-bridge -> %s", state)
elif svc == "flux":
was_ready = self._remote_flux_ready
self._remote_flux_ready = (state == "ready")
if self._remote_flux_ready != was_ready:
logger.info("[rvs] flux-bridge -> %s", state)
return return
elif msg_type == "config_request": elif msg_type == "config_request":
@@ -2475,6 +2574,105 @@ class ARIABridge:
except OSError: except OSError:
pass pass
# ── Flux-Roundtrip: Brain → Bridge → RVS → flux-bridge → zurueck ──
# FLUX-Render auf der 3060 dauert je nach Aufloesung/Steps 20-90 s.
# Beim 1. Render frisch nach Container-Start muss zudem das ~24 GB
# Modell von HF geladen werden — daher der grosse Loading-Timeout.
_FLUX_TIMEOUT_READY_S = 240.0 # 4 min nach erstem Render
_FLUX_TIMEOUT_LOADING_S = 900.0 # 15 min beim allerersten Mal (Modell-Download)
async def _flux_generate(self, prompt: str, width: int, height: int,
steps: Optional[int], guidance: Optional[float],
seed: Optional[int], model: Optional[str] = None) -> dict:
"""Schickt einen flux_request an die flux-bridge, wartet auf das fertige
PNG, speichert es nach /shared/uploads/aria_generated_<ts>.png.
Rueckgabe:
{ok: True, path, sizeBytes, width, height, steps, guidance, seed, model, renderSeconds}
{ok: False, error}
"""
if self.ws_rvs is None:
return {"ok": False, "error": "RVS-Verbindung nicht aktiv"}
request_id = str(uuid.uuid4())
loop = asyncio.get_event_loop()
future: asyncio.Future = loop.create_future()
self._pending_flux[request_id] = future
try:
req_payload: dict = {"requestId": request_id, "prompt": prompt,
"width": width, "height": height}
if steps is not None:
req_payload["steps"] = steps
if guidance is not None:
req_payload["guidance_scale"] = guidance
if seed is not None:
req_payload["seed"] = seed
if model:
# 'dev' | 'schnell' — flux-bridge mappt das auf HF-IDs.
# Ohne Angabe nimmt die flux-bridge ihren konfigurierten Default.
req_payload["model"] = model
logger.info("[rvs] flux_request → flux-bridge (id=%s, %dx%d, steps=%s, model=%s, prompt=%r)",
request_id[:8], width, height, steps, model or "default", prompt[:60])
ok = await self._send_to_rvs({
"type": "flux_request",
"payload": req_payload,
"timestamp": int(time.time() * 1000),
})
if not ok:
return {"ok": False, "error": "flux_request konnte nicht gesendet werden"}
timeout_s = (self._FLUX_TIMEOUT_READY_S
if self._remote_flux_ready
else self._FLUX_TIMEOUT_LOADING_S)
result = await asyncio.wait_for(future, timeout=timeout_s)
if not isinstance(result, dict) or result.get("error"):
err = (result or {}).get("error") if isinstance(result, dict) else "leeres Resultat"
return {"ok": False, "error": err or "flux-bridge Fehler"}
b64 = result.get("base64") or ""
if not b64:
return {"ok": False, "error": "flux_response ohne Bilddaten"}
try:
png_bytes = base64.b64decode(b64)
except Exception as e:
return {"ok": False, "error": f"PNG-Decode fehlgeschlagen: {e}"}
SHARED_DIR = "/shared/uploads"
os.makedirs(SHARED_DIR, exist_ok=True)
ts_ms = int(time.time() * 1000)
file_name = f"aria_generated_{ts_ms}.png"
path = os.path.join(SHARED_DIR, file_name)
try:
with open(path, "wb") as f:
f.write(png_bytes)
except Exception as e:
return {"ok": False, "error": f"Speichern fehlgeschlagen: {e}"}
logger.info("[rvs] flux PNG gespeichert: %s (%d KB)", path, len(png_bytes) // 1024)
return {
"ok": True,
"path": path,
"sizeBytes": len(png_bytes),
"width": result.get("width", width),
"height": result.get("height", height),
"steps": result.get("steps"),
"guidance": result.get("guidance"),
"seed": result.get("seed"),
"model": result.get("model", ""),
"renderSeconds": result.get("renderSeconds", 0),
}
except asyncio.TimeoutError:
return {"ok": False, "error": f"Render-Timeout ({int(timeout_s)}s) — flux-bridge offline?"}
except Exception as e:
logger.exception("[rvs] _flux_generate Fehler")
return {"ok": False, "error": str(e)[:200]}
finally:
self._pending_flux.pop(request_id, None)
async def _send_to_rvs(self, message: dict) -> bool: async def _send_to_rvs(self, message: dict) -> bool:
"""Sendet eine Nachricht an die App (via RVS) mit Verbindungs-Check. """Sendet eine Nachricht an die App (via RVS) mit Verbindungs-Check.
@@ -2524,6 +2722,50 @@ class ARIABridge:
status = await asyncio.get_event_loop().run_in_executor(None, _do_request) status = await asyncio.get_event_loop().run_in_executor(None, _do_request)
logger.info("[cancel] Diagnostic /api/cancel: %s", status) logger.info("[cancel] Diagnostic /api/cancel: %s", status)
async def _forward_oauth_callback(self, payload: dict) -> None:
"""Forwarded den OAuth-Callback (kommt via RVS vom RVS-HTTP-Handler)
per HTTP an Brain. Brain hat den pending-state + macht den token-
exchange. Fire-and-forget — bei Failure loggen wir nur."""
service = (payload.get("service") or "").strip()
if not service:
logger.warning("[oauth] callback ohne service, ignoriert")
return
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
url = f"{brain_url}/internal/oauth-callback"
def _do_request():
try:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url, data=data, method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.status, resp.read().decode("utf-8", "ignore")[:200]
except Exception as e:
return f"error: {e}", ""
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_request)
logger.info("[oauth] Forward %s → brain: %s %s", service, status, body)
async def _cancel_proxy_subprocesses(self) -> None:
"""Not-Aus: ruft den proxy-internen /cancel-all Side-Channel auf
(siehe proxy-patches/routes.js). Killt alle aktiven Claude-Code-
Subprocesses sofort. Bridge ist auf aria-net, Proxy auch — also
per Container-Name + Side-Channel-Port (Default 3457) erreichbar."""
url = os.environ.get("PROXY_INTERNAL_URL", "http://aria-proxy:3457") + "/cancel-all"
def _do_request():
try:
req = urllib.request.Request(url, method="POST", data=b"")
with urllib.request.urlopen(req, timeout=3) as resp:
return resp.status, resp.read().decode("utf-8", "ignore")[:200]
except Exception as e:
return f"error: {e}", ""
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_request)
logger.warning("[NOT-AUS] proxy /cancel-all: %s %s", status, body)
async def _emit_activity(self, activity: str, tool: str = "", force: bool = False) -> None: async def _emit_activity(self, activity: str, tool: str = "", force: bool = False) -> None:
"""Sendet agent_activity an die App — nur wenn sich der State geaendert hat. """Sendet agent_activity an die App — nur wenn sich der State geaendert hat.
@@ -2705,6 +2947,61 @@ class ARIABridge:
# selbst wenn derselbe Name zweimal in Folge kommt. # selbst wenn derselbe Name zweimal in Folge kommt.
asyncio.create_task(self._emit_activity("tool", tool, force=True)) asyncio.create_task(self._emit_activity("tool", tool, force=True))
await _send_response(writer, 200, {"ok": True}) await _send_response(writer, 200, {"ok": True})
elif method == "POST" and path == "/internal/agent-stream":
# Vom Proxy gefeuert: voller Live-Stream der Claude-Code-
# Session (assistant_text, tool_use mit Input, tool_result
# mit truncated Output, start/end Markers). Wir leiten 1:1
# als RVS agent_stream an Diagnostic (ARIA-Live-View) und
# App weiter — read-only Mirror der gerade laufenden
# ARIA-Aktivitaet.
try:
data = json.loads(body.decode("utf-8", "ignore"))
except Exception as exc:
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
return
asyncio.create_task(self._send_to_rvs({
"type": "agent_stream",
"payload": data,
"timestamp": int(time.time() * 1000),
}))
await _send_response(writer, 200, {"ok": True})
elif method == "POST" and path == "/internal/flux-generate":
# Vom Brain (flux_generate-Tool) gefeuert. Wir routen den
# Render-Request via RVS an die flux-bridge (Gamebox),
# warten synchron auf die PNG-Antwort, speichern sie nach
# /shared/uploads/ und melden Pfad + Render-Stats zurueck.
# Brain referenziert das Bild dann mit [FILE:]-Marker in
# seiner Antwort, die Bridge broadcastet daraufhin
# automatisch ein file_from_aria-Event an App+Diagnostic.
try:
data = json.loads(body.decode("utf-8", "ignore"))
except Exception as exc:
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
return
prompt = (data.get("prompt") or "").strip()
if not prompt:
await _send_response(writer, 400, {"error": "prompt erforderlich"})
return
try:
width = int(data.get("width") or 1024)
height = int(data.get("height") or 1024)
except (TypeError, ValueError):
width, height = 1024, 1024
steps_raw = data.get("steps")
guidance_raw = data.get("guidance_scale")
seed_raw = data.get("seed")
steps = int(steps_raw) if isinstance(steps_raw, (int, float)) else None
guidance = float(guidance_raw) if isinstance(guidance_raw, (int, float)) else None
seed = int(seed_raw) if isinstance(seed_raw, (int, float)) else None
model_raw = data.get("model")
model = model_raw.strip() if isinstance(model_raw, str) and model_raw.strip() in ("dev", "schnell") else None
result = await self._flux_generate(
prompt=prompt, width=width, height=height,
steps=steps, guidance=guidance, seed=seed, model=model,
)
status = 200 if result.get("ok") else 502
await _send_response(writer, status, result)
elif method == "POST" and path == "/internal/delete-chat-message": elif method == "POST" and path == "/internal/delete-chat-message":
try: try:
data = json.loads(body.decode("utf-8", "ignore")) data = json.loads(body.decode("utf-8", "ignore"))
+411 -125
View File
@@ -320,8 +320,7 @@
<input type="file" id="diag-file-input" multiple accept="image/*,application/pdf,.doc,.docx,.txt" style="display:none;" onchange="handleDiagFileSelect(this.files)"> <input type="file" id="diag-file-input" multiple accept="image/*,application/pdf,.doc,.docx,.txt" style="display:none;" onchange="handleDiagFileSelect(this.files)">
</label> </label>
<textarea id="chat-input" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" onpaste="handleDiagPaste(event)" oninput="autoResizeTextarea(this)"></textarea> <textarea id="chat-input" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" onpaste="handleDiagPaste(event)" oninput="autoResizeTextarea(this)"></textarea>
<button class="btn" id="btn-gw" onclick="testGateway()">Gateway senden</button> <button class="btn" id="btn-rvs" onclick="testRVS()">Senden</button>
<button class="btn" id="btn-rvs" onclick="testRVS()">Via RVS senden</button>
</div> </div>
</div> </div>
</div> </div>
@@ -338,8 +337,7 @@
</div> </div>
<div class="input-row" style="margin-top:8px;"> <div class="input-row" style="margin-top:8px;">
<textarea id="chat-input-fs" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" oninput="autoResizeTextarea(this)"></textarea> <textarea id="chat-input-fs" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" oninput="autoResizeTextarea(this)"></textarea>
<button class="btn" onclick="testGatewayFS()">Gateway senden</button> <button class="btn" onclick="testRVSFS()">Senden</button>
<button class="btn" onclick="testRVSFS()">Via RVS senden</button>
</div> </div>
</div> </div>
@@ -367,7 +365,6 @@
<div style="padding: 0 12px;"> <div style="padding: 0 12px;">
<div class="tab-bar"> <div class="tab-bar">
<button class="tab-btn active" data-tab="all" onclick="switchTab('all')">Alle <span class="tab-count" id="count-all">0</span></button> <button class="tab-btn active" data-tab="all" onclick="switchTab('all')">Alle <span class="tab-count" id="count-all">0</span></button>
<button class="tab-btn" data-tab="gateway" onclick="switchTab('gateway')">Gateway <span class="tab-count" id="count-gateway">0</span></button>
<button class="tab-btn" data-tab="rvs" onclick="switchTab('rvs')">RVS <span class="tab-count" id="count-rvs">0</span></button> <button class="tab-btn" data-tab="rvs" onclick="switchTab('rvs')">RVS <span class="tab-count" id="count-rvs">0</span></button>
<button class="tab-btn" data-tab="proxy" onclick="switchTab('proxy')">Proxy <span class="tab-count" id="count-proxy">0</span></button> <button class="tab-btn" data-tab="proxy" onclick="switchTab('proxy')">Proxy <span class="tab-count" id="count-proxy">0</span></button>
<button class="tab-btn" data-tab="bridge" onclick="switchTab('bridge')">Bridge <span class="tab-count" id="count-bridge">0</span></button> <button class="tab-btn" data-tab="bridge" onclick="switchTab('bridge')">Bridge <span class="tab-count" id="count-bridge">0</span></button>
@@ -386,7 +383,6 @@
</span> </span>
</div> </div>
<div class="log-box" id="log-all"></div> <div class="log-box" id="log-all"></div>
<div class="log-box hidden" id="log-gateway"></div>
<div class="log-box hidden" id="log-rvs"></div> <div class="log-box hidden" id="log-rvs"></div>
<div class="log-box hidden" id="log-proxy"></div> <div class="log-box hidden" id="log-proxy"></div>
<div class="log-box hidden" id="log-bridge"></div> <div class="log-box hidden" id="log-bridge"></div>
@@ -399,18 +395,29 @@
<div class="card" style="margin-top:12px; padding: 8px 0 0 0;"> <div class="card" style="margin-top:12px; padding: 8px 0 0 0;">
<div style="padding: 0 12px;"> <div style="padding: 0 12px;">
<div class="tab-bar"> <div class="tab-bar">
<button class="tab-btn active" id="live-tab-ssh" onclick="switchLiveTab('ssh')">SSH Terminal</button> <button class="tab-btn active" id="live-tab-aria" onclick="switchLiveTab('aria')">ARIA Live</button>
<button class="tab-btn" id="live-tab-desktop" onclick="switchLiveTab('desktop')">Desktop</button> <button class="tab-btn" id="live-tab-desktop" onclick="switchLiveTab('desktop')">Desktop</button>
</div> </div>
</div> </div>
<div style="background:#080810; border:1px solid #1E1E2E; border-radius:0 0 6px 6px; position:relative;"> <div style="background:#080810; border:1px solid #1E1E2E; border-radius:0 0 6px 6px; position:relative;">
<!-- SSH Terminal --> <!-- ARIA Live (read-only Mirror der Claude-Code-Session) -->
<div id="live-ssh" style="height:350px; padding:4px;"> <div id="live-aria" style="height:350px; padding:4px; display:flex; flex-direction:column;">
<div id="live-ssh-bar" style="display:flex;gap:6px;align-items:center;padding:4px 4px 6px;"> <div id="live-aria-bar" style="display:flex;gap:6px;align-items:center;padding:4px 4px 6px;flex-shrink:0;">
<button class="btn" onclick="startLiveSSH()" id="btn-live-ssh" style="padding:4px 12px;font-size:11px;">Verbinden</button> <span id="live-aria-status" style="font-size:11px;color:#8888AA;flex:1;">Idle — warte auf ARIA-Aktivitaet</span>
<span id="live-ssh-status" style="font-size:11px;color:#8888AA;">Nicht verbunden</span> <button class="btn" onclick="clearAriaLive()" style="padding:4px 12px;font-size:11px;" title="Live-Mitschrift leeren">Leeren</button>
<label style="font-size:11px;color:#8888AA;display:flex;align-items:center;gap:4px;cursor:pointer;" title="Bei jeder neuen Zeile ans Ende scrollen">
<input type="checkbox" id="live-aria-autoscroll" checked style="margin:0;"> Auto-Scroll
</label>
<button class="btn" onclick="ariaPanicStop()"
style="padding:4px 14px;font-size:11px;background:#FF3B30;color:#fff;border-color:#FF3B30;font-weight:bold;"
title="NOT-AUS: killt alle aktiven Claude-Code-Subprocesses sofort">
⛔ Not-Aus
</button>
</div>
<div id="live-aria-stream"
style="flex:1;overflow-y:auto;background:#040408;font-family:'Courier New',monospace;font-size:11px;line-height:1.4;color:#C0C0D0;padding:6px 8px;border-top:1px solid #1E1E2E;">
<div style="color:#555570;font-style:italic;">Sobald ARIA denkt oder ein Tool nutzt, taucht es hier in Echtzeit auf.</div>
</div> </div>
<div id="live-ssh-term" style="height:calc(100% - 32px);"></div>
</div> </div>
<!-- Desktop Viewer --> <!-- Desktop Viewer -->
<div id="live-desktop" style="height:350px; display:none; position:relative;"> <div id="live-desktop" style="height:350px; display:none; position:relative;">
@@ -613,6 +620,94 @@
</div> </div>
</div> </div>
<!-- FLUX Bildgenerierung -->
<div class="settings-section">
<h2>FLUX Bildgenerierung</h2>
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
Steuerung der Image-Generation (flux-bridge auf der Gamebox).
Default-Modell wird via RVS gepusht — Wechsel triggert Pipeline-Reload (15-30s
aus HF-Cache, mehrere Minuten beim Erst-Download). Keywords nutzt ARIAs Brain
im System-Prompt.
</div>
<div class="card" style="max-width:500px;">
<div style="display:flex;flex-direction:column;gap:8px;">
<label style="color:#8888AA;font-size:12px;">Default-Modell:</label>
<select id="diag-flux-default-model" onchange="sendVoiceConfig()"
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;">
<option value="dev">FLUX.1-dev (hoechste Qualitaet, 20-90s)</option>
<option value="schnell">FLUX.1-schnell (4-step, 5-15s)</option>
</select>
<label style="color:#8888AA;font-size:12px;">
Raw-Keyword — Pipe-Modus, ARIA leitet den Prompt 1:1 durch (kein Rewriting):
</label>
<input type="text" id="diag-flux-keyword-raw"
placeholder="flux"
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;">
<label style="color:#8888AA;font-size:12px;">
Switch-Keyword — zwingt das ANDERE Modell als das Default fuer diesen Request:
</label>
<input type="text" id="diag-flux-keyword-switch"
placeholder="fix"
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;">
<label style="color:#8888AA;font-size:12px;margin-top:4px;">
HuggingFace-Token (nur fuer FLUX.1-dev — gated Modell, Lizenz-Bestaetigung).
Wird per RVS an die flux-bridge gepusht. Leer = kein Token (Schnell-Modell laeuft auch ohne).
</label>
<div style="display:flex;gap:4px;">
<input type="password" id="diag-flux-hf-token"
placeholder="hf_..."
style="flex:1;min-width:0;box-sizing:border-box;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;font-family:monospace;">
<button type="button" class="btn secondary" onclick="toggleSecret('diag-flux-hf-token', this)" style="padding:4px 10px;flex-shrink:0;" title="Anzeigen/Verbergen">&#128065;</button>
</div>
<div style="color:#666680;font-size:10px;">
Erst auf <a href="https://huggingface.co/black-forest-labs/FLUX.1-dev" target="_blank" style="color:#0096FF;">huggingface.co/.../FLUX.1-dev</a> "Agree" klicken,
dann unter <a href="https://huggingface.co/settings/tokens" target="_blank" style="color:#0096FF;">Settings → Tokens</a> einen Read-Token erzeugen.
</div>
<div style="display:flex;gap:8px;align-items:center;margin-top:6px;">
<button class="btn primary" onclick="sendVoiceConfig()" style="padding:6px 14px;font-size:12px;">
Anwenden
</button>
<div style="color:#666680;font-size:10px;">
Beide Modelle = volle Qualitaet, schnell ist nur ein 4-Step-Distillat (Apache-2.0).
</div>
</div>
</div>
</div>
</div>
<!-- OAuth Apps -->
<div class="settings-section">
<h2>OAuth-Apps (Spotify, Google, GitHub, Strava, Microsoft, ...)</h2>
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
Trag pro Service `client_id` + `client_secret` ein (aus dem Developer-Dashboard
des Providers). RVS stellt die Callback-URL bereit — die musst Du EINMAL pro
Service im Provider-Dashboard als gueltige Redirect-URI eintragen.
Danach kann ARIA per `oauth_authorize`-Tool eine Auth-URL bauen; Stefan klickt,
autorisiert, ARIA bekommt den Token automatisch.
</div>
<div style="font-size:11px;color:#666680;margin-bottom:8px;" id="oauth-callback-hint">
Lade Callback-URL...
</div>
<div class="card" style="max-width:780px;">
<div id="oauth-services-list" style="display:flex;flex-direction:column;gap:8px;">
<div style="color:#555570;font-style:italic;">Lade Services...</div>
</div>
<div style="margin-top:14px;display:flex;gap:8px;align-items:center;">
<button class="btn secondary" onclick="loadOAuthServices()" style="padding:6px 14px;font-size:12px;">
↻ Neu laden
</button>
<div style="color:#666680;font-size:10px;">
client_secret wird verschlüsselt persistiert (file-mode 0600). Nicht in git, nicht im Repo.
</div>
</div>
</div>
</div>
<!-- Whisper (STT) --> <!-- Whisper (STT) -->
<div class="settings-section"> <div class="settings-section">
<h2>Whisper (Spracherkennung)</h2> <h2>Whisper (Spracherkennung)</h2>
@@ -1118,13 +1213,12 @@
const btnScroll = document.getElementById('btn-scroll'); const btnScroll = document.getElementById('btn-scroll');
let ws; let ws;
let activeTab = 'all'; let activeTab = 'all';
const DOCKER_TABS = ['gateway', 'proxy', 'bridge']; const DOCKER_TABS = ['proxy', 'bridge'];
const autoScroll = { all: true, gateway: true, rvs: true, proxy: true, bridge: true, server: true, trace: true }; const autoScroll = { all: true, rvs: true, proxy: true, bridge: true, server: true, trace: true };
const logCounts = { all: 0, gateway: 0, rvs: 0, proxy: 0, bridge: 0, server: 0, trace: 0 }; const logCounts = { all: 0, rvs: 0, proxy: 0, bridge: 0, server: 0, trace: 0 };
const logBoxes = { const logBoxes = {
all: document.getElementById('log-all'), all: document.getElementById('log-all'),
gateway: document.getElementById('log-gateway'),
rvs: document.getElementById('log-rvs'), rvs: document.getElementById('log-rvs'),
proxy: document.getElementById('log-proxy'), proxy: document.getElementById('log-proxy'),
bridge: document.getElementById('log-bridge'), bridge: document.getElementById('log-bridge'),
@@ -1178,7 +1272,9 @@
} }
function mapSourceToTab(source) { function mapSourceToTab(source) {
if (source === 'gateway') return 'gateway'; // Gateway-Source: deprecated — falls noch was reinkommt zeigen wir's
// einfach unter 'server'. Spart einen toten Tab.
if (source === 'gateway') return 'server';
if (source === 'rvs') return 'rvs'; if (source === 'rvs') return 'rvs';
if (source === 'proxy') return 'proxy'; if (source === 'proxy') return 'proxy';
if (source === 'bridge') return 'bridge'; if (source === 'bridge') return 'bridge';
@@ -1342,6 +1438,11 @@
setIfPresent('diag-f5tts-vocab', msg.f5ttsVocabFile); setIfPresent('diag-f5tts-vocab', msg.f5ttsVocabFile);
setIfPresent('diag-f5tts-cfg', msg.f5ttsCfgStrength); setIfPresent('diag-f5tts-cfg', msg.f5ttsCfgStrength);
setIfPresent('diag-f5tts-nfe', msg.f5ttsNfeStep); setIfPresent('diag-f5tts-nfe', msg.f5ttsNfeStep);
// FLUX-Settings wiederherstellen
setIfPresent('diag-flux-default-model', msg.fluxDefaultModel);
setIfPresent('diag-flux-keyword-raw', msg.fluxKeywordRaw);
setIfPresent('diag-flux-keyword-switch', msg.fluxKeywordSwitch);
setIfPresent('diag-flux-hf-token', msg.huggingfaceToken);
return; return;
} }
@@ -1350,6 +1451,11 @@
return; return;
} }
if (msg.type === 'agent_stream') {
appendAriaStreamEvent(msg.payload || {});
return;
}
if (msg.type === 'voice_preview_audio') { if (msg.type === 'voice_preview_audio') {
const statusEl = document.getElementById('voice-preview-status'); const statusEl = document.getElementById('voice-preview-status');
const audio = document.getElementById('voice-preview-audio'); const audio = document.getElementById('voice-preview-audio');
@@ -1493,8 +1599,8 @@
return; return;
} }
// core_auth WS-Event entfernt — aria-core ist raus. // core_auth WS-Event entfernt — aria-core ist raus.
// Live SSH + Desktop // SSH-Terminal entfernt — durch ARIA-Live-Mirror ersetzt.
if (msg.type?.startsWith('live_ssh_')) { handleLiveSSH(msg); return; } // Desktop bleibt.
if (msg.type === 'desktop_status') { handleDesktop(msg); return; } if (msg.type === 'desktop_status') { handleDesktop(msg); return; }
if (msg.type === 'term_ready') { if (msg.type === 'term_ready') {
@@ -1620,18 +1726,6 @@
renderDiagPending(); renderDiagPending();
} }
function testGateway() {
const input = document.getElementById('chat-input');
const text = input.value.trim();
if (!text && diagPendingFiles.length === 0) return;
if (diagPendingFiles.length > 0) sendDiagAttachments();
if (text) {
addChat('sent', text, 'Gateway direkt');
send({ action: 'test_gateway', text });
}
input.value = '';
}
function testRVS() { function testRVS() {
const input = document.getElementById('chat-input'); const input = document.getElementById('chat-input');
const text = input.value.trim(); const text = input.value.trim();
@@ -1771,7 +1865,6 @@
if (proxy.models && proxy.models.length) showProxyModels(proxy.models); if (proxy.models && proxy.models.length) showProxyModels(proxy.models);
// Buttons // Buttons
document.getElementById('btn-gw').disabled = gw.status !== 'connected';
document.getElementById('btn-rvs').disabled = rvs.status !== 'connected'; document.getElementById('btn-rvs').disabled = rvs.status !== 'connected';
} }
@@ -2094,14 +2187,6 @@
modal.style.display = 'none'; modal.style.display = 'none';
} }
} }
function testGatewayFS() {
const input = document.getElementById('chat-input-fs');
const text = input.value.trim();
if (!text) return;
addChat('sent', text, 'Gateway direkt');
send({ action: 'test_gateway', text });
input.value = '';
}
function testRVSFS() { function testRVSFS() {
const input = document.getElementById('chat-input-fs'); const input = document.getElementById('chat-input-fs');
const text = input.value.trim(); const text = input.value.trim();
@@ -2147,18 +2232,23 @@
// Liste neu aufbauen // Liste neu aufbauen
list.innerHTML = ''; list.innerHTML = '';
let anyLoading = false, anyError = false; let anyLoading = false, anyError = false;
const labels = { f5tts: 'F5-TTS', whisper: 'Whisper STT' }; const labels = { f5tts: 'F5-TTS', whisper: 'Whisper STT', flux: 'FLUX Image-Gen' };
for (const [s, info] of Object.entries(_serviceState)) { for (const [s, info] of Object.entries(_serviceState)) {
const row = document.createElement('div'); const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:6px;'; row.style.cssText = 'display:flex;align-items:center;gap:6px;';
let dot = '⚫', color = '#666680', text = ''; let dot = '⚫', color = '#666680', text = '';
if (info.state === 'loading') { if (info.state === 'loading') {
dot = '⏳'; color = '#FFD60A'; anyLoading = true; dot = info.downloading ? '⬇' : '⏳';
text = `${labels[s] || s}: laedt${info.model ? ' ' + info.model : ''}...`; color = '#FFD60A'; anyLoading = true;
const action = info.downloading
? 'laedt erstmalig runter (mehrere GB, kann dauern)'
: 'laedt';
text = `${labels[s] || s}: ${action}${info.model ? ' ' + info.model : ''}...`;
} else if (info.state === 'ready') { } else if (info.state === 'ready') {
dot = '✅'; color = '#34C759'; dot = info.freshlyDownloaded ? '🎉' : '✅'; color = '#34C759';
const sec = info.loadSeconds ? ` (${info.loadSeconds.toFixed(1)}s)` : ''; const sec = info.loadSeconds ? ` (${info.loadSeconds.toFixed(1)}s)` : '';
text = `${labels[s] || s}: bereit${info.model ? ' ' + info.model : ''}${sec}`; const downloadedHint = info.freshlyDownloaded ? ' — Download fertig!' : '';
text = `${labels[s] || s}: bereit${info.model ? ' ' + info.model : ''}${sec}${downloadedHint}`;
} else if (info.state === 'error') { } else if (info.state === 'error') {
dot = '❌'; color = '#FF3B30'; anyError = true; dot = '❌'; color = '#FF3B30'; anyError = true;
text = `${labels[s] || s}: Fehler ${info.error || ''}`; text = `${labels[s] || s}: Fehler ${info.error || ''}`;
@@ -2673,11 +2763,16 @@
const f5ttsNfeRaw = document.getElementById('diag-f5tts-nfe')?.value || ''; const f5ttsNfeRaw = document.getElementById('diag-f5tts-nfe')?.value || '';
const f5ttsCfgStrength = f5ttsCfgRaw ? parseFloat(f5ttsCfgRaw) : undefined; const f5ttsCfgStrength = f5ttsCfgRaw ? parseFloat(f5ttsCfgRaw) : undefined;
const f5ttsNfeStep = f5ttsNfeRaw ? parseInt(f5ttsNfeRaw, 10) : undefined; const f5ttsNfeStep = f5ttsNfeRaw ? parseInt(f5ttsNfeRaw, 10) : undefined;
const fluxDefaultModel = document.getElementById('diag-flux-default-model')?.value || undefined;
const fluxKeywordRaw = document.getElementById('diag-flux-keyword-raw')?.value;
const fluxKeywordSwitch = document.getElementById('diag-flux-keyword-switch')?.value;
const huggingfaceToken = document.getElementById('diag-flux-hf-token')?.value;
send({ send({
action: 'send_voice_config', action: 'send_voice_config',
ttsEnabled, xttsVoice, whisperModel, ttsEnabled, xttsVoice, whisperModel,
f5ttsModel, f5ttsCkptFile, f5ttsVocabFile, f5ttsModel, f5ttsCkptFile, f5ttsVocabFile,
f5ttsCfgStrength, f5ttsNfeStep, f5ttsCfgStrength, f5ttsNfeStep,
fluxDefaultModel, fluxKeywordRaw, fluxKeywordSwitch, huggingfaceToken,
}); });
const statusEl = document.getElementById('voice-status'); const statusEl = document.getElementById('voice-status');
if (statusEl && xttsVoice) { if (statusEl && xttsVoice) {
@@ -2911,96 +3006,133 @@
// ── ARIA Live-Ansicht (SSH + Desktop) ────────────────── // ── ARIA Live-Ansicht (SSH + Desktop) ──────────────────
let liveSshTerm = null;
let liveSshFit = null;
function switchLiveTab(tab) { function switchLiveTab(tab) {
document.getElementById('live-ssh').style.display = tab === 'ssh' ? 'block' : 'none'; document.getElementById('live-aria').style.display = tab === 'aria' ? 'flex' : 'none';
document.getElementById('live-desktop').style.display = tab === 'desktop' ? 'block' : 'none'; document.getElementById('live-desktop').style.display = tab === 'desktop' ? 'block' : 'none';
document.getElementById('live-tab-ssh').className = 'tab-btn' + (tab === 'ssh' ? ' active' : ''); document.getElementById('live-tab-aria').className = 'tab-btn' + (tab === 'aria' ? ' active' : '');
document.getElementById('live-tab-desktop').className = 'tab-btn' + (tab === 'desktop' ? ' active' : ''); document.getElementById('live-tab-desktop').className = 'tab-btn' + (tab === 'desktop' ? ' active' : '');
if (tab === 'ssh' && liveSshTerm && liveSshFit) {
setTimeout(() => liveSshFit.fit(), 50);
}
} }
function startLiveSSH() { // ── ARIA Live (read-only Mirror der Claude-Code-Session) ──────
const statusEl = document.getElementById('live-ssh-status'); //
const btn = document.getElementById('btn-live-ssh'); // Empfaengt agent_stream Events vom RVS (Proxy → Bridge → RVS → wir).
// Rendert sie als monospace-Liste — Tool-Calls in cyan, Tool-Results
// Wenn schon verbunden, trennen // in grau (truncated), ARIA-Text in weiss, Thinking kursiv. Auto-Scroll
if (liveSshTerm && liveSshTerm._sshConnected) { // bleibt am unteren Rand kleben solange der User nicht hochgescrollt hat.
send({ action: 'live_ssh_close' }); // Not-Aus killt via Bridge → Proxy-Side-Channel alle Subprocesses.
statusEl.textContent = 'Getrennt'; function _ariaStreamEl() { return document.getElementById('live-aria-stream'); }
statusEl.style.color = '#FF6B6B'; function _ariaStatusEl() { return document.getElementById('live-aria-status'); }
btn.textContent = 'Verbinden'; function _ariaIsAtBottom() {
liveSshTerm._sshConnected = false; const el = _ariaStreamEl();
return; if (!el) return true;
return (el.scrollHeight - el.scrollTop - el.clientHeight) < 24;
} }
function _ariaMaybeScroll() {
statusEl.textContent = 'Verbinde...'; if (!document.getElementById('live-aria-autoscroll')?.checked) return;
statusEl.style.color = '#FFD60A'; const el = _ariaStreamEl();
if (el) el.scrollTop = el.scrollHeight;
function initSSHTerm() {
const container = document.getElementById('live-ssh-term');
if (!liveSshTerm) {
liveSshTerm = new Terminal({
theme: { background: '#080810', foreground: '#E0E0F0', cursor: '#0096FF' },
fontFamily: 'Courier New, monospace',
fontSize: 12,
cursorBlink: true,
});
liveSshFit = new FitAddon.FitAddon();
liveSshTerm.loadAddon(liveSshFit);
liveSshTerm.open(container);
liveSshFit.fit();
liveSshTerm.onData((data) => {
send({ action: 'live_ssh_input', data });
});
} }
liveSshTerm.clear(); // Truncate UI: groessere Backlogs koennen viele MB werden. Wir halten
send({ action: 'live_ssh_start' }); // max 2000 Zeilen — beim Ueberlauf den oberen Block wegwerfen.
const ARIA_MAX_LINES = 2000;
function _ariaTrimBacklog() {
const el = _ariaStreamEl();
if (!el) return;
while (el.childElementCount > ARIA_MAX_LINES) {
el.removeChild(el.firstChild);
} }
}
if (typeof Terminal === 'undefined') { function _ariaTimePrefix(ts) {
const s = document.createElement('script'); try {
s.src = 'https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js'; const d = ts ? new Date(ts) : new Date();
s.onload = () => { const h = String(d.getHours()).padStart(2, '0');
const s2 = document.createElement('script'); const m = String(d.getMinutes()).padStart(2, '0');
s2.src = 'https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js'; const s = String(d.getSeconds()).padStart(2, '0');
s2.onload = () => initSSHTerm(); return `${h}:${m}:${s}`;
document.head.appendChild(s2); } catch (_) { return ''; }
}; }
document.head.appendChild(s); function _ariaEsc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function _ariaPushLine(html, color, opts = {}) {
const el = _ariaStreamEl();
if (!el) return;
const wasAtBottom = _ariaIsAtBottom();
const row = document.createElement('div');
row.style.cssText = `color:${color};${opts.style||''}`;
row.innerHTML = html;
// Erste statische "Sobald ARIA..."-Zeile beim ersten Event entfernen
const placeholder = el.querySelector('div[style*="italic"]');
if (placeholder && el.childElementCount === 1) el.removeChild(placeholder);
el.appendChild(row);
_ariaTrimBacklog();
if (wasAtBottom) _ariaMaybeScroll();
}
function appendAriaStreamEvent(p) {
const t = _ariaTimePrefix(p.ts);
const kind = p.kind || '';
if (kind === 'start') {
_ariaPushLine(
`<span style="color:#444460;">━━━ ${t} session start (${_ariaEsc(p.model || 'unknown')}) ━━━</span>`,
'#444460',
);
const st = _ariaStatusEl(); if (st) { st.textContent = 'ARIA aktiv...'; st.style.color = '#34C759'; }
} else if (kind === 'end') {
const reason = p.reason || '?';
const codePart = (p.code !== undefined && p.code !== null) ? ` code=${_ariaEsc(p.code)}` : '';
const errPart = p.error ? ` err=${_ariaEsc(String(p.error).slice(0,120))}` : '';
_ariaPushLine(
`<span style="color:#444460;">━━━ ${t} session end (${_ariaEsc(reason)}${codePart}${errPart}) ━━━</span>`,
'#444460',
);
const st = _ariaStatusEl(); if (st) { st.textContent = 'Idle'; st.style.color = '#8888AA'; }
} else if (kind === 'text') {
_ariaPushLine(
`<span style="color:#777799;">[${t}]</span> ${_ariaEsc(p.text || '')}`,
'#D0D0E0',
{ style: 'white-space:pre-wrap;word-break:break-word;' },
);
} else if (kind === 'thinking') {
_ariaPushLine(
`<span style="color:#777799;">[${t}]</span> <span style="font-style:italic;color:#888866;">💭 ${_ariaEsc(p.text || '')}</span>`,
'#888866',
{ style: 'white-space:pre-wrap;word-break:break-word;' },
);
} else if (kind === 'tool_use') {
const name = _ariaEsc(p.name || '?');
const inp = _ariaEsc(p.input || '');
const tail = p.inputTruncatedBytes ? `<span style="color:#777799;"> ...(+${p.inputTruncatedBytes} bytes)</span>` : '';
_ariaPushLine(
`<span style="color:#777799;">[${t}]</span> <span style="color:#0096FF;">▶ ${name}</span> <span style="color:#8888AA;">${inp}${tail}</span>`,
'#C0C0D0',
{ style: 'white-space:pre-wrap;word-break:break-word;' },
);
} else if (kind === 'tool_result') {
const isError = p.isError === true;
const head = isError ? '<span style="color:#FF6B6B;">✗ result (ERROR)</span>' : '<span style="color:#34C759;">✓ result</span>';
const tail = p.truncatedBytes ? `<span style="color:#777799;"> ...(+${p.truncatedBytes} bytes)</span>` : '';
_ariaPushLine(
`<span style="color:#777799;">[${t}]</span> ${head}<br><span style="color:#9090A0;white-space:pre-wrap;display:block;padding-left:14px;border-left:2px solid #2A2A3E;">${_ariaEsc(p.content || '')}${tail}</span>`,
'#9090A0',
);
} else { } else {
initSSHTerm(); _ariaPushLine(
`<span style="color:#777799;">[${t}]</span> <span style="color:#AAAACC;">${_ariaEsc(kind)}: ${_ariaEsc(JSON.stringify(p))}</span>`,
'#AAAACC',
);
} }
} }
function clearAriaLive() {
function handleLiveSSH(msg) { const el = _ariaStreamEl();
const statusEl = document.getElementById('live-ssh-status'); if (el) el.innerHTML = '<div style="color:#555570;font-style:italic;">Geleert.</div>';
const btn = document.getElementById('btn-live-ssh');
if (msg.type === 'live_ssh_data' && liveSshTerm) {
const raw = atob(msg.data);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
liveSshTerm.write(bytes);
} else if (msg.type === 'live_ssh_connected') {
statusEl.textContent = 'Verbunden mit aria-wohnung';
statusEl.style.color = '#34C759';
btn.textContent = 'Trennen';
if (liveSshTerm) liveSshTerm._sshConnected = true;
} else if (msg.type === 'live_ssh_error') {
statusEl.textContent = msg.error || 'Fehler';
statusEl.style.color = '#FF6B6B';
btn.textContent = 'Verbinden';
if (liveSshTerm) liveSshTerm._sshConnected = false;
} else if (msg.type === 'live_ssh_closed') {
statusEl.textContent = 'Getrennt';
statusEl.style.color = '#8888AA';
btn.textContent = 'Verbinden';
if (liveSshTerm) liveSshTerm._sshConnected = false;
} }
function ariaPanicStop() {
if (!confirm('Wirklich NOT-AUS? Alle aktiven Claude-Subprocesses werden sofort gekillt.')) return;
send({ action: 'aria_panic_stop' });
_ariaPushLine(
`<span style="color:#FF3B30;font-weight:bold;">━━━ ${_ariaTimePrefix()} ⛔ NOT-AUS ausgeloest ━━━</span>`,
'#FF3B30',
);
} }
function checkDesktop() { function checkDesktop() {
@@ -3038,11 +3170,12 @@
const oc = b.getAttribute('onclick') || ''; const oc = b.getAttribute('onclick') || '';
if (oc.includes(`'${tab}'`)) b.classList.add('active'); if (oc.includes(`'${tab}'`)) b.classList.add('active');
}); });
// Einstellungen: Config + QR laden // Einstellungen: Config + QR + OAuth-Apps laden
if (tab === 'settings') { if (tab === 'settings') {
send({ action: 'get_voice_config' }); send({ action: 'get_voice_config' });
loadRuntimeConfig(); loadRuntimeConfig();
loadOnboardingQR(); loadOnboardingQR();
loadOAuthServices();
} else if (tab === 'brain') { } else if (tab === 'brain') {
loadBrainStatus(); loadBrainStatus();
loadBrainMemoryList(); loadBrainMemoryList();
@@ -3700,6 +3833,159 @@
} }
} }
// ── OAuth-Apps UI ─────────────────────────────────────────
//
// Stefan traegt pro Service client_id + client_secret ein. RVS hat eine
// feste Callback-URL die Stefan im Provider-Dashboard registrieren muss.
// Status pro Service: configured / authenticated / expires_in.
function _ofmt(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function _oExpiryText(secs) {
if (secs == null) return '';
if (secs <= 0) return 'abgelaufen (refresh beim naechsten Call)';
if (secs < 60) return `${secs}s`;
if (secs < 3600) return `${Math.round(secs/60)} min`;
if (secs < 86400) return `${Math.round(secs/3600)} h`;
return `${Math.round(secs/86400)} Tage`;
}
async function loadOAuthServices() {
const listEl = document.getElementById('oauth-services-list');
const hintEl = document.getElementById('oauth-callback-hint');
if (!listEl) return;
listEl.innerHTML = '<div style="color:#555570;font-style:italic;">Lade Services...</div>';
try {
const [svcRes, appsRes, rcRes] = await Promise.all([
fetch('/api/brain/oauth/services'),
fetch('/api/brain/oauth/apps'),
fetch('/api/runtime-config'),
]);
const svc = await svcRes.json();
const apps = await appsRes.json();
const rc = await rcRes.json();
const host = rc.RVS_HOST || '';
const port = rc.RVS_PORT || '443';
const tls = String(rc.RVS_TLS) !== 'false';
const scheme = tls ? 'https' : 'http';
const portPart = ((tls && port === '443') || (!tls && port === '80')) ? '' : ':' + port;
const cbBase = host ? `${scheme}://${host}${portPart}/oauth/callback/` : '<RVS_HOST nicht gesetzt>';
if (hintEl) {
hintEl.innerHTML = host
? `<b>Callback-URL pro Service</b> (im Provider-Dashboard eintragen): <code style="color:#0096FF;">${_ofmt(cbBase)}&lt;service&gt;</code>`
: `⚠ RVS_HOST nicht gesetzt — OAuth-Callbacks koennen nicht funktionieren. Setze RVS_HOST in der .env auf den oeffentlich erreichbaren Hostname.`;
}
const services = svc.services || [];
const appDetails = apps.apps || {};
const knownDefaults = apps.defaults || [];
// Zusammenfuehren: jeder Service der entweder in services oder Defaults vorkommt
const allServices = Array.from(new Set([
...services.map(s => s.service),
...knownDefaults,
])).sort();
listEl.innerHTML = '';
for (const svcName of allServices) {
const s = services.find(x => x.service === svcName) || { service: svcName, configured: false, authenticated: false };
const app = appDetails[svcName] || {};
const card = document.createElement('div');
const statusColor = s.authenticated ? '#34C759' : (s.configured ? '#FFD60A' : '#666680');
const statusText = s.authenticated
? `✅ verbunden${s.expiresInSec != null ? ` · Token noch ${_oExpiryText(s.expiresInSec)} gueltig` : ''}${s.hasRefresh ? ' · refresh ok' : ' · KEIN refresh_token'}`
: (s.configured ? '🟡 konfiguriert, nicht autorisiert' : '⚫ noch nicht konfiguriert');
const isCustom = !knownDefaults.includes(svcName);
const customMark = isCustom ? ' <span style="color:#8888AA;font-size:10px;">(custom)</span>' : '';
card.style.cssText = 'background:#0D0D1A;border:1px solid #2A2A3E;border-radius:6px;padding:10px 12px;';
card.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<strong style="color:#FFF;text-transform:capitalize;">${_ofmt(svcName)}</strong>${customMark}
<span style="color:${statusColor};font-size:12px;flex:1;">${statusText}</span>
${s.authenticated ? `<button class="btn secondary" onclick="revokeOAuth('${_ofmt(svcName)}')" style="padding:2px 8px;font-size:10px;" title="Token loeschen">Abmelden</button>` : ''}
</div>
<div style="display:flex;flex-direction:column;gap:6px;">
<label style="color:#8888AA;font-size:11px;">client_id:</label>
<input type="text" id="oauth-cid-${_ofmt(svcName)}" value="${_ofmt(app.client_id || '')}" placeholder="aus dem Provider-Dashboard"
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:4px;padding:4px 8px;font-size:12px;font-family:monospace;">
<label style="color:#8888AA;font-size:11px;">client_secret: ${app.has_client_secret ? '<span style="color:#34C759;">(gespeichert — leer lassen zum Behalten)</span>' : '<span style="color:#FF6B6B;">(fehlt)</span>'}</label>
<div style="display:flex;gap:4px;">
<input type="password" id="oauth-sec-${_ofmt(svcName)}" placeholder="${app.has_client_secret ? 'leer lassen oder neuen eingeben' : 'aus dem Provider-Dashboard'}"
style="flex:1;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:4px;padding:4px 8px;font-size:12px;font-family:monospace;">
<button type="button" class="btn secondary" onclick="toggleSecret('oauth-sec-${_ofmt(svcName)}', this)" style="padding:2px 8px;font-size:10px;">👁</button>
</div>
<div style="display:flex;gap:6px;margin-top:4px;">
<button class="btn primary" onclick="saveOAuthApp('${_ofmt(svcName)}')" style="padding:4px 12px;font-size:11px;">Speichern</button>
<button class="btn secondary" onclick="authorizeOAuth('${_ofmt(svcName)}')" style="padding:4px 12px;font-size:11px;" ${!s.configured ? 'disabled title="Erst client_id+secret eintragen"' : ''}>
Autorisieren ↗
</button>
</div>
</div>
`;
listEl.appendChild(card);
}
if (allServices.length === 0) {
listEl.innerHTML = '<div style="color:#555570;">Keine Services bekannt.</div>';
}
} catch (e) {
listEl.innerHTML = `<div style="color:#FF6B6B;">Fehler beim Laden: ${_ofmt(e.message)}</div>`;
}
}
async function saveOAuthApp(service) {
const cid = document.getElementById('oauth-cid-' + service)?.value?.trim() || '';
const sec = document.getElementById('oauth-sec-' + service)?.value || '';
if (!cid) {
alert('client_id darf nicht leer sein.');
return;
}
try {
const r = await fetch('/api/brain/oauth/apps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service, client_id: cid, client_secret: sec }),
});
if (!r.ok) {
const t = await r.text();
alert('Speichern fehlgeschlagen: ' + t);
return;
}
loadOAuthServices();
} catch (e) {
alert('Speichern fehlgeschlagen: ' + e.message);
}
}
async function authorizeOAuth(service) {
try {
const r = await fetch('/api/brain/oauth/authorize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service }),
});
if (!r.ok) {
const t = await r.text();
alert('Authorize fehlgeschlagen: ' + t);
return;
}
const data = await r.json();
// Authorize-URL in neuem Tab oeffnen — Stefan kann dann beim Provider zustimmen
window.open(data.url, '_blank', 'noopener,noreferrer');
// Status nach ein paar Sekunden refreshen — Provider redirect → RVS → Brain
setTimeout(loadOAuthServices, 8000);
} catch (e) {
alert('Authorize fehlgeschlagen: ' + e.message);
}
}
async function revokeOAuth(service) {
if (!confirm(`Token fuer ${service} wirklich loeschen? ARIA muss danach neu autorisiert werden.`)) return;
try {
const r = await fetch('/api/brain/oauth/' + service + '/revoke', { method: 'POST' });
if (!r.ok) {
const t = await r.text();
alert('Revoke fehlgeschlagen: ' + t);
return;
}
loadOAuthServices();
} catch (e) {
alert('Revoke fehlgeschlagen: ' + e.message);
}
}
async function distillNow() { async function distillNow() {
if (!confirm('Destillat manuell auslösen?\n\nDie ältesten Turns werden zu fact-Memories verdichtet — kostet einen Claude-Call.')) return; if (!confirm('Destillat manuell auslösen?\n\nDie ältesten Turns werden zu fact-Memories verdichtet — kostet einen Claude-Call.')) return;
try { try {
+54 -15
View File
@@ -492,9 +492,10 @@ function handleGatewayMessage(msg) {
} }
function sendToGateway(text, isTrace) { function sendToGateway(text, isTrace) {
// OpenClaw-Gateway ist raus — Brain via Bridge via RVS ist die einzige
// Route. Wir loggen nichts mehr; alte Trace-Aufrufe schliessen wir clean.
if (!gatewayWs || gatewayWs.readyState !== WebSocket.OPEN) { if (!gatewayWs || gatewayWs.readyState !== WebSocket.OPEN) {
log("error", "gateway", "Nicht verbunden — kann nicht senden"); if (isTrace) traceEnd(false, "Gateway deprecated — nutze RVS");
if (isTrace) traceEnd(false, "Gateway nicht verbunden");
return false; return false;
} }
@@ -632,6 +633,11 @@ function connectRVS(forcePlain) {
tool: msg.payload?.tool || msg.tool || "", tool: msg.payload?.tool || msg.tool || "",
}); });
} }
} else if (msg.type === "agent_stream") {
// Voller Live-Stream der Claude-Code-Session (assistant_text +
// tool_use mit Input + tool_result mit truncated Output). Geht
// 1:1 an Browser durch — die ARIA-Live-View rendert's.
broadcast({ type: "agent_stream", payload: msg.payload });
} else if (msg.type === "memory_saved") { } else if (msg.type === "memory_saved") {
// ARIA hat selber etwas in die Qdrant-DB gespeichert (via memory_save Tool). // ARIA hat selber etwas in die Qdrant-DB gespeichert (via memory_save Tool).
const m = msg.payload || {}; const m = msg.payload || {};
@@ -757,22 +763,20 @@ function sendToRVS_raw(msgObj) {
} }
function sendToRVS(text, isTrace) { function sendToRVS(text, isTrace) {
// Ueber Gateway senden (zuverlaessig) UND an RVS fuer App-Sichtbarkeit // Brain-Pipeline: Diagnostic → RVS → Bridge → Brain (HTTP). OpenClaw-
// Die Bridge empfaengt RVS-Nachrichten von der App zuverlaessig, // Gateway-Pfad ist abgeschaltet. Sender 'diagnostic' damit die Bridge
// aber die Diagnostic→RVS→Bridge Route hat Zombie-Probleme. // den Text als User-Nachricht ans Brain weiterleitet und die App +
// Deshalb: Gateway fuer ARIA, RVS nur fuer App-Anzeige. // Diagnostic die Bubble live spiegeln koennen.
if (!rvsWs || rvsWs.readyState !== WebSocket.OPEN) {
// 1. An Gateway senden (damit ARIA antwortet) if (isTrace) traceEnd(false, "RVS nicht verbunden");
const gatewayOk = sendToGateway(text, isTrace); return false;
}
// 2. An RVS senden (damit die App die Nachricht sieht)
sendToRVS_raw({ sendToRVS_raw({
type: "chat", type: "chat",
payload: { text, sender: "diagnostic" }, payload: { text, sender: "diagnostic" },
timestamp: Date.now(), timestamp: Date.now(),
}); });
return true;
return gatewayOk;
} }
// ── Claude Proxy Test ──────────────────────────────────── // ── Claude Proxy Test ────────────────────────────────────
@@ -1836,8 +1840,11 @@ wss.on("connection", (ws) => {
const msg = JSON.parse(raw.toString()); const msg = JSON.parse(raw.toString());
if (msg.action === "test_gateway") { if (msg.action === "test_gateway") {
traceStart("Gateway", msg.text || "aria lebst du noch?"); // Deprecated — Gateway-Pfad ist raus. Wir leiten an RVS um damit
sendToGateway(msg.text || "aria lebst du noch?", true); // alte Browser-Sessions die noch den Button anzeigen nicht stumm
// ins Leere klicken. Neue Versionen kennen den Button nicht mehr.
traceStart("RVS", msg.text || "aria lebst du noch?");
sendToRVS(msg.text || "aria lebst du noch?", true);
} else if (msg.action === "test_rvs") { } else if (msg.action === "test_rvs") {
traceStart("RVS", msg.text || "aria lebst du noch?"); traceStart("RVS", msg.text || "aria lebst du noch?");
sendToRVS(msg.text || "aria lebst du noch?", true); sendToRVS(msg.text || "aria lebst du noch?", true);
@@ -1885,6 +1892,18 @@ wss.on("connection", (ws) => {
if (traceActive) traceEnd(false, "Vom Benutzer abgebrochen"); if (traceActive) traceEnd(false, "Vom Benutzer abgebrochen");
broadcast({ type: "agent_activity", activity: "idle" }); broadcast({ type: "agent_activity", activity: "idle" });
dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {}); dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {});
} else if (msg.action === "aria_panic_stop") {
// NOT-AUS aus ARIA-Live-View: lokales /api/cancel UND Hard-Kill via
// Bridge (die wiederum den Proxy-Side-Channel /cancel-all anruft).
log("warn", "server", "⛔ NOT-AUS — hard cancel + proxy /cancel-all");
pendingMessageTime = 0;
watchdogWarned = false;
watchdogFixAttempted = false;
if (traceActive) traceEnd(false, "Vom Benutzer per NOT-AUS abgebrochen");
broadcast({ type: "agent_activity", activity: "idle" });
// RVS-Broadcast cancel_request mit hard:true → aria-bridge ruft
// den Proxy-/cancel-all Side-Channel an, killt alle Subprocesses.
sendToRVS_raw({ type: "cancel_request", payload: { hard: true, source: "diagnostic-panic" }, timestamp: Date.now() });
} else if (msg.action === "voice_upload") { } else if (msg.action === "voice_upload") {
// Voice-Samples an XTTS-Bridge via RVS weiterleiten, auf Bestätigung warten // Voice-Samples an XTTS-Bridge via RVS weiterleiten, auf Bestätigung warten
log("info", "server", `Voice-Upload '${msg.name}' (${(msg.samples || []).length} Samples) sende an RVS...`); log("info", "server", `Voice-Upload '${msg.name}' (${(msg.samples || []).length} Samples) sende an RVS...`);
@@ -1943,6 +1962,26 @@ wss.on("connection", (ws) => {
if (msg.f5ttsNfeStep !== undefined && !isNaN(msg.f5ttsNfeStep)) { if (msg.f5ttsNfeStep !== undefined && !isNaN(msg.f5ttsNfeStep)) {
voiceConfig.f5ttsNfeStep = msg.f5ttsNfeStep; voiceConfig.f5ttsNfeStep = msg.f5ttsNfeStep;
} }
// FLUX-Settings (Default-Modell + User-Keywords). flux-bridge nutzt
// fluxDefaultModel zum Hot-Swap, Brain liest die Keywords direkt aus
// /shared/config/voice_config.json fuer den System-Prompt.
if (msg.fluxDefaultModel !== undefined) {
voiceConfig.fluxDefaultModel = (msg.fluxDefaultModel === "schnell") ? "schnell" : "dev";
}
if (msg.fluxKeywordRaw !== undefined) {
voiceConfig.fluxKeywordRaw = String(msg.fluxKeywordRaw || "").trim().toLowerCase() || "flux";
}
if (msg.fluxKeywordSwitch !== undefined) {
voiceConfig.fluxKeywordSwitch = String(msg.fluxKeywordSwitch || "").trim().toLowerCase() || "fix";
}
// HuggingFace-Token fuer gated FLUX.1-dev. Wird per RVS an die
// flux-bridge gepusht, dort als HF_TOKEN env gesetzt vor dem
// naechsten from_pretrained. Leerer String = "kein Token" (statt
// 'behalt was du hattest'), damit Stefan ihn auch wieder loeschen
// kann.
if (msg.huggingfaceToken !== undefined) {
voiceConfig.huggingfaceToken = String(msg.huggingfaceToken || "").trim();
}
try { try {
fs.mkdirSync("/shared/config", { recursive: true }); fs.mkdirSync("/shared/config", { recursive: true });
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2)); fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
+17 -1
View File
@@ -12,7 +12,7 @@ services:
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) && DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js && sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js && sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 1200000;/' $$DIST/subprocess/manager.js && sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 86400000;/' $$DIST/subprocess/manager.js &&
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js && cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js && cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
cp /proxy-patches/routes.js $$DIST/server/routes.js && cp /proxy-patches/routes.js $$DIST/server/routes.js &&
@@ -67,6 +67,22 @@ services:
- QDRANT_PORT=6333 - QDRANT_PORT=6333
- PROXY_URL=http://proxy:3456 - PROXY_URL=http://proxy:3456
- ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-} - ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-}
# Read-Timeout fuer den Proxy-Call. Hoch, weil Agent-Loops (Pentests
# etc.) auch eine Stunde+ dauern koennen. Der Proxy seinerseits hat
# einen Idle-Watchdog (Default 20min Inaktivitaet) der den Subprocess
# killt, der dann seinen close-Event sendet — Brain bekommt also
# immer was zurueck, auch bei wirklich haengenden Subprozessen.
# Connect/Write/Pool sind klein (10/30/10s) damit toter Proxy
# schnell erkannt wird (siehe proxy_client.py).
- PROXY_TIMEOUT_SEC=${PROXY_TIMEOUT_SEC:-86400}
# OAuth-Callback-URL Bestandteile. Brain baut daraus
# https://{RVS_HOST}:{RVS_PORT_PUBLIC}/oauth/callback/{service} als
# redirect_uri fuer Provider wie Spotify/Google/etc. RVS_PORT_PUBLIC
# ist der nach aussen exposed Port (= TLS-Port hinter Caddy/Nginx),
# nicht der interne RVS-Container-Port.
- RVS_HOST=${RVS_HOST:-}
- RVS_PORT_PUBLIC=${RVS_PORT_PUBLIC:-${RVS_PORT:-443}}
- RVS_TLS=${RVS_TLS:-true}
volumes: volumes:
- ./aria-data/brain/data:/data # Memory-Cache + Skills + Models (bind-mount fuer Export) - ./aria-data/brain/data:/data # Memory-Cache + Skills + Models (bind-mount fuer Export)
- ./aria-data/brain-import:/import:ro # Quell-MDs fuer den initialen Memory-Import (read-only) - ./aria-data/brain-import:/import:ro # Quell-MDs fuer den initialen Memory-Import (read-only)
+180
View File
@@ -0,0 +1,180 @@
# FLUX.1-dev Bildgenerierung — Architektur & Stand
Ergaenzung des ARIA-Agent-Stacks um native Text-to-Image-Generierung via
FLUX.1-dev auf der Gamebox. Folgt dem **gleichen Pattern wie f5tts / whisper**:
ein eigener Container auf dem Gaming-PC, der sich selbst per WebSocket zum
RVS verbindet und auf seinen Request-Typ lauscht.
## Pipeline
```
Stefan / App
│ Chat-Nachricht ("mal mir einen Sonnenuntergang ueberm Hangar")
aria-bridge ── send_to_core ──▶ aria-brain
│ chooses tool: flux_generate(prompt=..., width=..., ...)
│ POST /internal/flux-generate
aria-bridge (VM)
│ pushes {type: "flux_request",
│ payload: {requestId, prompt, ...}}
│ via RVS-Broadcast
RVS
│ fanout
flux-bridge (Gamebox)
│ FluxPipeline.from_pretrained(...)
│ pipeline(prompt, width, height, steps, guidance).images[0]
│ PIL → PNG → base64
│ {type: "flux_response", payload: {state:"done",
│ requestId, base64, mimeType, ...}}
RVS
aria-bridge (VM)
│ _pending_flux[requestId].set_result(payload)
│ base64-decode → /shared/uploads/aria_generated_<ts>.png
│ HTTP 200 zurueck an Brain mit {path, sizeBytes, ...}
aria-brain
│ Tool-Result + Hint: "schreib [FILE: {path}] in deine Antwort"
│ Final-Reply: "Hier dein Bild:\n[FILE: /shared/uploads/aria_generated_<ts>.png]"
aria-bridge
│ _FILE_MARKER_RE → file_from_aria-Event
│ Marker bleibt im Chat-Text fuer Hist; App rendert das Bild inline
App + Diagnostic
```
## Komponenten
### 1. `flux/bridge.py` (neu) — flux-bridge Container
- `FluxPipeline` (diffusers) mit `enable_model_cpu_offload()` als Default,
damit FLUX.1-dev (~24 GB on disk, ~12 B params) auf einer RTX 3060
(12 GB VRAM) ueberhaupt laeuft.
- Lazy-Load: Modell wird beim ersten `flux_request` (oder im Initial-Load)
geladen, `service_status: "flux", state: "loading" | "ready" | "error"`
wird via RVS broadcastet → Diagnostic-Badge zeigt's an.
- Single-Worker-Queue (`_flux_queue`) — GPU darf nicht parallel rendern,
sonst OOM oder Crash.
- Progress-Ping: `flux_response {state: "rendering"}` direkt nach
Queue-Pickup, damit die aria-bridge weiss "Auftrag angekommen", auch
wenn der eigentliche Render 60s braucht.
- Caps:
- `width`/`height`: 256 .. `FLUX_MAX_DIM` (Default 1536), gesnappt auf
Vielfache von 64.
- `steps`: 1 .. `FLUX_MAX_STEPS` (Default 50).
- `guidance_scale`: 0.0 .. 20.0.
- `prompt`: max 2000 chars.
- Env-Switches:
- `FLUX_MODEL` — Default `black-forest-labs/FLUX.1-dev` (non-commercial).
Alt: `FLUX.1-schnell` (Apache-2.0, 4 Steps, deutlich schneller).
- `FLUX_OFFLOAD``model` (default), `sequential` (sparsamer, langsamer)
oder `none` (alles auf GPU; nur fuer >=24 GB VRAM-Karten).
- `FLUX_DTYPE``bfloat16` (default) oder `float16`.
- `HF_TOKEN` — FLUX.1-dev braucht HuggingFace-Login.
### 2. `flux/docker-compose.yml` — eigener Stack
Bewusst NICHT mit in `xtts/docker-compose.yml` gepackt: FLUX kann auch
separat laufen (z.B. spaeter auf einer 4090, waehrend die 3060 weiter
TTS+STT bedient). Eigener Compose, eigene `.env.example`, eigenes
`hf-cache/`-Volume.
- GPU-Reservation analog zu f5tts/whisper.
- Volume `./hf-cache:/root/.cache/huggingface` — wenn flux auf der
gleichen Maschine wie xtts laeuft kann man `../xtts/hf-cache`
symlinken, dann ist der Modell-Cache geteilt.
- Restart `unless-stopped`.
### 3. `rvs/server.js` — Allowlist erweitert
Neue Typen: `flux_request`, `flux_response` (auch wenn das Initial-Load-
broadcast `service_status` bereits zugelassen war).
### 4. `bridge/aria_bridge.py`
- `self._pending_flux: dict[str, asyncio.Future]` — request_id → future.
- `self._remote_flux_ready: bool` — wird von `service_status` Updates
gefuellt; steuert den HTTP-Timeout (240 s wenn ready, 900 s waehrend
des allerersten Modell-Downloads).
- `flux_response`-Handler: Progress-Ping (`state == "rendering"`) bleibt
no-op auf der Future; `state == "done"` setzt die Future, Error setzt
`{"error": ...}`.
- `_flux_generate(prompt, width, height, steps, guidance, seed)` — Helper:
1. UUID + Future
2. `flux_request` broadcasten
3. `asyncio.wait_for(future, timeout=...)`
4. base64 → `/shared/uploads/aria_generated_<ts>.png`
5. dict mit `{ok, path, sizeBytes, width, height, steps, guidance, seed, model, renderSeconds}`
- HTTP-Endpoint `POST /internal/flux-generate` im internen Listener
(Port 8090). Validiert prompt + clamps, ruft `_flux_generate`, gibt
Result als JSON zurueck.
### 5. `aria-brain/agent.py` — META-Tool `flux_generate`
```jsonc
{
"name": "flux_generate",
"parameters": {
"prompt": "string (englischer Prompt — FLUX liefert auf EN besser)",
"width": "integer (256..1536, default 1024)",
"height": "integer (256..1536, default 1024)",
"steps": "integer (1..50, default 28)",
"guidance_scale": "number (default 3.5)",
"seed": "integer (optional)"
}
}
```
Dispatcher:
- POSTet `{prompt, width, height, steps, guidance_scale, seed}` an
`http://aria-bridge:8090/internal/flux-generate` (urllib, 1200 s Timeout
— der erste Render kann den 24 GB Modell-Download triggern).
- Bei `ok=true` gibt das Tool den **Pfad** + Render-Stats zurueck und
weist Claude explizit an: *"Schreibe `[FILE: <path>]` in deine
Antwort an Stefan, dann zeigt die App das Bild inline."*
- Brain ueberlegt sich den Begleittext selber und packt den Marker an
passende Stelle.
### 6. `diagnostic/index.html` — Status-Badge
Label `flux: 'FLUX Image-Gen'` zum bestehenden `updateServiceStatus()`-Switch
hinzugefuegt — kein neuer Code, gleicher Banner-Mechanismus wie F5-TTS /
Whisper.
## File-Lifecycle
Generierte Bilder leben unter `/shared/uploads/aria_generated_<ts>.png`
(gleicher Folder wie User-Uploads). Damit:
- `[FILE: ...]`-Marker funktioniert (Bridge erlaubt nur Pfade unter
`/shared/uploads/`).
- File-Manager-Endpoints in Diagnostic (Liste/Loeschen/Zip) sehen sie
ohne Sonderbehandlung.
- Memory-Anhaenge: ARIA kann ein generiertes Bild im selben Turn an
einen Memory-Eintrag haengen (`memory_save(attach_paths=[path])`).
## Bekannte Stolpersteine
- **HF-Login**: FLUX.1-dev ist gated. Vor erstem Start `HF_TOKEN` im
`.env` setzen oder im Container `huggingface-cli login` machen, sonst
403 beim ersten Download.
- **Erster Render dauert lang**: 24 GB Modell laden + CUDA-Warmup → 5-10
min realistisch. Brain-HTTP-Timeout ist 1200 s, RVS-Future-Timeout
900 s (loading-Modus). Stefan sollte beim ersten "Mal mir was"-Request
ein bisschen Geduld haben — danach sind Renders ~30-90 s.
- **Lizenz**: FLUX.1-dev ist *non-commercial* (FLUX.1 Dev Non-Commercial
License). Fuer kommerzielle Nutzung muss man auf `FLUX.1-schnell`
(Apache-2.0) oder `FLUX.1-pro` (API only) wechseln. Stefan kann das
ueber `FLUX_MODEL` in der `.env` umstellen.
- **VRAM**: 12 GB (3060) reichen NUR mit `enable_model_cpu_offload`. Bei
Out-of-Memory in den Logs auf `FLUX_OFFLOAD=sequential` switchen
(deutlich langsamer, aber peak-VRAM ~6 GB).
- **Parallele Calls**: Single-Worker-Queue in der flux-bridge — ein
zweiter `flux_generate`-Tool-Call von Brain wartet, bis der erste fertig
ist. In der Praxis kein Problem, weil Stefan eh nicht zwei Bilder
gleichzeitig macht.
+36
View File
@@ -0,0 +1,36 @@
# ════════════════════════════════════════════════
# ARIA FLUX-Bridge — Konfiguration
# Kopieren nach .env und anpassen
# ════════════════════════════════════════════════
# RVS Verbindung (gleiche Daten wie auf der ARIA-VM / xtts/.env)
RVS_HOST=mobil.hacker-net.de
RVS_PORT=444
RVS_TLS=true
RVS_TLS_FALLBACK=true
RVS_TOKEN=dein_token_hier
# HuggingFace-Token + Default-Modell werden in ARIA Diagnostic verwaltet
# (Section "FLUX Bildgenerierung") und per RVS an die flux-bridge gepusht.
# Hier nichts noetig.
#
# Token-Pflicht NUR fuer FLUX.1-dev (gated). Workflow falls Du dev nutzen
# willst:
# 1) https://huggingface.co/black-forest-labs/FLUX.1-dev → "Agree"
# 2) https://huggingface.co/settings/tokens → "Read"-Token erzeugen
# 3) Token in Diagnostic > FLUX Bildgenerierung > HuggingFace-Token
# FLUX.1-schnell (Apache-2.0) laeuft ohne Token.
# Offloading-Strategie (VRAM-Steuerung):
# model — Default. Komponentenweise CPU-Offload, gut fuer 12 GB Karten.
# sequential — sparsamer (Peak ~6 GB), aber 2-3x langsamer.
# none — alles auf GPU. Nur fuer >= 24 GB VRAM-Karten.
FLUX_OFFLOAD=model
# Float-Type. bfloat16 ist FLUX-native; auf alten Karten ohne BF16-Support
# auf float16 wechseln.
FLUX_DTYPE=bfloat16
# Hard-Caps gegen versehentlich teure Renders
FLUX_MAX_STEPS=50
FLUX_MAX_DIM=1536
+5
View File
@@ -0,0 +1,5 @@
# HuggingFace Model-Cache (FLUX.1-dev ~24 GB on disk)
hf-cache/
# Docker .env
.env
+30
View File
@@ -0,0 +1,30 @@
FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# PyTorch CUDA-Wheels zuerst, damit diffusers nicht CPU-Torch zieht.
# Torch 2.5+ ist Pflicht: aktuelle transformers (4.50+, von diffusers
# transitiv reingezogen) registriert in integrations/moe.py einen
# custom_op mit String-Forward-References (`input: 'torch.Tensor'`).
# Erst torch 2.5's infer_schema kann die aufloesen — 2.4.1 crasht mit
# "Parameter input has unsupported type torch.Tensor" beim Import von
# diffusers.pipelines.flux.pipeline_flux.
# torchvision wird von den CLIP-/Siglip-ImageProcessors verlangt.
# cu121 bleibt — passt zum CUDA 12.2 Base-Image.
RUN pip3 install --no-cache-dir \
torch==2.5.1 torchvision==0.20.1 \
--index-url https://download.pytorch.org/whl/cu121
COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt
COPY bridge.py .
CMD ["python3", "bridge.py"]
+557
View File
@@ -0,0 +1,557 @@
#!/usr/bin/env python3
"""
ARIA FLUX-Bridge laeuft auf der Gamebox (RTX 3060).
Empfaengt flux_request via RVS FLUX.1-dev/-schnell auf GPU sendet
flux_response mit base64-PNG zurueck an die aria-bridge. Diese speichert
die Datei nach /shared/uploads/ und ARIA referenziert sie mit
[FILE: ...]-Marker in ihrer Antwort.
12 GB VRAM auf der 3060 reichen fuer FLUX.1-dev nur mit
`enable_model_cpu_offload()` sonst OOM. Setze FLUX_OFFLOAD=sequential
fuer Maximal-Sparsamkeit (langsamer) oder FLUX_OFFLOAD=none wenn die
GPU genug VRAM hat (z.B. spaeter 4090).
Env:
RVS_HOST, RVS_PORT, RVS_TLS, RVS_TLS_FALLBACK, RVS_TOKEN
FLUX_MODEL Default: black-forest-labs/FLUX.1-dev
Alt: black-forest-labs/FLUX.1-schnell (4-Step, Apache-2.0)
FLUX_DEVICE Default: cuda
FLUX_DTYPE Default: bfloat16 (alt: float16)
FLUX_OFFLOAD Default: model (alt: sequential | none)
FLUX_MAX_STEPS Default: 50
FLUX_MAX_DIM Default: 1536
"""
import asyncio
import base64
import io
import json
import logging
import os
import sys
import time
import uuid
from typing import Optional
import websockets
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("flux-bridge")
# HuggingFace/Torch download-Logs daempfen
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
RVS_HOST = os.getenv("RVS_HOST", "").strip()
RVS_PORT = int(os.getenv("RVS_PORT", "443"))
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
# Bootstrap-Fallback: nur relevant wenn beim allerersten Start KEIN
# Diagnostic-config-Broadcast eintrifft UND der erste Render-Request
# auch kein 'model' enthaelt. Default 'schnell', weil Apache-2.0
# (kein HF-Token noetig) — Stefan stellt sein gewuenschtes Default ueber
# Diagnostic ein. ENV ist also nur fuer den extremen Edge-Case da, in
# der .env.example absichtlich nicht mehr dokumentiert.
FLUX_MODEL = os.getenv("FLUX_MODEL", "black-forest-labs/FLUX.1-schnell").strip()
FLUX_DEVICE = os.getenv("FLUX_DEVICE", "cuda").strip()
FLUX_DTYPE = os.getenv("FLUX_DTYPE", "bfloat16").strip().lower()
FLUX_OFFLOAD = os.getenv("FLUX_OFFLOAD", "model").strip().lower()
FLUX_MAX_STEPS = int(os.getenv("FLUX_MAX_STEPS", "50"))
FLUX_MAX_DIM = int(os.getenv("FLUX_MAX_DIM", "1536"))
# FLUX-dev native: guidance=3.5, steps=28. FLUX-schnell: guidance=0.0, steps=4.
DEFAULT_STEPS_DEV = 28
DEFAULT_STEPS_SCHNELL = 4
DEFAULT_GUIDANCE_DEV = 3.5
DEFAULT_GUIDANCE_SCHNELL = 0.0
# Mapping fuer das User-facing Tag → HF-Modell-ID. Stefan stellt in Diagnostic
# nur 'dev' / 'schnell' ein; FLUX_MODEL aus der env kann zwar eine custom-ID
# sein (Bootstrap), wird aber beim ersten config-Broadcast normalerweise
# durch die Diagnostic-Wahl uebersteuert.
MODEL_TAGS: dict[str, str] = {
"dev": "black-forest-labs/FLUX.1-dev",
"schnell": "black-forest-labs/FLUX.1-schnell",
}
def _tag_to_model_id(tag: str) -> str:
"""Mappt 'dev'/'schnell' auf HF-ID. Andere Strings werden 1:1 durchgereicht
(custom-IDs aus FLUX_MODEL env). Leere/ungueltige Werte FLUX_MODEL Default."""
if not tag:
return FLUX_MODEL
t = tag.strip()
return MODEL_TAGS.get(t, t)
def _is_schnell(model_id: str) -> bool:
return "schnell" in model_id.lower()
def _is_model_cached(model_id: str) -> bool:
"""Prueft ob ein HF-Modell-Snapshot lokal im hf-cache vorhanden ist.
HF speichert unter ~/.cache/huggingface/hub/models--{org}--{name}/snapshots/{rev}/.
Wenn das snapshots-Verzeichnis nicht existiert oder leer ist Erst-Download
steht an (24+ GB fuer FLUX.1-dev, 24+ GB fuer FLUX.1-schnell Stefan kriegt
dann nen Hinweis im Banner).
"""
if not model_id:
return False
cache_root = os.environ.get("HF_HOME") or os.path.expanduser("~/.cache/huggingface")
safe = "models--" + model_id.replace("/", "--")
snapshots = os.path.join(cache_root, "hub", safe, "snapshots")
if not os.path.isdir(snapshots):
return False
try:
for rev in os.listdir(snapshots):
rev_dir = os.path.join(snapshots, rev)
if os.path.isdir(rev_dir) and any(os.scandir(rev_dir)):
return True
except OSError:
return False
return False
def _torch_dtype():
"""Lazy-resolve damit Torch erst beim Modell-Laden importiert wird."""
import torch
return {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}\
.get(FLUX_DTYPE, torch.bfloat16)
def _snap_dim(v: int, default: int = 1024) -> int:
"""FLUX braucht Multiples von 16 (sicher: 64). Clamp + Snap."""
try:
n = int(v)
except (TypeError, ValueError):
n = default
n = max(256, min(FLUX_MAX_DIM, n))
# Auf naechstes Vielfaches von 64 abrunden
n = (n // 64) * 64
return max(256, n)
class FluxRunner:
"""Haelt EINE FLUX-Pipeline. Bei Modell-Wechsel wird die alte verworfen
und die neue geladen (~15-30 s aus HF-Cache, keine Re-Downloads).
Pro Request kann ein 'dev'/'schnell'-Tag mitkommen; ohne Angabe wird
`default_model_id` genommen (steht Bootstrap auf FLUX_MODEL, wird beim
ersten config-Broadcast von der aria-bridge auf die Diagnostic-Wahl
aktualisiert).
"""
def __init__(self) -> None:
self.pipe = None
self._lock = asyncio.Lock()
# Aktuell geladenes Modell — leer solange noch nix geladen wurde.
self.model_id: str = ""
# Was bei einem Request OHNE explizite model-Angabe benutzt wird.
# Wird durch Diagnostic-config gesetzt; FLUX_MODEL bleibt nur als
# Edge-Case-Fallback wenn weder Config noch Request einen Wert nennen.
self.default_model_id: str = FLUX_MODEL
self.last_load_seconds: float = 0.0
# True wenn der letzte _load_blocking einen Fresh-Download triggern
# musste (Modell war nicht im HF-Cache). Wird vom Caller geprueft
# und in den 'ready'-service_status als freshlyDownloaded gesetzt.
self.last_load_was_download: bool = False
def _load_blocking(self, model_id: str) -> None:
import torch
from diffusers import FluxPipeline
# Alte Pipeline freigeben damit der HF-Loader VRAM/RAM kriegt
if self.pipe is not None:
logger.info("Verwerfe alte Pipeline '%s'", self.model_id)
try:
del self.pipe
except Exception:
pass
self.pipe = None
try:
torch.cuda.empty_cache()
except Exception:
pass
import gc
gc.collect()
was_cached = _is_model_cached(model_id)
self.last_load_was_download = not was_cached
if not was_cached:
logger.warning("FLUX '%s' nicht im HF-Cache — Erst-Download steht bevor (kann 5-10 min dauern).",
model_id)
logger.info("Lade FLUX '%s' (dtype=%s, offload=%s, cached=%s)...",
model_id, FLUX_DTYPE, FLUX_OFFLOAD, was_cached)
t0 = time.time()
pipe = FluxPipeline.from_pretrained(model_id, torch_dtype=_torch_dtype())
if FLUX_OFFLOAD == "sequential":
pipe.enable_sequential_cpu_offload()
elif FLUX_OFFLOAD == "none":
pipe.to(FLUX_DEVICE)
else: # "model" — default, Sweet-Spot fuer 12 GB Karten
pipe.enable_model_cpu_offload()
# VAE-Tiling spart VRAM bei grossen Bildern (>1024)
try:
pipe.vae.enable_tiling()
except Exception:
pass
self.pipe = pipe
self.model_id = model_id
self.last_load_seconds = time.time() - t0
logger.info("FLUX '%s' geladen in %.1fs", model_id, self.last_load_seconds)
try:
torch.cuda.empty_cache()
except Exception:
pass
async def ensure_loaded(self, model_id: Optional[str] = None) -> bool:
"""Stellt sicher dass die richtige Pipeline geladen ist. Wenn ein
anderes Modell gewuenscht ist als gerade aktiv, wird geswappt.
Returns True wenn ein Swap/Load stattgefunden hat."""
target = model_id or self.default_model_id or FLUX_MODEL
async with self._lock:
if self.pipe is not None and self.model_id == target:
return False
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._load_blocking, target)
return True
def _generate_blocking(self, prompt: str, width: int, height: int,
steps: int, guidance: float, seed: Optional[int]) -> bytes:
import torch
gen = None
if seed is not None and seed >= 0:
gen = torch.Generator(device=FLUX_DEVICE).manual_seed(int(seed))
logger.info("Render (%s): %dx%d, steps=%d, guidance=%.2f, seed=%s, prompt=%r",
self.model_id, width, height, steps, guidance, seed, prompt[:80])
out = self.pipe(
prompt=prompt,
width=width,
height=height,
num_inference_steps=steps,
guidance_scale=guidance,
generator=gen,
)
image = out.images[0]
buf = io.BytesIO()
image.save(buf, format="PNG", optimize=True)
png_bytes = buf.getvalue()
# VRAM zurueckgeben fuer den naechsten Render
try:
torch.cuda.empty_cache()
except Exception:
pass
return png_bytes
async def generate(self, prompt: str, width: int, height: int,
steps: int, guidance: float, seed: Optional[int],
model_id: Optional[str] = None) -> bytes:
await self.ensure_loaded(model_id)
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None, self._generate_blocking, prompt, width, height, steps, guidance, seed,
)
# ── Helpers ─────────────────────────────────────────────────
async def _send(ws, mtype: str, payload: dict) -> None:
try:
await ws.send(json.dumps({
"type": mtype,
"payload": payload,
"timestamp": int(time.time() * 1000),
}))
except Exception as e:
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
async def _broadcast_status(ws, state: str, **extra) -> None:
"""Sendet service_status fuer das Flux-Modul.
state: 'loading' | 'ready' | 'error'."""
payload = {"service": "flux", "state": state}
payload.update(extra)
await _send(ws, "service_status", payload)
# ── Flux-Request Queue ──────────────────────────────────────
# Eine GPU, ein Render gleichzeitig. Parallele Requests OOM-en sonst.
_flux_queue: "asyncio.Queue[tuple]" = asyncio.Queue()
def _resolve_request(payload: dict, runner: FluxRunner) -> tuple[str, int, int, int, float, Optional[int], str]:
"""Liest Felder aus dem flux_request payload + clampt auf Caps.
Returns (prompt, width, height, steps, guidance, seed, resolved_model_id).
"""
prompt = (payload.get("prompt") or "").strip()
if not prompt:
raise ValueError("prompt fehlt")
if len(prompt) > 2000:
prompt = prompt[:2000]
width = _snap_dim(payload.get("width", 1024))
height = _snap_dim(payload.get("height", 1024))
# Modell-Wahl: explizit per Request > runner.default_model_id > FLUX_MODEL.
req_model = (payload.get("model") or "").strip()
resolved_model_id = _tag_to_model_id(req_model) if req_model else (runner.default_model_id or FLUX_MODEL)
schnell = _is_schnell(resolved_model_id)
default_steps = DEFAULT_STEPS_SCHNELL if schnell else DEFAULT_STEPS_DEV
default_guidance = DEFAULT_GUIDANCE_SCHNELL if schnell else DEFAULT_GUIDANCE_DEV
try:
steps = int(payload.get("steps", default_steps))
except (TypeError, ValueError):
steps = default_steps
steps = max(1, min(FLUX_MAX_STEPS, steps))
try:
guidance = float(payload.get("guidance_scale", default_guidance))
except (TypeError, ValueError):
guidance = default_guidance
if not (0.0 <= guidance <= 20.0):
guidance = default_guidance
seed = payload.get("seed")
if seed is not None:
try:
seed = int(seed)
except (TypeError, ValueError):
seed = None
return prompt, width, height, steps, guidance, seed, resolved_model_id
async def _flux_worker(ws, runner: FluxRunner) -> None:
"""Serialisiert Renders — eine GPU, ein Bild gleichzeitig."""
while True:
payload = await _flux_queue.get()
request_id = payload.get("requestId") or str(uuid.uuid4())
try:
await _do_render(ws, runner, payload, request_id)
except Exception:
logger.exception("Flux-Worker Fehler")
await _send(ws, "flux_response", {
"requestId": request_id,
"error": "internal error",
})
finally:
_flux_queue.task_done()
async def _do_render(ws, runner: FluxRunner, payload: dict, request_id: str) -> None:
t0 = time.time()
try:
prompt, width, height, steps, guidance, seed, target_model_id = _resolve_request(payload, runner)
except ValueError as e:
logger.warning("flux_request invalid: %s", e)
await _send(ws, "flux_response", {"requestId": request_id, "error": str(e)})
return
# Modell-Swap noetig? Status broadcasten damit Diagnostic-Banner es zeigt.
swap_needed = (runner.pipe is None or runner.model_id != target_model_id)
will_download = swap_needed and not _is_model_cached(target_model_id)
if swap_needed:
await _broadcast_status(ws, "loading", model=target_model_id,
downloading=will_download)
await _send(ws, "flux_response", {
"requestId": request_id,
"state": "switching_model",
"model": target_model_id,
"downloading": will_download,
})
# Progress-Ping: User soll sehen dass was passiert (Render >30s realistisch)
await _send(ws, "flux_response", {
"requestId": request_id,
"state": "rendering",
"width": width, "height": height, "steps": steps,
"model": target_model_id,
})
try:
png = await runner.generate(prompt, width, height, steps, guidance, seed,
model_id=target_model_id)
except Exception as e:
logger.exception("FLUX Render-Fehler")
await _send(ws, "flux_response", {"requestId": request_id, "error": str(e)[:200]})
if swap_needed:
await _broadcast_status(ws, "error", error=str(e)[:200])
return
if swap_needed:
await _broadcast_status(ws, "ready",
model=runner.model_id,
loadSeconds=runner.last_load_seconds,
freshlyDownloaded=runner.last_load_was_download)
dt = time.time() - t0
b64 = base64.b64encode(png).decode("ascii")
logger.info("Render fertig: %dx%d, %d KB PNG, %.1fs (%s)",
width, height, len(png) // 1024, dt, runner.model_id)
await _send(ws, "flux_response", {
"requestId": request_id,
"state": "done",
"base64": b64,
"mimeType": "image/png",
"width": width,
"height": height,
"steps": steps,
"guidance": guidance,
"seed": seed,
"model": runner.model_id,
"renderSeconds": round(dt, 2),
"sizeBytes": len(png),
})
# ── Haupt-Loop ──────────────────────────────────────────────
async def run_loop(runner: FluxRunner) -> None:
use_tls = RVS_TLS
retry_s = 2
tls_fallback_tried = False
while True:
scheme = "wss" if use_tls else "ws"
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
try:
logger.info("Verbinde zu RVS: %s", masked)
# max_size 100 MB damit ein 4 MP PNG (~5-10 MB → ~13 MB base64)
# locker reinpasst. Mit dem RVS-Limit (100 MB) konsistent.
async with websockets.connect(url, ping_interval=20, ping_timeout=10,
max_size=100 * 1024 * 1024) as ws:
logger.info("RVS verbunden")
retry_s = 2
tls_fallback_tried = False
async def _load_with_status():
"""Bei Connect KEIN Eager-Load — wir fragen erst die
Diagnostic-Config ab. Welches Modell tatsaechlich geladen
wird entscheidet sich entweder durch den config-Broadcast
(kommt direkt danach) oder durch den ersten flux_request.
Bis dahin gibt's keinen service_status, das Banner taucht
erst auf wenn wir wirklich was laden."""
try:
if runner.pipe is not None:
# Pipeline ueberlebt nur Container-Lifetime; hier
# also nur falls schon ein Modell aktiv ist (Reconnect).
await _broadcast_status(ws, "ready",
model=runner.model_id,
loadSeconds=runner.last_load_seconds)
logger.info("Initial: sende config_request an aria-bridge "
"(kein Eager-Load, warte auf Diagnostic-Wahl)")
await _send(ws, "config_request", {"service": "flux"})
except Exception as e:
logger.exception("Initial-Setup crashed: %s", e)
try:
await _broadcast_status(ws, "error", error=str(e)[:200])
except Exception:
pass
asyncio.create_task(_load_with_status())
worker = asyncio.create_task(_flux_worker(ws, runner))
async def _apply_default_change(new_tag: str):
"""Wechselt den Default. Wenn ein anderes Modell als aktuell
aktiv gewuenscht ist, wird eager geladen der naechste
Render ist dann ohne Swap-Delay."""
new_model_id = _tag_to_model_id(new_tag)
runner.default_model_id = new_model_id
if runner.model_id == new_model_id:
logger.info("[config] Default-Modell bleibt: %s", new_model_id)
return
will_download = not _is_model_cached(new_model_id)
logger.info("[config] Default-Modell wechselt: %s%s (download=%s)",
runner.model_id or "(none)", new_model_id, will_download)
try:
await _broadcast_status(ws, "loading", model=new_model_id,
downloading=will_download)
await runner.ensure_loaded(new_model_id)
await _broadcast_status(ws, "ready",
model=runner.model_id,
loadSeconds=runner.last_load_seconds,
freshlyDownloaded=runner.last_load_was_download)
except Exception as e:
logger.exception("Modell-Swap fehlgeschlagen")
try:
await _broadcast_status(ws, "error", error=str(e)[:200])
except Exception:
pass
try:
async for raw in ws:
try:
msg = json.loads(raw)
except Exception:
continue
mtype = msg.get("type", "")
payload = msg.get("payload", {}) or {}
if mtype == "flux_request":
await _flux_queue.put(payload)
elif mtype == "config":
# Diagnostic-Broadcast (oder aria-bridge nach Reconnect).
# HuggingFace-Token MUSS vor dem Modell-Swap gesetzt sein,
# weil FluxPipeline.from_pretrained den Token aus der env
# liest. Reihenfolge im selben Tick gewaehrleistet das.
if "huggingfaceToken" in payload:
tok = (payload.get("huggingfaceToken") or "").strip()
if tok:
os.environ["HF_TOKEN"] = tok
os.environ["HUGGING_FACE_HUB_TOKEN"] = tok
logger.info("[config] HF-Token gesetzt (len=%d)", len(tok))
else:
os.environ.pop("HF_TOKEN", None)
os.environ.pop("HUGGING_FACE_HUB_TOKEN", None)
logger.info("[config] HF-Token entfernt (leerer Wert)")
tag = (payload.get("fluxDefaultModel") or "").strip()
if tag:
asyncio.create_task(_apply_default_change(tag))
finally:
worker.cancel()
try:
await worker
except asyncio.CancelledError:
pass
except Exception as e:
logger.warning("Verbindung verloren: %s", e)
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
logger.info("TLS fehlgeschlagen — Fallback auf ws://")
use_tls = False
tls_fallback_tried = True
continue
await asyncio.sleep(min(retry_s, 30))
retry_s = min(retry_s * 2, 30)
async def main() -> None:
if not RVS_HOST:
logger.error("RVS_HOST nicht gesetzt — Abbruch")
sys.exit(1)
runner = FluxRunner()
await run_loop(runner)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
sys.exit(0)
+57
View File
@@ -0,0 +1,57 @@
# ════════════════════════════════════════════════
# ARIA FLUX-Bridge — Text-to-Image (GPU)
# Eigener Stack, weil FLUX auch auf einer anderen
# Maschine als f5tts/whisper laufen kann (z.B. 4090
# separat vom Gaming-PC). Verbindet sich selbst per
# WebSocket zum RVS und lauscht auf flux_request.
# ════════════════════════════════════════════════
#
# Voraussetzungen:
# - NVIDIA-GPU mit >= 12 GB VRAM (3060 reicht mit
# enable_model_cpu_offload). Bei < 12 GB:
# FLUX_OFFLOAD=sequential setzen, sonst OOM.
# - Docker mit NVIDIA Container Toolkit
# - HuggingFace-Token in .env (FLUX.1-dev ist gated)
# - .env mit RVS-Verbindungsdaten (gleiche wie xtts!)
#
# Start: docker compose up -d
# ════════════════════════════════════════════════
services:
# ─── FLUX Bildgenerierung (GPU) ─────────
# Empfaengt flux_request via RVS, rendert PNG mit FLUX (12B Params)
# und broadcastet flux_response mit base64-PNG zurueck. aria-bridge speichert
# die Datei nach /shared/uploads/ und ARIA referenziert sie via [FILE:]-Marker.
#
# Modell-Wahl + HuggingFace-Token werden in ARIA Diagnostic eingestellt
# ("FLUX Bildgenerierung") und per RVS gepusht — hier nichts noetig.
flux-bridge:
build: .
container_name: aria-flux-bridge
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
environment:
- RVS_HOST=${RVS_HOST}
- RVS_PORT=${RVS_PORT:-443}
- RVS_TLS=${RVS_TLS:-true}
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
- RVS_TOKEN=${RVS_TOKEN}
# Hardware-Bootstrap (Diagnostic-Settings uebersteuern alles andere
# zur Laufzeit — diese envs sind nur Edge-Case-Fallbacks).
- FLUX_DEVICE=${FLUX_DEVICE:-cuda}
- FLUX_DTYPE=${FLUX_DTYPE:-bfloat16}
- FLUX_OFFLOAD=${FLUX_OFFLOAD:-model}
- FLUX_MAX_STEPS=${FLUX_MAX_STEPS:-50}
- FLUX_MAX_DIM=${FLUX_MAX_DIM:-1536}
volumes:
- ./hf-cache:/root/.cache/huggingface # Bind-Mount. FLUX.1-dev ~24 GB on disk!
# Wenn flux auf der gleichen Maschine
# wie xtts laeuft: ../xtts/hf-cache
# symlinken um den Cache zu teilen.
restart: unless-stopped
+9
View File
@@ -0,0 +1,9 @@
diffusers>=0.30.0
transformers>=4.43.0
accelerate>=0.33.0
sentencepiece>=0.2.0
protobuf>=4.25.0
pillow>=10.0.0
huggingface_hub>=0.24.0
websockets>=12.0
numpy>=1.24
+15
View File
@@ -377,6 +377,20 @@ Skills mit Tool-Use.
- [x] **About-Text rendete `—` literal**: JSX-Text-Knoten interpretieren keine JS-String-Escapes — `—` blieb als Backslash-u-Sequenz sichtbar. Fix: `{'—'}` als JS-Expression-Block - [x] **About-Text rendete `—` literal**: JSX-Text-Knoten interpretieren keine JS-String-Escapes — `—` blieb als Backslash-u-Sequenz sichtbar. Fix: `{'—'}` als JS-Expression-Block
- [x] **GPS-Heartbeat fuer stationaere User**: `watchPosition` mit `distanceFilter: 30` sendet keine Updates ohne 30 m Bewegung. Stefan stationaer → nach initialer Position keine weiteren Updates → Brain verwirft Position nach `NEAR_MAX_AGE_SEC=300` als veraltet → `near()`-Watcher feuern nie. Fix: zusaetzlich zum watchPosition laeuft ein `setInterval(60s)` Heartbeat der die zuletzt empfangene Position erneut sendet. Kein extra GPS-Wakeup, akkufreundlich — und Brain-State bleibt frisch auch ohne Bewegung - [x] **GPS-Heartbeat fuer stationaere User**: `watchPosition` mit `distanceFilter: 30` sendet keine Updates ohne 30 m Bewegung. Stefan stationaer → nach initialer Position keine weiteren Updates → Brain verwirft Position nach `NEAR_MAX_AGE_SEC=300` als veraltet → `near()`-Watcher feuern nie. Fix: zusaetzlich zum watchPosition laeuft ein `setInterval(60s)` Heartbeat der die zuletzt empfangene Position erneut sendet. Kein extra GPS-Wakeup, akkufreundlich — und Brain-State bleibt frisch auch ohne Bewegung
### Brain-Timeouts + Subprocess-Cleanup
- [x] **Brain-Timeout nach exakt 20min trotz aktiver ARIA**: `httpx.Client` im `proxy_client.py` hatte einen 1200s-Read-Timeout — der gleiche Wert den wir Tage zuvor am Proxy auf 24h hochgezogen hatten, aber im Brain uebersehen. Bei langen Pentests timed Brain raus obwohl der Proxy-Subprocess noch fleissig Events emittierte. Fix: `PROXY_TIMEOUT_SEC=86400` Env in der Compose, plus split-Timeouts in `httpx.Timeout(connect=10, read=86400, write=30, pool=10)` — toter Proxy wird in 10s erkannt, lange ARIA-Sessions duerfen 24h laufen
- [x] **Verwaiste Claude-Subprocesses nach Brain-Disconnect**: `handleNonStreamingResponse` in `routes.js` hatte keinen `res.on("close")` (nur der Streaming-Branch). Wenn Brain die Verbindung gekappt hat (z.B. nach Timeout), lief der Claude-Subprocess weiter ohne dass noch jemand lauschte — Ressourcen-Leak. Fix: `res.on("close")` mit `isComplete`-Flag, Subprocess wird sofort gekillt bei Client-Disconnect
- [x] **Conversation-Inkonsistenz bei Brain-Exception**: `agent.chat()` fuegte den User-Turn ein BEVOR der Proxy-Call lief — bei Exception blieb der User-Turn ohne Assistant-Pair stehen, naechster Brain-Call sah `user → user` als letzte zwei Turns und konnte mit Tool-Calls fehlschlagen. Fix: try/except um den Tool-Loop, bei Exception wird ein Error-Marker (`[Fehler: ...]`) als Assistant-Turn geschrieben — Conversation bleibt konsistent
### OAuth-Pipeline (Spotify / Google / GitHub / Strava / Microsoft)
- [x] **Externe OAuth2-Provider per RVS-Callback**: ARIA brauchte Tokens fuer Spotify-Skill — bisher `redirect_uri=http://localhost:...` was vom Handy aus nicht erreichbar war, Stefan musste den Code manuell aus der URL kopieren (fragil, OAuth-Codes sind ~10min gueltig). Loesung: RVS-Server hat jetzt einen HTTP-Listener (selber Port wie WebSocket, hybrid via `http.createServer` + `wss.handleUpgrade`). Provider redirected an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet `oauth_callback`-Message → aria-bridge forwarded an Brain → Brain matched `state` (CSRF-Schutz), tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json` (file-mode 0600). Token-Refresh laeuft automatisch wenn <60s Restzeit
- [x] **Brain-Tools fuer ARIA**: `oauth_authorize(service, scopes?)` baut Auth-URL + speichert pending state, `oauth_get_token(service)` liefert aktuelles access_token (refresh wenn noetig), `oauth_revoke(service)` loescht. Skills nutzen diese statt selber Auth-Flow zu machen
- [x] **Generische Provider-Configs**: `DEFAULT_PROVIDERS` in `oauth.py` deckt Spotify, Google, GitHub, Strava, Microsoft mit ihren Quirks ab (Basic-Auth vs Body-Auth, Accept-Header fuer GitHub, `access_type=offline` fuer Google, etc.). Custom-Provider via `oauth_apps.json` moeglich
- [x] **Diagnostic-UI**: Einstellungen → OAuth-Apps. Pro Service Karte mit Status (verbunden/konfiguriert/leer), `client_id` + `client_secret` (Passwort-Toggle), Speichern + Autorisieren-Buttons. Autorisieren oeffnet Provider-Auth in neuem Tab; nach 8s Auto-Refresh
- [x] **Schoene Browser-Antwort vom RVS**: nach Callback bekommt der User eine Dark-Mode-HTML-Seite (✅ "OAuth erfolgreich, du kannst Tab schliessen — ARIA hat den Zugang erhalten") mit 4s Auto-Close — kein nackter JSON-Response
## Offen ## Offen
### App Features ### App Features
@@ -389,3 +403,4 @@ Skills mit Tool-Use.
- [ ] Erste Skills bauen lassen (yt-dlp, pdf-extract, image-resize, etc.) — durch normale Anfragen, ARIA legt sie selbst an - [ ] Erste Skills bauen lassen (yt-dlp, pdf-extract, image-resize, etc.) — durch normale Anfragen, ARIA legt sie selbst an
- [ ] Heartbeat (periodische Selbst-Checks) - [ ] Heartbeat (periodische Selbst-Checks)
- [ ] Lokales LLM als Waechter (Triage vor Claude-Call) - [ ] Lokales LLM als Waechter (Triage vor Claude-Call)
- [ ] **Subprocess-Resume nach Kill/Timeout (Variante A — halb-automatisch)**: bei Idle-Timeout oder Brain-Disconnect ist die ARIA-Session weg (in-memory state des Claude-Code-Subprozesses, alle Tool-Outputs, Files-Reads). Stefan muss heute manuell *"weitermachen"* sagen, ARIA improvisiert aus dem Conversation-Window was sie noch weiss. Variante A: agent_stream-Events zusaetzlich in einer JSONL persistieren, beim naechsten Brain-Call die letzten N Events als „Resume-Context" in den System-Prompt einbauen — ARIA weiss dann konkret welche Tool-Calls zuletzt liefen und kann sauber fortsetzen. Aufwand ~1-2h. Nur angehen wenn die 24h-Timeouts (Commit 0887674) wirklich nochmal triggern
+238 -16
View File
@@ -7,6 +7,10 @@
* (ARIA_TOOL_HOOK_URL, default http://aria-bridge:8090/internal/agent-activity). * (ARIA_TOOL_HOOK_URL, default http://aria-bridge:8090/internal/agent-activity).
* Bridge spiegelt das als RVS `agent_activity` an App+Diagnostic * Bridge spiegelt das als RVS `agent_activity` an App+Diagnostic
* Gedanken-Stream zeigt live was ARIA gerade tool-maessig macht. * Gedanken-Stream zeigt live was ARIA gerade tool-maessig macht.
* - Voller Live-Stream (assistant_text, tool_use mit input, tool_result)
* geht an ARIA_STREAM_HOOK_URL Bridge RVS `agent_stream` Diagnostic
* "ARIA Live"-View (TeamViewer-mäßiger Mirror der Claude-Code-Session).
* - Subprocess-Tracking + POST /v1/cancel-all fuer Not-Aus (Hard-Kill).
* - Fire-and-forget, fail-open. Wenn die Bridge nicht antwortet, bricht * - Fire-and-forget, fail-open. Wenn die Bridge nicht antwortet, bricht
* der Brain-Call NICHT ab. * der Brain-Call NICHT ab.
* *
@@ -21,42 +25,180 @@ import { cliResultToOpenai, createDoneChunk, } from "../adapter/cli-to-openai.js
const TOOL_HOOK_URL = process.env.ARIA_TOOL_HOOK_URL const TOOL_HOOK_URL = process.env.ARIA_TOOL_HOOK_URL
|| "http://aria-bridge:8090/internal/agent-activity"; || "http://aria-bridge:8090/internal/agent-activity";
const STREAM_HOOK_URL = process.env.ARIA_STREAM_HOOK_URL
|| "http://aria-bridge:8090/internal/agent-stream";
// Tool-Output kann sehr lang werden (git log -p, find /). Wir truncaten
// hart auf 4 KB pro Event — der User sieht weiterhin den Anfang und einen
// "...(N bytes truncated)" Hinweis. Vollstaendiger Output bleibt im Brain
// und wird normal verarbeitet, das hier ist NUR fuer den Live-Mirror.
const TOOL_RESULT_MAX_CHARS = 4096;
const TOOL_INPUT_MAX_CHARS = 2048;
// Idle-Timeout: Subprocess wird gekillt wenn ueber IDLE_TIMEOUT_MS keine
// Aktivitaet (message/content_delta) ankommt. Loest das alte Hard-Timeout-
// Problem fuer lange Agent-Sessions (Pentests etc.) — ARIA darf ewig
// arbeiten solange sie regelmaessig was emittiert, aber wenn der Subprocess
// hartnaeckig haengt, schlaegt der Watchdog trotzdem zu.
// Default 20min Idle. Override via env ARIA_IDLE_TIMEOUT_MS.
// 0 = deaktiviert (nicht empfohlen).
const IDLE_TIMEOUT_MS = parseInt(process.env.ARIA_IDLE_TIMEOUT_MS || "1200000", 10);
/** /**
* Pusht einen Tool-Use-Event an die Bridge. Fire-and-forget keine Awaits, * Generic Fire-and-forget POST an die Bridge. Keine Awaits, keine Fehler
* keine Fehler nach oben. Logged Fehler still. * nach oben. Eingesetzt fuer Tool-Hook + Stream-Hook.
*/ */
function _emitToolEvent(toolName) { function _postJson(url, body) {
if (!toolName) return;
try { try {
const u = new URL(TOOL_HOOK_URL); const u = new URL(url);
const body = JSON.stringify({ tool: String(toolName) }); const data = JSON.stringify(body);
const req = http.request({ const req = http.request({
method: "POST", method: "POST",
hostname: u.hostname, hostname: u.hostname,
port: u.port || 80, port: u.port || 80,
path: u.pathname, path: u.pathname,
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }, headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) },
timeout: 2000, timeout: 2000,
}, (res) => { res.resume(); }); }, (res) => { res.resume(); });
req.on("error", () => {}); req.on("error", () => {});
req.on("timeout", () => req.destroy()); req.on("timeout", () => req.destroy());
req.write(body); req.write(data);
req.end(); req.end();
} catch (_) { /* niemals weiterwerfen */ } } catch (_) { /* niemals weiterwerfen */ }
} }
/** /**
* Hookt die `assistant`-Events des Subprozesses. Jedes assistant-Message * Pusht einen Tool-Use-Event an die Bridge (alter Gedanken-Stream-Pfad).
* kann mehrere content-Bloecke haben tool_use-Bloecke pushen wir live.
*/ */
function _attachToolHook(subprocess) { function _emitToolEvent(toolName) {
if (!toolName) return;
_postJson(TOOL_HOOK_URL, { tool: String(toolName) });
}
/**
* Pusht ein Stream-Event an die Bridge (neuer "ARIA Live"-Pfad).
* kind: "start" | "text" | "tool_use" | "tool_result" | "end"
*/
function _emitStreamEvent(requestId, kind, fields) {
_postJson(STREAM_HOOK_URL, { requestId, kind, ts: Date.now(), ...fields });
}
function _truncate(str, max) {
if (typeof str !== "string") str = String(str ?? "");
if (str.length <= max) return { text: str, truncatedBytes: 0 };
return { text: str.slice(0, max), truncatedBytes: str.length - max };
}
// ── Subprocess-Tracking fuer Not-Aus ──────────────────────────
// requestId → ClaudeSubprocess. Eintraege werden beim close/result-Event
// wieder entfernt. /v1/cancel-all iteriert und ruft .kill() auf jeden.
const _activeSubprocesses = new Map();
function _trackSubprocess(requestId, subprocess) {
_activeSubprocesses.set(requestId, subprocess);
const cleanup = () => _activeSubprocesses.delete(requestId);
subprocess.on("close", cleanup);
subprocess.on("error", cleanup);
}
/**
* Idle-Watchdog: killt den Subprocess wenn ueber IDLE_TIMEOUT_MS hinweg
* keine message/content_delta Events ankommen. Wird beim Start gesetzt,
* bei jedem Event reset, bei close/error/result gestoppt.
*
* Stream-Event 'end' wird durch den normalen close-Listener im Handler
* gefeuert wir muessen hier nichts extra emittieren.
*/
function _attachIdleWatchdog(subprocess, requestId) {
if (!IDLE_TIMEOUT_MS || IDLE_TIMEOUT_MS <= 0) return; // disabled
let timer = null;
let killed = false;
function _kill() {
if (killed) return;
killed = true;
const mins = Math.round(IDLE_TIMEOUT_MS / 60000);
console.warn(`[aria-idle] killing subprocess ${requestId} after ${mins}min idle`);
try { subprocess.kill(); } catch (_) {}
_emitStreamEvent(requestId, "end", { reason: "idle_timeout", idleMs: IDLE_TIMEOUT_MS });
}
function _reset() {
if (killed) return;
if (timer) clearTimeout(timer);
timer = setTimeout(_kill, IDLE_TIMEOUT_MS);
}
function _stop() {
if (timer) { clearTimeout(timer); timer = null; }
}
// Initial-Timer setzen
_reset();
// Jedes Event vom Subprozess zaehlt als Lebenszeichen
subprocess.on("message", _reset);
subprocess.on("content_delta", _reset);
// Result/close/error → endgueltig stop
subprocess.on("result", _stop);
subprocess.on("close", _stop);
subprocess.on("error", _stop);
}
/**
* Hookt assistant + user Events und pusht beides an Bridge:
* - Alt-API: nur Tool-Namen an /internal/agent-activity (Gedanken-Stream)
* - Neu-API: voller Stream (text/tool_use/tool_result) an /internal/agent-stream
*/
function _attachToolHook(subprocess, requestId) {
subprocess.on("assistant", (message) => { subprocess.on("assistant", (message) => {
try { try {
const blocks = message?.message?.content || []; const blocks = message?.message?.content || [];
for (const b of blocks) { for (const b of blocks) {
if (b && b.type === "tool_use" && b.name) { if (!b) continue;
_emitToolEvent(b.name); if (b.type === "tool_use") {
if (b.name) _emitToolEvent(b.name);
const inputStr = b.input ? JSON.stringify(b.input) : "";
const inp = _truncate(inputStr, TOOL_INPUT_MAX_CHARS);
_emitStreamEvent(requestId, "tool_use", {
id: b.id || null,
name: b.name || "",
input: inp.text,
inputTruncatedBytes: inp.truncatedBytes,
});
} else if (b.type === "text" && b.text) {
_emitStreamEvent(requestId, "text", { text: b.text });
} else if (b.type === "thinking" && b.thinking) {
// Wenn das Modell Extended Thinking emittiert — selten in
// Claude Code CLI, aber moeglich. Markieren wir extra.
_emitStreamEvent(requestId, "thinking", { text: b.thinking });
}
}
} catch (_) { /* fail-open */ }
});
// tool_result Blocks kommen in user-Messages — die werden vom
// subprocess-Manager NICHT als 'user'-Event emittiert (gibt's nicht),
// sondern nur ueber das generische 'message'-Event mit type:'user'.
// 'message' feuert auch fuer assistant/result — wir filtern auf user
// damit wir nicht doppelt rendern (assistant geht ueber den eigenen
// assistant-Handler oben).
subprocess.on("message", (message) => {
try {
if (message?.type !== "user") return;
const blocks = message?.message?.content || [];
for (const b of blocks) {
if (b && b.type === "tool_result") {
let content = "";
if (typeof b.content === "string") content = b.content;
else if (Array.isArray(b.content)) {
content = b.content.map(c => (c && c.type === "text" && c.text) ? c.text : "").join("");
}
const out = _truncate(content, TOOL_RESULT_MAX_CHARS);
_emitStreamEvent(requestId, "tool_result", {
id: b.tool_use_id || null,
content: out.text,
truncatedBytes: out.truncatedBytes,
isError: b.is_error === true,
});
} }
} }
} catch (_) { /* fail-open */ } } catch (_) { /* fail-open */ }
@@ -86,9 +228,17 @@ export async function handleChatCompletions(req, res) {
// Convert to CLI input format // Convert to CLI input format
const cliInput = openaiToCli(body); const cliInput = openaiToCli(body);
const subprocess = new ClaudeSubprocess(); const subprocess = new ClaudeSubprocess();
// ARIA-Patch: Tool-Use-Events live an die Bridge weiterleiten. // ARIA-Patch: Tool-Use-Events + voller Live-Stream an die Bridge.
// Greift fuer beide Branches (stream + non-stream). // Plus: Subprocess fuer Not-Aus tracken (Hard-Kill via /v1/cancel-all).
_attachToolHook(subprocess); // Plus: Idle-Watchdog — Subprocess darf ewig laufen solange Events
// kommen, wird aber gekillt nach IDLE_TIMEOUT_MS Inaktivitaet.
_attachToolHook(subprocess, requestId);
_trackSubprocess(requestId, subprocess);
_attachIdleWatchdog(subprocess, requestId);
_emitStreamEvent(requestId, "start", { model: body.model || null });
subprocess.on("result", () => _emitStreamEvent(requestId, "end", { reason: "result" }));
subprocess.on("close", (code) => _emitStreamEvent(requestId, "end", { reason: "close", code }));
subprocess.on("error", (err) => _emitStreamEvent(requestId, "end", { reason: "error", error: String(err?.message || err) }));
if (stream) { if (stream) {
await handleStreamingResponse(req, res, subprocess, cliInput, requestId); await handleStreamingResponse(req, res, subprocess, cliInput, requestId);
} }
@@ -217,11 +367,25 @@ async function handleStreamingResponse(req, res, subprocess, cliInput, requestId
async function handleNonStreamingResponse(res, subprocess, cliInput, requestId) { async function handleNonStreamingResponse(res, subprocess, cliInput, requestId) {
return new Promise((resolve) => { return new Promise((resolve) => {
let finalResult = null; let finalResult = null;
let isComplete = false;
// Client-Disconnect-Handler — wenn Brain die HTTP-Verbindung kappt
// (z.B. nach Read-Timeout), den noch laufenden Subprocess killen.
// Im Streaming-Branch existiert das schon; non-streaming hatte's
// bisher nicht → Subprozess lief verwaist weiter, Ressourcen-Leak.
res.on("close", () => {
if (!isComplete) {
console.warn("[NonStreaming] Client disconnected before result — killing subprocess", requestId);
try { subprocess.kill(); } catch (_) {}
}
resolve();
});
subprocess.on("result", (result) => { subprocess.on("result", (result) => {
finalResult = result; finalResult = result;
}); });
subprocess.on("error", (error) => { subprocess.on("error", (error) => {
console.error("[NonStreaming] Error:", error.message); console.error("[NonStreaming] Error:", error.message);
isComplete = true;
if (!res.headersSent) {
res.status(500).json({ res.status(500).json({
error: { error: {
message: error.message, message: error.message,
@@ -229,9 +393,16 @@ async function handleNonStreamingResponse(res, subprocess, cliInput, requestId)
code: null, code: null,
}, },
}); });
}
resolve(); resolve();
}); });
subprocess.on("close", (code) => { subprocess.on("close", (code) => {
isComplete = true;
if (res.writableEnded) {
// Client ist eh schon weg — nichts mehr zu senden.
resolve();
return;
}
if (finalResult) { if (finalResult) {
res.json(cliResultToOpenai(finalResult, requestId)); res.json(cliResultToOpenai(finalResult, requestId));
} }
@@ -306,4 +477,55 @@ export function handleHealth(_req, res) {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
} }
// ── Not-Aus Side-Channel ───────────────────────────────────
//
// claude-max-api-proxy steuert seine eigene Route-Registrierung — wir
// koennen da nicht reinpatchen ohne sed-Operationen am npm-Paket. Saubrer:
// ein dedizierter kleiner HTTP-Listener nur fuer den Not-Aus, auf einem
// internen Port im aria-net. Bridge ruft den, killt alle aktiven Claude-
// Subprocesses. App + Diagnostic sehen den Stream sofort enden.
const INTERNAL_PORT = parseInt(process.env.ARIA_PROXY_INTERNAL_PORT || "3457", 10);
const INTERNAL_HOST = "0.0.0.0"; // im aria-net erreichbar, nicht nach extern exposed
function _cancelAll() {
const ids = Array.from(_activeSubprocesses.keys());
let killed = 0;
for (const [id, subp] of _activeSubprocesses) {
try {
subp.kill();
killed++;
} catch (e) {
console.error("[aria-not-aus] kill failed for", id, e?.message);
}
}
_activeSubprocesses.clear();
return { killed, requestIds: ids };
}
try {
const internalServer = http.createServer((req, res) => {
if (req.method === "POST" && req.url === "/cancel-all") {
const result = _cancelAll();
console.warn("[aria-not-aus] /cancel-all — killed", result.killed, "subprocess(es)");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, ...result }));
return;
}
if (req.method === "GET" && req.url === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, active: _activeSubprocesses.size }));
return;
}
res.writeHead(404).end();
});
internalServer.on("error", (err) => {
console.error("[aria-not-aus] internal listener error:", err.message);
});
internalServer.listen(INTERNAL_PORT, INTERNAL_HOST, () => {
console.log("[aria-not-aus] internal listener on", INTERNAL_HOST + ":" + INTERNAL_PORT);
});
} catch (e) {
console.error("[aria-not-aus] startup failed:", e?.message);
}
//# sourceMappingURL=routes.js.map //# sourceMappingURL=routes.js.map
+136 -7
View File
@@ -1,6 +1,7 @@
"use strict"; "use strict";
const { WebSocketServer } = require("ws"); const { WebSocketServer } = require("ws");
const http = require("http");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
@@ -39,6 +40,9 @@ const ALLOWED_TYPES = new Set([
"stt_request", "stt_response", "stt_request", "stt_response",
"service_status", "service_status",
"config_request", "config_request",
"flux_request", "flux_response",
"agent_stream",
"oauth_callback",
]); ]);
// Token-Raum: token -> { clients: Set<ws> } // Token-Raum: token -> { clients: Set<ws> }
@@ -69,20 +73,145 @@ function cleanupRooms() {
} }
} }
// ── WebSocket-Server starten ──────────────────────────────────────── // ── HTTP + WebSocket Server (hybrid) ────────────────────────────────
//
// maxPayload 50MB: TTS-Streaming + Voice-Upload (WAV als base64) + // Der gleiche Port handelt jetzt sowohl WebSocket-Upgrades (App, Bridges,
// Diagnostic) als auch normale HTTP-Requests (OAuth-Callbacks von Spotify,
// Google etc.). TLS-Termination passiert wie bisher vor dem RVS-Container
// (Caddy/Nginx); RVS selber bleibt plain HTTP. Wichtig fuer OAuth: aus
// Provider-Sicht ist die Callback-URL `https://{RVS_HOST}:{PORT_oeffentlich}
// /oauth/callback/{service}` — RVS schnappt den ?code=..&state=.., broadcastet
// als WS-Message `oauth_callback` und antwortet dem Browser mit einer
// schoenen "Tab schliessen"-Seite.
//
// maxPayload 100MB: TTS-Streaming + Voice-Upload (WAV als base64) +
// audio_pcm Chunks koennen die ws-Library Default 1MB ueberschreiten. // audio_pcm Chunks koennen die ws-Library Default 1MB ueberschreiten.
// Default-Limit war der Killer fuer die voice_upload Pipeline. // Plus: file_request/file_response fuer Re-Download von Anhaengen.
const wss = new WebSocketServer({ port: PORT, maxPayload: 50 * 1024 * 1024 }); // 40 MB MP4 → ~53 MB base64 → vorher mit 50 MB Limit zerschossen
// (Code 1009 message too big, Bridge crashed im cleanup). 100 MB
// deckt bis ~70 MB binaer ab; groessere Files werden Bridge-seitig
// abgewiesen (siehe file_request-Handler) bevor die WS abreisst.
const httpServer = http.createServer(handleHttpRequest);
const wss = new WebSocketServer({ noServer: true, maxPayload: 100 * 1024 * 1024 });
wss.on("listening", () => { // HTTP-Upgrade-Pfad → an WebSocket-Server reichen
log(`RVS läuft auf Port ${PORT} | Max Sessions: ${MAX_SESSIONS}`); httpServer.on("upgrade", (req, socket, head) => {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});
httpServer.listen(PORT, () => {
log(`RVS läuft auf Port ${PORT} (HTTP + WS) | Max Sessions: ${MAX_SESSIONS}`);
// Beim Start pruefen ob eine APK da ist // Beim Start pruefen ob eine APK da ist
const apkInfo = getLatestAPK(); const apkInfo = getLatestAPK();
if (apkInfo) log(`APK bereit: v${apkInfo.version} (${(fs.statSync(apkInfo.path).size / 1024 / 1024).toFixed(1)}MB)`); if (apkInfo) log(`APK bereit: v${apkInfo.version} (${(fs.statSync(apkInfo.path).size / 1024 / 1024).toFixed(1)}MB)`);
}); });
// ── HTTP Route-Handler ──────────────────────────────────────────────
function handleHttpRequest(req, res) {
try {
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
const pathname = url.pathname;
// OAuth-Callback: GET /oauth/callback/{service}?code=...&state=...&error=...
// Pattern fuer Spotify, Google, Strava, GitHub, ... — alle OAuth2 Auth-Code-Flow.
// Wir broadcasten an alle Raeume (App ist nicht im selben Raum wie Bridge,
// aber Bridge schon — sie picks-up und forwardet ans Brain).
const oauthMatch = pathname.match(/^\/oauth\/callback\/([a-zA-Z0-9_-]+)\/?$/);
if (req.method === "GET" && oauthMatch) {
const service = oauthMatch[1];
const code = url.searchParams.get("code") || "";
const state = url.searchParams.get("state") || "";
const err = url.searchParams.get("error") || "";
const errDesc = url.searchParams.get("error_description") || "";
log(`OAuth-Callback: service=${service} code=${code.slice(0, 8)}... state=${state.slice(0, 8)}... err=${err}`);
const payload = { service, code, state };
if (err) {
payload.error = err;
if (errDesc) payload.errorDescription = errDesc;
}
// An alle Clients in allen Raeumen broadcasten — Bridge picks-up.
const msg = JSON.stringify({
type: "oauth_callback",
payload,
timestamp: Date.now(),
});
let receivers = 0;
for (const [, room] of rooms) {
for (const client of room.clients) {
if (client.readyState === 1) {
try { client.send(msg); receivers++; } catch (_) {}
}
}
}
log(`OAuth-Callback gebroadcastet an ${receivers} Client(s)`);
// Browser-Antwort: schoene HTML-Seite (auch bei Error)
const ok = !err;
const title = ok ? "OAuth erfolgreich" : "OAuth fehlgeschlagen";
const bodyColor = ok ? "#34C759" : "#FF3B30";
const icon = ok ? "✅" : "❌";
const subtitle = ok
? "Du kannst dieses Tab schliessen — ARIA hat den Zugang erhalten."
: `Fehler: ${escapeHtml(err)} ${errDesc ? "— " + escapeHtml(errDesc) : ""}`;
const html = `<!doctype html>
<html lang="de"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${title} ${escapeHtml(service)}</title>
<style>
html,body{margin:0;padding:0;height:100%;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0D0D1A;color:#E0E0F0;}
body{display:flex;align-items:center;justify-content:center;}
.card{background:#1E1E2E;border:1px solid #2A2A3E;border-radius:12px;padding:32px;max-width:420px;text-align:center;box-shadow:0 4px 24px rgba(0,0,0,0.4);}
.icon{font-size:64px;line-height:1;margin-bottom:16px;}
.title{font-size:20px;font-weight:600;color:${bodyColor};margin-bottom:8px;}
.service{font-size:13px;color:#8888AA;margin-bottom:20px;text-transform:uppercase;letter-spacing:0.1em;}
.sub{font-size:14px;color:#C0C0D0;line-height:1.5;}
.hint{font-size:11px;color:#666680;margin-top:24px;}
</style></head><body>
<div class="card">
<div class="icon">${icon}</div>
<div class="title">${title}</div>
<div class="service">${escapeHtml(service)}</div>
<div class="sub">${subtitle}</div>
<div class="hint">Du kannst zur ARIA-App zurueckkehren.</div>
</div>
<script>setTimeout(()=>{try{window.close();}catch(e){}}, 4000);</script>
</body></html>`;
res.writeHead(ok ? 200 : 400, {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
});
res.end(html);
return;
}
// Health-Endpoint
if (req.method === "GET" && pathname === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, rooms: rooms.size }));
return;
}
// Default: 404
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not Found\n");
} catch (e) {
log(`HTTP handler error: ${e.message}`);
try { res.writeHead(500).end("Internal Server Error"); } catch (_) {}
}
}
function escapeHtml(s) {
return String(s || "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
wss.on("connection", (ws, req) => { wss.on("connection", (ws, req) => {
// Token aus URL-Query lesen: ws://host:port/?token=abc123 // Token aus URL-Query lesen: ws://host:port/?token=abc123
const url = new URL(req.url, `http://${req.headers.host}`); const url = new URL(req.url, `http://${req.headers.host}`);
+3
View File
@@ -2,6 +2,9 @@
# ARIA Gamebox Stack — GPU F5-TTS + Whisper STT # ARIA Gamebox Stack — GPU F5-TTS + Whisper STT
# Laeuft auf dem Gaming-PC (RTX 3060) # Laeuft auf dem Gaming-PC (RTX 3060)
# Verbindet sich zum RVS fuer TTS/STT-Requests # Verbindet sich zum RVS fuer TTS/STT-Requests
#
# FLUX-Bildgenerierung liegt im /flux Verzeichnis im Repo-Root —
# eigener Compose-Stack, kann auch auf einer anderen Maschine laufen.
# ════════════════════════════════════════════════ # ════════════════════════════════════════════════
# #
# Voraussetzungen: # Voraussetzungen: