Compare commits

...

28 Commits

Author SHA1 Message Date
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
duffyduck c758727345 release: bump version to 0.1.5.5 2026-05-16 11:29:45 +02:00
duffyduck cb0e879118 feat(app): Hintergrund-Modus — App laeuft weiter wenn minimiert
Bisher pausierte Android nach ~30s im Hintergrund die JS-Engine.
WebSocket schlief ein, Trigger-Replies vom Brain kamen nicht durch,
Timer-Erinnerungen feuerten in der App nicht obwohl im Brain
ausgeloest. Nach laengerer Hintergrund-Pause warf Android den
Prozess ganz raus → beim Wiedereroeffnen Cold-Start, sah aus wie Crash.

Loesung: Foreground-Service mit persistenter Notification — die ist
ohnehin schon da fuer TTS/Mic-Aktivitaet (`AriaPlaybackService`).
Wir erweitern das Slot-System um einen `background`-Slot der dauerhaft
aktiv ist (Settings-Toggle, default an). Notification zeigt "ARIA aktiv
— Hintergrund-Modus" wenn nichts spezifisches laeuft, escaliert zu
"ARIA spricht/hoert" bei TTS/Mic. Tap → App.

Drei Dateien:
- services/backgroundAudio.ts: 'background' als 4. Slot (niedrigste
  Prio, Fallback-Notification). Bestehende tts/rec/wake unveraendert.
- App.tsx: beim Start `acquireBackgroundAudio('background')` aufrufen
  wenn Settings nicht explizit deaktiviert. Plus POST_NOTIFICATIONS-
  Permission-Request (Android 13+).
- screens/SettingsScreen.tsx: neuer Toggle in Allgemein-Section.
  Plus Hinweis auf Android-Akku-Optimierung-Whitelist falls trotzdem
  was klemmt (manche Hersteller-ROMs killen aggressiv).

AndroidManifest unveraendert — foregroundServiceType="mediaPlayback|
microphone" deckt unseren Use-Case ab (ARIA spielt regelmaessig TTS
ab, was den Type rechtfertigt). Service stoppt sich selbst wenn alle
Slots leer sind, das passiert nur wenn der User in Settings den
Hintergrund-Modus deaktiviert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:27:01 +02:00
duffyduck ce6f5b551e fix(chat): Gedanken-Stream scrollt jetzt + Suche praeziser
(1) Gedanken-Stream Modal: vorheriger Fix mit onStartShouldSetResponder
    war falsch — der View wurde komplett zum Responder, die FlatList drin
    bekam null Touch-Events. Jetzt: outer View ohne Touch-Handling, ein
    separates TouchableOpacity-Element oberhalb des Sheets nur fuer den
    Tap-Outside-Close. Sheet-View ist plain View → FlatList scrollt frei.

(2) Such-Sprung praeziser: drei Verbesserungen
    - MAX_SCROLL_RETRIES 3 → 6: bei weiten Spruengen (Bubble #150 von
      Position 0) braucht FlatList mehrere Iterationen bis die Items in
      der Naehe gemessen sind
    - Pre-Scroll-Offset: Fallback fuer unmeasured Items ist jetzt der
      dynamische Mittel der bisher gemessenen Items (statt Pauschal-150).
      Beim Cold-Start sind nur die untersten 10 gemessen, aber deren
      Mittel ist immer noch eine bessere Schaetzung
    - Render-Pause nach Pre-Scroll 200 → 350 ms: bei weiten Spruengen
      braucht FlatList Zeit die Items zu mounten und onLayout zu feuern

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:11:38 +02:00
duffyduck b6a68b7658 release: bump version to 0.1.5.4 2026-05-15 22:51:27 +02:00
duffyduck 03edee8881 fix(app): Inbox Scroll-Bug + feat(diagnostic): Trigger-Edit
App-Inbox-Modal:
- ScrollView der Top-Section ('Aus diesem Chat') nestedScrollEnabled=true
- MemoryBrowser darunter in einen flex:1-Wrapper gepackt damit er den
  verbleibenden Platz bekommt — ohne den hat seine FlatList intern
  null Hoehe gehabt und Scroll-Gestures verschluckt.

Diagnostic Trigger-Tab:
- ✎ Bearbeiten-Knopf pro Zeile (neben Aktivieren/Deaktivieren/Loeschen)
- Modal hat jetzt einen Edit-Modus: Type+Name disabled, Save-Button
  zeigt 'Speichern', Modal-Title 'Trigger bearbeiten — <name>'
- Fuer Timer im Edit-Modus ein zusaetzliches Feld 'Feuert am (ISO, UTC)'
  damit man den absoluten Zeitpunkt direkt aendern kann (statt 'in X
  Minuten ab jetzt' das nur fuer Create Sinn macht)
- saveTrigger() unterscheidet jetzt zwischen Create-Modus (POST
  /triggers/timer|watcher) und Edit-Modus (PATCH /triggers/{name})
- openTriggerEdit(name) fuellt das Modal mit Werten aus dem Cache

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:49:51 +02:00
duffyduck 7093ebaf0b feat(app): Trigger-CRUD-Section in Settings + nested-Scroll-Fix
Settings hatte zwei Probleme:

1) Gedächtnis-Liste scrollte nur runter, nicht hoch. Klassisches Android
   nested-Scroll-Problem: aeussere ScrollView + innere FlatList mit
   fixer height:600 = nur eine Richtung wird respektiert.

   Fix: outer ScrollView mit scrollEnabled=false wenn die Section eine
   eigene voll-hoch-scrollende Sub-Liste hat (memory/triggers). Plus
   dynamische Hoehe via useWindowDimensions (winHeight - 220 statt
   hardcoded 600) damit MemoryBrowser sauber den verfuegbaren Platz
   nutzt.

2) Trigger waren bisher nur via Diagnostic-Tab editierbar — keine App-
   side CRUD. Stefan wollte das.

   Neu: TriggerBrowser-Komponente (analog MemoryBrowser-Struktur)
   - Liste aller Trigger mit Filter (alle/aktive/inaktive)
   - Toggle aktiv/inaktiv via Switch direkt in der Zeile
   - Tap oeffnet TriggerEditModal (Nachricht/Condition/fires_at/intervals
     editieren, Loeschen-Knopf mit Confirm)
   - "+ Neu"-Knopf oeffnet TriggerNewModal mit Type-Switch (Watcher/Timer),
     Watcher zeigt Hinweis auf verfuegbare Funktionen + Variablen
   - Live Reload-Button, Meta-Info (fire_count, last_fired_at, ...)

   brainApi um Trigger-Endpoints erweitert: listTriggers, getTrigger,
   createTimer, createWatcher, updateTrigger (patch), deleteTrigger,
   getTriggerConditions, getTriggerLogs. Plus Trigger-Type-Definition.

Settings-Liste hat eine neue Section " Trigger" zwischen Gedaechtnis
und Protokoll.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:44:24 +02:00
duffyduck b4923bc221 docs: Such-Praezision, Such-Reihenfolge, GPS-Heartbeat, About-Escape
issue.md — zwei neue Blocks:

'Such-Sprung-Praezision + Such-Reihenfolge':
- Cold-Start-Sprung (itemHeights-Cache via onLayout, initialNumToRender
  hoch)
- Such-Scroll-Endlos-Loop (MAX_SCROLL_RETRIES + setMessages-no-op-skip)
- searchMatchIds aus chatVisibleMessages (kein Treffer in Spezial-Bubbles)
- Reihenfolge neueste zuerst (WhatsApp-analog)

'Misc App-Polish':
- About-Text '—' literal → {'—'} expression block
- GPS-Heartbeat 60 s gegen stationaere-User-Veraltung der Position

README:
- Chat-Such-Zeile um Reihenfolge + onLayout-Cache ergaenzt
- GPS-Tracking-Zeile um Heartbeat ergaenzt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:35:38 +02:00
duffyduck 7a66752655 release: bump version to 0.1.5.3 2026-05-15 22:33:10 +02:00
duffyduck b510ccd93a fix(app): Such-Reihenfolge + About-Escape + GPS-Heartbeat fuer near()
(1) Such-Treffer jetzt neueste zuerst (analog WhatsApp/Telegram). User
    ist visuell unten, der erste Sprung landet meist im Viewport ohne
    weiten Pre-Scroll (= weniger Cold-Start-Fail-Risiko). „Naechster"
    geht in die Vergangenheit. Plus Pre-Scroll-Wartezeit 80→200 ms damit
    FlatList beim ersten Versuch wirklich Zeit zum Rendern hat.

(2) SettingsScreen Ueber-Text: `—` wurde literal gerendert weil
    JSX-Text-Knoten keine JS-String-Escapes interpretieren. Fix:
    `{'—'}` als JS-Expression-Block.

(3) GPS-Tracking sendete nach der initialen Position nichts mehr wenn
    der User stationaer war — `distanceFilter: 30` blockiert
    watchPosition-Updates ohne Bewegung. Nach 5 min (NEAR_MAX_AGE_SEC)
    verwirft das Brain die Position als veraltet → near()-Watcher feuern
    nie. Stefan's DRK-Trigger waren so chronisch tot.

    Fix: zusaetzlich zum watchPosition laeuft ein setInterval(60s)
    Heartbeat der die zuletzt empfangene Position erneut sendet. Kein
    extra GPS-Wakeup — akkufreundlich. Damit bleibt der Brain-State
    frisch auch bei stationaerem User; near() funktioniert sobald der
    User tatsaechlich im Radius ist.

Anmerkung zu Stefan's konkretem Test: er war 1.5–2 km von den DRK-
Triggern entfernt (Radius je 300 m) — selbst mit frischen GPS-Updates
haetten die nicht gefeuert. Der Heartbeat-Fix ist trotzdem noetig
damit Trigger ueberhaupt eine Chance haben wenn er tatsaechlich dort
vorbeifaehrt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:30:51 +02:00
duffyduck bbd51406a9 release: bump version to 0.1.5.2 2026-05-15 21:48:14 +02:00
duffyduck 2cd436f6e9 fix(chat): Such-Sprung praezise via Layout-Cache + Filter
Symptom: Suche nach 'cessna' sprang zur Oberhausen-Bubble (~15 Bubbles
daneben), egal welcher Versuch.

Zwei Ursachen:

1) searchMatchIds suchte in `messages` (alle Bubbles inkl. Memory/Skill/
   Trigger-Spezial-Bubbles), aber gescrollt wird in `invertedMessages`
   die diese filtert. Wenn 'cessna' nur in einer Memory-Bubble vorkam,
   war die ID in searchMatchIds aber nicht in invertedMessages →
   findIndex=-1 → kein Scroll, Pre-Scroll-Offset von voriger Aktion
   blieb sichtbar. Fix: searchMatchIds aus chatVisibleMessages.

2) AVG_BUBBLE_HEIGHT=150 als Pauschalschaetzung war zu grob — Voice-
   Bubbles sind ~70 px, lange ARIA-Antworten 400+. Pre-Scroll-Offset
   landete bei langen Listen weit daneben. Fix: itemHeights-Ref-Map
   wird per onLayout in renderMessage gefuettert. Pre-Scroll summiert
   echte gemessene Hoehen (Fallback AVG fuer noch nicht gerenderte) —
   beim zweiten Such-Versuch lernt der Cache, beim ersten klappt's
   schon besser als mit dem Pauschalwert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:42:08 +02:00
29 changed files with 2699 additions and 136 deletions
+2 -2
View File
@@ -366,7 +366,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- **Lokale Voice-Wahl**: Pro Geraet eigene Stimme moeglich (in Settings). Diagnostic-Wechsel ueberschreibt alle App-Wahlen.
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
- **Chat-Suche**: Lupe in der Statusleiste — Highlight + Next/Prev springt zum Treffer (Bubble landet am Text-Anfang oben am Viewport)
- **Chat-Suche**: Lupe in der Statusleiste — Highlight + Next/Prev springt zum Treffer (Bubble landet am Text-Anfang oben am Viewport). Reihenfolge **neueste zuerst** (analog WhatsApp), „Naechster" geht in die Vergangenheit. Item-Hoehen werden per `onLayout` gecached fuer praezisen Pre-Scroll auch bei langen Listen
- **Jump-to-Bottom-Button**: erscheint rechts unten sobald man weg von der neuesten Nachricht scrollt, ein Tap fuehrt zurueck
- **Delivery-Status pro User-Bubble** (WhatsApp-Style): `⏱` (queued, wartet auf Verbindung) → `⏳` (sending) → `✓` (Bridge hat ACK gesendet) → `✓✓` (ARIA hat verarbeitet). Bei Netzausfall werden Nachrichten lokal als queued gehalten und beim Reconnect automatisch geflusht. Bei drei ACK-Timeouts → `⚠ tippen f. Retry`. Idempotenz auf der Bridge (LRU ueber `clientMsgId`) verhindert Doppelte beim Retry
- **Mülltonne pro Bubble** (mit Confirm): gezielt eine Nachricht loeschen — geht nicht nur aus der UI weg, sondern auch aus `chat_backup.jsonl`, Brain-Conversation-Window und allen anderen Clients (RVS-Broadcast). Wichtig damit ARIA den Turn auch beim naechsten Prompt nicht mehr im Kontext hat
@@ -380,7 +380,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- **Einstellungen**: TTS-aktiv, F5-TTS-Voice, Pre-Roll-Buffer, Stille-Toleranz, Speicherort, Auto-Download, GPS, Verbose-Logging
- **Auto-Update**: Prueft beim Start + per Button auf neue Version, Download + Installation ueber RVS (FileProvider)
- GPS-Position (optional, mit Runtime-Permission-Request) — wird in jeden Chat/Audio-Payload mitgegeben und ist in Diagnostic als Debug-Block einblendbar
- **GPS-Tracking (kontinuierlich)**: Toggle in Settings → Standort. Wenn aktiv, pushed die App alle ~15s bzw. ab 30m Bewegung ein `location_update` an die Bridge — Voraussetzung damit Watcher mit `near(lat, lon, m)` (z.B. Blitzer-Warner, Ankunft-Erinnerungen) ueberhaupt feuern koennen. ARIA selbst kann das Tracking via `request_location_tracking`-Tool an-/ausschalten und tut das automatisch wenn sie einen GPS-Watcher anlegt
- **GPS-Tracking (kontinuierlich)**: Toggle in Settings → Standort. Wenn aktiv, pushed die App ab 30m Bewegung ein `location_update` an die Bridge — Voraussetzung damit Watcher mit `near(lat, lon, m)` (z.B. Blitzer-Warner, Ankunft-Erinnerungen) ueberhaupt feuern koennen. **Heartbeat alle 60 s**: auch ohne Bewegung wird die letzte bekannte Position erneut an die Bridge geschickt damit der Brain-State nicht nach 5 min (NEAR_MAX_AGE_SEC) veraltet — kein extra GPS-Wakeup, akkufreundlich. ARIA selbst kann das Tracking via `request_location_tracking`-Tool an-/ausschalten und tut das automatisch wenn sie einen GPS-Watcher anlegt
- QR-Code Scanner fuer Token-Pairing
- **ARIA-Dateien empfangen**: Wenn ARIA eine PDF/Bild/Markdown/ZIP fuer dich erstellt (Marker `[FILE: /shared/uploads/aria_*]` in der Antwort), erscheint sie als eigene Anhang-Bubble. Tippen → wird via RVS geladen + mit Android-Intent-Picker geoeffnet (PDF-Viewer, Bildbetrachter, Standard-App). Inline-Bilder aus Markdown-`![alt](url)`-Syntax werden direkt unter dem Text gerendert (PNG/JPG via Image, SVG via react-native-svg)
- **Vollbild mit Pinch-Zoom**: Bilder im Vollbild-Modal sind pinch-zoombar (1x..5x), 1-Finger-Pan wenn gezoomt, Doppel-Tap toggelt 1x↔2.5x — alles ohne externe Lib
+39 -1
View File
@@ -6,7 +6,8 @@
*/
import React, { useEffect } from 'react';
import { StatusBar, StyleSheet } from 'react-native';
import { PermissionsAndroid, Platform, StatusBar, StyleSheet } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
@@ -14,6 +15,7 @@ import ChatScreen from './src/screens/ChatScreen';
import SettingsScreen from './src/screens/SettingsScreen';
import rvs from './src/services/rvs';
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
// --- Navigation ---
@@ -61,6 +63,42 @@ const App: React.FC = () => {
};
initConnection();
// Hintergrund-Modus: Foreground-Service starten damit JS-Engine +
// WebSocket auch ueberleben wenn die App im Hintergrund ist.
// Trigger-Replies, Reconnects, Timer-Erinnerungen kommen sonst nicht
// durch weil Android nach ~30s die JS-Engine pausiert.
//
// Default an, kann in Settings → Hintergrund-Modus deaktiviert werden.
// Braucht POST_NOTIFICATIONS Permission ab Android 13.
const initBackground = async () => {
const setting = await AsyncStorage.getItem('aria_background_mode');
if (setting === 'false') {
console.log('[App] Hintergrund-Modus deaktiviert (Settings)');
return;
}
// Permission fuer die persistente Notification
if (Platform.OS === 'android' && Platform.Version >= 33) {
try {
await PermissionsAndroid.request(
'android.permission.POST_NOTIFICATIONS' as any,
{
title: 'Hintergrund-Modus',
message: 'ARIA zeigt eine Notification damit Trigger und Reconnects auch laufen wenn die App im Hintergrund ist.',
buttonPositive: 'Erlauben',
buttonNegative: 'Spaeter',
},
);
} catch {}
}
try {
await acquireBackgroundAudio('background');
console.log('[App] Hintergrund-Modus aktiv');
} catch (err: any) {
console.warn('[App] Hintergrund-Modus konnte nicht starten:', err?.message || err);
}
};
initBackground();
// Beim Beenden: Verbindung sauber trennen
return () => {
rvs.disconnect();
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10501
versionName "0.1.5.1"
versionCode 10601
versionName "0.1.6.1"
// Fallback fuer Libraries mit Product Flavors
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.ReactMethod
import com.facebook.react.modules.core.DeviceEventManagerModule
import java.util.concurrent.Executors
/**
* 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 modernCallback: Any? = null // TelephonyCallback ab API 31
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
fun start(promise: Promise) {
@@ -59,7 +65,7 @@ class PhoneCallModule(reactContext: ReactApplicationContext) : ReactContextBaseJ
handleStateChange(state)
}
}
tm.registerTelephonyCallback(reactApplicationContext.mainExecutor, cb)
tm.registerTelephonyCallback(callbackExecutor, cb)
modernCallback = cb
} else {
@Suppress("DEPRECATION")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.1.5.1",
"version": "0.1.6.1",
"private": true,
"scripts": {
"android": "react-native run-android",
+583
View File
@@ -0,0 +1,583 @@
/**
* Trigger-Browser — Liste aller Trigger (timer + watcher) mit Toggle,
* Tap-zum-Bearbeiten und "+ Neu"-Knopf.
*
* Eingesetzt von SettingsScreen → Sektion "Trigger".
*
* Brain-API ueber brainApi (RVS-Brain-Proxy).
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Modal,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import brainApi, { Trigger } from '../services/brainApi';
const COL_ACTIVE = '#34C759';
const COL_INACTIVE = '#555570';
const COL_TIMER = '#0096FF';
const COL_WATCHER = '#FFD60A';
function relTime(iso: string | null | undefined): string {
if (!iso) return '—';
const t = new Date(iso).getTime();
if (!t) return '—';
const diffSec = Math.floor((Date.now() - t) / 1000);
if (diffSec < 60) return `vor ${diffSec}s`;
if (diffSec < 3600) return `vor ${Math.floor(diffSec / 60)}min`;
if (diffSec < 86400) return `vor ${Math.floor(diffSec / 3600)}h`;
return `vor ${Math.floor(diffSec / 86400)}d`;
}
export const TriggerBrowser: React.FC = () => {
const [items, setItems] = useState<Trigger[]>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [filter, setFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [editTrigger, setEditTrigger] = useState<Trigger | null>(null);
const [showNew, setShowNew] = useState(false);
const load = useCallback(() => {
setLoading(true); setErr(null);
brainApi.listTriggers()
.then(t => {
// Sortierung: aktive zuerst, dann nach Name
t.sort((a, b) => {
if (a.active !== b.active) return a.active ? -1 : 1;
return (a.name || '').localeCompare(b.name || '');
});
setItems(t);
})
.catch(e => setErr(String(e?.message || e)))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const visible = items.filter(t => {
if (filter === 'active') return t.active;
if (filter === 'inactive') return !t.active;
return true;
});
const toggleActive = (t: Trigger) => {
brainApi.updateTrigger(t.name, { active: !t.active })
.then(() => load())
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
};
const deleteTrigger = (t: Trigger) => {
Alert.alert(
'Trigger löschen?',
`"${t.name}" — diese Aktion ist nicht rückgängig zu machen.`,
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Löschen',
style: 'destructive',
onPress: () => {
brainApi.deleteTrigger(t.name)
.then(() => { setEditTrigger(null); load(); })
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
},
},
],
);
};
const renderItem = ({ item }: { item: Trigger }) => {
const typeColor = item.type === 'timer' ? COL_TIMER : COL_WATCHER;
const typeLabel = item.type === 'timer' ? '⏰ Timer' : '👁 Watcher';
return (
<TouchableOpacity style={s.row} onPress={() => setEditTrigger(item)}>
<View style={{flex: 1, marginRight: 8}}>
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 4}}>
<Text style={{color: typeColor, fontSize: 11, fontWeight: '700'}}>{typeLabel}</Text>
<Text style={{color: '#E0E0F0', fontWeight: '600', flex: 1}} numberOfLines={1}>{item.name}</Text>
</View>
<Text style={{color: '#8888AA', fontSize: 12}} numberOfLines={2}>{item.message}</Text>
{item.type === 'watcher' && item.condition ? (
<Text style={{color: '#555570', fontSize: 11, marginTop: 4, fontFamily: 'monospace'}} numberOfLines={1}>
{item.condition}
</Text>
) : null}
{item.type === 'timer' && item.fires_at ? (
<Text style={{color: '#555570', fontSize: 11, marginTop: 4}}>
feuert: {new Date(item.fires_at).toLocaleString('de-DE')}
</Text>
) : null}
<Text style={{color: '#444460', fontSize: 10, marginTop: 4}}>
{item.fire_count || 0}× gefeuert · zuletzt: {relTime(item.last_fired_at)}
</Text>
</View>
<Switch
value={item.active}
onValueChange={() => toggleActive(item)}
trackColor={{ false: '#1E1E2E', true: COL_ACTIVE }}
thumbColor="#E0E0F0"
/>
</TouchableOpacity>
);
};
return (
<View style={{flex: 1}}>
{/* Filter-Leiste + Reload + Neu */}
<View style={s.toolbar}>
{(['all', 'active', 'inactive'] as const).map(f => (
<TouchableOpacity
key={f}
style={[s.chip, filter === f && s.chipActive]}
onPress={() => setFilter(f)}
>
<Text style={{color: filter === f ? '#0D0D1A' : '#8888AA', fontSize: 12, fontWeight: '600'}}>
{f === 'all' ? 'Alle' : f === 'active' ? 'Aktive' : 'Inaktive'}
</Text>
</TouchableOpacity>
))}
<View style={{flex: 1}} />
<TouchableOpacity onPress={load} style={s.iconBtn}>
<Text style={{fontSize: 16}}>{'↻'}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => setShowNew(true)} style={[s.iconBtn, {backgroundColor: '#0096FF'}]}>
<Text style={{fontSize: 14, color: '#fff', fontWeight: '700'}}>+ Neu</Text>
</TouchableOpacity>
</View>
{err ? <Text style={s.err}>{err}</Text> : null}
{loading && items.length === 0 ? (
<ActivityIndicator color="#0096FF" style={{marginTop: 20}} />
) : (
<FlatList
data={visible}
keyExtractor={t => t.name}
renderItem={renderItem}
nestedScrollEnabled={true}
ListEmptyComponent={
<Text style={{color: '#555570', textAlign: 'center', padding: 20, fontStyle: 'italic'}}>
{items.length === 0 ? '(keine Trigger angelegt)' : '(keine Treffer für diesen Filter)'}
</Text>
}
contentContainerStyle={{paddingBottom: 20}}
/>
)}
{editTrigger ? (
<TriggerEditModal
trigger={editTrigger}
onClose={() => setEditTrigger(null)}
onSaved={() => { setEditTrigger(null); load(); }}
onDelete={() => deleteTrigger(editTrigger)}
/>
) : null}
{showNew ? (
<TriggerNewModal
onClose={() => setShowNew(false)}
onCreated={() => { setShowNew(false); load(); }}
/>
) : null}
</View>
);
};
// ── Edit-Modal ─────────────────────────────────────────────────────────
interface EditProps {
trigger: Trigger;
onClose: () => void;
onSaved: () => void;
onDelete: () => void;
}
const TriggerEditModal: React.FC<EditProps> = ({ trigger, onClose, onSaved, onDelete }) => {
const [message, setMessage] = useState(trigger.message || '');
const [condition, setCondition] = useState(trigger.condition || '');
const [firesAt, setFiresAt] = useState(trigger.fires_at || '');
const [checkInterval, setCheckInterval] = useState(String(trigger.check_interval_sec || 300));
const [throttle, setThrottle] = useState(String(trigger.throttle_sec || 3600));
const [saving, setSaving] = useState(false);
const save = () => {
setSaving(true);
const patch: any = { message };
if (trigger.type === 'watcher') {
patch.condition = condition;
patch.check_interval_sec = parseInt(checkInterval, 10) || 300;
patch.throttle_sec = parseInt(throttle, 10) || 3600;
} else if (trigger.type === 'timer') {
patch.fires_at = firesAt;
}
brainApi.updateTrigger(trigger.name, patch)
.then(onSaved)
.catch(e => Alert.alert('Fehler beim Speichern', String(e?.message || e)))
.finally(() => setSaving(false));
};
return (
<Modal visible animationType="slide" onRequestClose={onClose} transparent>
<View style={s.modalBg}>
<View style={s.modal}>
<View style={s.modalHeader}>
<Text style={{color: trigger.type === 'timer' ? COL_TIMER : COL_WATCHER, fontWeight: '700', fontSize: 16, flex: 1}}>
{trigger.type === 'timer' ? '⏰' : '👁'} {trigger.name}
</Text>
<TouchableOpacity onPress={onClose}>
<Text style={{color: '#8888AA', fontSize: 24}}>×</Text>
</TouchableOpacity>
</View>
<ScrollView style={{padding: 14}} nestedScrollEnabled>
<Text style={s.label}>Nachricht</Text>
<TextInput
style={s.input}
value={message}
onChangeText={setMessage}
multiline
placeholder="Was soll ARIA sagen wenn der Trigger feuert?"
placeholderTextColor="#555570"
/>
{trigger.type === 'watcher' ? (
<>
<Text style={s.label}>Condition</Text>
<TextInput
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
value={condition}
onChangeText={setCondition}
placeholder="z.B. near(53.0, 8.5, 300)"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
<View style={{flexDirection: 'row', gap: 8}}>
<View style={{flex: 1}}>
<Text style={s.label}>Check-Intervall (s)</Text>
<TextInput
style={s.input}
value={checkInterval}
onChangeText={setCheckInterval}
keyboardType="number-pad"
/>
</View>
<View style={{flex: 1}}>
<Text style={s.label}>Throttle (s)</Text>
<TextInput
style={s.input}
value={throttle}
onChangeText={setThrottle}
keyboardType="number-pad"
/>
</View>
</View>
</>
) : (
<>
<Text style={s.label}>Feuert am (ISO, UTC)</Text>
<TextInput
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
value={firesAt}
onChangeText={setFiresAt}
placeholder="2026-05-15T20:00:00+00:00"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
</>
)}
<View style={s.metaBox}>
<Text style={s.meta}>Status: {trigger.active ? '🟢 aktiv' : '⚪ inaktiv'}</Text>
<Text style={s.meta}>Gefeuert: {trigger.fire_count || 0}×</Text>
<Text style={s.meta}>Zuletzt gefeuert: {relTime(trigger.last_fired_at)}</Text>
<Text style={s.meta}>Zuletzt geprüft: {relTime(trigger.last_checked_at)}</Text>
{trigger.author ? <Text style={s.meta}>Angelegt von: {trigger.author}</Text> : null}
</View>
</ScrollView>
<View style={s.modalFooter}>
<TouchableOpacity onPress={onDelete} style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: '#FF3B30'}]}>
<Text style={{color: '#FF3B30', fontWeight: '700'}}>🗑 Löschen</Text>
</TouchableOpacity>
<View style={{flex: 1}} />
<TouchableOpacity onPress={save} disabled={saving} style={[s.btn, {backgroundColor: '#0096FF', opacity: saving ? 0.5 : 1}]}>
<Text style={{color: '#fff', fontWeight: '700'}}>{saving ? 'Speichert...' : 'Speichern'}</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
// ── Neu-Modal ──────────────────────────────────────────────────────────
interface NewProps {
onClose: () => void;
onCreated: () => void;
}
const TriggerNewModal: React.FC<NewProps> = ({ onClose, onCreated }) => {
const [ttype, setTtype] = useState<'timer' | 'watcher'>('watcher');
const [name, setName] = useState('');
const [message, setMessage] = useState('');
const [condition, setCondition] = useState('');
const [firesAt, setFiresAt] = useState('');
const [checkInterval, setCheckInterval] = useState('300');
const [throttle, setThrottle] = useState('3600');
const [saving, setSaving] = useState(false);
const create = () => {
if (!name.trim() || !message.trim()) {
Alert.alert('Name und Nachricht erforderlich');
return;
}
setSaving(true);
const promise = ttype === 'timer'
? brainApi.createTimer({
name: name.trim(),
fires_at: firesAt.trim(),
message: message.trim(),
})
: brainApi.createWatcher({
name: name.trim(),
condition: condition.trim(),
message: message.trim(),
check_interval_sec: parseInt(checkInterval, 10) || 300,
throttle_sec: parseInt(throttle, 10) || 3600,
});
promise
.then(onCreated)
.catch(e => Alert.alert('Fehler beim Anlegen', String(e?.message || e)))
.finally(() => setSaving(false));
};
return (
<Modal visible animationType="slide" onRequestClose={onClose} transparent>
<View style={s.modalBg}>
<View style={s.modal}>
<View style={s.modalHeader}>
<Text style={{color: '#FFD60A', fontWeight: '700', fontSize: 16, flex: 1}}>+ Neuer Trigger</Text>
<TouchableOpacity onPress={onClose}>
<Text style={{color: '#8888AA', fontSize: 24}}>×</Text>
</TouchableOpacity>
</View>
<ScrollView style={{padding: 14}} nestedScrollEnabled>
<Text style={s.label}>Typ</Text>
<View style={{flexDirection: 'row', gap: 8, marginBottom: 12}}>
{(['watcher', 'timer'] as const).map(t => (
<TouchableOpacity
key={t}
onPress={() => setTtype(t)}
style={[s.chip, ttype === t && s.chipActive, {flex: 1, paddingVertical: 10}]}
>
<Text style={{color: ttype === t ? '#0D0D1A' : '#8888AA', fontWeight: '700', textAlign: 'center'}}>
{t === 'watcher' ? '👁 Watcher' : '⏰ Timer'}
</Text>
</TouchableOpacity>
))}
</View>
<Text style={s.label}>Name (kebab-case)</Text>
<TextInput
style={s.input}
value={name}
onChangeText={setName}
placeholder="z.B. drk-kreyenbrueck-warnung"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
<Text style={s.label}>Nachricht</Text>
<TextInput
style={s.input}
value={message}
onChangeText={setMessage}
multiline
placeholder="Was soll ARIA sagen?"
placeholderTextColor="#555570"
/>
{ttype === 'watcher' ? (
<>
<Text style={s.label}>Condition</Text>
<TextInput
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
value={condition}
onChangeText={setCondition}
placeholder="z.B. entered_near(53.0, 8.5, 300)"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
<Text style={s.hint}>
Funktionen: near() / entered_near() / left_near() · Variablen: disk_free_gb, hour_of_day, current_lat, current_lon, last_user_message_ago_sec
</Text>
<View style={{flexDirection: 'row', gap: 8}}>
<View style={{flex: 1}}>
<Text style={s.label}>Check-Intervall (s)</Text>
<TextInput
style={s.input}
value={checkInterval}
onChangeText={setCheckInterval}
keyboardType="number-pad"
/>
</View>
<View style={{flex: 1}}>
<Text style={s.label}>Throttle (s)</Text>
<TextInput
style={s.input}
value={throttle}
onChangeText={setThrottle}
keyboardType="number-pad"
/>
</View>
</View>
</>
) : (
<>
<Text style={s.label}>Feuert am (ISO, UTC)</Text>
<TextInput
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
value={firesAt}
onChangeText={setFiresAt}
placeholder="2026-05-15T20:00:00+00:00"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
<Text style={s.hint}>Beispiel oben: heute 20:00 UTC = 22:00 CEST</Text>
</>
)}
</ScrollView>
<View style={s.modalFooter}>
<View style={{flex: 1}} />
<TouchableOpacity onPress={create} disabled={saving} style={[s.btn, {backgroundColor: '#0096FF', opacity: saving ? 0.5 : 1}]}>
<Text style={{color: '#fff', fontWeight: '700'}}>{saving ? 'Legt an...' : 'Anlegen'}</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
const s = StyleSheet.create({
toolbar: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginBottom: 8,
},
chip: {
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
backgroundColor: '#1E1E2E',
},
chipActive: {
backgroundColor: '#FFD60A',
},
iconBtn: {
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
backgroundColor: '#1E1E2E',
},
err: {
color: '#FF3B30',
padding: 12,
fontSize: 12,
},
row: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
backgroundColor: '#1A1A2E',
borderRadius: 8,
marginBottom: 6,
},
modalBg: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
justifyContent: 'center',
alignItems: 'center',
padding: 16,
},
modal: {
backgroundColor: '#0D0D1A',
borderRadius: 12,
width: '100%',
maxWidth: 600,
maxHeight: '90%',
borderWidth: 1,
borderColor: '#1E1E2E',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
padding: 14,
borderBottomWidth: 1,
borderBottomColor: '#1E1E2E',
},
modalFooter: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderTopWidth: 1,
borderTopColor: '#1E1E2E',
gap: 8,
},
label: {
color: '#8888AA',
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginTop: 8,
marginBottom: 4,
},
input: {
backgroundColor: '#1A1A2E',
borderWidth: 1,
borderColor: '#1E1E2E',
borderRadius: 6,
color: '#E0E0F0',
padding: 10,
fontSize: 14,
marginBottom: 8,
},
hint: {
color: '#555570',
fontSize: 11,
fontStyle: 'italic',
marginTop: -4,
marginBottom: 10,
},
metaBox: {
backgroundColor: '#1A1A2E',
borderRadius: 6,
padding: 10,
marginTop: 10,
gap: 4,
},
meta: {
color: '#8888AA',
fontSize: 12,
},
btn: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 6,
borderWidth: 1,
borderColor: 'transparent',
},
});
export default TriggerBrowser;
+169 -49
View File
@@ -149,6 +149,22 @@ const MAX_THOUGHTS = 500;
// im Gespraechsmodus bei sehr vielen Nachrichten.
const capMessages = (msgs: ChatMessage[]): ChatMessage[] =>
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 STORAGE_PATH_KEY = 'aria_attachment_storage_path';
@@ -274,11 +290,17 @@ const ChatScreen: React.FC = () => {
// Stream zumuellen. Eigentlich seltener Fall, aber billig zu pruefen.
const lastThoughtKeyRef = useRef<string>('');
// 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);
// Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button)
const [ttsDeviceEnabled, setTtsDeviceEnabled] = useState(true);
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)
const localXttsVoiceRef = useRef<string>('');
// Geraetelokale TTS-Wiedergabegeschwindigkeit (speed-Param an F5-TTS)
@@ -446,6 +468,8 @@ const ChatScreen: React.FC = () => {
ttsSpeedRef.current = await loadTtsSpeed();
const gps = await AsyncStorage.getItem('aria_gps_enabled');
setGpsEnabled(gps === 'true');
const hints = await AsyncStorage.getItem('aria_show_hints');
setShowSystemHints(hints === 'true'); // default false
};
loadSettings();
const interval = setInterval(loadSettings, 2000);
@@ -480,14 +504,40 @@ const ChatScreen: React.FC = () => {
return () => { phoneCallService.stop().catch(() => {}); };
}, []);
// App-Resume: kurzer Wake-Word-Cooldown — beim Wechsel Background→Foreground
// gibt's haeufig Audio-Pegel-Spikes (AudioFocus-Switch, AudioTrack re-route)
// die openWakeWord sonst faelschlich als Wake-Word interpretiert.
// App-Resume: drei Schutzmaßnahmen gegen verirrte Wake-Word-Trigger
// beim Wechsel Background→Foreground:
// (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(() => {
let lastState: string = AppState.currentState;
let lastBackgroundAt = 0;
const sub = AppState.addEventListener('change', (next) => {
if (lastState !== 'active' && next === 'active') {
wakeWordService.setResumeCooldown(1500);
if (next === 'background' || next === 'inactive') {
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;
});
@@ -838,6 +888,16 @@ const ChatScreen: React.FC = () => {
const b64 = (message.payload.base64 as string) || '';
const serverPath = (message.payload.serverPath 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) {
const fileName = (message.payload.name as string) || 'download';
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)) {
const p = message.payload as any;
const svc = (p?.service as string) || '';
if (!svc) return;
const newState = (p?.state as string) || 'unknown';
const freshlyDownloaded = p?.freshlyDownloaded === true;
setServiceStatus(prev => ({
...prev,
[svc]: {
state: (p?.state as string) || 'unknown',
state: newState,
model: p?.model as string | undefined,
loadSeconds: p?.loadSeconds as number | undefined,
error: p?.error as string | undefined,
downloading: p?.downloading === true,
freshlyDownloaded,
},
}));
// 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 {}
}
}
});
@@ -1381,15 +1458,22 @@ const ChatScreen: React.FC = () => {
);
const invertedMessages = useMemo(() => [...chatVisibleMessages].reverse(), [chatVisibleMessages]);
// Such-Treffer: alle Message-IDs die zur Query passen, in chronologischer
// Reihenfolge (aelteste zuerst). Bei Query-Change resetten wir den Index.
// Such-Treffer: alle Message-IDs die zur Query passen. NEUESTE ZUERST —
// analog zu WhatsApp/Telegram: User ist visuell unten im Chat, der erste
// Treffer ist meist schon im Viewport (kein weiter Pre-Scroll, kein
// Cold-Start-Sprung-Fail). „Naechster" geht in die Vergangenheit.
// WICHTIG: nur in chatVisibleMessages suchen — Spezial-Bubbles (Memory/
// Skill/Trigger) sind im Chat-Stream nicht sichtbar und Treffer auf die
// wuerden zu „ID nicht im FlatList → findIndex=-1 → kein Scroll"-Fail
// fuehren.
const searchMatchIds = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return [] as string[];
return messages
return chatVisibleMessages
.filter(m => (m.text || '').toLowerCase().includes(q))
.map(m => m.id);
}, [messages, searchQuery]);
.map(m => m.id)
.reverse();
}, [chatVisibleMessages, searchQuery]);
useEffect(() => {
setSearchIndex(0);
@@ -1408,7 +1492,10 @@ const ChatScreen: React.FC = () => {
// verfuegbar wird (z.B. weil setMessages mitten in der Sequenz die
// FlatList re-rendert).
const scrollRetryCount = useRef<number>(0);
const MAX_SCROLL_RETRIES = 3;
// 6 Retries: bei weiten Spruengen (Suche auf Bubble #150 von Position 0)
// kann FlatList mehrere Iterationen brauchen bis die Items in der Naehe
// gemessen sind. Vorher 3 = vorzeitig aufgegeben.
const MAX_SCROLL_RETRIES = 6;
const clearPendingScrollRetry = () => {
if (pendingScrollRetry.current) {
clearTimeout(pendingScrollRetry.current);
@@ -1425,11 +1512,11 @@ const ChatScreen: React.FC = () => {
// Den aktuellen Snapshot von invertedMessages holen wir via Ref.
const invertedMessagesRef = useRef(invertedMessages);
invertedMessagesRef.current = invertedMessages;
// Schaetzwert fuer durchschnittliche Bubble-Hoehe. Wird fuer den
// ersten Pre-Scroll genutzt wenn FlatList noch keine Layouts hat.
// 150 px ist Mittel zwischen kurzen Voice-Bubbles (~70) und langen
// ARIA-Antworten (~250).
const AVG_BUBBLE_HEIGHT = 150;
// Cache fuer echte Bubble-Hoehen, gefuettert per onLayout in
// renderMessage. Wird beim Pre-Scroll genutzt damit der grobe Sprung
// praezise landet (statt mit dem 150-px-Pauschalwert weit daneben).
const itemHeights = useRef<Map<string, number>>(new Map());
const AVG_BUBBLE_HEIGHT = 150; // Fallback fuer noch nicht gemessene Items
useEffect(() => {
if (!searchMatchIds.length) {
lastSearchScrollKey.current = '';
@@ -1447,19 +1534,34 @@ const ChatScreen: React.FC = () => {
clearPendingScrollRetry();
const idx = invertedMessagesRef.current.findIndex(m => m.id === id);
if (idx < 0 || !flatListRef.current) return;
// Pre-Scroll: erst grob mit konstanter Hoehe in die Naehe springen.
// Damit hat FlatList ueberhaupt die Chance die Layouts der Bubbles in
// der Naehe zu rendern — sonst basiert info.averageItemLength im
// Failed-Handler nur auf den ersten 10 Items (Default initialNumToRender)
// und liefert beim ersten Such-Versuch nach App-Start einen voellig
// falschen Sprung.
// Pre-Scroll: erst grob in die Naehe springen, damit FlatList die
// Bubbles in der Umgebung ueberhaupt rendert (sonst basiert
// averageItemLength im Failed-Handler nur auf den ersten ~10 Items
// und liefert einen voellig falschen Sprung).
// Offset = Summe echter Hoehen (aus itemHeights-Cache, gefuettert per
// onLayout) + dynamischer Fallback aus dem Mittel der bisher
// gemessenen Items. Beim Cold-Start gibt's nur 10 Messungen (die
// neuesten unten in der invertierten Liste) — der Mittel daraus ist
// immer noch besser als die Pauschal-150.
const measured = Array.from(itemHeights.current.values());
const dynamicAvg = measured.length >= 5
? measured.reduce((a, b) => a + b, 0) / measured.length
: AVG_BUBBLE_HEIGHT;
let preOffset = 0;
const inv = invertedMessagesRef.current;
for (let i = 0; i < idx; i++) {
preOffset += itemHeights.current.get(inv[i].id) || dynamicAvg;
}
try {
flatListRef.current?.scrollToOffset({
offset: idx * AVG_BUBBLE_HEIGHT,
offset: preOffset,
animated: false,
});
} catch {}
// Nach kurzer Render-Pause praezise nachsetzen
// Nach Render-Pause praezise nachsetzen. 350 ms — bei weiten Spruengen
// (Pre-Scroll 5000+ px) braucht FlatList Zeit die Items dort zu
// mounten und onLayout zu feuern. Zu kurz → averageItemLength im
// Failed-Handler basiert noch auf den falschen Items.
requestAnimationFrame(() => {
setTimeout(() => {
try {
@@ -1467,7 +1569,7 @@ const ChatScreen: React.FC = () => {
} catch {
// onScrollToIndexFailed-Handler uebernimmt den Fallback
}
}, 80);
}, 350);
});
}, [searchIndex, searchMatchIds]);
@@ -1885,7 +1987,15 @@ const ChatScreen: React.FC = () => {
}
return (
<View style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble, searchHighlightStyle]}>
<View
style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble, searchHighlightStyle]}
onLayout={e => {
// Echte Hoehe in Cache speichern — Pre-Scroll der Suche nutzt
// die summierten Cache-Werte fuer praezisen Sprung. Bei
// unbekannten Items faellt's auf AVG_BUBBLE_HEIGHT zurueck.
itemHeights.current.set(item.id, e.nativeEvent.layout.height);
}}
>
{/* Anhang-Vorschau */}
{item.attachments?.map((att, idx) => (
<View key={idx}>
@@ -1973,7 +2083,7 @@ const ChatScreen: React.FC = () => {
{/* 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)) && (
<MessageText
text={item.text}
text={showSystemHints ? item.text : stripSystemHints(item.text)}
style={[styles.messageText, isUser ? styles.userText : styles.ariaText]}
/>
)}
@@ -2103,7 +2213,7 @@ const ChatScreen: React.FC = () => {
const allReady = !anyLoading && !anyError && entries.every(([, v]) => v.state === 'ready');
const bg = anyError ? '#3A1F1F' : anyLoading ? '#3A331F' : '#1F3A2A';
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 (
<TouchableOpacity
activeOpacity={allReady ? 0.6 : 1.0}
@@ -2113,11 +2223,16 @@ const ChatScreen: React.FC = () => {
{entries.map(([svc, info]) => {
let icon = '\u23F3', text = '';
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') {
icon = '\u2705';
icon = info.freshlyDownloaded ? '\uD83C\uDF89' : '\u2705'; // \uD83C\uDF89 vs \u2705
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') {
icon = '\u274C';
text = `${labels[svc] || svc}: Fehler ${info.error || ''}`;
@@ -2387,19 +2502,19 @@ const ChatScreen: React.FC = () => {
transparent
onRequestClose={() => setThoughtsVisible(false)}
>
<TouchableOpacity
style={{flex:1, backgroundColor:'rgba(0,0,0,0.5)', justifyContent:'flex-end'}}
activeOpacity={1}
onPress={() => setThoughtsVisible(false)}
>
{/* View statt TouchableOpacity, sonst konsumiert das die Touch-
Events und die FlatList laesst sich nicht scrollen.
onStartShouldSetResponder={true} blockt aber die Propagation
an das aeussere TouchableOpacity (close-on-tap-outside). */}
<View style={{flex:1, backgroundColor:'rgba(0,0,0,0.5)', justifyContent:'flex-end'}}>
{/* Tap-Outside-Bereich oberhalb des Sheets — separater Touchable
damit das Sheet-View NICHT als Responder den FlatList-Scroll
blockiert. Frueher hatten wir den ganzen Hintergrund als
TouchableOpacity + inneren View mit onStartShouldSetResponder
= das hat alle Touch-Events kassiert. */}
<TouchableOpacity
style={{flex:1}}
activeOpacity={1}
onPress={() => setThoughtsVisible(false)}
/>
<View
style={{height:'60%', backgroundColor:'#0D0D1A', borderTopLeftRadius:16, borderTopRightRadius:16}}
onStartShouldSetResponder={() => true}
onResponderTerminationRequest={() => false}
>
{/* Drag-Indicator */}
<View style={{alignItems:'center', paddingTop:8, paddingBottom:4}}>
@@ -2480,7 +2595,7 @@ const ChatScreen: React.FC = () => {
/>
)}
</View>
</TouchableOpacity>
</View>
</Modal>
{/* Notizen-Inbox — Listet alle Memories aus dem aktuellen Chat (Special-Bubbles).
@@ -2515,7 +2630,7 @@ const ChatScreen: React.FC = () => {
<Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:8, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
Aus diesem Chat
</Text>
<ScrollView style={{paddingHorizontal:8}}>
<ScrollView style={{paddingHorizontal:8}} nestedScrollEnabled={true}>
{specials.map(m => {
if (m.memorySaved) {
const ms = m.memorySaved;
@@ -2571,7 +2686,12 @@ const ChatScreen: React.FC = () => {
<Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:10, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
Alle Memories aus der DB
</Text>
<MemoryBrowser onOpenMemory={(id) => { setInboxVisible(false); setMemoryDetailId(id); }} />
{/* flex:1 Wrapper damit MemoryBrowser den verbleibenden Platz
bekommt (sonst rendert die FlatList intern mit 0 Hoehe und
nimmt nur was der Inhalt sagt → Scroll-Gestures verschwinden). */}
<View style={{flex:1}}>
<MemoryBrowser onOpenMemory={(id) => { setInboxVisible(false); setMemoryDetailId(id); }} />
</View>
</View>
</ErrorBoundary>
</Modal>
+125 -3
View File
@@ -19,6 +19,7 @@ import {
ActivityIndicator,
Modal,
PermissionsAndroid,
useWindowDimensions,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs';
@@ -52,7 +53,9 @@ import {
} from '../services/audio';
import audioService from '../services/audio';
import gpsTrackingService from '../services/gpsTracking';
import { acquireBackgroundAudio, releaseBackgroundAudio } from '../services/backgroundAudio';
import MemoryBrowser from '../components/MemoryBrowser';
import TriggerBrowser from '../components/TriggerBrowser';
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
import {
isWakeReadySoundEnabled,
@@ -102,6 +105,7 @@ const SETTINGS_SECTIONS = [
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
{ id: 'memory', icon: '🧠', label: 'Gedächtnis', desc: 'ARIA-Memories durchsuchen, anlegen, bearbeiten, löschen' },
{ id: 'triggers', icon: '⏰', label: 'Trigger', desc: 'Timer + Watcher anlegen, bearbeiten, löschen' },
{ id: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' },
{ id: 'about', icon: '️', label: 'Ueber', desc: 'App-Version, Update' },
] as const;
@@ -118,6 +122,7 @@ const SOURCE_COLORS: Record<string, string> = {
// --- Komponente ---
const SettingsScreen: React.FC = () => {
const winDims = useWindowDimensions();
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
const [manualToken, setManualToken] = useState('');
const [manualHost, setManualHost] = useState('');
@@ -125,6 +130,8 @@ const SettingsScreen: React.FC = () => {
const [currentMode, setCurrentMode] = useState('normal');
const [gpsEnabled, setGpsEnabled] = useState(false);
const [gpsTracking, setGpsTracking] = useState(gpsTrackingService.isActive());
const [backgroundMode, setBackgroundMode] = useState(true); // Default an
const [showSystemHints, setShowSystemHints] = useState(false); // Default aus
const [scannerVisible, setScannerVisible] = useState(false);
const [logTab, setLogTab] = useState<LogTab>('live');
const [logs, setLogs] = useState<LogEntry[]>([]);
@@ -192,6 +199,14 @@ const SettingsScreen: React.FC = () => {
AsyncStorage.getItem('aria_gps_enabled').then(saved => {
if (saved !== null) setGpsEnabled(saved === 'true');
});
AsyncStorage.getItem('aria_background_mode').then(saved => {
// Default ist an — nur explicit 'false' deaktiviert
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
setGpsTracking(gpsTrackingService.isActive());
const offGps = gpsTrackingService.onChange(setGpsTracking);
@@ -575,6 +590,44 @@ const SettingsScreen: React.FC = () => {
AsyncStorage.setItem('aria_gps_enabled', String(value)).catch(() => {});
}, []);
// --- Hintergrund-Modus Toggle ---
const handleBackgroundModeToggle = useCallback(async (value: boolean) => {
setBackgroundMode(value);
AsyncStorage.setItem('aria_background_mode', String(value)).catch(() => {});
try {
if (value) {
// Permission fuer Notification (Android 13+) — sonst sieht der User
// den Hintergrund-Modus nicht und wundert sich
if (Platform.OS === 'android' && Platform.Version >= 33) {
await PermissionsAndroid.request(
'android.permission.POST_NOTIFICATIONS' as any,
{
title: 'Hintergrund-Modus',
message: 'ARIA zeigt eine Notification damit die App im Hintergrund laufen darf.',
buttonPositive: 'Erlauben',
buttonNegative: 'Spaeter',
},
);
}
await acquireBackgroundAudio('background');
ToastAndroid.show('Hintergrund-Modus aktiv', ToastAndroid.SHORT);
} else {
await releaseBackgroundAudio('background');
ToastAndroid.show('Hintergrund-Modus aus', ToastAndroid.SHORT);
}
} catch (err: any) {
console.warn('[Settings] Background-Toggle gescheitert:', err?.message || err);
}
}, []);
// --- System-Hints Toggle ---
const handleShowSystemHintsToggle = useCallback((value: boolean) => {
setShowSystemHints(value);
AsyncStorage.setItem('aria_show_hints', String(value)).catch(() => {});
}, []);
// --- XTTS Voice ---
const selectVoice = useCallback((voiceName: string) => {
@@ -868,7 +921,15 @@ const SettingsScreen: React.FC = () => {
})()}
</View>
</Modal>
<ScrollView style={styles.container} contentContainerStyle={styles.content} nestedScrollEnabled={true}>
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
nestedScrollEnabled={true}
// Wenn eine Section eine eigene voll-hoch-scrollende Sub-Liste hat
// (Memory, Trigger), den outer Scroll deaktivieren — Android-nested-
// scrolling laesst sonst nur in eine Richtung scrollen.
scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers'}
>
{currentSection === null && (
<>
@@ -1053,6 +1114,55 @@ const SettingsScreen: React.FC = () => {
/>
</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 === */}
<Text style={styles.sectionTitle}>Hintergrund-Modus</Text>
<View style={styles.card}>
<View style={styles.toggleRow}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>App im Hintergrund weiterlaufen</Text>
<Text style={styles.toggleHint}>
Haelt die Verbindung zu ARIA auch dann offen wenn die App minimiert
ist. Sonst pausiert Android nach ~30s die JS-Engine und Timer-/Watcher-
Trigger kommen nicht durch. Notification "ARIA aktiv" bleibt sichtbar
waehrend der Modus laeuft (das ist Android-Vorschrift fuer Foreground-
Services). Akku-Mehrverbrauch minimal solange ARIA nichts tut.
{'\n\n'}
Wenn nach Akku-Optimierung Trigger trotzdem nicht durchkommen:
Android-Einstellungen → Apps → ARIA Cockpit → Akku → "Uneingeschraenkt"
setzen.
</Text>
</View>
<Switch
value={backgroundMode}
onValueChange={handleBackgroundModeToggle}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={backgroundMode ? '#FFFFFF' : '#666680'}
/>
</View>
</View>
</>)}
{/* === Spracheingabe (geraetelokal) === */}
@@ -1682,11 +1792,23 @@ const SettingsScreen: React.FC = () => {
Alle Memory-Einträge aus ARIAs Vector-DB. Tippen zum Bearbeiten mit Anhängen, pinned-Status,
Tags. Neue Einträge anlegen via "+ Neu".
</Text>
<View style={{height: 600, marginBottom: 8}}>
<View style={{height: winDims.height - 220, marginBottom: 8}}>
<MemoryBrowser />
</View>
</>)}
{/* === Trigger === */}
{currentSection === 'triggers' && (<>
<Text style={styles.sectionTitle}>Trigger</Text>
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
Timer (einmalige Erinnerung) + Watcher (recurring mit Condition, z.B. GPS-near). Toggle aktiv/inaktiv,
Tap zum Bearbeiten, "+ Neu" zum Anlegen.
</Text>
<View style={{height: winDims.height - 220, marginBottom: 8}}>
<TriggerBrowser />
</View>
</>)}
{/* === Logs === */}
{currentSection === 'protocol' && (<>
<Text style={styles.sectionTitle}>Protokoll</Text>
@@ -1798,7 +1920,7 @@ const SettingsScreen: React.FC = () => {
<Text style={styles.aboutTitle}>ARIA Cockpit</Text>
<Text style={styles.aboutVersion}>Version {require('../../package.json').version}</Text>
<Text style={styles.aboutInfo}>
ARIA \u2014 Autonomous Reasoning & Intelligence Assistant.{'\n'}
ARIA {'\u2014'} Autonomous Reasoning & Intelligence Assistant.{'\n'}
Stefans Kommandozentrale.{'\n'}
Gebaut mit React Native + TypeScript.
</Text>
+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 */
async stopRecording(): Promise<RecordingResult | null> {
if (this.recordingState !== 'recording') {
+15 -10
View File
@@ -1,17 +1,21 @@
/**
* Background-Audio: ARIAs TTS, Mic-Aufnahme und Wake-Word-Lauschen sollen
* auch bei minimierter App weiterlaufen. Wir starten dafuer einen Foreground-
* Background-Audio + Hintergrund-Persistenz: ARIAs TTS, Mic-Aufnahme,
* Wake-Word-Lauschen UND der allgemeine Hintergrund-Modus laufen
* weiter wenn die App minimiert ist. Wir starten dafuer einen Foreground-
* Service mit foregroundServiceType=mediaPlayback|microphone, der eine
* persistente Notification zeigt waehrend irgendein Audio-Slot aktiv ist.
* persistente Notification zeigt solange irgendein Slot aktiv ist.
*
* Mehrere Komponenten koennen den Service unabhaengig "halten":
* - 'tts' : ARIA spricht
* - 'rec' : Aufnahme laeuft
* - 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
* - 'tts' : ARIA spricht
* - 'rec' : Aufnahme laeuft
* - 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
* - 'background' : Persistenter Hintergrund-Modus (Settings-Toggle).
* Haelt JS-Engine + WebSocket auch ohne Audio am Leben
* → Trigger-Replies, Reconnects, Push-Reaktionen.
*
* Solange mindestens ein Slot aktiv ist, laeuft der Service. Wenn alle
* Slots leer sind, wird er gestoppt. Der Notification-Text passt sich an
* den hoechstprioren Slot an (tts > rec > wake).
* den hoechstprioren Slot an (tts > rec > wake > background).
*/
import { NativeModules } from 'react-native';
@@ -23,12 +27,13 @@ interface BackgroundAudioNative {
const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative };
type Slot = 'tts' | 'rec' | 'wake';
type Slot = 'tts' | 'rec' | 'wake' | 'background';
const slots = new Set<Slot>();
// Prioritaet fuer den Notification-Text — hoechste zuerst.
const PRIORITY: Slot[] = ['tts', 'rec', 'wake'];
// Prioritaet fuer den Notification-Text — hoechste zuerst. 'background'
// ist die fallback-Anzeige wenn nichts anderes laeuft.
const PRIORITY: Slot[] = ['tts', 'rec', 'wake', 'background'];
function topReason(): string {
for (const s of PRIORITY) {
+86
View File
@@ -121,6 +121,24 @@ export interface Memory {
attachments?: MemoryAttachment[];
}
/** Trigger-Manifest wie aus Brain `/triggers/list` zurueckkommt. */
export interface Trigger {
name: string;
type: 'timer' | 'watcher' | string;
active: boolean;
author?: string;
message: string;
fires_at?: string; // ISO, nur timer
condition?: string; // nur watcher
check_interval_sec?: number; // nur watcher
throttle_sec?: number; // nur watcher
fire_count?: number;
last_fired_at?: string | null;
last_checked_at?: string | null;
created_at?: string;
updated_at?: string;
}
// ── Memory CRUD ──────────────────────────────────────────────────────
export const brainApi = {
@@ -215,6 +233,74 @@ export const brainApi = {
{ expectBinary: true, timeoutMs: 60000 },
);
},
// ── Triggers ────────────────────────────────────────────────────────
/** Liste aller Trigger (aktive + inaktive). */
listTriggers(): Promise<Trigger[]> {
return _send('/triggers/list');
},
/** Einzelnen Trigger holen (inkl. fire_count, last_fired_at, ...). */
getTrigger(name: string): Promise<Trigger> {
return _send(`/triggers/${encodeURIComponent(name)}`);
},
/** Verfuegbare Condition-Variablen + Funktionen (fuer Watcher-Editor). */
getTriggerConditions(): Promise<{ variables: any[]; functions: any[] }> {
return _send('/triggers/conditions');
},
/** Trigger-Logs (last N Feuerungen). */
getTriggerLogs(name: string, limit: number = 50): Promise<any[]> {
return _send(`/triggers/${encodeURIComponent(name)}/logs?limit=${limit}`);
},
/** Timer anlegen. fires_at = ISO timestamp (UTC). */
createTimer(body: { name: string; fires_at: string; message: string; author?: string }): Promise<Trigger> {
return _send('/triggers/timer', {
method: 'POST',
body: { author: 'app', ...body },
});
},
/** Watcher anlegen. */
createWatcher(body: {
name: string;
condition: string;
message: string;
check_interval_sec?: number;
throttle_sec?: number;
author?: string;
}): Promise<Trigger> {
return _send('/triggers/watcher', {
method: 'POST',
body: { author: 'app', ...body },
});
},
/** Trigger patchen (active/message/condition/throttle/interval/fires_at). */
updateTrigger(name: string, body: Partial<{
active: boolean;
message: string;
condition: string;
throttle_sec: number;
check_interval_sec: number;
fires_at: string;
}>): Promise<Trigger> {
return _send(`/triggers/${encodeURIComponent(name)}`, {
method: 'PATCH',
body,
});
},
/** Trigger loeschen. */
deleteTrigger(name: string): Promise<{ deleted: string }> {
return _send(`/triggers/${encodeURIComponent(name)}`, {
method: 'DELETE',
timeoutMs: 15000,
});
},
};
export default brainApi;
+24
View File
@@ -26,6 +26,13 @@ class GpsTrackingService {
private listeners: Set<Listener> = new Set();
// Defensive: nicht zu schnell oeffentlich togglen
private lastChangeAt = 0;
// Letzte bekannte Position — wird vom Heartbeat-Timer alle 60s erneut
// an die Bridge gesendet, sonst veraltet near() im Brain (NEAR_MAX_AGE_SEC
// = 5 min) wenn der User stationaer ist und distanceFilter keine Updates
// mehr triggert.
private lastLat: number | null = null;
private lastLon: number | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
isActive(): boolean {
return this.active;
@@ -84,6 +91,8 @@ class GpsTrackingService {
(pos) => {
const lat = pos.coords.latitude;
const lon = pos.coords.longitude;
this.lastLat = lat;
this.lastLon = lon;
rvs.send('location_update' as any, { lat, lon });
},
(err) => {
@@ -96,6 +105,17 @@ class GpsTrackingService {
fastestInterval: 10000, // (Android) max Frequenz
} as any,
);
// Heartbeat: alle 60s die letzte bekannte Position erneut senden.
// Sonst bleibt der Brain-State stale wenn der User stationaer ist
// (distanceFilter blockt watchPosition-Updates) → near()-Watcher
// verwerfen die Position als veraltet (NEAR_MAX_AGE_SEC = 300s).
// Kein neuer GPS-Wakeup, nur Re-Send der letzten Werte → akkufreundlich.
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
this.heartbeatTimer = setInterval(() => {
if (this.lastLat != null && this.lastLon != null) {
rvs.send('location_update' as any, { lat: this.lastLat, lon: this.lastLon });
}
}, 60_000);
this.active = true;
this.lastChangeAt = Date.now();
this.notify();
@@ -118,6 +138,10 @@ class GpsTrackingService {
try { Geolocation.clearWatch(this.watchId); } catch {}
this.watchId = null;
}
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
this.active = false;
this.lastChangeAt = Date.now();
this.notify();
+40
View File
@@ -43,6 +43,42 @@ class PhoneCallService {
/** Damit Resume nach VoIP-Loss nicht doppelt feuert wenn auch
* TelephonyManager-IDLE-Event kommt. */
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> {
if (this.started || Platform.OS !== 'android') return false;
@@ -82,7 +118,10 @@ class PhoneCallService {
'PhoneCallStateChanged',
(e: { state: PhoneState }) => this._onStateChanged(e.state),
);
this.telephonyAttached = true;
console.log('[PhoneCall] TelephonyManager-Listener aktiv');
} else {
console.warn('[PhoneCall] PhoneCall.start() lieferte false — Native-Listener nicht aktiv');
}
} else {
console.warn('[PhoneCall] READ_PHONE_STATE abgelehnt — VoIP-Calls werden trotzdem ueber AudioFocus erkannt');
@@ -108,6 +147,7 @@ class PhoneCallService {
this.started = false;
this.lastState = 'idle';
this.interruptedByFocus = false;
this.telephonyAttached = false;
}
private _onStateChanged(state: PhoneState): void {
+33
View File
@@ -86,6 +86,11 @@ class WakeWordService {
* oft einen Audio-Pegel-Spike (AudioFocus-Switch, AudioTrack re-route),
* der openWakeWord faelschlich triggern kann. */
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 nativeReady: boolean = false;
@@ -231,6 +236,7 @@ class WakeWordService {
}
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
this.keyword, this.state, this.bargeListening);
this.lastTriggerAt = now;
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {}
}
@@ -341,6 +347,33 @@ class WakeWordService {
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 */
async resume(): Promise<void> {
if (this.state !== 'conversing') return;
+165 -1
View File
@@ -18,6 +18,9 @@ from __future__ import annotations
import json
import logging
import os
import urllib.error
import urllib.request
from typing import Optional
from conversation import Conversation, Turn
@@ -28,6 +31,33 @@ import skills as skills_mod
import triggers as triggers_mod
import watcher as watcher_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__)
@@ -215,6 +245,78 @@ META_TOOLS = [
},
},
},
{
"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",
"function": {
@@ -437,10 +539,12 @@ class Agent:
condition_funcs = watcher_mod.describe_functions()
# 5. System-Prompt + Window-Messages
flux_config = _load_flux_config()
system_prompt = build_system_prompt(hot, cold, skills=all_skills,
triggers=all_triggers,
condition_vars=condition_vars,
condition_funcs=condition_funcs)
condition_funcs=condition_funcs,
flux_config=flux_config)
messages = [ProxyMessage(role="system", content=system_prompt)]
for t in self.conversation.window():
messages.append(ProxyMessage(role=t.role, content=t.content))
@@ -607,6 +711,66 @@ class Agent:
else:
lines.append(f"- {t['name']} ({t['type']}, {state})")
return "\n".join(lines)
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":
query = (arguments.get("query") or "").strip()
if not query:
+36 -1
View File
@@ -240,6 +240,37 @@ def build_triggers_section(
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(
pinned: List[MemoryPoint],
cold: List[MemoryPoint] | None = None,
@@ -247,8 +278,9 @@ def build_system_prompt(
triggers: List[dict] | None = None,
condition_vars: List[dict] | None = None,
condition_funcs: List[dict] | None = None,
flux_config: dict | None = None,
) -> str:
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers."""
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX."""
parts = [build_hot_memory_section(pinned), "", build_time_section()]
if skills:
parts.append("")
@@ -256,6 +288,9 @@ def build_system_prompt(
if condition_vars:
parts.append("")
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))
if cold:
parts.append("")
parts.append(build_cold_memory_section(cold))
+227 -5
View File
@@ -487,6 +487,7 @@ class ARIABridge:
self.tts_enabled = True
self.xtts_voice = ""
self._f5tts_config: dict = {}
self._flux_config: dict = {}
vc: dict = {}
# Gespeicherte Voice-Config laden
try:
@@ -503,9 +504,14 @@ class ARIABridge:
"f5ttsCfgStrength", "f5ttsNfeStep"):
if k in vc:
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._f5tts_config or "defaults")
self._f5tts_config or "defaults",
self._flux_config or "defaults")
except Exception as e:
logger.warning("Voice-Config laden fehlgeschlagen: %s", e)
# 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,
# weil das Modell beim ersten Request noch ~1-2 Min runtergeladen werden kann.
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
# sprengt die argv-Liste beim Claude-Subprocess-Spawn (E2BIG). Bei
# COMPACT_AFTER erreicht → Sessions reset + Container restart.
@@ -1232,6 +1244,7 @@ class ARIABridge:
"whisperModel": self.stt_engine.model_size,
}
payload.update(getattr(self, "_f5tts_config", {}) or {})
payload.update(getattr(self, "_flux_config", {}) or {})
await self._send_to_rvs({
"type": "config",
"payload": payload,
@@ -1478,8 +1491,11 @@ class ARIABridge:
try:
url = f"{current_url}?token={self.rvs_token}"
logger.info("[rvs] Verbinde: %s", current_url)
# max_size=50MB (siehe core-Connect oben — gleicher Grund).
async with websockets.connect(url, max_size=50 * 1024 * 1024) as ws:
# max_size=100MB synchron zum RVS-Server (siehe rvs/server.js).
# 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
retry_delay = 2
logger.info("[rvs] Verbunden — warte auf App-Nachrichten")
@@ -1767,6 +1783,15 @@ class ARIABridge:
self._f5tts_config = {}
self._f5tts_config[k] = payload[k]
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
if changed:
try:
@@ -1777,6 +1802,7 @@ class ARIABridge:
"whisperModel": self.stt_engine.model_size,
}
config_data.update(getattr(self, "_f5tts_config", {}))
config_data.update(getattr(self, "_flux_config", {}))
with open("/shared/config/voice_config.json", "w") as f:
json.dump(config_data, f, indent=2)
logger.info("[rvs] Voice-Config gespeichert: %s", config_data)
@@ -2204,6 +2230,33 @@ class ARIABridge:
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
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:
file_b64 = base64.b64encode(f.read()).decode("ascii")
mime, _ = mimetypes.guess_type(server_path)
@@ -2279,8 +2332,36 @@ class ARIABridge:
future.set_result(text)
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":
# 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
# im 'loading' steckt, geben wir der Bridge mehr Zeit (Modell-Download
# kann 1-2 Min dauern), statt nach 45s lokal zu fallbacken.
@@ -2291,6 +2372,11 @@ class ARIABridge:
self._remote_stt_ready = (state == "ready")
if self._remote_stt_ready != was_ready:
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
elif msg_type == "config_request":
@@ -2475,6 +2561,105 @@ class ARIABridge:
except OSError:
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:
"""Sendet eine Nachricht an die App (via RVS) mit Verbindungs-Check.
@@ -2705,6 +2890,43 @@ class ARIABridge:
# selbst wenn derselbe Name zweimal in Folge kommt.
asyncio.create_task(self._emit_activity("tool", tool, force=True))
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":
try:
data = json.loads(body.decode("utf-8", "ignore"))
+186 -42
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)">
</label>
<textarea id="chat-input" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" onpaste="handleDiagPaste(event)" oninput="autoResizeTextarea(this)"></textarea>
<button class="btn" id="btn-gw" onclick="testGateway()">Gateway senden</button>
<button class="btn" id="btn-rvs" onclick="testRVS()">Via RVS senden</button>
<button class="btn" id="btn-rvs" onclick="testRVS()">Senden</button>
</div>
</div>
</div>
@@ -338,8 +337,7 @@
</div>
<div class="input-row" style="margin-top:8px;">
<textarea id="chat-input-fs" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" oninput="autoResizeTextarea(this)"></textarea>
<button class="btn" onclick="testGatewayFS()">Gateway senden</button>
<button class="btn" onclick="testRVSFS()">Via RVS senden</button>
<button class="btn" onclick="testRVSFS()">Senden</button>
</div>
</div>
@@ -367,7 +365,6 @@
<div style="padding: 0 12px;">
<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" 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="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>
@@ -386,7 +383,6 @@
</span>
</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-proxy"></div>
<div class="log-box hidden" id="log-bridge"></div>
@@ -613,6 +609,66 @@
</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>
<!-- Whisper (STT) -->
<div class="settings-section">
<h2>Whisper (Spracherkennung)</h2>
@@ -968,11 +1024,11 @@
</div>
</div><!-- /tab-triggers -->
<!-- Trigger-Create Modal -->
<!-- Trigger-Create/Edit Modal -->
<div class="modal-overlay" id="trigger-modal">
<div class="modal-box" style="max-width:600px;">
<div class="modal-header">
<h3>Neuer Trigger</h3>
<h3 id="trigger-modal-title">Neuer Trigger</h3>
<button class="modal-close" onclick="closeTriggerModal()">&times;</button>
</div>
<div class="modal-body" style="padding:16px;">
@@ -986,8 +1042,16 @@
<!-- Timer-spezifisch -->
<div id="trigger-timer-fields">
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">In wievielen Minuten?</label>
<input type="number" id="trigger-timer-minutes" min="1" max="10080" value="10" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
<!-- Create-mode: relativ („in X Minuten ab jetzt") -->
<div id="trigger-timer-create-fields">
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">In wievielen Minuten?</label>
<input type="number" id="trigger-timer-minutes" min="1" max="10080" value="10" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
</div>
<!-- Edit-mode: absoluter ISO-Timestamp (UTC) -->
<div id="trigger-timer-edit-fields" style="display:none;">
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Feuert am (ISO, UTC)</label>
<input type="text" id="trigger-timer-fires-at" placeholder="2026-05-15T20:00:00+00:00" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:monospace;margin-bottom:10px;">
</div>
</div>
<!-- Watcher-spezifisch -->
@@ -1008,7 +1072,7 @@
</div>
<div class="modal-footer" style="padding:10px 16px;border-top:1px solid #1E1E2E;display:flex;justify-content:flex-end;gap:8px;">
<button class="btn secondary" onclick="closeTriggerModal()">Abbrechen</button>
<button class="btn" onclick="saveTrigger()">Anlegen</button>
<button class="btn" id="trigger-modal-save-btn" onclick="saveTrigger()">Anlegen</button>
</div>
</div>
</div>
@@ -1110,13 +1174,12 @@
const btnScroll = document.getElementById('btn-scroll');
let ws;
let activeTab = 'all';
const DOCKER_TABS = ['gateway', 'proxy', 'bridge'];
const autoScroll = { all: true, gateway: 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 DOCKER_TABS = ['proxy', 'bridge'];
const autoScroll = { all: true, rvs: true, proxy: true, bridge: true, server: true, trace: true };
const logCounts = { all: 0, rvs: 0, proxy: 0, bridge: 0, server: 0, trace: 0 };
const logBoxes = {
all: document.getElementById('log-all'),
gateway: document.getElementById('log-gateway'),
rvs: document.getElementById('log-rvs'),
proxy: document.getElementById('log-proxy'),
bridge: document.getElementById('log-bridge'),
@@ -1170,7 +1233,9 @@
}
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 === 'proxy') return 'proxy';
if (source === 'bridge') return 'bridge';
@@ -1334,6 +1399,11 @@
setIfPresent('diag-f5tts-vocab', msg.f5ttsVocabFile);
setIfPresent('diag-f5tts-cfg', msg.f5ttsCfgStrength);
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;
}
@@ -1612,18 +1682,6 @@
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() {
const input = document.getElementById('chat-input');
const text = input.value.trim();
@@ -1763,7 +1821,6 @@
if (proxy.models && proxy.models.length) showProxyModels(proxy.models);
// Buttons
document.getElementById('btn-gw').disabled = gw.status !== 'connected';
document.getElementById('btn-rvs').disabled = rvs.status !== 'connected';
}
@@ -2086,14 +2143,6 @@
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() {
const input = document.getElementById('chat-input-fs');
const text = input.value.trim();
@@ -2139,18 +2188,23 @@
// Liste neu aufbauen
list.innerHTML = '';
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)) {
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:6px;';
let dot = '⚫', color = '#666680', text = '';
if (info.state === 'loading') {
dot = '⏳'; color = '#FFD60A'; anyLoading = true;
text = `${labels[s] || s}: laedt${info.model ? ' ' + info.model : ''}...`;
dot = info.downloading ? '⬇' : '⏳';
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') {
dot = '✅'; color = '#34C759';
dot = info.freshlyDownloaded ? '🎉' : '✅'; color = '#34C759';
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') {
dot = '❌'; color = '#FF3B30'; anyError = true;
text = `${labels[s] || s}: Fehler ${info.error || ''}`;
@@ -2665,11 +2719,16 @@
const f5ttsNfeRaw = document.getElementById('diag-f5tts-nfe')?.value || '';
const f5ttsCfgStrength = f5ttsCfgRaw ? parseFloat(f5ttsCfgRaw) : 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({
action: 'send_voice_config',
ttsEnabled, xttsVoice, whisperModel,
f5ttsModel, f5ttsCkptFile, f5ttsVocabFile,
f5ttsCfgStrength, f5ttsNfeStep,
fluxDefaultModel, fluxKeywordRaw, fluxKeywordSwitch, huggingfaceToken,
});
const statusEl = document.getElementById('voice-status');
if (statusEl && xttsVoice) {
@@ -3101,6 +3160,7 @@
<div style="color:#8888AA;font-size:11px;margin-top:4px;">${detailLine}</div>
<div style="color:#888;font-size:12px;margin-top:2px;">"${escapeHtml(t.message || '')}"</div>
<div style="margin-top:6px;display:flex;gap:6px;">
<button class="btn secondary" onclick="openTriggerEdit('${escapeHtml(t.name)}')" style="padding:2px 10px;font-size:10px;color:#0096FF;border-color:#0096FF;">✎ Bearbeiten</button>
<button class="btn secondary" onclick="toggleTriggerActive('${escapeHtml(t.name)}', ${!active})" style="padding:2px 10px;font-size:10px;color:#FF9500;border-color:#FF9500;">${active ? '⏸ Deaktivieren' : '▶ Aktivieren'}</button>
<button class="btn secondary" onclick="deleteTrigger('${escapeHtml(t.name)}')" style="padding:2px 10px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;">🗑 Löschen</button>
</div>
@@ -3138,10 +3198,21 @@
document.getElementById('trigger-watcher-fields').style.display = t === 'watcher' ? '' : 'none';
}
// null = Create-Modus, string = Edit-Modus (Name der bearbeiteten Bubble)
let editingTriggerName = null;
async function openTriggerCreate() {
editingTriggerName = null;
document.getElementById('trigger-modal-title').textContent = 'Neuer Trigger';
document.getElementById('trigger-modal-save-btn').textContent = 'Anlegen';
document.getElementById('trigger-type').disabled = false;
document.getElementById('trigger-name').disabled = false;
document.getElementById('trigger-timer-create-fields').style.display = '';
document.getElementById('trigger-timer-edit-fields').style.display = 'none';
document.getElementById('trigger-type').value = 'timer';
document.getElementById('trigger-name').value = '';
document.getElementById('trigger-timer-minutes').value = '10';
document.getElementById('trigger-timer-fires-at').value = '';
document.getElementById('trigger-condition').value = '';
document.getElementById('trigger-check-interval').value = '300';
document.getElementById('trigger-throttle').value = '3600';
@@ -3170,6 +3241,52 @@
function closeTriggerModal() {
document.getElementById('trigger-modal').classList.remove('open');
editingTriggerName = null;
}
/** Edit-Modus: Modal mit existierenden Trigger-Werten fuellen. */
async function openTriggerEdit(name) {
const t = triggersCache.find(x => x.name === name);
if (!t) { alert('Trigger nicht in cache, lade neu...'); loadTriggers(); return; }
editingTriggerName = name;
document.getElementById('trigger-modal-title').textContent = 'Trigger bearbeiten — ' + name;
document.getElementById('trigger-modal-save-btn').textContent = 'Speichern';
// Type + Name sind im Edit-Modus nicht aenderbar
document.getElementById('trigger-type').value = t.type;
document.getElementById('trigger-type').disabled = true;
document.getElementById('trigger-name').value = t.name;
document.getElementById('trigger-name').disabled = true;
// Timer: relative-Minutes-Feld aus, absoluter ISO-Feld an
document.getElementById('trigger-timer-create-fields').style.display = 'none';
document.getElementById('trigger-timer-edit-fields').style.display = '';
document.getElementById('trigger-timer-fires-at').value = t.fires_at || '';
// Watcher-Felder vorbefuellen
document.getElementById('trigger-condition').value = t.condition || '';
document.getElementById('trigger-check-interval').value = String(t.check_interval_sec || 300);
document.getElementById('trigger-throttle').value = String(t.throttle_sec || 3600);
document.getElementById('trigger-message').value = t.message || '';
document.getElementById('trigger-modal-error').style.display = 'none';
onTriggerTypeChange();
// Variablen-Hinweis fuer Watcher auch im Edit-Modus
if (t.type === 'watcher') {
try {
const r = await fetch('/api/brain/triggers/conditions');
const d = await r.json();
const info = document.getElementById('trigger-vars-info');
if (info) {
const vars = (d.variables || []).map(v =>
`<code>${escapeHtml(v.name)}</code>=${escapeHtml(String(d.current[v.name]))} <span style="color:#444;">(${escapeHtml(v.desc)})</span>`
).join(' · ');
const fns = (d.functions || []).map(f =>
`<code>${escapeHtml(f.signature)}</code> — ${escapeHtml(f.desc)}`
).join('<br>');
info.innerHTML =
'<strong>Variablen:</strong> ' + vars +
(fns ? '<br><br><strong>Funktionen:</strong><br>' + fns : '');
}
} catch {}
}
document.getElementById('trigger-modal').classList.add('open');
}
async function saveTrigger() {
@@ -3181,6 +3298,33 @@
if (!name) { errEl.textContent = 'Name fehlt.'; errEl.style.display = 'block'; return; }
if (!message) { errEl.textContent = 'Nachricht fehlt.'; errEl.style.display = 'block'; return; }
try {
// ── EDIT-MODUS ──────────────────────────────────────────
if (editingTriggerName) {
const patch = { message };
if (ttype === 'watcher') {
const condition = document.getElementById('trigger-condition').value.trim();
if (!condition) { errEl.textContent = 'Condition fehlt.'; errEl.style.display = 'block'; return; }
patch.condition = condition;
patch.check_interval_sec = parseInt(document.getElementById('trigger-check-interval').value, 10) || 300;
patch.throttle_sec = parseInt(document.getElementById('trigger-throttle').value, 10) || 3600;
} else if (ttype === 'timer') {
const fa = document.getElementById('trigger-timer-fires-at').value.trim();
if (fa) patch.fires_at = fa;
}
const r = await fetch('/api/brain/triggers/' + encodeURIComponent(editingTriggerName), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!r.ok) {
const txt = await r.text();
throw new Error('HTTP ' + r.status + ': ' + txt.slice(0, 200));
}
closeTriggerModal();
loadTriggers();
return;
}
// ── CREATE-MODUS ────────────────────────────────────────
let url, body;
if (ttype === 'timer') {
const mins = parseInt(document.getElementById('trigger-timer-minutes').value, 10) || 10;
+37 -15
View File
@@ -492,9 +492,10 @@ function handleGatewayMessage(msg) {
}
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) {
log("error", "gateway", "Nicht verbunden — kann nicht senden");
if (isTrace) traceEnd(false, "Gateway nicht verbunden");
if (isTrace) traceEnd(false, "Gateway deprecated — nutze RVS");
return false;
}
@@ -757,22 +758,20 @@ function sendToRVS_raw(msgObj) {
}
function sendToRVS(text, isTrace) {
// Ueber Gateway senden (zuverlaessig) UND an RVS fuer App-Sichtbarkeit
// Die Bridge empfaengt RVS-Nachrichten von der App zuverlaessig,
// aber die Diagnostic→RVS→Bridge Route hat Zombie-Probleme.
// Deshalb: Gateway fuer ARIA, RVS nur fuer App-Anzeige.
// 1. An Gateway senden (damit ARIA antwortet)
const gatewayOk = sendToGateway(text, isTrace);
// 2. An RVS senden (damit die App die Nachricht sieht)
// Brain-Pipeline: Diagnostic → RVS → Bridge → Brain (HTTP). OpenClaw-
// Gateway-Pfad ist abgeschaltet. Sender 'diagnostic' damit die Bridge
// den Text als User-Nachricht ans Brain weiterleitet und die App +
// Diagnostic die Bubble live spiegeln koennen.
if (!rvsWs || rvsWs.readyState !== WebSocket.OPEN) {
if (isTrace) traceEnd(false, "RVS nicht verbunden");
return false;
}
sendToRVS_raw({
type: "chat",
payload: { text, sender: "diagnostic" },
timestamp: Date.now(),
});
return gatewayOk;
return true;
}
// ── Claude Proxy Test ────────────────────────────────────
@@ -1836,8 +1835,11 @@ wss.on("connection", (ws) => {
const msg = JSON.parse(raw.toString());
if (msg.action === "test_gateway") {
traceStart("Gateway", msg.text || "aria lebst du noch?");
sendToGateway(msg.text || "aria lebst du noch?", true);
// Deprecated — Gateway-Pfad ist raus. Wir leiten an RVS um damit
// 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") {
traceStart("RVS", msg.text || "aria lebst du noch?");
sendToRVS(msg.text || "aria lebst du noch?", true);
@@ -1943,6 +1945,26 @@ wss.on("connection", (ws) => {
if (msg.f5ttsNfeStep !== undefined && !isNaN(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 {
fs.mkdirSync("/shared/config", { recursive: true });
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
+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
+12
View File
@@ -365,6 +365,18 @@ Skills mit Tool-Use.
- [x] **Gedanken-Stream in App + Diagnostic**: chronologisches Log was ARIA intern macht, gefuettert aus `agent_activity`-Events (thinking/tool/assistant/idle). Bleibt zwischen Denk-Phasen stehen, lange Pausen sichtbar als Trennlinie mit Minuten-Hint. App: 💭-Icon in der Statusleiste oeffnet Bottom-Sheet mit chronologischer Liste, 🗑-Confirm zum Leeren. Diagnostic: 💭 Gedanken-Button im Chat-Test-Header oeffnet zentrales Modal, Live-Update wenn neue Eintraege kommen (autoscroll ans Ende). Persistierung in AsyncStorage / localStorage, capped auf 500 Eintraege
- [x] **Live-Tool-Events vom Proxy**: dritter Proxy-Patch (`proxy-patches/routes.js`) hookt Claude-CLI `assistant`-Events — bei jedem `tool_use`-Block (Bash, Read, Edit, Grep, ...) wird per HTTP-POST an die Bridge gemeldet. Bridge spiegelt das als `agent_activity tool=<name>` an RVS-Clients. Vorher kam pro Brain-Call nur EIN „💭 denkt" am Anfang und EIN „✓ fertig" am Ende — jetzt sieht man **live** in beiden UIs wie ARIA durch die Tools haengt. Hook ist fire-and-forget (ARIA_TOOL_HOOK_URL Env-Variable, default http://aria-bridge:8090/internal/agent-activity)
### Such-Sprung-Praezision + Such-Reihenfolge
- [x] **Such-Sprung kalt nach App-Start**: scrollToIndex landete bei langen Listen weit daneben (Cessna-Treffer → Sprung zur Oberhausen-Bubble 15 Stellen daneben). `info.averageItemLength` aus `onScrollToIndexFailed` basierte auf den ersten ~10 gerenderten Items — bei sehr unterschiedlichen Bubble-Hoehen (Voice ~70 px, lange ARIA-Antworten 400+ px) eine grottige Schaetzung. Fix: `itemHeights`-Ref-Map wird per `onLayout` in `renderMessage` gefuettert; Pre-Scroll summiert echte gemessene Hoehen (Fallback `AVG_BUBBLE_HEIGHT=150` fuer noch nicht gerenderte). Plus `initialNumToRender: 30` (Default 10) und `windowSize: 41` (Default 21) → mehr Items beim Mount gemessen
- [x] **Such-Scroll Endlos-Loop (Wiederkehr)**: `onScrollToIndexFailed` retried unbegrenzt — jeder failed Retry rief den Handler erneut auf → neuer Timer → fail → loop. Plus: `setMessages` im `agent_activity`-Handler rief `prev.map()` auch wenn nichts zu aendern war → neues Array bei jedem Tool-Event → FlatList-Layouts invalidiert mitten in der Scroll-Sequenz. Fix: hartes `MAX_SCROLL_RETRIES = 3` plus `prev.some()`-Check vor `.map()` damit reference-stable bei No-Op
- [x] **Such-Treffer in Spezial-Bubbles**: `searchMatchIds` suchte in `messages` (alle Bubbles inkl. Memory/Skill/Trigger), aber gescrollt wird in `invertedMessages` die diese filtert → `findIndex=-1` → kein Scroll, alter Pre-Scroll-Stand bleibt sichtbar. Fix: `searchMatchIds` aus `chatVisibleMessages`. Memory-Inhalte sind weiterhin ueber die 🗂️-Inbox erreichbar
- [x] **Such-Reihenfolge: neueste zuerst** (WhatsApp/Telegram-analog): User ist visuell unten im Chat, der erste Treffer ist meist schon im Viewport ohne weiten Pre-Scroll. „Naechster" geht in die Vergangenheit. Plus Pre-Scroll-Wartezeit 80→200 ms damit FlatList beim ersten Versuch Render-Zeit hat
### Misc App-Polish
- [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
## Offen
### App Features
+8 -3
View File
@@ -39,6 +39,7 @@ const ALLOWED_TYPES = new Set([
"stt_request", "stt_response",
"service_status",
"config_request",
"flux_request", "flux_response",
]);
// Token-Raum: token -> { clients: Set<ws> }
@@ -71,10 +72,14 @@ function cleanupRooms() {
// ── WebSocket-Server starten ────────────────────────────────────────
// maxPayload 50MB: TTS-Streaming + Voice-Upload (WAV als base64) +
// maxPayload 100MB: TTS-Streaming + Voice-Upload (WAV als base64) +
// audio_pcm Chunks koennen die ws-Library Default 1MB ueberschreiten.
// Default-Limit war der Killer fuer die voice_upload Pipeline.
const wss = new WebSocketServer({ port: PORT, maxPayload: 50 * 1024 * 1024 });
// Plus: file_request/file_response fuer Re-Download von Anhaengen.
// 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 wss = new WebSocketServer({ port: PORT, maxPayload: 100 * 1024 * 1024 });
wss.on("listening", () => {
log(`RVS läuft auf Port ${PORT} | Max Sessions: ${MAX_SESSIONS}`);
+3
View File
@@ -2,6 +2,9 @@
# ARIA Gamebox Stack — GPU F5-TTS + Whisper STT
# Laeuft auf dem Gaming-PC (RTX 3060)
# 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: