Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33d5be781f | |||
| 785f5d0805 | |||
| fac87474ec | |||
| 8227266aea | |||
| 5d24e01d4b | |||
| 4fe72cc4a8 | |||
| eeeb1d43f5 | |||
| 0044e222db | |||
| 048d231b60 | |||
| 2bac9c26ca | |||
| c758727345 | |||
| cb0e879118 | |||
| ce6f5b551e | |||
| b6a68b7658 | |||
| 03edee8881 | |||
| 7093ebaf0b | |||
| b4923bc221 | |||
| 7a66752655 | |||
| b510ccd93a | |||
| bbd51406a9 | |||
| 2cd436f6e9 | |||
| 22adc91c1e | |||
| 61cf8e3bcc | |||
| 3e38f1dad3 | |||
| 635944299e | |||
| b2ac013765 | |||
| 93db6a3156 | |||
| 579a466402 | |||
| 5133f0bc2d | |||
| a476a4b734 | |||
| 11b205ddaf | |||
| 71c60ade8a | |||
| bf3dc635d9 | |||
| 8ca899aaf5 | |||
| 15facf48eb | |||
| 71fc90fcb8 | |||
| 856701fb6f | |||
| 6037b62612 | |||
| 8f88cb0030 | |||
| c224562423 | |||
| 5c07aef526 | |||
| d54d37061f | |||
| a6afec0e11 | |||
| 205112021b | |||
| 853f2737f1 | |||
| 7c61107f87 | |||
| 7a22474efd | |||
| f2cf4e0d58 | |||
| db4bebfa57 | |||
| 435b77e1df | |||
| 6f80e442cf | |||
| 0fcbf5e3ed | |||
| 3cf6308b79 | |||
| 7e5a4da659 | |||
| d27fcaf342 | |||
| 5b28a065c0 | |||
| e74e1eaf70 | |||
| ff7c6333bb | |||
| 2c85df3499 |
@@ -25,6 +25,10 @@ aria-data/brain-import/*
|
|||||||
!aria-data/brain-import/.gitkeep
|
!aria-data/brain-import/.gitkeep
|
||||||
!aria-data/brain-import/README.md
|
!aria-data/brain-import/README.md
|
||||||
|
|
||||||
|
# .aria-debug/ — App-Crash-Logs die tools/fetch-app-logs.sh hier ablegt.
|
||||||
|
# Komplett lokal, enthaelt potentiell private Stacktraces / Daten.
|
||||||
|
.aria-debug/
|
||||||
|
|
||||||
# ── ARIAs Gedächtnis (Vector-DB, Skills, Models) ──
|
# ── ARIAs Gedächtnis (Vector-DB, Skills, Models) ──
|
||||||
# Backup via Diagnostic → Gehirn-Export (tar.gz), nicht via Git.
|
# Backup via Diagnostic → Gehirn-Export (tar.gz), nicht via Git.
|
||||||
aria-data/brain/data/
|
aria-data/brain/data/
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ Die Diagnostic-UI hat sechs Top-Tabs:
|
|||||||
- **Main** — Live-Chat-Test, Status (Brain / RVS / Proxy), End-to-End-Trace
|
- **Main** — Live-Chat-Test, Status (Brain / RVS / Proxy), End-to-End-Trace
|
||||||
- **Gehirn** — Memory-Verwaltung (Vector-DB), Token/Call-Metrics (Subscription-Quota), Bootstrap & Migration, Komplett-Gehirn Export/Import
|
- **Gehirn** — Memory-Verwaltung (Vector-DB), Token/Call-Metrics (Subscription-Quota), Bootstrap & Migration, Komplett-Gehirn Export/Import
|
||||||
- **Skills** — Liste mit Logs, Run, Activate/Deactivate, Export/Import als tar.gz
|
- **Skills** — Liste mit Logs, Run, Activate/Deactivate, Export/Import als tar.gz
|
||||||
- **Trigger** — Timer + Watcher anlegen/anzeigen/loeschen, Live-Variablen-Anzeige (disk_free, current_lat, hour_of_day, …), near(lat, lon, m) als Condition-Funktion
|
- **Trigger** — Timer + Watcher anlegen/anzeigen/loeschen, Live-Variablen-Anzeige (disk_free, current_lat, hour_of_day, …), GPS-Funktionen `near() / entered_near() / left_near()` für unterschiedliche Geofencing-Modi
|
||||||
- **Dateien** — alle Dateien aus `/shared/uploads/` mit Multi-Select, Bulk-Download (ZIP) + Bulk-Delete
|
- **Dateien** — alle Dateien aus `/shared/uploads/` mit Multi-Select, Bulk-Download (ZIP) + Bulk-Delete
|
||||||
- **Einstellungen** — Reparatur (Container-Restart), Wipe, Sprachausgabe, Whisper, Sprachmodell, Runtime-Config, App-Onboarding (QR), Komplett-Reset
|
- **Einstellungen** — Reparatur (Container-Restart), Wipe, Sprachausgabe, Whisper, Sprachmodell, Runtime-Config, App-Onboarding (QR), Komplett-Reset
|
||||||
|
|
||||||
@@ -219,11 +219,15 @@ Der Proxy-Container (`node:22-alpine`) installiert bei jedem Start:
|
|||||||
Danach wird der Proxy gepatcht:
|
Danach wird der Proxy gepatcht:
|
||||||
1. **Host-Binding** (sed): Server hoert auf `0.0.0.0` statt localhost
|
1. **Host-Binding** (sed): Server hoert auf `0.0.0.0` statt localhost
|
||||||
2. **Tool-Permissions** (sed): `--dangerously-skip-permissions` Flag injizieren
|
2. **Tool-Permissions** (sed): `--dangerously-skip-permissions` Flag injizieren
|
||||||
3. **Tool-Use-Adapter** (Datei-Overwrite aus [`proxy-patches/`](proxy-patches/)):
|
3. **CLI-Timeout** (sed): `DEFAULT_TIMEOUT 300000 → 1200000` (5 → 20 Min) im subprocess-manager. Multi-Tool-Workflows mit echtem Bash + curl + DB-Inserts brauchen oft 8–15 Min; 5 Min war chronisch zu kurz
|
||||||
|
4. **Tool-Use-Adapter** (Datei-Overwrite aus [`proxy-patches/`](proxy-patches/)):
|
||||||
- `openai-to-cli.js` injiziert das OpenAI-`tools`-Feld als `<system>`-Block mit Schema-Beschreibungen + Anweisung `<tool_call name="X">{json}</tool_call>` als Antwortformat. `role=tool`-Messages werden als `<tool_result>`-Bloecke eingewoben. Multimodal-Content (Array von Parts) bleibt String-kompatibel.
|
- `openai-to-cli.js` injiziert das OpenAI-`tools`-Feld als `<system>`-Block mit Schema-Beschreibungen + Anweisung `<tool_call name="X">{json}</tool_call>` als Antwortformat. `role=tool`-Messages werden als `<tool_result>`-Bloecke eingewoben. Multimodal-Content (Array von Parts) bleibt String-kompatibel.
|
||||||
- `cli-to-openai.js` parsed `<tool_call>`-Bloecke aus Claudes Antwort und liefert sie als echte OpenAI `tool_calls` mit `finish_reason="tool_calls"`. Pre-Tool-Text bleibt im `content`. Mehrere parallele Calls werden korrekt aufgeteilt. Model-Name null-safe.
|
- `cli-to-openai.js` parsed `<tool_call>`-Bloecke aus Claudes Antwort und liefert sie als echte OpenAI `tool_calls` mit `finish_reason="tool_calls"`. Pre-Tool-Text bleibt im `content`. Mehrere parallele Calls werden korrekt aufgeteilt. Model-Name null-safe.
|
||||||
|
- `routes.js` hookt die `assistant`-Events des Subprozesses und feuert pro `tool_use`-Block (Bash, Read, Edit, Grep, …) einen HTTP-POST an die Bridge (`/internal/agent-activity`). Bridge spiegelt das als RVS `agent_activity` an App+Diagnostic → der Gedanken-Stream zeigt live mit was ARIA gerade tut. Fire-and-forget, fail-open — Brain-Call bricht nicht ab wenn die Bridge mal nicht da ist.
|
||||||
|
|
||||||
**Warum?** Die npm-Version des Proxys ignoriert das `tools`-Feld komplett und reicht nur einen Prompt-String an die CLI weiter. Claude Code nutzt dann ihre internen Tools (Bash, Read, …) und „simuliert" Aktionen — z.B. `sleep 120` statt `trigger_timer`. Mit den eigenen Adaptern landen ARIA-Tools wieder auf der Linie und Side-Effects (Trigger anlegen, Skills aufrufen, GPS-Tracking schalten) funktionieren.
|
**Warum?** Die npm-Version des Proxys ignoriert das `tools`-Feld komplett und reicht nur einen Prompt-String an die CLI weiter. Claude Code nutzt dann ihre internen Tools (Bash, Read, …) und „simuliert" Aktionen — z.B. `sleep 120` statt `trigger_timer`. Mit den eigenen Adaptern landen ARIA-Tools wieder auf der Linie und Side-Effects (Trigger anlegen, Skills aufrufen, GPS-Tracking schalten) funktionieren. Der Tool-Hook im `routes.js` macht zusaetzlich das interne Claude-Code-Werkzeug-Geschehen fuer den User sichtbar.
|
||||||
|
|
||||||
|
**Brain ↔ Bridge ist async**: `_handle_rvs_message` ruft `send_to_core` als `asyncio.create_task` statt `await` — sonst blockierte der WS-recv-Loop bis zu 20 Min und der RVS-Server (mobil.hacker-net.de) droppte die Bridge nach ~4 Min Idle-Timeout. Brain laeuft jetzt im Hintergrund-Task, RVS-Verbindung bleibt waehrend ARIA arbeitet aktiv.
|
||||||
|
|
||||||
**Wichtige Umgebungsvariablen im Proxy:**
|
**Wichtige Umgebungsvariablen im Proxy:**
|
||||||
- `HOST=0.0.0.0` — API von aussen erreichbar (Docker-Netz)
|
- `HOST=0.0.0.0` — API von aussen erreichbar (Docker-Netz)
|
||||||
@@ -316,10 +320,17 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
|||||||
|
|
||||||
### Tabs
|
### Tabs
|
||||||
|
|
||||||
- **Main**: Brain/RVS/Proxy-Status, Chat-Test, "ARIA denkt..."-Indikator, End-to-End-Trace, Container-Logs
|
- **Main**: Brain/RVS/Proxy-Status, Chat-Test, "ARIA denkt..."-Indikator, **💭 Gedanken-Stream** (zentrales Modal, zeigt live alle Tool-Calls + Phasen mit Zeitstempel und Trennlinien bei langen Pausen), End-to-End-Trace, Container-Logs
|
||||||
- **Gehirn**: Memory-Browser (Vector-DB), Suche mit zwei Modi (**📝 Wortlich** = Substring-Match Default + **🧠 Semantisch** mit Score-Threshold), **Advanced Search** (aufklappbares Panel, beliebig viele AND/OR-verknuepfte Felder, + Button fuer mehr Zeilen), Type+Pinned-Filter (greifen auch in der Suche), klappbare Type-Kategorien (Default eingeklappt), Add/Edit/Delete mit Category-Autosuggest, **📎 Anhaenge** pro Memory (Bilder/PDFs/...): Upload + Thumbnail-Vorschau + Lightbox + Lösch-Button, 📎N-Badge in der Liste, automatischer Cleanup beim Memory-Delete. ℹ-Info-Modal das erklaert welche Types FEST in den Prompt vs. Cold Memory wandern. **📄 Druckansicht** (Strg+P → PDF). Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz)
|
- **Gehirn**: Memory-Browser (Vector-DB), Suche mit zwei Modi (**📝 Wortlich** = Substring-Match Default + **🧠 Semantisch** mit Score-Threshold), **Advanced Search** (aufklappbares Panel, beliebig viele AND/OR-verknuepfte Felder, + Button fuer mehr Zeilen), Type+Pinned-Filter (greifen auch in der Suche), klappbare Type-Kategorien (Default eingeklappt), Add/Edit/Delete mit Category-Autosuggest, **📎 Anhaenge** pro Memory (Bilder/PDFs/...): Upload + Thumbnail-Vorschau + Lightbox + Lösch-Button, 📎N-Badge in der Liste, automatischer Cleanup beim Memory-Delete. ℹ-Info-Modal das erklaert welche Types FEST in den Prompt vs. Cold Memory wandern. **📄 Druckansicht** (Strg+P → PDF). Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz)
|
||||||
- **Skills**: Liste aller Skills mit Logs pro Run, Activate/Deactivate, Export/Import als tar.gz, "von ARIA"-Badge fuer selbst gebaute
|
- **Skills**: Liste aller Skills mit Logs pro Run, Activate/Deactivate, Export/Import als tar.gz, "von ARIA"-Badge fuer selbst gebaute
|
||||||
- **Trigger**: passive Aufweck-Quellen. **Timer** (einmalig, ISO-Timestamp oder via `in_seconds` als Server-Berechnung) + **Watcher** (recurring, mit Condition + Throttle). Liste aktiver Trigger + Logs pro Feuer-Event. Modal mit Type-Dropdown, Live-Anzeige aller verfuegbaren Condition-Variablen (`disk_free_gb`, `hour_of_day`, `current_lat/lon`, `last_user_message_ago_sec`, …) und Condition-Funktionen (`near(lat, lon, m)` fuer GPS-Geofencing). Sicherer Condition-Parser via Python `ast` (Whitelist, kein `eval`). Der System-Prompt enthaelt zusaetzlich einen `## Aktuelle Zeit`-Block (UTC + Europa/Berlin) damit ARIA Timer-Zeitpunkte korrekt setzen kann.
|
- **Trigger**: passive Aufweck-Quellen. **Timer** (einmalig, ISO-Timestamp oder via `in_seconds` als Server-Berechnung) + **Watcher** (recurring, mit Condition + Throttle). Liste aktiver Trigger + Logs pro Feuer-Event. Modal mit Type-Dropdown, Live-Anzeige aller verfuegbaren Condition-Variablen (`disk_free_gb`, `hour_of_day`, `current_lat/lon`, `last_user_message_ago_sec`, …). **Drei GPS-Funktionen** mit unterschiedlicher Semantik:
|
||||||
|
- `near(lat, lon, r)` — SOLANGE im Radius (mit Throttle gegen Spam). Use-Case: „bin ich noch in der Nähe von X?"
|
||||||
|
- `entered_near(lat, lon, r)` — EINMAL beim Eintritt (Übergang außen→innen). Use-Case: Blitzer-Warner mit r=2000 → 2 km Vorwarnung, oder Ankunfts-Erinnerung mit r=100
|
||||||
|
- `left_near(lat, lon, r)` — EINMAL beim Verlassen (Übergang innen→außen). Use-Case: „Hast du am Parkplatz X was vergessen?"
|
||||||
|
|
||||||
|
Sicherer Condition-Parser via Python `ast` (Whitelist, kein `eval`). Der System-Prompt enthaelt zusaetzlich einen `## Aktuelle Zeit`-Block (UTC + Europa/Berlin) damit ARIA Timer-Zeitpunkte korrekt setzen kann.
|
||||||
|
|
||||||
|
**Auflösung**: Background-Loop tickt alle 8s (vorher 30s — bei 100 km/h durch einen 300m-Radius war eine Vorbeifahrt nur ~22s drin und konnte verpasst werden). Plus event-getrieben: Bridge ruft nach jedem `location_update` von der App sofort einen `/triggers/check-now` im Brain — Watcher sehen die frische Position in Millisekunden statt im Polling-Takt. `near()`-Funktionen ignorieren GPS-Daten älter als 5 Minuten (verhindert Phantom-Fires bei abgeschaltetem Tracking).
|
||||||
- **Dateien**: Browser fuer `/shared/uploads/` mit Multi-Select + "Alle markieren" + Bulk-Download (ZIP bei 2+) + Bulk-Delete. Live-Update der Chat-Bubbles beim Delete.
|
- **Dateien**: Browser fuer `/shared/uploads/` mit Multi-Select + "Alle markieren" + Bulk-Download (ZIP bei 2+) + Bulk-Delete. Live-Update der Chat-Bubbles beim Delete.
|
||||||
- **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning + Voice Export/Import, Whisper, Sprachmodell (brainModel), Onboarding-QR, App-Cleanup
|
- **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning + Voice Export/Import, Whisper, Sprachmodell (brainModel), Onboarding-QR, App-Cleanup
|
||||||
|
|
||||||
@@ -355,15 +366,21 @@ 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.
|
- **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
|
- **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)
|
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
|
||||||
- **Chat-Suche**: Lupe in der Statusleiste filtert Nachrichten live
|
- **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
|
- **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
|
||||||
|
- **💭 Gedanken-Stream**: chronologisches Log was ARIA intern macht — gefuettert aus `agent_activity`-Events (denkt / 🔧 Tool-Name / schreibt / ✓ fertig). Live-Update waehrend Brain arbeitet: pro Tool-Call (Bash, Read, Edit, Grep, …) erscheint sofort ein Eintrag, durchgereicht vom claude-max-api-proxy via `proxy-patches/routes.js`-Hook. Lange Pausen zwischen Denk-Phasen werden als Trennlinie mit Minuten-Hint sichtbar. App: Icon in der Statusleiste oeffnet ein Bottom-Sheet, persistiert in AsyncStorage (capped 500). Diagnostic: identische Funktion als zentrales Modal im Chat-Test-Header
|
||||||
|
- **🗂️ Notizen-Inbox + Memory-Editor**: Neben der Lupe oeffnet `🗂️` ein Vollbild-Modal mit allen Memory/Trigger/Skill-Spezial-Bubbles aus dem Chat plus dem vollen DB-Browser. Tap auf eine Memory oeffnet ein **Detail/Edit-Modal**: Felder editieren, Anhaenge hoch-/runterladen + loeschen, Memory komplett loeschen. Identischer Editor auch in Settings → 🧠 Gedaechtnis. Spezial-Bubbles werden aus dem Chat-Stream gefiltert (keine ewig-unten-haengenden Notiz-Bubbles mehr)
|
||||||
|
- **Bubble-Header dynamic**: „ARIA hat etwas gemerkt" / „Notiz geaendert" (gelb) / „Notiz geloescht" (rot) — je nach action im memory_saved-Event
|
||||||
|
- **App-Crash-Reporting**: ungefangene JS-Errors + React-Render-Fehler landen automatisch in `/shared/logs/app.log` via RVS — kein ADB noetig, Logs holen via `tools/fetch-app-logs.sh` oder Diagnostic GET `/api/app-log`. ErrorBoundary verhindert White-Screen, zeigt stattdessen Error-Box im Modal mit Stack-Trace + Schliessen-Button
|
||||||
- **Mehrere Anhaenge**: Bilder + Dateien sammeln, Text hinzufuegen, dann zusammen senden
|
- **Mehrere Anhaenge**: Bilder + Dateien sammeln, Text hinzufuegen, dann zusammen senden
|
||||||
- **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic)
|
- **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic)
|
||||||
- **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS
|
- **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS
|
||||||
- **Einstellungen**: TTS-aktiv, F5-TTS-Voice, Pre-Roll-Buffer, Stille-Toleranz, Speicherort, Auto-Download, GPS, Verbose-Logging
|
- **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)
|
- **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-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
|
- 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-``-Syntax werden direkt unter dem Text gerendert (PNG/JPG via Image, SVG via react-native-svg)
|
- **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-``-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
|
- **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
|
||||||
@@ -867,10 +884,12 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
|||||||
- [x] **Phase B Punkt 2:** Migration aus `aria-data/brain-import/` → atomare Memory-Punkte (Identity / Rule / Preference / Tool / Skill, idempotent ueber migration_key) + Bootstrap-Snapshot Export/Import (nur pinned)
|
- [x] **Phase B Punkt 2:** Migration aus `aria-data/brain-import/` → atomare Memory-Punkte (Identity / Rule / Preference / Tool / Skill, idempotent ueber migration_key) + Bootstrap-Snapshot Export/Import (nur pinned)
|
||||||
- [x] **Phase B Punkt 3:** Brain Conversation-Loop (Single-Chat UI, Rolling Window 50 Turns, Schwelle 60 → automatisches Destillat, manueller Trigger)
|
- [x] **Phase B Punkt 3:** Brain Conversation-Loop (Single-Chat UI, Rolling Window 50 Turns, Schwelle 60 → automatisches Destillat, manueller Trigger)
|
||||||
- [x] **Phase B Punkt 4:** Skills-System (Python-only via local-venv, skill_create als Tool, dynamische run_<skill> Tools, Diagnostic Skills-Tab mit Logs/Toggle/Export/Import, skill_created Live-Notification in App+Diagnostic, harte Schwelle "pip → Skill")
|
- [x] **Phase B Punkt 4:** Skills-System (Python-only via local-venv, skill_create als Tool, dynamische run_<skill> Tools, Diagnostic Skills-Tab mit Logs/Toggle/Export/Import, skill_created Live-Notification in App+Diagnostic, harte Schwelle "pip → Skill")
|
||||||
- [x] **Phase B Punkt 5:** Triggers-System (passive Aufweck-Quellen — Timer + Watcher mit safe Condition-Parser, GPS-near(), Diagnostic Trigger-Tab, kontinuierliches GPS-Tracking in der App fuer Use-Cases wie Blitzer-Warner). Inklusive Brain → Bridge HTTP-Push (Port 8090 intern) damit Trigger-Antworten ueber RVS in App + Diagnostic + TTS landen.
|
- [x] **Phase B Punkt 5:** Triggers-System (passive Aufweck-Quellen — Timer + Watcher mit safe Condition-Parser, drei GPS-Funktionen `near()` / `entered_near()` / `left_near()` für unterschiedliche Geofencing-Modi, Diagnostic Trigger-Tab, kontinuierliches GPS-Tracking in der App fuer Use-Cases wie Blitzer-Warner). Tick-Frequenz 8s + event-getriebene Auswertung bei jedem `location_update` (statt 30s-Polling) damit auch Auto-Vorbeifahrten bei 100+ km/h durch kleine Radien zuverlässig erwischt werden. `near()`-Funktionen ignorieren GPS-Daten älter als 5 Minuten. Inklusive Brain → Bridge HTTP-Push (Port 8090 intern) damit Trigger-Antworten ueber RVS in App + Diagnostic + TTS landen.
|
||||||
- [x] **Proxy Tool-Use durchreichen**: claude-max-api-proxy patcht via eigene Adapter (`proxy-patches/`) den `tools`/`tool_calls`-Roundtrip — Claude Code rief vorher ihre internen Tools (Bash, sleep) statt der ARIA-Brain-Tools (trigger_timer, skill_*, ...). Jetzt funktioniert Tool-Use End-to-End.
|
- [x] **Proxy Tool-Use durchreichen**: claude-max-api-proxy patcht via eigene Adapter (`proxy-patches/`) den `tools`/`tool_calls`-Roundtrip — Claude Code rief vorher ihre internen Tools (Bash, sleep) statt der ARIA-Brain-Tools (trigger_timer, skill_*, ...). Jetzt funktioniert Tool-Use End-to-End.
|
||||||
- [x] **Single Source of Truth — Qdrant**: `memory_save`-Tool fuer ARIA, Claude-Code-Auto-Memory abgeklemmt (tmpfs ueber `~/.claude/projects` im Proxy-Container), `brain-import/` zum reinen Drop-Folder degradiert, Cold-Memory mit Score-Threshold (0.30) gegen Embedder-Noise/Crosstalk, Diagnostic-Gehirn-UI mit Wortlich-/Semantisch-Suche, Advanced Search (AND/OR mit + Button), Memory-Druckansicht, Muelltonne pro Chat-Bubble. DB ist jetzt durchgaengig die einzige Wissensquelle, kein paralleles File-Memory mehr.
|
- [x] **Single Source of Truth — Qdrant**: `memory_save`-Tool fuer ARIA, Claude-Code-Auto-Memory abgeklemmt (tmpfs ueber `~/.claude/projects` im Proxy-Container), `brain-import/` zum reinen Drop-Folder degradiert, Cold-Memory mit Score-Threshold (0.30) gegen Embedder-Noise/Crosstalk, Diagnostic-Gehirn-UI mit Wortlich-/Semantisch-Suche, Advanced Search (AND/OR mit + Button), Memory-Druckansicht, Muelltonne pro Chat-Bubble. DB ist jetzt durchgaengig die einzige Wissensquelle, kein paralleles File-Memory mehr.
|
||||||
- [x] **Memory-Anhaenge mit Vision-Pipeline**: Pro Memory koennen Bilder/PDFs/beliebige Dateien angehaengt werden (unter `/shared/memory-attachments/<id>/`, max 20 MB). Diagnostic-UI mit Thumbnail-Vorschau + Lightbox, App `memory_saved`-Bubble mit Tap-to-Load via RVS, System-Prompt zeigt Anhang-Pfade. **ARIA sieht Bilder echt** via Claude Code's eingebautes multi-modales `Read`-Tool — kein Proxy-Patch noetig. `memory_save` hat `attach_paths`-Parameter sodass ARIA ein User-Foto im selben Tool-Call lesen, Infos extrahieren (Kennzeichen, Marken, Texte) und als Memory + Anhang persistieren kann. Bilder bleiben am Memory haengen — bei spaeteren Detail-Fragen liest ARIA das Bild einfach nochmal.
|
- [x] **Memory-Anhaenge mit Vision-Pipeline**: Pro Memory koennen Bilder/PDFs/beliebige Dateien angehaengt werden (unter `/shared/memory-attachments/<id>/`, max 20 MB). Diagnostic-UI mit Thumbnail-Vorschau + Lightbox, App `memory_saved`-Bubble mit Tap-to-Load via RVS, System-Prompt zeigt Anhang-Pfade. **ARIA sieht Bilder echt** via Claude Code's eingebautes multi-modales `Read`-Tool — kein Proxy-Patch noetig. `memory_save` hat `attach_paths`-Parameter sodass ARIA ein User-Foto im selben Tool-Call lesen, Infos extrahieren (Kennzeichen, Marken, Texte) und als Memory + Anhang persistieren kann. Bilder bleiben am Memory haengen — bei spaeteren Detail-Fragen liest ARIA das Bild einfach nochmal.
|
||||||
|
- [x] **Memory-Editor in der App** (5 Etappen): Notizen-Inbox-Button neben der Lupe oeffnet ein Modal mit allen Spezial-Bubbles aus dem aktuellen Chat plus dem vollen DB-Browser. Tap auf eine Memory → Detail-Modal mit Anhang-Vorschau, Stift-Icon wechselt in Edit-Mode (Felder editieren + Anhaenge hoch-/runterladen + loeschen). Identischer Editor unter Settings → 🧠 Gedaechtnis. Bubble-Header dynamic je nach Aktion (created/updated/deleted). RVS-Brain-Proxy als Fundament (`brain_request`/`brain_response`) damit die App beliebige Brain-HTTP-Endpoints adressieren kann. `memory_search` + `memory_update` als ARIA-Tools damit sie aktiv die DB pruefen und Eintraege patchen kann statt zu fragmentieren.
|
||||||
|
- [x] **App-Crash-Reporting via RVS**: ErrorBoundary + global JS-Error-Handler + Promise-Rejection-Tracker schicken Crashes als `app_log`-Event durch RVS. Bridge sammelt in `/shared/logs/app.log`, Diagnostic GET `/api/app-log`. `tools/fetch-app-logs.sh` holt die Logs auf die Dev-Maschine (gitignored `.aria-debug/`). Damit kann Stefan unterwegs ohne ADB debuggen — der erste Bug (URLSearchParams in Hermes) wurde so in 5 Minuten gefunden.
|
||||||
- [x] Sprachmodell-Setting wieder funktional (brainModel in runtime.json statt aria-core)
|
- [x] Sprachmodell-Setting wieder funktional (brainModel in runtime.json statt aria-core)
|
||||||
- [x] App-Chat-Sync: kompletter Server-Sync bei Reconnect (Server = Source of Truth) + chat_cleared Live-Update. Lokal-only Bubbles (Skill-Notifications, laufende Voice ohne STT) bleiben erhalten.
|
- [x] App-Chat-Sync: kompletter Server-Sync bei Reconnect (Server = Source of Truth) + chat_cleared Live-Update. Lokal-only Bubbles (Skill-Notifications, laufende Voice ohne STT) bleiben erhalten.
|
||||||
- [x] App: Chat-Suche mit Next/Prev Navigation statt Filter
|
- [x] App: Chat-Suche mit Next/Prev Navigation statt Filter
|
||||||
|
|||||||
+39
-1
@@ -6,7 +6,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect } from 'react';
|
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 { NavigationContainer, DefaultTheme } from '@react-navigation/native';
|
||||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ import ChatScreen from './src/screens/ChatScreen';
|
|||||||
import SettingsScreen from './src/screens/SettingsScreen';
|
import SettingsScreen from './src/screens/SettingsScreen';
|
||||||
import rvs from './src/services/rvs';
|
import rvs from './src/services/rvs';
|
||||||
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
||||||
|
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
|
||||||
|
|
||||||
// --- Navigation ---
|
// --- Navigation ---
|
||||||
|
|
||||||
@@ -61,6 +63,42 @@ const App: React.FC = () => {
|
|||||||
};
|
};
|
||||||
initConnection();
|
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
|
// Beim Beenden: Verbindung sauber trennen
|
||||||
return () => {
|
return () => {
|
||||||
rvs.disconnect();
|
rvs.disconnect();
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 10305
|
versionCode 10600
|
||||||
versionName "0.1.3.5"
|
versionName "0.1.6.0"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import com.facebook.react.bridge.ReactApplicationContext
|
|||||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||||
import com.facebook.react.bridge.ReactMethod
|
import com.facebook.react.bridge.ReactMethod
|
||||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lauscht auf Anruf-Statusaenderungen — wenn das Telefon klingelt oder ein
|
* Lauscht auf Anruf-Statusaenderungen — wenn das Telefon klingelt oder ein
|
||||||
@@ -35,6 +36,11 @@ class PhoneCallModule(reactContext: ReactApplicationContext) : ReactContextBaseJ
|
|||||||
private var legacyListener: PhoneStateListener? = null
|
private var legacyListener: PhoneStateListener? = null
|
||||||
private var modernCallback: Any? = null // TelephonyCallback ab API 31
|
private var modernCallback: Any? = null // TelephonyCallback ab API 31
|
||||||
private var lastState: Int = TelephonyManager.CALL_STATE_IDLE
|
private var lastState: Int = TelephonyManager.CALL_STATE_IDLE
|
||||||
|
// Eigener Single-Thread-Executor statt mainExecutor — der wird bei
|
||||||
|
// pausierter Activity verzoegert oder gar nicht abgearbeitet, der eigene
|
||||||
|
// Thread laeuft unabhaengig solange der App-Prozess lebt (was er ja tut,
|
||||||
|
// wir haben einen Foreground-Service der das garantiert).
|
||||||
|
private val callbackExecutor = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun start(promise: Promise) {
|
fun start(promise: Promise) {
|
||||||
@@ -59,7 +65,7 @@ class PhoneCallModule(reactContext: ReactApplicationContext) : ReactContextBaseJ
|
|||||||
handleStateChange(state)
|
handleStateChange(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tm.registerTelephonyCallback(reactApplicationContext.mainExecutor, cb)
|
tm.registerTelephonyCallback(callbackExecutor, cb)
|
||||||
modernCallback = cb
|
modernCallback = cb
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.1.3.5",
|
"version": "0.1.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -169,6 +169,12 @@ export const MemoryBrowser: React.FC<Props> = ({ restrictToIds, title, flatStyle
|
|||||||
data={filtered}
|
data={filtered}
|
||||||
keyExtractor={m => m.id}
|
keyExtractor={m => m.id}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
|
// nestedScrollEnabled: notwendig damit die FlatList auf Android
|
||||||
|
// scrollt wenn sie in einer aeusseren ScrollView haengt (Settings-
|
||||||
|
// Screen ist ScrollView). Ohne das frisst der aeussere ScrollView
|
||||||
|
// alle Gesten und die innere Liste ist tot.
|
||||||
|
nestedScrollEnabled={true}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<Text style={{color:'#555570',textAlign:'center',padding:20,fontStyle:'italic'}}>
|
<Text style={{color:'#555570',textAlign:'center',padding:20,fontStyle:'italic'}}>
|
||||||
{items.length === 0 ? '(keine Memories in der DB)' : '(keine Treffer für diese Filter)'}
|
{items.length === 0 ? '(keine Memories in der DB)' : '(keine Treffer für diese Filter)'}
|
||||||
|
|||||||
@@ -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;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Modal,
|
Modal,
|
||||||
PermissionsAndroid,
|
PermissionsAndroid,
|
||||||
|
useWindowDimensions,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import RNFS from 'react-native-fs';
|
import RNFS from 'react-native-fs';
|
||||||
@@ -52,7 +53,9 @@ import {
|
|||||||
} from '../services/audio';
|
} from '../services/audio';
|
||||||
import audioService from '../services/audio';
|
import audioService from '../services/audio';
|
||||||
import gpsTrackingService from '../services/gpsTracking';
|
import gpsTrackingService from '../services/gpsTracking';
|
||||||
|
import { acquireBackgroundAudio, releaseBackgroundAudio } from '../services/backgroundAudio';
|
||||||
import MemoryBrowser from '../components/MemoryBrowser';
|
import MemoryBrowser from '../components/MemoryBrowser';
|
||||||
|
import TriggerBrowser from '../components/TriggerBrowser';
|
||||||
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
|
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
|
||||||
import {
|
import {
|
||||||
isWakeReadySoundEnabled,
|
isWakeReadySoundEnabled,
|
||||||
@@ -102,6 +105,7 @@ const SETTINGS_SECTIONS = [
|
|||||||
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
|
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
|
||||||
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
|
{ 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: '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: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' },
|
||||||
{ id: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' },
|
{ id: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -118,6 +122,7 @@ const SOURCE_COLORS: Record<string, string> = {
|
|||||||
// --- Komponente ---
|
// --- Komponente ---
|
||||||
|
|
||||||
const SettingsScreen: React.FC = () => {
|
const SettingsScreen: React.FC = () => {
|
||||||
|
const winDims = useWindowDimensions();
|
||||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
||||||
const [manualToken, setManualToken] = useState('');
|
const [manualToken, setManualToken] = useState('');
|
||||||
const [manualHost, setManualHost] = useState('');
|
const [manualHost, setManualHost] = useState('');
|
||||||
@@ -125,6 +130,8 @@ const SettingsScreen: React.FC = () => {
|
|||||||
const [currentMode, setCurrentMode] = useState('normal');
|
const [currentMode, setCurrentMode] = useState('normal');
|
||||||
const [gpsEnabled, setGpsEnabled] = useState(false);
|
const [gpsEnabled, setGpsEnabled] = useState(false);
|
||||||
const [gpsTracking, setGpsTracking] = useState(gpsTrackingService.isActive());
|
const [gpsTracking, setGpsTracking] = useState(gpsTrackingService.isActive());
|
||||||
|
const [backgroundMode, setBackgroundMode] = useState(true); // Default an
|
||||||
|
const [showSystemHints, setShowSystemHints] = useState(false); // Default aus
|
||||||
const [scannerVisible, setScannerVisible] = useState(false);
|
const [scannerVisible, setScannerVisible] = useState(false);
|
||||||
const [logTab, setLogTab] = useState<LogTab>('live');
|
const [logTab, setLogTab] = useState<LogTab>('live');
|
||||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
@@ -192,6 +199,14 @@ const SettingsScreen: React.FC = () => {
|
|||||||
AsyncStorage.getItem('aria_gps_enabled').then(saved => {
|
AsyncStorage.getItem('aria_gps_enabled').then(saved => {
|
||||||
if (saved !== null) setGpsEnabled(saved === 'true');
|
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
|
// gpsTrackingService status syncen + auf Aenderungen lauschen
|
||||||
setGpsTracking(gpsTrackingService.isActive());
|
setGpsTracking(gpsTrackingService.isActive());
|
||||||
const offGps = gpsTrackingService.onChange(setGpsTracking);
|
const offGps = gpsTrackingService.onChange(setGpsTracking);
|
||||||
@@ -575,6 +590,44 @@ const SettingsScreen: React.FC = () => {
|
|||||||
AsyncStorage.setItem('aria_gps_enabled', String(value)).catch(() => {});
|
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 ---
|
// --- XTTS Voice ---
|
||||||
|
|
||||||
const selectVoice = useCallback((voiceName: string) => {
|
const selectVoice = useCallback((voiceName: string) => {
|
||||||
@@ -868,7 +921,15 @@ const SettingsScreen: React.FC = () => {
|
|||||||
})()}
|
})()}
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
<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 && (
|
{currentSection === null && (
|
||||||
<>
|
<>
|
||||||
@@ -1053,6 +1114,55 @@ const SettingsScreen: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* === Bubble-Anzeige === */}
|
||||||
|
<Text style={styles.sectionTitle}>Chat-Bubbles</Text>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.toggleRow}>
|
||||||
|
<View style={styles.toggleInfo}>
|
||||||
|
<Text style={styles.toggleLabel}>System-Hints in Bubbles anzeigen</Text>
|
||||||
|
<Text style={styles.toggleHint}>
|
||||||
|
Wenn aktiviert: GPS-Position, Barge-In-Hinweise und andere
|
||||||
|
System-Praefixe in eckigen Klammern bleiben in der User-Bubble
|
||||||
|
sichtbar (Debug). Standardmaessig versteckt — Brain bekommt sie
|
||||||
|
trotzdem, sie sind nur fuer dich nicht relevant.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={showSystemHints}
|
||||||
|
onValueChange={handleShowSystemHintsToggle}
|
||||||
|
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
|
||||||
|
thumbColor={showSystemHints ? '#FFFFFF' : '#666680'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* === Hintergrund-Modus === */}
|
||||||
|
<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) === */}
|
{/* === 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,
|
Alle Memory-Einträge aus ARIAs Vector-DB. Tippen zum Bearbeiten — mit Anhängen, pinned-Status,
|
||||||
Tags. Neue Einträge anlegen via "+ Neu".
|
Tags. Neue Einträge anlegen via "+ Neu".
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{height: 600, marginBottom: 8}}>
|
<View style={{height: winDims.height - 220, marginBottom: 8}}>
|
||||||
<MemoryBrowser />
|
<MemoryBrowser />
|
||||||
</View>
|
</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 === */}
|
{/* === Logs === */}
|
||||||
{currentSection === 'protocol' && (<>
|
{currentSection === 'protocol' && (<>
|
||||||
<Text style={styles.sectionTitle}>Protokoll</Text>
|
<Text style={styles.sectionTitle}>Protokoll</Text>
|
||||||
@@ -1798,7 +1920,7 @@ const SettingsScreen: React.FC = () => {
|
|||||||
<Text style={styles.aboutTitle}>ARIA Cockpit</Text>
|
<Text style={styles.aboutTitle}>ARIA Cockpit</Text>
|
||||||
<Text style={styles.aboutVersion}>Version {require('../../package.json').version}</Text>
|
<Text style={styles.aboutVersion}>Version {require('../../package.json').version}</Text>
|
||||||
<Text style={styles.aboutInfo}>
|
<Text style={styles.aboutInfo}>
|
||||||
ARIA \u2014 Autonomous Reasoning & Intelligence Assistant.{'\n'}
|
ARIA {'\u2014'} Autonomous Reasoning & Intelligence Assistant.{'\n'}
|
||||||
Stefans Kommandozentrale.{'\n'}
|
Stefans Kommandozentrale.{'\n'}
|
||||||
Gebaut mit React Native + TypeScript.
|
Gebaut mit React Native + TypeScript.
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -727,6 +727,31 @@ class AudioService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Aufnahme abbrechen ohne RecordingResult zu emittieren — z.B. bei
|
||||||
|
* Wake-Word-False-Positive beim App-Resume aus laengerem Hintergrund.
|
||||||
|
* Aufgenommene Datei wird sofort verworfen. */
|
||||||
|
async cancelRecording(): Promise<void> {
|
||||||
|
if (this.recordingState !== 'recording') return;
|
||||||
|
console.log('[Audio] Aufnahme abgebrochen (cancel)');
|
||||||
|
this.vadEnabled = false;
|
||||||
|
if (this.vadTimer) { clearInterval(this.vadTimer); this.vadTimer = null; }
|
||||||
|
if (this.maxDurationTimer) { clearTimeout(this.maxDurationTimer); this.maxDurationTimer = null; }
|
||||||
|
if (this.noSpeechTimer) { clearTimeout(this.noSpeechTimer); this.noSpeechTimer = null; }
|
||||||
|
try {
|
||||||
|
const path = await this.recorder.stopRecorder();
|
||||||
|
this.recorder.removeRecordBackListener();
|
||||||
|
// Datei loeschen wenn da
|
||||||
|
if (path && path !== 'Already stopped') {
|
||||||
|
const local = path.replace(/^file:\/\//, '');
|
||||||
|
try { await RNFS.unlink(local); } catch {}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Audio] cancelRecording stop fehlgeschlagen:', err);
|
||||||
|
}
|
||||||
|
this._releaseFocusDeferred();
|
||||||
|
this.setState('idle');
|
||||||
|
}
|
||||||
|
|
||||||
/** Aufnahme stoppen und Ergebnis zurueckgeben */
|
/** Aufnahme stoppen und Ergebnis zurueckgeben */
|
||||||
async stopRecording(): Promise<RecordingResult | null> {
|
async stopRecording(): Promise<RecordingResult | null> {
|
||||||
if (this.recordingState !== 'recording') {
|
if (this.recordingState !== 'recording') {
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Background-Audio: ARIAs TTS, Mic-Aufnahme und Wake-Word-Lauschen sollen
|
* Background-Audio + Hintergrund-Persistenz: ARIAs TTS, Mic-Aufnahme,
|
||||||
* auch bei minimierter App weiterlaufen. Wir starten dafuer einen Foreground-
|
* 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
|
* 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":
|
* Mehrere Komponenten koennen den Service unabhaengig "halten":
|
||||||
* - 'tts' : ARIA spricht
|
* - 'tts' : ARIA spricht
|
||||||
* - 'rec' : Aufnahme laeuft
|
* - 'rec' : Aufnahme laeuft
|
||||||
* - 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
|
* - '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
|
* Solange mindestens ein Slot aktiv ist, laeuft der Service. Wenn alle
|
||||||
* Slots leer sind, wird er gestoppt. Der Notification-Text passt sich an
|
* 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';
|
import { NativeModules } from 'react-native';
|
||||||
@@ -23,12 +27,13 @@ interface BackgroundAudioNative {
|
|||||||
|
|
||||||
const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative };
|
const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative };
|
||||||
|
|
||||||
type Slot = 'tts' | 'rec' | 'wake';
|
type Slot = 'tts' | 'rec' | 'wake' | 'background';
|
||||||
|
|
||||||
const slots = new Set<Slot>();
|
const slots = new Set<Slot>();
|
||||||
|
|
||||||
// Prioritaet fuer den Notification-Text — hoechste zuerst.
|
// Prioritaet fuer den Notification-Text — hoechste zuerst. 'background'
|
||||||
const PRIORITY: Slot[] = ['tts', 'rec', 'wake'];
|
// ist die fallback-Anzeige wenn nichts anderes laeuft.
|
||||||
|
const PRIORITY: Slot[] = ['tts', 'rec', 'wake', 'background'];
|
||||||
|
|
||||||
function topReason(): string {
|
function topReason(): string {
|
||||||
for (const s of PRIORITY) {
|
for (const s of PRIORITY) {
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ function _newRequestId(): string {
|
|||||||
return `brain_${Date.now().toString(36)}_${_nextId}`;
|
return `brain_${Date.now().toString(36)}_${_nextId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mini-Query-String-Builder ohne URLSearchParams (Hermes-Polyfill kennt
|
||||||
|
* kein URLSearchParams.set, crasht). Akzeptiert object mit string/number/
|
||||||
|
* bool-Values; undefined/null/leere Strings werden ausgelassen. */
|
||||||
|
function _qs(params: Record<string, unknown>): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v === undefined || v === null || v === '') continue;
|
||||||
|
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
||||||
|
}
|
||||||
|
return parts.length ? `?${parts.join('&')}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
interface SendOpts {
|
interface SendOpts {
|
||||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||||
body?: AnyJson;
|
body?: AnyJson;
|
||||||
@@ -109,6 +121,24 @@ export interface Memory {
|
|||||||
attachments?: MemoryAttachment[];
|
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 ──────────────────────────────────────────────────────
|
// ── Memory CRUD ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const brainApi = {
|
export const brainApi = {
|
||||||
@@ -119,29 +149,31 @@ export const brainApi = {
|
|||||||
|
|
||||||
/** Liste aller Memories, optional nach Type gefiltert. */
|
/** Liste aller Memories, optional nach Type gefiltert. */
|
||||||
listMemories(opts: { type?: string; limit?: number } = {}): Promise<Memory[]> {
|
listMemories(opts: { type?: string; limit?: number } = {}): Promise<Memory[]> {
|
||||||
const qs = new URLSearchParams();
|
const qs = _qs({ type: opts.type, limit: opts.limit || 500 });
|
||||||
if (opts.type) qs.set('type', opts.type);
|
return _send(`/memory/list${qs}`);
|
||||||
qs.set('limit', String(opts.limit || 500));
|
|
||||||
return _send(`/memory/list?${qs.toString()}`);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Volltext-Substring-Suche. */
|
/** Volltext-Substring-Suche. */
|
||||||
searchText(q: string, opts: { type?: string; includePinned?: boolean; k?: number } = {}): Promise<Memory[]> {
|
searchText(q: string, opts: { type?: string; includePinned?: boolean; k?: number } = {}): Promise<Memory[]> {
|
||||||
const qs = new URLSearchParams({ q });
|
const qs = _qs({
|
||||||
if (opts.type) qs.set('type', opts.type);
|
q,
|
||||||
qs.set('include_pinned', String(opts.includePinned !== false));
|
type: opts.type,
|
||||||
qs.set('k', String(opts.k || 50));
|
include_pinned: opts.includePinned !== false,
|
||||||
return _send(`/memory/search-text?${qs.toString()}`);
|
k: opts.k || 50,
|
||||||
|
});
|
||||||
|
return _send(`/memory/search-text${qs}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Semantische Suche (Embedder). */
|
/** Semantische Suche (Embedder). */
|
||||||
searchSemantic(q: string, opts: { type?: string; includePinned?: boolean; k?: number; threshold?: number } = {}): Promise<Memory[]> {
|
searchSemantic(q: string, opts: { type?: string; includePinned?: boolean; k?: number; threshold?: number } = {}): Promise<Memory[]> {
|
||||||
const qs = new URLSearchParams({ q });
|
const qs = _qs({
|
||||||
if (opts.type) qs.set('type', opts.type);
|
q,
|
||||||
qs.set('include_pinned', String(opts.includePinned !== false));
|
type: opts.type,
|
||||||
qs.set('k', String(opts.k || 10));
|
include_pinned: opts.includePinned !== false,
|
||||||
qs.set('score_threshold', String(opts.threshold ?? 0.30));
|
k: opts.k || 10,
|
||||||
return _send(`/memory/search?${qs.toString()}`);
|
score_threshold: opts.threshold ?? 0.30,
|
||||||
|
});
|
||||||
|
return _send(`/memory/search${qs}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Memory anlegen. */
|
/** Memory anlegen. */
|
||||||
@@ -201,6 +233,74 @@ export const brainApi = {
|
|||||||
{ expectBinary: true, timeoutMs: 60000 },
|
{ 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;
|
export default brainApi;
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ class GpsTrackingService {
|
|||||||
private listeners: Set<Listener> = new Set();
|
private listeners: Set<Listener> = new Set();
|
||||||
// Defensive: nicht zu schnell oeffentlich togglen
|
// Defensive: nicht zu schnell oeffentlich togglen
|
||||||
private lastChangeAt = 0;
|
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 {
|
isActive(): boolean {
|
||||||
return this.active;
|
return this.active;
|
||||||
@@ -84,6 +91,8 @@ class GpsTrackingService {
|
|||||||
(pos) => {
|
(pos) => {
|
||||||
const lat = pos.coords.latitude;
|
const lat = pos.coords.latitude;
|
||||||
const lon = pos.coords.longitude;
|
const lon = pos.coords.longitude;
|
||||||
|
this.lastLat = lat;
|
||||||
|
this.lastLon = lon;
|
||||||
rvs.send('location_update' as any, { lat, lon });
|
rvs.send('location_update' as any, { lat, lon });
|
||||||
},
|
},
|
||||||
(err) => {
|
(err) => {
|
||||||
@@ -96,6 +105,17 @@ class GpsTrackingService {
|
|||||||
fastestInterval: 10000, // (Android) max Frequenz
|
fastestInterval: 10000, // (Android) max Frequenz
|
||||||
} as any,
|
} 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.active = true;
|
||||||
this.lastChangeAt = Date.now();
|
this.lastChangeAt = Date.now();
|
||||||
this.notify();
|
this.notify();
|
||||||
@@ -118,6 +138,10 @@ class GpsTrackingService {
|
|||||||
try { Geolocation.clearWatch(this.watchId); } catch {}
|
try { Geolocation.clearWatch(this.watchId); } catch {}
|
||||||
this.watchId = null;
|
this.watchId = null;
|
||||||
}
|
}
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
}
|
||||||
this.active = false;
|
this.active = false;
|
||||||
this.lastChangeAt = Date.now();
|
this.lastChangeAt = Date.now();
|
||||||
this.notify();
|
this.notify();
|
||||||
|
|||||||
@@ -43,6 +43,42 @@ class PhoneCallService {
|
|||||||
/** Damit Resume nach VoIP-Loss nicht doppelt feuert wenn auch
|
/** Damit Resume nach VoIP-Loss nicht doppelt feuert wenn auch
|
||||||
* TelephonyManager-IDLE-Event kommt. */
|
* TelephonyManager-IDLE-Event kommt. */
|
||||||
private interruptedByFocus: boolean = false;
|
private interruptedByFocus: boolean = false;
|
||||||
|
/** True wenn der TelephonyManager-Listener (Pfad 1) wirklich registriert
|
||||||
|
* ist. False wenn READ_PHONE_STATE abgelehnt wurde oder Native nicht ging. */
|
||||||
|
private telephonyAttached: boolean = false;
|
||||||
|
|
||||||
|
/** Status fuer Diagnose: laeuft die Anruf-Erkennung tatsaechlich? */
|
||||||
|
status(): { focusAttached: boolean; telephonyAttached: boolean } {
|
||||||
|
return {
|
||||||
|
focusAttached: this.focusSubscription !== null,
|
||||||
|
telephonyAttached: this.telephonyAttached,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nach App-Resume: pruefen ob die Listener noch leben. Wenn der
|
||||||
|
* TelephonyManager-Listener verloren ging (kann passieren wenn der
|
||||||
|
* React-Bridge-Context recreated wurde), neu attachen. */
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
if (!this.started) return;
|
||||||
|
if (this.telephonyAttached) return; // alles ok
|
||||||
|
if (!PhoneCall) return;
|
||||||
|
try {
|
||||||
|
const ok = await PhoneCall.start();
|
||||||
|
if (ok) {
|
||||||
|
if (!this.subscription) {
|
||||||
|
const emitter = new NativeEventEmitter(NativeModules.PhoneCall as any);
|
||||||
|
this.subscription = emitter.addListener(
|
||||||
|
'PhoneCallStateChanged',
|
||||||
|
(e: { state: PhoneState }) => this._onStateChanged(e.state),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.telephonyAttached = true;
|
||||||
|
console.log('[PhoneCall] refresh: TelephonyManager-Listener re-attached');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn('[PhoneCall] refresh fehlgeschlagen:', err?.message || err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async start(): Promise<boolean> {
|
async start(): Promise<boolean> {
|
||||||
if (this.started || Platform.OS !== 'android') return false;
|
if (this.started || Platform.OS !== 'android') return false;
|
||||||
@@ -82,7 +118,10 @@ class PhoneCallService {
|
|||||||
'PhoneCallStateChanged',
|
'PhoneCallStateChanged',
|
||||||
(e: { state: PhoneState }) => this._onStateChanged(e.state),
|
(e: { state: PhoneState }) => this._onStateChanged(e.state),
|
||||||
);
|
);
|
||||||
|
this.telephonyAttached = true;
|
||||||
console.log('[PhoneCall] TelephonyManager-Listener aktiv');
|
console.log('[PhoneCall] TelephonyManager-Listener aktiv');
|
||||||
|
} else {
|
||||||
|
console.warn('[PhoneCall] PhoneCall.start() lieferte false — Native-Listener nicht aktiv');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('[PhoneCall] READ_PHONE_STATE abgelehnt — VoIP-Calls werden trotzdem ueber AudioFocus erkannt');
|
console.warn('[PhoneCall] READ_PHONE_STATE abgelehnt — VoIP-Calls werden trotzdem ueber AudioFocus erkannt');
|
||||||
@@ -108,6 +147,7 @@ class PhoneCallService {
|
|||||||
this.started = false;
|
this.started = false;
|
||||||
this.lastState = 'idle';
|
this.lastState = 'idle';
|
||||||
this.interruptedByFocus = false;
|
this.interruptedByFocus = false;
|
||||||
|
this.telephonyAttached = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onStateChanged(state: PhoneState): void {
|
private _onStateChanged(state: PhoneState): void {
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ class WakeWordService {
|
|||||||
* oft einen Audio-Pegel-Spike (AudioFocus-Switch, AudioTrack re-route),
|
* oft einen Audio-Pegel-Spike (AudioFocus-Switch, AudioTrack re-route),
|
||||||
* der openWakeWord faelschlich triggern kann. */
|
* der openWakeWord faelschlich triggern kann. */
|
||||||
private cooldownUntilMs: number = 0;
|
private cooldownUntilMs: number = 0;
|
||||||
|
/** Zeitpunkt des letzten echten Wake-Word-Triggers — gebraucht damit
|
||||||
|
* ChatScreen entscheiden kann ob ein 'conversing'-State bei App-Resume
|
||||||
|
* ein false-positive war (Wake-Word im Hintergrund getriggert waehrend
|
||||||
|
* Stefan gar nicht in der App war). */
|
||||||
|
private lastTriggerAt: number = 0;
|
||||||
|
|
||||||
private keyword: WakeKeyword = DEFAULT_KEYWORD;
|
private keyword: WakeKeyword = DEFAULT_KEYWORD;
|
||||||
private nativeReady: boolean = false;
|
private nativeReady: boolean = false;
|
||||||
@@ -231,6 +236,7 @@ class WakeWordService {
|
|||||||
}
|
}
|
||||||
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
|
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
|
||||||
this.keyword, this.state, this.bargeListening);
|
this.keyword, this.state, this.bargeListening);
|
||||||
|
this.lastTriggerAt = now;
|
||||||
if (this.nativeReady && OpenWakeWord) {
|
if (this.nativeReady && OpenWakeWord) {
|
||||||
try { await OpenWakeWord.stop(); } catch {}
|
try { await OpenWakeWord.stop(); } catch {}
|
||||||
}
|
}
|
||||||
@@ -341,6 +347,33 @@ class WakeWordService {
|
|||||||
this.setState('off');
|
this.setState('off');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wenn ein conversing-State auf einem Wake-Word-Trigger juenger als
|
||||||
|
* maxAgeMs basiert: false-positive verwerfen, zurueck zu armed.
|
||||||
|
* Wird vom ChatScreen aufgerufen wenn die App aus laengerem Hintergrund
|
||||||
|
* zurueck kommt — dann ist ein „gerade getriggertes" Wake-Word sehr
|
||||||
|
* wahrscheinlich ein TV-Spike, Husten, ARIAs eigene TTS-Aufnahme etc.
|
||||||
|
* Returnt true wenn verworfen wurde. */
|
||||||
|
async discardIfFreshlyTriggered(maxAgeMs: number = 10_000): Promise<boolean> {
|
||||||
|
if (this.state !== 'conversing') return false;
|
||||||
|
if (this.lastTriggerAt === 0) return false;
|
||||||
|
const age = Date.now() - this.lastTriggerAt;
|
||||||
|
if (age > maxAgeMs) return false;
|
||||||
|
console.log('[WakeWord] Resume: verwerfe verdaechtiges conversing (age=%dms)', age);
|
||||||
|
this.lastTriggerAt = 0;
|
||||||
|
if (this.nativeReady && OpenWakeWord) {
|
||||||
|
try {
|
||||||
|
await OpenWakeWord.start();
|
||||||
|
ToastAndroid.show('Hintergrund-Trigger verworfen — lausche wieder', ToastAndroid.SHORT);
|
||||||
|
this.setState('armed');
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[WakeWord] re-arm nach discard fehlgeschlagen:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.setState('off');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten */
|
/** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten */
|
||||||
async resume(): Promise<void> {
|
async resume(): Promise<void> {
|
||||||
if (this.state !== 'conversing') return;
|
if (this.state !== 'conversing') return;
|
||||||
|
|||||||
+11
-2
@@ -134,10 +134,19 @@ META_TOOLS = [
|
|||||||
"function": {
|
"function": {
|
||||||
"name": "trigger_watcher",
|
"name": "trigger_watcher",
|
||||||
"description": (
|
"description": (
|
||||||
"Lege einen Watcher-Trigger an — pollt alle paar Minuten eine Condition, "
|
"Lege einen Watcher-Trigger an — pollt eine Condition, "
|
||||||
"feuert wenn sie wahr wird (mit Throttle damit's nicht spammt). "
|
"feuert wenn sie wahr wird (mit Throttle damit's nicht spammt). "
|
||||||
"Use-Case: 'sag bescheid wenn Disk unter 5GB', 'pingt mich wenn um 8 Uhr'. "
|
"Use-Case: 'sag bescheid wenn Disk unter 5GB', 'pingt mich wenn um 8 Uhr'. "
|
||||||
"Welche Variablen verfuegbar sind und ihre Bedeutung steht im System-Prompt."
|
"Welche Variablen verfuegbar sind und ihre Bedeutung steht im System-Prompt.\n\n"
|
||||||
|
"Fuer GPS-Trigger gibt es DREI Modi — waehle nach Use-Case:\n"
|
||||||
|
"- **`near(lat, lon, r)`**: SOLANGE im Radius (mit Throttle gegen Spam). "
|
||||||
|
"Use-Case: 'bin ich noch in der Naehe von X?'. Empfohlener throttle 300-3600s.\n"
|
||||||
|
"- **`entered_near(lat, lon, r)`**: EINMAL beim Eintritt (Uebergang draussen→innen). "
|
||||||
|
"Use-Case: Blitzer-Warner, Ankunfts-Erinnerung. Mit grossem r (z.B. 2000) "
|
||||||
|
"wird's zur Vorwarnung 2 km vor dem Ziel. Empfohlener throttle: kurz (30-60s, "
|
||||||
|
"nur gegen GPS-Jitter).\n"
|
||||||
|
"- **`left_near(lat, lon, r)`**: EINMAL beim Verlassen (Uebergang innen→draussen). "
|
||||||
|
"Use-Case: 'Hast du am Parkplatz X was vergessen?'. Empfohlener throttle: kurz."
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
+68
-19
@@ -27,7 +27,12 @@ import watcher as watcher_mod
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
TICK_SEC = 30
|
# Polling-Frequenz des Background-Loops. Vorher 30s → Auto-Vorbeifahrt
|
||||||
|
# durch einen 300m-Radius bei >50 km/h konnte zwischen zwei Ticks komplett
|
||||||
|
# verpasst werden. Mit 8s ist auch eine 18-Sekunden-Durchfahrt (120 km/h
|
||||||
|
# durch 300m) garantiert mind. einmal getroffen. Der Loop ist billig
|
||||||
|
# (paar Dateilesungen + AST-Eval), das macht Brain nicht warm.
|
||||||
|
TICK_SEC = 8
|
||||||
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
||||||
|
|
||||||
|
|
||||||
@@ -159,7 +164,12 @@ async def _fire(trigger: dict, agent_factory) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def _tick(agent_factory) -> None:
|
async def _tick(agent_factory) -> None:
|
||||||
"""Ein Pruefdurchlauf. Geht ueber alle Triggers, feuert was zu feuern ist."""
|
"""Ein Pruefdurchlauf. Geht ueber alle Triggers, feuert was zu feuern ist.
|
||||||
|
|
||||||
|
near()-State-Tracking: entered_near/left_near brauchen die Information
|
||||||
|
ob ein near()-Aufruf beim letzten Tick true war (Uebergang erkennen).
|
||||||
|
Wir halten das pro Trigger als near_states-Dict im Manifest und
|
||||||
|
aktualisieren es nach jedem Eval — auch wenn nicht gefeuert wird."""
|
||||||
try:
|
try:
|
||||||
all_triggers = triggers_mod.list_triggers(active_only=True)
|
all_triggers = triggers_mod.list_triggers(active_only=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -168,35 +178,74 @@ async def _tick(agent_factory) -> None:
|
|||||||
if not all_triggers:
|
if not all_triggers:
|
||||||
return
|
return
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
# Variablen einmal pro Tick sammeln (nicht pro Trigger — Disk-Stat ist teuer)
|
|
||||||
try:
|
|
||||||
vars_ = watcher_mod.collect_variables()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("collect_variables: %s", e)
|
|
||||||
vars_ = {}
|
|
||||||
|
|
||||||
# Watcher: last_checked_at jetzt updaten (auch wenn nicht gefeuert wird,
|
|
||||||
# damit der Check-Interval respektiert wird)
|
|
||||||
for t in all_triggers:
|
|
||||||
if t.get("type") == "watcher":
|
|
||||||
try:
|
|
||||||
t["last_checked_at"] = _now_iso()
|
|
||||||
triggers_mod.write(t["name"], t)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
for trigger in all_triggers:
|
for trigger in all_triggers:
|
||||||
|
if trigger.get("type") != "watcher":
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
if _should_fire(trigger, vars_, now):
|
# Variablen pro Trigger sammeln — wegen prev_near_states-Closure
|
||||||
|
prev = trigger.get("near_states") or {}
|
||||||
|
vars_ = watcher_mod.collect_variables(prev_near_states=prev)
|
||||||
|
|
||||||
|
# Condition evaluieren via _should_fire (intern ruft watcher.evaluate)
|
||||||
|
fired = _should_fire(trigger, vars_, now)
|
||||||
|
|
||||||
|
# State immer updaten, egal ob gefeuert wurde — sonst greift
|
||||||
|
# entered_near/left_near nicht
|
||||||
|
new_states = vars_.get("_new_near_states") or {}
|
||||||
|
trigger["near_states"] = new_states
|
||||||
|
trigger["last_checked_at"] = _now_iso()
|
||||||
|
try:
|
||||||
|
triggers_mod.write(trigger["name"], trigger)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("trigger.write %s: %s", trigger.get("name"), e)
|
||||||
|
|
||||||
|
if fired:
|
||||||
# Feuern als eigener Task — wenn ARIA langsam antwortet,
|
# Feuern als eigener Task — wenn ARIA langsam antwortet,
|
||||||
# darf der naechste Tick nicht blockieren
|
# darf der naechste Tick nicht blockieren
|
||||||
asyncio.create_task(_fire(trigger, agent_factory))
|
asyncio.create_task(_fire(trigger, agent_factory))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Trigger-Check %s: %s", trigger.get("name"), e)
|
logger.warning("Trigger-Check %s: %s", trigger.get("name"), e)
|
||||||
|
|
||||||
|
# Timer (one-shot) — separat ohne near-State
|
||||||
|
timer_vars = None
|
||||||
|
for trigger in all_triggers:
|
||||||
|
if trigger.get("type") != "timer":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if timer_vars is None:
|
||||||
|
timer_vars = watcher_mod.collect_variables()
|
||||||
|
if _should_fire(trigger, timer_vars, now):
|
||||||
|
asyncio.create_task(_fire(trigger, agent_factory))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Timer-Check %s: %s", trigger.get("name"), e)
|
||||||
|
|
||||||
|
|
||||||
|
# Module-Level-Slot fuer die agent_factory damit on-demand-Ticks (von
|
||||||
|
# z.B. POST /triggers/check-now) Zugang haben ohne durch den ganzen
|
||||||
|
# Lifespan-Pfad geschleust zu werden.
|
||||||
|
_AGENT_FACTORY = None
|
||||||
|
|
||||||
|
|
||||||
|
async def tick_now() -> dict:
|
||||||
|
"""Sofortiger Trigger-Check — nicht warten auf den naechsten Loop-Tick.
|
||||||
|
Wird genutzt wenn ein neues GPS-Update reinkommt: Bridge ruft das nach
|
||||||
|
_persist_location, damit Watcher mit near() den frischen Wert sofort
|
||||||
|
sehen statt bis zu TICK_SEC Sekunden zu warten."""
|
||||||
|
if _AGENT_FACTORY is None:
|
||||||
|
return {"ok": False, "error": "Background-Loop noch nicht gestartet"}
|
||||||
|
try:
|
||||||
|
await _tick(_AGENT_FACTORY)
|
||||||
|
return {"ok": True}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("tick_now: %s", exc)
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
async def run_loop(agent_factory) -> None:
|
async def run_loop(agent_factory) -> None:
|
||||||
"""Endlosschleife — wird vom main lifespan gestartet + gestoppt."""
|
"""Endlosschleife — wird vom main lifespan gestartet + gestoppt."""
|
||||||
|
global _AGENT_FACTORY
|
||||||
|
_AGENT_FACTORY = agent_factory
|
||||||
logger.info("Trigger-Loop gestartet (TICK_SEC=%d)", TICK_SEC)
|
logger.info("Trigger-Loop gestartet (TICK_SEC=%d)", TICK_SEC)
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -657,6 +657,16 @@ def triggers_list(active_only: bool = False):
|
|||||||
return {"triggers": triggers_mod.list_triggers(active_only=active_only)}
|
return {"triggers": triggers_mod.list_triggers(active_only=active_only)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/triggers/check-now")
|
||||||
|
async def triggers_check_now():
|
||||||
|
"""Sofortiger Trigger-Check, statt auf den naechsten Background-Tick
|
||||||
|
zu warten. Wird von der Bridge nach jedem location_update gerufen
|
||||||
|
damit GPS-Watcher (near()) den frischen Wert SOFORT sehen — bei
|
||||||
|
Auto-Vorbeifahrt durch einen 300m-Radius hat man sonst nur ~20s
|
||||||
|
Drinnen-Zeit, was unter TICK_SEC fallen kann."""
|
||||||
|
return await background_mod.tick_now()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/triggers/conditions")
|
@app.get("/triggers/conditions")
|
||||||
def triggers_conditions():
|
def triggers_conditions():
|
||||||
"""Verfuegbare Variablen + Funktionen fuer Watcher-Conditions
|
"""Verfuegbare Variablen + Funktionen fuer Watcher-Conditions
|
||||||
|
|||||||
+11
-10
@@ -164,15 +164,17 @@ def build_skills_section(skills: List[dict]) -> str:
|
|||||||
"static-ffmpeg, beautifulsoup4, …). Falls etwas WIRKLICH nur via apt geht: "
|
"static-ffmpeg, beautifulsoup4, …). Falls etwas WIRKLICH nur via apt geht: "
|
||||||
"Stefan fragen ob es ins Brain-Dockerfile soll.")
|
"Stefan fragen ob es ins Brain-Dockerfile soll.")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("**Harte Regel — IMMER Skill anlegen wenn:** die Loesung erfordert eine "
|
lines.append("**Goldene Regel: NIE ungefragt Skills anlegen.** Selbst wenn die Aufgabe "
|
||||||
"pip-Library. Begruendung: Brain-Container hat keinen persistenten State "
|
"eine pip-Library braucht — erst die Aufgabe loesen (mit Bash, `pip install` "
|
||||||
"ausser /data/skills/. Ohne Skill wuerde der Install bei jedem "
|
"im Brain ist ok, oder Workaround), und nur wenn Stefan EXPLIZIT sagt "
|
||||||
"Container-Restart wiederholt.")
|
"'mach daraus einen Skill' / 'leg den als Skill an' / 'dafuer einen Skill' "
|
||||||
|
"rufst du `skill_create` auf. Begruendung: Skill-Setup (venv + pip install) "
|
||||||
|
"blockt das Brain bis zu 12 Minuten. Ein unaufgefordert angelegter Skill "
|
||||||
|
"macht ARIA stumm und nervt Stefan jedes Mal.")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("**Sonst — Skill nur wenn alle vier zutreffen:**")
|
lines.append("**Wenn Stefan einen Skill explizit moechte, pruef:**")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("1. **Wiederkehrend** — die Aufgabe wird realistisch nochmal gestellt. "
|
lines.append("1. **Wiederkehrend** — die Aufgabe wird realistisch nochmal gestellt.")
|
||||||
"Einmal-Faelle (\"wie spaet ist es jetzt\") kein Skill.")
|
|
||||||
lines.append("2. **Nicht-trivial** — mehrere Schritte. Ein einzelner Shell-Befehl "
|
lines.append("2. **Nicht-trivial** — mehrere Schritte. Ein einzelner Shell-Befehl "
|
||||||
"(`date`, `hostname`, `ls`) ist KEIN Skill — das macht Bash direkt.")
|
"(`date`, `hostname`, `ls`) ist KEIN Skill — das macht Bash direkt.")
|
||||||
lines.append("3. **Parametrisierbar** — der Skill nimmt Eingaben (URL, Datei, Suchbegriff) "
|
lines.append("3. **Parametrisierbar** — der Skill nimmt Eingaben (URL, Datei, Suchbegriff) "
|
||||||
@@ -180,9 +182,8 @@ def build_skills_section(skills: List[dict]) -> str:
|
|||||||
lines.append("4. **Wiederverwendbar als ganzes** — Stefan wuerde es zukuenftig per Name "
|
lines.append("4. **Wiederverwendbar als ganzes** — Stefan wuerde es zukuenftig per Name "
|
||||||
"ansprechen (\"mach mir den YouTube zu MP3\") statt jedes Mal zu erklaeren.")
|
"ansprechen (\"mach mir den YouTube zu MP3\") statt jedes Mal zu erklaeren.")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Wenn nichts installiert werden muss UND nicht alle vier zutreffen: einfach "
|
lines.append("Wenn auch nur EINE der vier nicht zutrifft: hoeflich nachfragen ob er "
|
||||||
"die Aufgabe loesen ohne Skill anzulegen. Stefan kann jederzeit sagen "
|
"wirklich einen permanenten Skill will oder die Aufgabe einmalig reicht.")
|
||||||
"'bau daraus einen Skill'.")
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
|
|||||||
RUNTIME_CONFIG_FILE = Path("/shared/config/runtime.json")
|
RUNTIME_CONFIG_FILE = Path("/shared/config/runtime.json")
|
||||||
ENV_MODEL = os.environ.get("BRAIN_MODEL", "claude-sonnet-4")
|
ENV_MODEL = os.environ.get("BRAIN_MODEL", "claude-sonnet-4")
|
||||||
PROXY_URL = os.environ.get("PROXY_URL", "http://proxy:3456")
|
PROXY_URL = os.environ.get("PROXY_URL", "http://proxy:3456")
|
||||||
PROXY_TIMEOUT_SEC = float(os.environ.get("PROXY_TIMEOUT_SEC", "300"))
|
PROXY_TIMEOUT_SEC = float(os.environ.get("PROXY_TIMEOUT_SEC", "1200"))
|
||||||
|
|
||||||
|
|
||||||
def _read_model_from_runtime() -> str:
|
def _read_model_from_runtime() -> str:
|
||||||
|
|||||||
+81
-7
@@ -25,7 +25,7 @@ import shutil
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -91,6 +91,12 @@ def _cpu_load_1min() -> float:
|
|||||||
|
|
||||||
_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||||
|
|
||||||
|
# Maximales GPS-Alter fuer near()-Auswertung. Wenn die App laenger nicht
|
||||||
|
# gepushed hat (z.B. Tracking aus, Mobilfunk weg, App geschlossen), gilt
|
||||||
|
# die Position als "unbekannt" und near() liefert False — verhindert
|
||||||
|
# Phantom-Fires basierend auf einer wochen-alten Position.
|
||||||
|
NEAR_MAX_AGE_SEC = 5 * 60
|
||||||
|
|
||||||
|
|
||||||
def _gps_state() -> dict[str, Any]:
|
def _gps_state() -> dict[str, Any]:
|
||||||
"""Letzte bekannte Position aus /shared/state/location.json.
|
"""Letzte bekannte Position aus /shared/state/location.json.
|
||||||
@@ -119,8 +125,22 @@ def _user_activity_age() -> int:
|
|||||||
return int(time.time() - ts)
|
return int(time.time() - ts)
|
||||||
|
|
||||||
|
|
||||||
def collect_variables() -> dict[str, Any]:
|
def _near_key(lat: float, lon: float, radius_m: float) -> str:
|
||||||
"""Liefert aktuellen Snapshot aller Built-in-Variablen + near()-Helper."""
|
"""Stabiler Schluessel pro near()-Aufruf — fuer entered_near/left_near
|
||||||
|
State-Tracking pro Trigger pro Aufrufstelle."""
|
||||||
|
return f"{float(lat):.6f},{float(lon):.6f},{int(float(radius_m))}"
|
||||||
|
|
||||||
|
|
||||||
|
def collect_variables(prev_near_states: Optional[Dict[str, bool]] = None) -> Dict[str, Any]:
|
||||||
|
"""Liefert aktuellen Snapshot aller Built-in-Variablen + near()-Helper.
|
||||||
|
|
||||||
|
prev_near_states: pro Trigger gespeicherter Zustand vom letzten Eval
|
||||||
|
(für entered_near/left_near). Wird vom background-Loop reingegeben.
|
||||||
|
Nach dem Eval kann man `vars_['_new_near_states']` auslesen, um den
|
||||||
|
Update-Snapshot zurueck ins Trigger-Manifest zu schreiben."""
|
||||||
|
if prev_near_states is None:
|
||||||
|
prev_near_states = {}
|
||||||
|
new_near_states: Dict[str, bool] = {}
|
||||||
free_gb, free_pct = _disk_stats()
|
free_gb, free_pct = _disk_stats()
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
gps = _gps_state()
|
gps = _gps_state()
|
||||||
@@ -176,12 +196,17 @@ def collect_variables() -> dict[str, Any]:
|
|||||||
|
|
||||||
# Funktion-Helper — wird vom Parser als ast.Call mit Name "near" erkannt.
|
# Funktion-Helper — wird vom Parser als ast.Call mit Name "near" erkannt.
|
||||||
# Closure ueber die GPS-Werte, damit eval keine extra Variablen braucht.
|
# Closure ueber die GPS-Werte, damit eval keine extra Variablen braucht.
|
||||||
def _near(lat: float, lon: float, radius_m: float) -> bool:
|
def _compute_near(lat: float, lon: float, radius_m: float) -> bool:
|
||||||
"""Haversine-Distanz: True wenn aktuelle Position < radius_m vom Punkt."""
|
"""Haversine-Distanz: True wenn aktuelle Position < radius_m vom Punkt.
|
||||||
|
Plus Age-Schutz: GPS-Daten aelter als NEAR_MAX_AGE_SEC werden als
|
||||||
|
veraltet betrachtet → False."""
|
||||||
cur_lat = vars_.get("current_lat")
|
cur_lat = vars_.get("current_lat")
|
||||||
cur_lon = vars_.get("current_lon")
|
cur_lon = vars_.get("current_lon")
|
||||||
if cur_lat is None or cur_lon is None:
|
if cur_lat is None or cur_lon is None:
|
||||||
return False
|
return False
|
||||||
|
age = vars_.get("location_age_sec")
|
||||||
|
if isinstance(age, (int, float)) and age >= 0 and age > NEAR_MAX_AGE_SEC:
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
R = 6371000.0
|
R = 6371000.0
|
||||||
phi1 = math.radians(float(cur_lat))
|
phi1 = math.radians(float(cur_lat))
|
||||||
@@ -194,7 +219,39 @@ def collect_variables() -> dict[str, Any]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _near(lat: float, lon: float, radius_m: float) -> bool:
|
||||||
|
"""True solange im Radius drin. Plus State-Tracking fuer
|
||||||
|
entered_near/left_near — wir merken uns das letzte Ergebnis
|
||||||
|
damit Uebergaenge erkannt werden koennen."""
|
||||||
|
current = _compute_near(lat, lon, radius_m)
|
||||||
|
new_near_states[_near_key(lat, lon, radius_m)] = current
|
||||||
|
return current
|
||||||
|
|
||||||
|
def _entered_near(lat: float, lon: float, radius_m: float) -> bool:
|
||||||
|
"""True NUR beim Uebergang draussen → innen. Use-Case: einmal
|
||||||
|
feuern wenn der User in den Radius reinfaehrt (Blitzer-Warner,
|
||||||
|
Ankunft-Erinnerung). Bei groesserem Radius = Vorwarnung."""
|
||||||
|
current = _compute_near(lat, lon, radius_m)
|
||||||
|
key = _near_key(lat, lon, radius_m)
|
||||||
|
new_near_states[key] = current
|
||||||
|
prev = bool(prev_near_states.get(key, False))
|
||||||
|
return current and not prev
|
||||||
|
|
||||||
|
def _left_near(lat: float, lon: float, radius_m: float) -> bool:
|
||||||
|
"""True NUR beim Uebergang innen → draussen. Use-Case: 'Hast
|
||||||
|
du am Parkplatz X was vergessen?' beim Verlassen."""
|
||||||
|
current = _compute_near(lat, lon, radius_m)
|
||||||
|
key = _near_key(lat, lon, radius_m)
|
||||||
|
new_near_states[key] = current
|
||||||
|
prev = bool(prev_near_states.get(key, False))
|
||||||
|
return prev and not current
|
||||||
|
|
||||||
vars_["near"] = _near
|
vars_["near"] = _near
|
||||||
|
vars_["entered_near"] = _entered_near
|
||||||
|
vars_["left_near"] = _left_near
|
||||||
|
# Update-Snapshot fuer den Caller (background-Loop schreibt das pro
|
||||||
|
# Trigger zurueck damit beim naechsten Tick prev_near_states stimmt)
|
||||||
|
vars_["_new_near_states"] = new_near_states
|
||||||
return vars_
|
return vars_
|
||||||
|
|
||||||
|
|
||||||
@@ -236,8 +293,25 @@ def describe_functions() -> list[dict]:
|
|||||||
{
|
{
|
||||||
"name": "near",
|
"name": "near",
|
||||||
"signature": "near(lat, lon, radius_m)",
|
"signature": "near(lat, lon, radius_m)",
|
||||||
"desc": "True wenn die aktuelle GPS-Position innerhalb von radius_m Metern "
|
"desc": "True SOLANGE die aktuelle GPS-Position innerhalb von radius_m "
|
||||||
"vom Punkt (lat, lon) liegt. Haversine. Bei unbekannter Position: False.",
|
"Metern vom Punkt (lat, lon) liegt. Feuert wiederholt (mit throttle). "
|
||||||
|
"Use-Case: 'bin noch in der Naehe von X?'. "
|
||||||
|
"Haversine. Bei unbekannter oder > 5min alter Position: False.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "entered_near",
|
||||||
|
"signature": "entered_near(lat, lon, radius_m)",
|
||||||
|
"desc": "True NUR im Moment des Eintritts in den Radius (Uebergang "
|
||||||
|
"draussen → innen). Use-Case: einmaliger Fire bei Ankunft / "
|
||||||
|
"Blitzer-Warnung. Mit grossem Radius (z.B. 2000) wird das zur "
|
||||||
|
"Vorwarnung bevor man am Punkt ist.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "left_near",
|
||||||
|
"signature": "left_near(lat, lon, radius_m)",
|
||||||
|
"desc": "True NUR im Moment des Verlassens des Radius (Uebergang "
|
||||||
|
"innen → draussen). Use-Case: 'Hast du am Parkplatz X was "
|
||||||
|
"vergessen?' beim Wegfahren.",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+178
-19
@@ -25,6 +25,7 @@ import time
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections import OrderedDict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -475,6 +476,13 @@ class ARIABridge:
|
|||||||
self.current_mode = self._load_persisted_mode()
|
self.current_mode = self._load_persisted_mode()
|
||||||
self.running = False
|
self.running = False
|
||||||
|
|
||||||
|
# Idempotenz: zuletzt gesehene clientMsgIds (App-seitig generiert).
|
||||||
|
# Beim Reconnect/Retry sendet die App dieselbe ID nochmal — wir
|
||||||
|
# antworten erneut mit ACK aber leiten NICHT doppelt an Brain weiter.
|
||||||
|
# OrderedDict als FIFO mit Capping (Insertion-Order).
|
||||||
|
self._seen_client_msg_ids: "OrderedDict[str, float]" = OrderedDict()
|
||||||
|
self._SEEN_CLIENT_MSG_LIMIT = 200
|
||||||
|
|
||||||
# Komponenten (TTS: F5-TTS remote auf der Gamebox, lokales TTS wurde entfernt)
|
# Komponenten (TTS: F5-TTS remote auf der Gamebox, lokales TTS wurde entfernt)
|
||||||
self.tts_enabled = True
|
self.tts_enabled = True
|
||||||
self.xtts_voice = ""
|
self.xtts_voice = ""
|
||||||
@@ -938,7 +946,12 @@ class ARIABridge:
|
|||||||
def _persist_location(self, location: Optional[dict]) -> None:
|
def _persist_location(self, location: Optional[dict]) -> None:
|
||||||
"""Speichert die letzte bekannte GPS-Position fuer Watcher.
|
"""Speichert die letzte bekannte GPS-Position fuer Watcher.
|
||||||
Erwartet {lat, lon} oder {lat, lng}. Nicht-Dicts und fehlende
|
Erwartet {lat, lon} oder {lat, lng}. Nicht-Dicts und fehlende
|
||||||
Koordinaten werden ignoriert."""
|
Koordinaten werden ignoriert.
|
||||||
|
|
||||||
|
Plus: triggert sofort einen on-demand Trigger-Check im Brain
|
||||||
|
(POST /triggers/check-now). Ohne das wartet der Watcher-Loop
|
||||||
|
bis zu TICK_SEC Sekunden — bei Auto-Vorbeifahrt durch einen
|
||||||
|
300m-Radius (18-43s drin) kann das den Trigger verpassen."""
|
||||||
if not isinstance(location, dict):
|
if not isinstance(location, dict):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -950,9 +963,31 @@ class ARIABridge:
|
|||||||
"lat": float(lat),
|
"lat": float(lat),
|
||||||
"lon": float(lon),
|
"lon": float(lon),
|
||||||
})
|
})
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
# Fire-and-forget: Brain-on-demand-Tick. Wenn Brain nicht antwortet
|
||||||
|
# oder langsam ist, blockt das nicht den GPS-Pfad.
|
||||||
|
try:
|
||||||
|
asyncio.create_task(self._trigger_brain_check_now())
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def _trigger_brain_check_now(self) -> None:
|
||||||
|
"""Brain-Endpoint POST /triggers/check-now anstossen."""
|
||||||
|
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
|
||||||
|
def _post():
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{brain_url}/triggers/check-now",
|
||||||
|
data=b"", method="POST",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=8) as r:
|
||||||
|
return r.status
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
await asyncio.get_event_loop().run_in_executor(None, _post)
|
||||||
|
|
||||||
def _persist_user_activity(self) -> None:
|
def _persist_user_activity(self) -> None:
|
||||||
"""Markiert dass der User gerade etwas gemacht hat (Chat/Voice).
|
"""Markiert dass der User gerade etwas gemacht hat (Chat/Voice).
|
||||||
Watcher: last_user_message_ago_sec basiert darauf."""
|
Watcher: last_user_message_ago_sec basiert darauf."""
|
||||||
@@ -962,8 +997,13 @@ class ARIABridge:
|
|||||||
"""Schreibt eine Zeile in /shared/config/chat_backup.jsonl.
|
"""Schreibt eine Zeile in /shared/config/chat_backup.jsonl.
|
||||||
Wird von Diagnostic + App als History-Quelle gelesen.
|
Wird von Diagnostic + App als History-Quelle gelesen.
|
||||||
entry braucht mindestens {role, text}; ts wird ergaenzt.
|
entry braucht mindestens {role, text}; ts wird ergaenzt.
|
||||||
Returns den ts (auch fuer Bubble-Loeschen-Tracking)."""
|
Returns den ts (auch fuer Bubble-Loeschen-Tracking).
|
||||||
ts = int(asyncio.get_event_loop().time() * 1000)
|
|
||||||
|
WICHTIG: ts ist UNIX-ms (time.time()*1000), NICHT loop-time.
|
||||||
|
Loop-time ist Container-monotonic — bei jedem Restart wieder 0.
|
||||||
|
Das brach die App-History-Sortierung weil App-side Date.now()
|
||||||
|
(echtes UNIX-ms) mit Bridge-Container-Uptime gemischt wurde."""
|
||||||
|
ts = int(time.time() * 1000)
|
||||||
try:
|
try:
|
||||||
line = {"ts": ts}
|
line = {"ts": ts}
|
||||||
line.update(entry)
|
line.update(entry)
|
||||||
@@ -1281,10 +1321,12 @@ class ARIABridge:
|
|||||||
self._pending_files_flush_task = None
|
self._pending_files_flush_task = None
|
||||||
text = self._build_pending_files_message(user_text)
|
text = self._build_pending_files_message(user_text)
|
||||||
self._pending_files = []
|
self._pending_files = []
|
||||||
await self.send_to_core(text, source="app-file+chat")
|
# create_task statt await — sonst blockt der RVS-recv-Loop bis Brain
|
||||||
|
# fertig ist (siehe chat-handler oben).
|
||||||
|
asyncio.create_task(self.send_to_core(text, source="app-file+chat"))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def send_to_core(self, text: str, source: str = "bridge") -> None:
|
async def send_to_core(self, text: str, source: str = "bridge", client_msg_id: Optional[str] = None) -> None:
|
||||||
"""Sendet Text an aria-brain (HTTP /chat) und broadcastet die Antwort.
|
"""Sendet Text an aria-brain (HTTP /chat) und broadcastet die Antwort.
|
||||||
|
|
||||||
Nicht-Streaming: wir warten bis Brain fertig ist, dann pushen wir
|
Nicht-Streaming: wir warten bis Brain fertig ist, dann pushen wir
|
||||||
@@ -1298,8 +1340,13 @@ class ARIABridge:
|
|||||||
logger.info("[brain] chat ← %s '%s'", source, text[:80])
|
logger.info("[brain] chat ← %s '%s'", source, text[:80])
|
||||||
|
|
||||||
# User-Nachricht in chat_backup.jsonl loggen — wird beim App-Reconnect
|
# User-Nachricht in chat_backup.jsonl loggen — wird beim App-Reconnect
|
||||||
# / Diagnostic-Reload als History-Quelle gelesen.
|
# / Diagnostic-Reload als History-Quelle gelesen. clientMsgId speichern
|
||||||
self._append_chat_backup({"role": "user", "text": text, "source": source})
|
# damit die App beim chat_history_response ihre lokale Bubble
|
||||||
|
# dedupen kann (sonst verschwindet sie nach Offline→Online-Race).
|
||||||
|
entry: dict = {"role": "user", "text": text, "source": source}
|
||||||
|
if client_msg_id:
|
||||||
|
entry["clientMsgId"] = client_msg_id
|
||||||
|
self._append_chat_backup(entry)
|
||||||
|
|
||||||
# agent_activity → thinking. _emit_activity statt direktem _send_to_rvs
|
# agent_activity → thinking. _emit_activity statt direktem _send_to_rvs
|
||||||
# damit der State-Cache fuer die spaetere idle-Dedup richtig steht.
|
# damit der State-Cache fuer die spaetere idle-Dedup richtig steht.
|
||||||
@@ -1311,8 +1358,10 @@ class ARIABridge:
|
|||||||
url, data=payload, method="POST",
|
url, data=payload, method="POST",
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
# Cold-Start kann lange dauern, 5min Timeout
|
# 20 Min Timeout — lange Multi-Tool-Workflows (Karten,
|
||||||
with urllib.request.urlopen(req, timeout=300) as resp:
|
# PDFs, viele curl-Calls) brauchen das. 5 Min waren chronisch
|
||||||
|
# zu knapp und haben ARIA mitten in der Arbeit gekappt.
|
||||||
|
with urllib.request.urlopen(req, timeout=1200) as resp:
|
||||||
return resp.status, resp.read().decode("utf-8", errors="ignore")
|
return resp.status, resp.read().decode("utf-8", errors="ignore")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return None, str(exc)
|
return None, str(exc)
|
||||||
@@ -1429,8 +1478,11 @@ class ARIABridge:
|
|||||||
try:
|
try:
|
||||||
url = f"{current_url}?token={self.rvs_token}"
|
url = f"{current_url}?token={self.rvs_token}"
|
||||||
logger.info("[rvs] Verbinde: %s", current_url)
|
logger.info("[rvs] Verbinde: %s", current_url)
|
||||||
# max_size=50MB (siehe core-Connect oben — gleicher Grund).
|
# max_size=100MB synchron zum RVS-Server (siehe rvs/server.js).
|
||||||
async with websockets.connect(url, max_size=50 * 1024 * 1024) as ws:
|
# File-Re-Download fuer Anhaenge braucht Platz fuer base64-
|
||||||
|
# inflate (~1.33×). Groessere Files lehnt der file_request-
|
||||||
|
# Handler proaktiv ab bevor's zur 1009-Disconnection kommt.
|
||||||
|
async with websockets.connect(url, max_size=100 * 1024 * 1024) as ws:
|
||||||
self.ws_rvs = ws
|
self.ws_rvs = ws
|
||||||
retry_delay = 2
|
retry_delay = 2
|
||||||
logger.info("[rvs] Verbunden — warte auf App-Nachrichten")
|
logger.info("[rvs] Verbunden — warte auf App-Nachrichten")
|
||||||
@@ -1503,6 +1555,36 @@ class ARIABridge:
|
|||||||
except Exception:
|
except Exception:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
async def _send_chat_ack(self, client_msg_id: Optional[str]) -> None:
|
||||||
|
"""Bestaetigt der App den Empfang einer chat/audio-Nachricht.
|
||||||
|
App nutzt das fuer Delivery-Status (✓ = sent). Ohne ACK wuerde die
|
||||||
|
App nach Timeout retryen — gegen Verlust bei Netz-Hicksern.
|
||||||
|
"""
|
||||||
|
if not client_msg_id:
|
||||||
|
return
|
||||||
|
await self._send_to_rvs({
|
||||||
|
"type": "chat_ack",
|
||||||
|
"payload": {"clientMsgId": client_msg_id},
|
||||||
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
|
})
|
||||||
|
|
||||||
|
def _is_duplicate_client_msg(self, client_msg_id: Optional[str]) -> bool:
|
||||||
|
"""Prueft ob wir diese clientMsgId schon verarbeitet haben.
|
||||||
|
Wenn ja → True (Caller soll ACK senden aber NICHT an Brain forwarden).
|
||||||
|
Wenn nein → in den Seen-Cache aufnehmen + False zurueck.
|
||||||
|
"""
|
||||||
|
if not client_msg_id:
|
||||||
|
return False
|
||||||
|
if client_msg_id in self._seen_client_msg_ids:
|
||||||
|
logger.info("[rvs] Idempotenz: cmid=%s bereits verarbeitet, ignoriere",
|
||||||
|
client_msg_id)
|
||||||
|
return True
|
||||||
|
self._seen_client_msg_ids[client_msg_id] = time.time()
|
||||||
|
# Capping: aelteste Eintraege rauswerfen
|
||||||
|
while len(self._seen_client_msg_ids) > self._SEEN_CLIENT_MSG_LIMIT:
|
||||||
|
self._seen_client_msg_ids.popitem(last=False)
|
||||||
|
return False
|
||||||
|
|
||||||
async def _handle_rvs_message(self, raw_message: str) -> None:
|
async def _handle_rvs_message(self, raw_message: str) -> None:
|
||||||
"""Verarbeitet Nachrichten von der App (via RVS).
|
"""Verarbeitet Nachrichten von der App (via RVS).
|
||||||
|
|
||||||
@@ -1527,6 +1609,13 @@ class ARIABridge:
|
|||||||
sender = payload.get("sender", "")
|
sender = payload.get("sender", "")
|
||||||
if sender in ("aria", "stt"):
|
if sender in ("aria", "stt"):
|
||||||
return
|
return
|
||||||
|
# Delivery-ACK: immer zurueckschicken (auch bei Idempotenz-Hit),
|
||||||
|
# damit die App den Status auf 'sent' setzen kann. Idempotenz-
|
||||||
|
# Check VERHINDERT aber die Doppel-Weiterleitung an Brain.
|
||||||
|
client_msg_id = payload.get("clientMsgId") or None
|
||||||
|
await self._send_chat_ack(client_msg_id)
|
||||||
|
if self._is_duplicate_client_msg(client_msg_id):
|
||||||
|
return
|
||||||
text = payload.get("text", "")
|
text = payload.get("text", "")
|
||||||
# Voice-Override fuer Folgenachrichten setzen — gilt bis zum naechsten
|
# Voice-Override fuer Folgenachrichten setzen — gilt bis zum naechsten
|
||||||
# chat-Event. Leerer String "" = explizit Default-Voice (override loeschen).
|
# chat-Event. Leerer String "" = explizit Default-Voice (override loeschen).
|
||||||
@@ -1562,7 +1651,16 @@ class ARIABridge:
|
|||||||
" [BARGE-IN]" if interrupted else "",
|
" [BARGE-IN]" if interrupted else "",
|
||||||
" [GPS]" if location else "",
|
" [GPS]" if location else "",
|
||||||
text[:80])
|
text[:80])
|
||||||
await self.send_to_core(core_text, source="app" + (" [barge-in]" if interrupted else ""))
|
# KEIN await: send_to_core kann 20 Min dauern. Wenn wir
|
||||||
|
# hier awaiten, blockt der `async for raw_message in ws`-
|
||||||
|
# Loop solange → RVS-Server droppt uns nach ~4 Min idle.
|
||||||
|
# Als Task: Brain laeuft im Hintergrund, RVS-recv bleibt
|
||||||
|
# bedienbar, Pings werden beantwortet, Verbindung lebt.
|
||||||
|
asyncio.create_task(self.send_to_core(
|
||||||
|
core_text,
|
||||||
|
source="app" + (" [barge-in]" if interrupted else ""),
|
||||||
|
client_msg_id=client_msg_id,
|
||||||
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
if msg_type == "cancel_request":
|
if msg_type == "cancel_request":
|
||||||
@@ -1738,7 +1836,8 @@ class ARIABridge:
|
|||||||
|
|
||||||
if not file_b64:
|
if not file_b64:
|
||||||
text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen."
|
text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen."
|
||||||
await self.send_to_core(text, source="app-file")
|
# create_task statt await — RVS-recv darf nicht blocken
|
||||||
|
asyncio.create_task(self.send_to_core(text, source="app-file"))
|
||||||
return
|
return
|
||||||
|
|
||||||
if file_type.startswith("image/"):
|
if file_type.startswith("image/"):
|
||||||
@@ -2108,6 +2207,33 @@ class ARIABridge:
|
|||||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
# Groessen-Check VOR base64-Encode + Send. Sonst zerreisst's bei
|
||||||
|
# grossen Files (>~70 MB binaer) die WebSocket-Verbindung mit
|
||||||
|
# Code 1009 (message too big) — RVS-Server droppt, Bridge crasht
|
||||||
|
# im cleanup (websockets-Lib-Bug). Limit deckt typische Videos
|
||||||
|
# und Bilder ab; alles drueber soll der User per SSH abholen.
|
||||||
|
FILE_MAX_BYTES = 70 * 1024 * 1024
|
||||||
|
try:
|
||||||
|
file_size = os.path.getsize(server_path)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("[rvs] getsize fehlgeschlagen: %s", exc)
|
||||||
|
file_size = 0
|
||||||
|
if file_size > FILE_MAX_BYTES:
|
||||||
|
logger.warning("[rvs] Re-Download abgelehnt: %s zu gross (%dMB > %dMB)",
|
||||||
|
server_path, file_size // (1024 * 1024),
|
||||||
|
FILE_MAX_BYTES // (1024 * 1024))
|
||||||
|
await self._send_to_rvs({
|
||||||
|
"type": "file_response",
|
||||||
|
"payload": {
|
||||||
|
"requestId": req_id,
|
||||||
|
"serverPath": server_path,
|
||||||
|
"name": os.path.basename(server_path),
|
||||||
|
"error": f"Datei zu gross fuer Transfer ({file_size // (1024 * 1024)} MB, Limit {FILE_MAX_BYTES // (1024 * 1024)} MB)",
|
||||||
|
"sizeBytes": file_size,
|
||||||
|
},
|
||||||
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
|
})
|
||||||
|
return
|
||||||
with open(server_path, "rb") as f:
|
with open(server_path, "rb") as f:
|
||||||
file_b64 = base64.b64encode(f.read()).decode("ascii")
|
file_b64 = base64.b64encode(f.read()).decode("ascii")
|
||||||
mime, _ = mimetypes.guess_type(server_path)
|
mime, _ = mimetypes.guess_type(server_path)
|
||||||
@@ -2126,6 +2252,12 @@ class ARIABridge:
|
|||||||
|
|
||||||
elif msg_type == "audio":
|
elif msg_type == "audio":
|
||||||
# Audio von der App → decodieren → STT → an aria-core
|
# Audio von der App → decodieren → STT → an aria-core
|
||||||
|
# Delivery-ACK + Idempotenz wie bei chat — App nutzt die ACKs
|
||||||
|
# auch fuer Sprach-Bubbles (Status auf der Bubble: ✓ sent).
|
||||||
|
client_msg_id = payload.get("clientMsgId") or None
|
||||||
|
await self._send_chat_ack(client_msg_id)
|
||||||
|
if self._is_duplicate_client_msg(client_msg_id):
|
||||||
|
return
|
||||||
audio_b64 = payload.get("base64", "")
|
audio_b64 = payload.get("base64", "")
|
||||||
mime_type = payload.get("mimeType", "audio/mp4")
|
mime_type = payload.get("mimeType", "audio/mp4")
|
||||||
duration_ms = payload.get("durationMs", 0)
|
duration_ms = payload.get("durationMs", 0)
|
||||||
@@ -2156,7 +2288,8 @@ class ARIABridge:
|
|||||||
" [GPS]" if location else "",
|
" [GPS]" if location else "",
|
||||||
f" reqId={audio_request_id[:16]}" if audio_request_id else "")
|
f" reqId={audio_request_id[:16]}" if audio_request_id else "")
|
||||||
asyncio.create_task(self._process_app_audio(
|
asyncio.create_task(self._process_app_audio(
|
||||||
audio_b64, mime_type, interrupted, audio_request_id, location))
|
audio_b64, mime_type, interrupted, audio_request_id, location,
|
||||||
|
client_msg_id=client_msg_id))
|
||||||
|
|
||||||
elif msg_type == "stt_response":
|
elif msg_type == "stt_response":
|
||||||
# Antwort der whisper-bridge auf unseren stt_request
|
# Antwort der whisper-bridge auf unseren stt_request
|
||||||
@@ -2215,7 +2348,8 @@ class ARIABridge:
|
|||||||
async def _process_app_audio(self, audio_b64: str, mime_type: str,
|
async def _process_app_audio(self, audio_b64: str, mime_type: str,
|
||||||
interrupted: bool = False,
|
interrupted: bool = False,
|
||||||
audio_request_id: str = "",
|
audio_request_id: str = "",
|
||||||
location: Optional[dict] = None) -> None:
|
location: Optional[dict] = None,
|
||||||
|
client_msg_id: Optional[str] = None) -> None:
|
||||||
"""App-Audio → STT → aria-core. Primaer via whisper-bridge (RVS), Fallback lokal.
|
"""App-Audio → STT → aria-core. Primaer via whisper-bridge (RVS), Fallback lokal.
|
||||||
|
|
||||||
interrupted=True wenn der User waehrend ARIA noch sprach/dachte aufgenommen hat
|
interrupted=True wenn der User waehrend ARIA noch sprach/dachte aufgenommen hat
|
||||||
@@ -2271,7 +2405,9 @@ class ARIABridge:
|
|||||||
|
|
||||||
# Dann an Brain — der blockt synchron bis ARIA fertig ist.
|
# Dann an Brain — der blockt synchron bis ARIA fertig ist.
|
||||||
core_text = self._build_core_text(text, interrupted, location)
|
core_text = self._build_core_text(text, interrupted, location)
|
||||||
await self.send_to_core(core_text, source="app-voice" + (" [barge-in]" if interrupted else ""))
|
await self.send_to_core(core_text,
|
||||||
|
source="app-voice" + (" [barge-in]" if interrupted else ""),
|
||||||
|
client_msg_id=client_msg_id)
|
||||||
else:
|
else:
|
||||||
logger.info("[rvs] Keine Sprache erkannt — ignoriert")
|
logger.info("[rvs] Keine Sprache erkannt — ignoriert")
|
||||||
|
|
||||||
@@ -2418,17 +2554,22 @@ class ARIABridge:
|
|||||||
status = await asyncio.get_event_loop().run_in_executor(None, _do_request)
|
status = await asyncio.get_event_loop().run_in_executor(None, _do_request)
|
||||||
logger.info("[cancel] Diagnostic /api/cancel: %s", status)
|
logger.info("[cancel] Diagnostic /api/cancel: %s", status)
|
||||||
|
|
||||||
async def _emit_activity(self, activity: str, tool: str = "") -> None:
|
async def _emit_activity(self, activity: str, tool: str = "", force: bool = False) -> None:
|
||||||
"""Sendet agent_activity an die App — nur wenn sich der State geaendert hat.
|
"""Sendet agent_activity an die App — nur wenn sich der State geaendert hat.
|
||||||
|
|
||||||
Trailing Agent-Events nach chat:final werden 3s lang unterdrueckt
|
Trailing Agent-Events nach chat:final werden 3s lang unterdrueckt
|
||||||
(nur 'idle' kommt immer durch)."""
|
(nur 'idle' kommt immer durch).
|
||||||
|
|
||||||
|
force=True: kein State-Dedup — wird vom Proxy-Tool-Hook genutzt
|
||||||
|
damit auch wiederholte gleiche Tool-Aufrufe (z.B. 3x Bash
|
||||||
|
hintereinander) im Gedanken-Stream als eigene Eintraege sichtbar
|
||||||
|
bleiben."""
|
||||||
if activity != "idle" and self._last_chat_final_at > 0:
|
if activity != "idle" and self._last_chat_final_at > 0:
|
||||||
since_final = asyncio.get_event_loop().time() - self._last_chat_final_at
|
since_final = asyncio.get_event_loop().time() - self._last_chat_final_at
|
||||||
if since_final < 3.0:
|
if since_final < 3.0:
|
||||||
return
|
return
|
||||||
state = (activity, tool)
|
state = (activity, tool)
|
||||||
if state == self._last_activity_state:
|
if not force and state == self._last_activity_state:
|
||||||
return
|
return
|
||||||
self._last_activity_state = state
|
self._last_activity_state = state
|
||||||
await self._send_to_rvs({
|
await self._send_to_rvs({
|
||||||
@@ -2576,6 +2717,24 @@ class ARIABridge:
|
|||||||
self._handle_trigger_fired(reply, trigger_name, ttype, events)
|
self._handle_trigger_fired(reply, trigger_name, ttype, events)
|
||||||
)
|
)
|
||||||
await _send_response(writer, 200, {"ok": True})
|
await _send_response(writer, 200, {"ok": True})
|
||||||
|
elif method == "POST" and path == "/internal/agent-activity":
|
||||||
|
# Vom Proxy gefeuert bei jedem Claude-Code-tool_use-Event
|
||||||
|
# (Bash, Read, Edit, Grep, ...). Wir spiegeln das als
|
||||||
|
# RVS agent_activity an App+Diagnostic damit der Gedanken-
|
||||||
|
# Stream live mitlaufen kann.
|
||||||
|
try:
|
||||||
|
data = json.loads(body.decode("utf-8", "ignore"))
|
||||||
|
except Exception as exc:
|
||||||
|
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
|
||||||
|
return
|
||||||
|
tool = (data.get("tool") or "").strip()
|
||||||
|
if not tool:
|
||||||
|
await _send_response(writer, 400, {"error": "tool erforderlich"})
|
||||||
|
return
|
||||||
|
# Force-emit (kein Dedup): User soll JEDEN Tool-Call sehen
|
||||||
|
# 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/delete-chat-message":
|
elif method == "POST" and path == "/internal/delete-chat-message":
|
||||||
try:
|
try:
|
||||||
data = json.loads(body.decode("utf-8", "ignore"))
|
data = json.loads(body.decode("utf-8", "ignore"))
|
||||||
|
|||||||
+233
-35
@@ -301,6 +301,7 @@
|
|||||||
<input type="checkbox" id="gps-debug-toggle" onchange="toggleGpsDebug()" style="margin-right:4px;vertical-align:middle;">
|
<input type="checkbox" id="gps-debug-toggle" onchange="toggleGpsDebug()" style="margin-right:4px;vertical-align:middle;">
|
||||||
GPS-Position einblenden
|
GPS-Position einblenden
|
||||||
</label>
|
</label>
|
||||||
|
<button class="btn secondary" onclick="openThoughtStream()" id="btn-thoughts" title="Gedanken-Stream — was ARIA intern tut" style="padding:4px 10px;font-size:11px;">💭 Gedanken <span id="thoughts-count" style="color:#8888AA;"></span></button>
|
||||||
<button class="btn secondary" onclick="toggleChatFullscreen()" id="btn-chat-fs" style="padding:4px 10px;font-size:11px;">Vollbild</button>
|
<button class="btn secondary" onclick="toggleChatFullscreen()" id="btn-chat-fs" style="padding:4px 10px;font-size:11px;">Vollbild</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -319,8 +320,7 @@
|
|||||||
<input type="file" id="diag-file-input" multiple accept="image/*,application/pdf,.doc,.docx,.txt" style="display:none;" onchange="handleDiagFileSelect(this.files)">
|
<input type="file" id="diag-file-input" multiple accept="image/*,application/pdf,.doc,.docx,.txt" style="display:none;" onchange="handleDiagFileSelect(this.files)">
|
||||||
</label>
|
</label>
|
||||||
<textarea id="chat-input" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" onpaste="handleDiagPaste(event)" oninput="autoResizeTextarea(this)"></textarea>
|
<textarea id="chat-input" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" onpaste="handleDiagPaste(event)" oninput="autoResizeTextarea(this)"></textarea>
|
||||||
<button class="btn" id="btn-gw" onclick="testGateway()">Gateway senden</button>
|
<button class="btn" id="btn-rvs" onclick="testRVS()">Senden</button>
|
||||||
<button class="btn" id="btn-rvs" onclick="testRVS()">Via RVS senden</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -337,8 +337,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="input-row" style="margin-top:8px;">
|
<div class="input-row" style="margin-top:8px;">
|
||||||
<textarea id="chat-input-fs" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" oninput="autoResizeTextarea(this)"></textarea>
|
<textarea id="chat-input-fs" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" oninput="autoResizeTextarea(this)"></textarea>
|
||||||
<button class="btn" onclick="testGatewayFS()">Gateway senden</button>
|
<button class="btn" onclick="testRVSFS()">Senden</button>
|
||||||
<button class="btn" onclick="testRVSFS()">Via RVS senden</button>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gedanken-Stream Modal — chronologisches Log was ARIA intern tut.
|
||||||
|
Zentrales Modal (max 720px breit), Liste mit Auto-Scroll ans Ende
|
||||||
|
wenn neue Eintraege reinkommen. -->
|
||||||
|
<div id="thought-stream-modal" style="display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.7);z-index:1100;align-items:center;justify-content:center;padding:24px;" onclick="if(event.target===this) closeThoughtStream();">
|
||||||
|
<div style="background:#0D0D1A;border:1px solid #1E1E2E;border-radius:12px;width:100%;max-width:720px;height:70vh;display:flex;flex-direction:column;">
|
||||||
|
<div style="display:flex;align-items:center;padding:14px;border-bottom:1px solid #1E1E2E;">
|
||||||
|
<h2 style="margin:0;color:#FFD60A;flex:1;font-size:16px;">💭 Gedanken-Stream <span id="thoughts-count-modal" style="color:#8888AA;font-weight:normal;"></span></h2>
|
||||||
|
<button class="btn secondary" onclick="clearThoughtStream()" id="btn-clear-thoughts" title="Stream leeren" style="padding:4px 10px;font-size:11px;color:#FF3B30;border-color:#FF3B30;margin-right:6px;">🗑 Leeren</button>
|
||||||
|
<button class="btn secondary" onclick="closeThoughtStream()" style="padding:4px 12px;">Schliessen</button>
|
||||||
|
</div>
|
||||||
|
<div id="thought-stream-list" style="flex:1;overflow-y:auto;padding:8px 0;font-size:13px;font-family:monospace;">
|
||||||
|
<!-- gefuellt durch renderThoughtStream() -->
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -350,7 +365,6 @@
|
|||||||
<div style="padding: 0 12px;">
|
<div style="padding: 0 12px;">
|
||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
<button class="tab-btn active" data-tab="all" onclick="switchTab('all')">Alle <span class="tab-count" id="count-all">0</span></button>
|
<button class="tab-btn active" data-tab="all" onclick="switchTab('all')">Alle <span class="tab-count" id="count-all">0</span></button>
|
||||||
<button class="tab-btn" data-tab="gateway" onclick="switchTab('gateway')">Gateway <span class="tab-count" id="count-gateway">0</span></button>
|
|
||||||
<button class="tab-btn" data-tab="rvs" onclick="switchTab('rvs')">RVS <span class="tab-count" id="count-rvs">0</span></button>
|
<button class="tab-btn" data-tab="rvs" onclick="switchTab('rvs')">RVS <span class="tab-count" id="count-rvs">0</span></button>
|
||||||
<button class="tab-btn" data-tab="proxy" onclick="switchTab('proxy')">Proxy <span class="tab-count" id="count-proxy">0</span></button>
|
<button class="tab-btn" data-tab="proxy" onclick="switchTab('proxy')">Proxy <span class="tab-count" id="count-proxy">0</span></button>
|
||||||
<button class="tab-btn" data-tab="bridge" onclick="switchTab('bridge')">Bridge <span class="tab-count" id="count-bridge">0</span></button>
|
<button class="tab-btn" data-tab="bridge" onclick="switchTab('bridge')">Bridge <span class="tab-count" id="count-bridge">0</span></button>
|
||||||
@@ -369,7 +383,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="log-box" id="log-all"></div>
|
<div class="log-box" id="log-all"></div>
|
||||||
<div class="log-box hidden" id="log-gateway"></div>
|
|
||||||
<div class="log-box hidden" id="log-rvs"></div>
|
<div class="log-box hidden" id="log-rvs"></div>
|
||||||
<div class="log-box hidden" id="log-proxy"></div>
|
<div class="log-box hidden" id="log-proxy"></div>
|
||||||
<div class="log-box hidden" id="log-bridge"></div>
|
<div class="log-box hidden" id="log-bridge"></div>
|
||||||
@@ -951,11 +964,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div><!-- /tab-triggers -->
|
</div><!-- /tab-triggers -->
|
||||||
|
|
||||||
<!-- Trigger-Create Modal -->
|
<!-- Trigger-Create/Edit Modal -->
|
||||||
<div class="modal-overlay" id="trigger-modal">
|
<div class="modal-overlay" id="trigger-modal">
|
||||||
<div class="modal-box" style="max-width:600px;">
|
<div class="modal-box" style="max-width:600px;">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>Neuer Trigger</h3>
|
<h3 id="trigger-modal-title">Neuer Trigger</h3>
|
||||||
<button class="modal-close" onclick="closeTriggerModal()">×</button>
|
<button class="modal-close" onclick="closeTriggerModal()">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body" style="padding:16px;">
|
<div class="modal-body" style="padding:16px;">
|
||||||
@@ -969,9 +982,17 @@
|
|||||||
|
|
||||||
<!-- Timer-spezifisch -->
|
<!-- Timer-spezifisch -->
|
||||||
<div id="trigger-timer-fields">
|
<div id="trigger-timer-fields">
|
||||||
|
<!-- 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>
|
<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;">
|
<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>
|
</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 -->
|
<!-- Watcher-spezifisch -->
|
||||||
<div id="trigger-watcher-fields" style="display:none;">
|
<div id="trigger-watcher-fields" style="display:none;">
|
||||||
@@ -991,7 +1012,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer" style="padding:10px 16px;border-top:1px solid #1E1E2E;display:flex;justify-content:flex-end;gap:8px;">
|
<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 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1093,13 +1114,12 @@
|
|||||||
const btnScroll = document.getElementById('btn-scroll');
|
const btnScroll = document.getElementById('btn-scroll');
|
||||||
let ws;
|
let ws;
|
||||||
let activeTab = 'all';
|
let activeTab = 'all';
|
||||||
const DOCKER_TABS = ['gateway', 'proxy', 'bridge'];
|
const DOCKER_TABS = ['proxy', 'bridge'];
|
||||||
const autoScroll = { all: true, gateway: true, rvs: true, proxy: true, bridge: true, server: true, trace: true };
|
const autoScroll = { all: true, rvs: true, proxy: true, bridge: true, server: true, trace: true };
|
||||||
const logCounts = { all: 0, gateway: 0, rvs: 0, proxy: 0, bridge: 0, server: 0, trace: 0 };
|
const logCounts = { all: 0, rvs: 0, proxy: 0, bridge: 0, server: 0, trace: 0 };
|
||||||
|
|
||||||
const logBoxes = {
|
const logBoxes = {
|
||||||
all: document.getElementById('log-all'),
|
all: document.getElementById('log-all'),
|
||||||
gateway: document.getElementById('log-gateway'),
|
|
||||||
rvs: document.getElementById('log-rvs'),
|
rvs: document.getElementById('log-rvs'),
|
||||||
proxy: document.getElementById('log-proxy'),
|
proxy: document.getElementById('log-proxy'),
|
||||||
bridge: document.getElementById('log-bridge'),
|
bridge: document.getElementById('log-bridge'),
|
||||||
@@ -1153,7 +1173,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapSourceToTab(source) {
|
function mapSourceToTab(source) {
|
||||||
if (source === 'gateway') return 'gateway';
|
// Gateway-Source: deprecated — falls noch was reinkommt zeigen wir's
|
||||||
|
// einfach unter 'server'. Spart einen toten Tab.
|
||||||
|
if (source === 'gateway') return 'server';
|
||||||
if (source === 'rvs') return 'rvs';
|
if (source === 'rvs') return 'rvs';
|
||||||
if (source === 'proxy') return 'proxy';
|
if (source === 'proxy') return 'proxy';
|
||||||
if (source === 'bridge') return 'bridge';
|
if (source === 'bridge') return 'bridge';
|
||||||
@@ -1595,18 +1617,6 @@
|
|||||||
renderDiagPending();
|
renderDiagPending();
|
||||||
}
|
}
|
||||||
|
|
||||||
function testGateway() {
|
|
||||||
const input = document.getElementById('chat-input');
|
|
||||||
const text = input.value.trim();
|
|
||||||
if (!text && diagPendingFiles.length === 0) return;
|
|
||||||
if (diagPendingFiles.length > 0) sendDiagAttachments();
|
|
||||||
if (text) {
|
|
||||||
addChat('sent', text, 'Gateway direkt');
|
|
||||||
send({ action: 'test_gateway', text });
|
|
||||||
}
|
|
||||||
input.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function testRVS() {
|
function testRVS() {
|
||||||
const input = document.getElementById('chat-input');
|
const input = document.getElementById('chat-input');
|
||||||
const text = input.value.trim();
|
const text = input.value.trim();
|
||||||
@@ -1746,7 +1756,6 @@
|
|||||||
if (proxy.models && proxy.models.length) showProxyModels(proxy.models);
|
if (proxy.models && proxy.models.length) showProxyModels(proxy.models);
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
document.getElementById('btn-gw').disabled = gw.status !== 'connected';
|
|
||||||
document.getElementById('btn-rvs').disabled = rvs.status !== 'connected';
|
document.getElementById('btn-rvs').disabled = rvs.status !== 'connected';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2069,14 +2078,6 @@
|
|||||||
modal.style.display = 'none';
|
modal.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function testGatewayFS() {
|
|
||||||
const input = document.getElementById('chat-input-fs');
|
|
||||||
const text = input.value.trim();
|
|
||||||
if (!text) return;
|
|
||||||
addChat('sent', text, 'Gateway direkt');
|
|
||||||
send({ action: 'test_gateway', text });
|
|
||||||
input.value = '';
|
|
||||||
}
|
|
||||||
function testRVSFS() {
|
function testRVSFS() {
|
||||||
const input = document.getElementById('chat-input-fs');
|
const input = document.getElementById('chat-input-fs');
|
||||||
const text = input.value.trim();
|
const text = input.value.trim();
|
||||||
@@ -2166,6 +2167,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateThinkingIndicator(msg) {
|
function updateThinkingIndicator(msg) {
|
||||||
|
// Gedanken-Stream fuettern — JEDES Event (auch idle als ✓ fertig)
|
||||||
|
pushThought(msg.activity || '', msg.tool || '');
|
||||||
|
|
||||||
const indicators = [
|
const indicators = [
|
||||||
document.getElementById('thinking-indicator'),
|
document.getElementById('thinking-indicator'),
|
||||||
document.getElementById('thinking-indicator-fs'),
|
document.getElementById('thinking-indicator-fs'),
|
||||||
@@ -2202,6 +2206,114 @@
|
|||||||
}, 120000);
|
}, 120000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Gedanken-Stream ─────────────────────────────
|
||||||
|
// Chronologisches Log von agent_activity-Events. Wird in localStorage
|
||||||
|
// persistiert (ueberlebt Page-Reload), capped auf MAX_THOUGHTS.
|
||||||
|
const THOUGHT_STORAGE_KEY = 'aria_thought_stream';
|
||||||
|
const MAX_THOUGHTS = 500;
|
||||||
|
let thoughtStream = [];
|
||||||
|
let lastThoughtKey = '';
|
||||||
|
let _thoughtSaveTimer = null;
|
||||||
|
|
||||||
|
function loadThoughtStream() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(THOUGHT_STORAGE_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed)) thoughtStream = parsed.slice(-MAX_THOUGHTS);
|
||||||
|
} catch {}
|
||||||
|
updateThoughtsBadge();
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistThoughtStream() {
|
||||||
|
if (_thoughtSaveTimer) clearTimeout(_thoughtSaveTimer);
|
||||||
|
_thoughtSaveTimer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (thoughtStream.length === 0) localStorage.removeItem(THOUGHT_STORAGE_KEY);
|
||||||
|
else localStorage.setItem(THOUGHT_STORAGE_KEY, JSON.stringify(thoughtStream.slice(-MAX_THOUGHTS)));
|
||||||
|
} catch {}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushThought(activity, tool) {
|
||||||
|
// Dedup gegen direkt aufeinanderfolgende identische Events. Tool-
|
||||||
|
// Events NIE dedupen — drei Bash-Calls in Folge sollen drei Eintraege
|
||||||
|
// ergeben, nicht einen.
|
||||||
|
const key = `${activity}|${tool || ''}`;
|
||||||
|
if (activity !== 'tool' && key === lastThoughtKey) return;
|
||||||
|
lastThoughtKey = key;
|
||||||
|
thoughtStream.push({ ts: Date.now(), activity, tool: tool || '' });
|
||||||
|
if (thoughtStream.length > MAX_THOUGHTS) thoughtStream = thoughtStream.slice(-MAX_THOUGHTS);
|
||||||
|
updateThoughtsBadge();
|
||||||
|
// Wenn das Modal offen ist: live nachrendern + ans Ende scrollen
|
||||||
|
const modal = document.getElementById('thought-stream-modal');
|
||||||
|
if (modal && modal.style.display !== 'none') renderThoughtStream(true);
|
||||||
|
persistThoughtStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateThoughtsBadge() {
|
||||||
|
const a = document.getElementById('thoughts-count');
|
||||||
|
if (a) a.textContent = thoughtStream.length ? `(${thoughtStream.length})` : '';
|
||||||
|
const b = document.getElementById('thoughts-count-modal');
|
||||||
|
if (b) b.textContent = thoughtStream.length ? `(${thoughtStream.length})` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openThoughtStream() {
|
||||||
|
const modal = document.getElementById('thought-stream-modal');
|
||||||
|
if (!modal) return;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
renderThoughtStream(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeThoughtStream() {
|
||||||
|
const modal = document.getElementById('thought-stream-modal');
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearThoughtStream() {
|
||||||
|
if (thoughtStream.length === 0) return;
|
||||||
|
if (!confirm(`Gedanken-Stream leeren? ${thoughtStream.length} Eintraege werden geloescht.`)) return;
|
||||||
|
thoughtStream = [];
|
||||||
|
lastThoughtKey = '';
|
||||||
|
updateThoughtsBadge();
|
||||||
|
renderThoughtStream(false);
|
||||||
|
persistThoughtStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderThoughtStream(autoscroll) {
|
||||||
|
const list = document.getElementById('thought-stream-list');
|
||||||
|
if (!list) return;
|
||||||
|
if (thoughtStream.length === 0) {
|
||||||
|
list.innerHTML = '<div style="padding:24px;text-align:center;color:#555570;font-style:italic;">Noch keine Gedanken aufgezeichnet.<br>Sobald ARIA was tut, taucht\'s hier auf.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = [];
|
||||||
|
let prevTs = 0;
|
||||||
|
for (const t of thoughtStream) {
|
||||||
|
const gapMin = prevTs ? Math.floor((t.ts - prevTs) / 60000) : 0;
|
||||||
|
if (gapMin >= 1) {
|
||||||
|
const label = gapMin < 60 ? `${gapMin} Min` : `${Math.floor(gapMin/60)}h ${gapMin%60}m`;
|
||||||
|
rows.push(`<div style="display:flex;align-items:center;padding:6px 16px;gap:8px;"><div style="flex:1;height:1px;background:#1E1E2E;"></div><span style="color:#555570;font-size:10px;">${label}</span><div style="flex:1;height:1px;background:#1E1E2E;"></div></div>`);
|
||||||
|
}
|
||||||
|
prevTs = t.ts;
|
||||||
|
const d = new Date(t.ts);
|
||||||
|
const time = `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`;
|
||||||
|
let icon, label, color;
|
||||||
|
if (t.activity === 'idle') { icon = '✓'; label = 'fertig'; color = '#34C759'; }
|
||||||
|
else if (t.activity === 'tool') { icon = '🔧'; label = t.tool || 'tool'; color = '#E0E0F0'; }
|
||||||
|
else if (t.activity === 'assistant'){ icon = '✍️'; label = 'schreibt'; color = '#E0E0F0'; }
|
||||||
|
else if (t.activity === 'thinking'){ icon = '💭'; label = 'denkt'; color = '#E0E0F0'; }
|
||||||
|
else { icon = '•'; label = t.activity; color = '#E0E0F0'; }
|
||||||
|
rows.push(`<div style="display:flex;padding:4px 16px;align-items:baseline;"><span style="color:#555570;width:78px;font-size:11px;">${time}</span><span style="width:24px;">${icon}</span><span style="color:${color};flex:1;">${_escapeHtml(label)}</span></div>`);
|
||||||
|
}
|
||||||
|
list.innerHTML = rows.join('');
|
||||||
|
if (autoscroll) list.scrollTop = list.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
// ── XTTS Panel ─────────────────────────────
|
// ── XTTS Panel ─────────────────────────────
|
||||||
function renderVoiceList(voices) {
|
function renderVoiceList(voices) {
|
||||||
const box = document.getElementById('xtts-voice-list');
|
const box = document.getElementById('xtts-voice-list');
|
||||||
@@ -2973,6 +3085,7 @@
|
|||||||
<div style="color:#8888AA;font-size:11px;margin-top:4px;">${detailLine}</div>
|
<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="color:#888;font-size:12px;margin-top:2px;">"${escapeHtml(t.message || '')}"</div>
|
||||||
<div style="margin-top:6px;display:flex;gap:6px;">
|
<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="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>
|
<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>
|
</div>
|
||||||
@@ -3010,10 +3123,21 @@
|
|||||||
document.getElementById('trigger-watcher-fields').style.display = t === 'watcher' ? '' : 'none';
|
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() {
|
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-type').value = 'timer';
|
||||||
document.getElementById('trigger-name').value = '';
|
document.getElementById('trigger-name').value = '';
|
||||||
document.getElementById('trigger-timer-minutes').value = '10';
|
document.getElementById('trigger-timer-minutes').value = '10';
|
||||||
|
document.getElementById('trigger-timer-fires-at').value = '';
|
||||||
document.getElementById('trigger-condition').value = '';
|
document.getElementById('trigger-condition').value = '';
|
||||||
document.getElementById('trigger-check-interval').value = '300';
|
document.getElementById('trigger-check-interval').value = '300';
|
||||||
document.getElementById('trigger-throttle').value = '3600';
|
document.getElementById('trigger-throttle').value = '3600';
|
||||||
@@ -3042,6 +3166,52 @@
|
|||||||
|
|
||||||
function closeTriggerModal() {
|
function closeTriggerModal() {
|
||||||
document.getElementById('trigger-modal').classList.remove('open');
|
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() {
|
async function saveTrigger() {
|
||||||
@@ -3053,6 +3223,33 @@
|
|||||||
if (!name) { errEl.textContent = 'Name fehlt.'; errEl.style.display = 'block'; return; }
|
if (!name) { errEl.textContent = 'Name fehlt.'; errEl.style.display = 'block'; return; }
|
||||||
if (!message) { errEl.textContent = 'Nachricht fehlt.'; errEl.style.display = 'block'; return; }
|
if (!message) { errEl.textContent = 'Nachricht fehlt.'; errEl.style.display = 'block'; return; }
|
||||||
try {
|
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;
|
let url, body;
|
||||||
if (ttype === 'timer') {
|
if (ttype === 'timer') {
|
||||||
const mins = parseInt(document.getElementById('trigger-timer-minutes').value, 10) || 10;
|
const mins = parseInt(document.getElementById('trigger-timer-minutes').value, 10) || 10;
|
||||||
@@ -4696,6 +4893,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadThoughtStream();
|
||||||
connectWS();
|
connectWS();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+17
-15
@@ -492,9 +492,10 @@ function handleGatewayMessage(msg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendToGateway(text, isTrace) {
|
function sendToGateway(text, isTrace) {
|
||||||
|
// OpenClaw-Gateway ist raus — Brain via Bridge via RVS ist die einzige
|
||||||
|
// Route. Wir loggen nichts mehr; alte Trace-Aufrufe schliessen wir clean.
|
||||||
if (!gatewayWs || gatewayWs.readyState !== WebSocket.OPEN) {
|
if (!gatewayWs || gatewayWs.readyState !== WebSocket.OPEN) {
|
||||||
log("error", "gateway", "Nicht verbunden — kann nicht senden");
|
if (isTrace) traceEnd(false, "Gateway deprecated — nutze RVS");
|
||||||
if (isTrace) traceEnd(false, "Gateway nicht verbunden");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,22 +758,20 @@ function sendToRVS_raw(msgObj) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendToRVS(text, isTrace) {
|
function sendToRVS(text, isTrace) {
|
||||||
// Ueber Gateway senden (zuverlaessig) UND an RVS fuer App-Sichtbarkeit
|
// Brain-Pipeline: Diagnostic → RVS → Bridge → Brain (HTTP). OpenClaw-
|
||||||
// Die Bridge empfaengt RVS-Nachrichten von der App zuverlaessig,
|
// Gateway-Pfad ist abgeschaltet. Sender 'diagnostic' damit die Bridge
|
||||||
// aber die Diagnostic→RVS→Bridge Route hat Zombie-Probleme.
|
// den Text als User-Nachricht ans Brain weiterleitet und die App +
|
||||||
// Deshalb: Gateway fuer ARIA, RVS nur fuer App-Anzeige.
|
// Diagnostic die Bubble live spiegeln koennen.
|
||||||
|
if (!rvsWs || rvsWs.readyState !== WebSocket.OPEN) {
|
||||||
// 1. An Gateway senden (damit ARIA antwortet)
|
if (isTrace) traceEnd(false, "RVS nicht verbunden");
|
||||||
const gatewayOk = sendToGateway(text, isTrace);
|
return false;
|
||||||
|
}
|
||||||
// 2. An RVS senden (damit die App die Nachricht sieht)
|
|
||||||
sendToRVS_raw({
|
sendToRVS_raw({
|
||||||
type: "chat",
|
type: "chat",
|
||||||
payload: { text, sender: "diagnostic" },
|
payload: { text, sender: "diagnostic" },
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
return gatewayOk;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Claude Proxy Test ────────────────────────────────────
|
// ── Claude Proxy Test ────────────────────────────────────
|
||||||
@@ -1836,8 +1835,11 @@ wss.on("connection", (ws) => {
|
|||||||
const msg = JSON.parse(raw.toString());
|
const msg = JSON.parse(raw.toString());
|
||||||
|
|
||||||
if (msg.action === "test_gateway") {
|
if (msg.action === "test_gateway") {
|
||||||
traceStart("Gateway", msg.text || "aria lebst du noch?");
|
// Deprecated — Gateway-Pfad ist raus. Wir leiten an RVS um damit
|
||||||
sendToGateway(msg.text || "aria lebst du noch?", true);
|
// alte Browser-Sessions die noch den Button anzeigen nicht stumm
|
||||||
|
// ins Leere klicken. Neue Versionen kennen den Button nicht mehr.
|
||||||
|
traceStart("RVS", msg.text || "aria lebst du noch?");
|
||||||
|
sendToRVS(msg.text || "aria lebst du noch?", true);
|
||||||
} else if (msg.action === "test_rvs") {
|
} else if (msg.action === "test_rvs") {
|
||||||
traceStart("RVS", msg.text || "aria lebst du noch?");
|
traceStart("RVS", msg.text || "aria lebst du noch?");
|
||||||
sendToRVS(msg.text || "aria lebst du noch?", true);
|
sendToRVS(msg.text || "aria lebst du noch?", true);
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ services:
|
|||||||
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
|
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
|
||||||
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
|
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
|
||||||
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
|
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
|
||||||
|
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 1200000;/' $$DIST/subprocess/manager.js &&
|
||||||
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
|
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
|
||||||
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
|
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
|
||||||
|
cp /proxy-patches/routes.js $$DIST/server/routes.js &&
|
||||||
claude-max-api"
|
claude-max-api"
|
||||||
volumes:
|
volumes:
|
||||||
- ~/.claude:/root/.claude # Claude CLI Auth (Credentials in /root/.claude/.credentials.json)
|
- ~/.claude:/root/.claude # Claude CLI Auth (Credentials in /root/.claude/.credentials.json)
|
||||||
|
|||||||
@@ -297,6 +297,23 @@ Skills mit Tool-Use.
|
|||||||
- [x] **Gehirn-Kategorien standardmaessig eingeklappt**: Beim ersten Aufruf alle Type-Sections collapsed, Stefan klappt gezielt auf was er sehen will. State persistiert in localStorage
|
- [x] **Gehirn-Kategorien standardmaessig eingeklappt**: Beim ersten Aufruf alle Type-Sections collapsed, Stefan klappt gezielt auf was er sehen will. State persistiert in localStorage
|
||||||
- [x] **Klappbare Type-Header + Category-AutoSuggest + Info-Modal**: Type-Header (▼/▶) klappbar, Category-Feld im Neu/Edit-Modal mit `<datalist>`-Vorschlaegen aller existierenden Categories, ℹ-Button-Modal erklaert welche Types FEST im System-Prompt vs. Cold Memory sind
|
- [x] **Klappbare Type-Header + Category-AutoSuggest + Info-Modal**: Type-Header (▼/▶) klappbar, Category-Feld im Neu/Edit-Modal mit `<datalist>`-Vorschlaegen aller existierenden Categories, ℹ-Button-Modal erklaert welche Types FEST im System-Prompt vs. Cold Memory sind
|
||||||
|
|
||||||
|
### GPS-Trigger-Verbesserungen (entered_near + left_near + Timing-Fix)
|
||||||
|
|
||||||
|
- [x] **near() bei Auto-Vorbeifahrten verpasst — gefixt**: Background-Loop tickte alle 30s, Vorbeifahrt durch 300m-Radius bei 50-120 km/h dauert nur 18-43s → Tick konnte komplett dazwischen liegen. Fix: `TICK_SEC` 30 → 8 (Loop ist billig, Brain merkt das nicht). Plus event-getrieben: Bridge ruft nach jedem `location_update` ein POST `/triggers/check-now` im Brain → Watcher sehen die frische Position in Millisekunden statt im Polling-Takt. Polling läuft parallel als Fallback für Watcher ohne GPS-Bezug
|
||||||
|
- [x] **near() Age-Schutz**: GPS-Daten älter als 5 Minuten (`NEAR_MAX_AGE_SEC=300`) gelten als veraltet → `near()` liefert False. Vorher hätte ein wochen-alter Wert die Funktion weiter als „in der Nähe" eingeordnet → Phantom-Fires wenn Tracking aus war
|
||||||
|
- [x] **Drei GPS-Modi statt einem**: `near()` bleibt = „solange drin". Neu: **`entered_near(lat, lon, r)`** feuert NUR beim Übergang außen→innen (Blitzer-Warner mit r=2000 = 2 km Vorwarnung, Ankunft mit r=100), **`left_near(lat, lon, r)`** feuert NUR beim Übergang innen→außen („Hast du am Parkplatz was vergessen?"). State-Tracking pro Trigger pro near-Aufruf (`near_states`-Dict im Manifest) — Background-Loop schreibt den letzten Auswertungswert immer zurück, damit beim nächsten Tick die Übergangs-Erkennung greift. ARIA's `trigger_watcher`-Tool-Description erklärt die drei Modi inkl. empfohlener Throttle-Werte (kurz für entered/left, lang für near)
|
||||||
|
|
||||||
|
### App-Memory-Editor + Crash-Reporting
|
||||||
|
|
||||||
|
- [x] **Bubble-Header dynamic** (created/updated/deleted): Die `🧠`-Bubble zeigt jetzt was passiert ist — "ARIA hat etwas gemerkt" / "Notiz geändert" / "Notiz gelöscht" (rot bei delete). Brain-Tools schicken `action`-Feld im memory_saved-Event mit
|
||||||
|
- [x] **Tap auf Memory-Bubble → Detail-Modal**: Komponente `MemoryDetailModal` zeigt alle Felder (Titel, Type, Category, Tags, voller Content, Anhang-Vorschau mit Thumbnails). Stift-Icon wechselt in Edit-Mode mit Form-Feldern + 📌 Pinned-Toggle. **Anhänge hoch-/runterladen + löschen** im Modal (DocumentPicker, multipart-Upload via RVS-Brain-Proxy). Memory komplett löschen mit Confirm
|
||||||
|
- [x] **Notizen-Inbox-Button (`🗂️`)** neben der Lupe in der Status-Leiste: Vollbild-Modal mit zwei Sections — „Aus diesem Chat" (kompakte Liste der Spezial-Bubbles aus dem aktuellen Verlauf, klickbar) + „Alle Memories aus der DB" mit dem `MemoryBrowser`. Spezial-Bubbles (memorySaved/triggerCreated/skillCreated) werden im Chat-Stream gefiltert (statt unten zu kleben)
|
||||||
|
- [x] **Memory-Editor in App-Settings**: neue Sektion 🧠 „Gedächtnis" in den App-Einstellungen. Komplette CRUD-UI mit Wortlich-Suche, Type-Dropdown, Pinned/Cold-Filter, „+ Neu" anlegen. Selbe `MemoryBrowser`-Komponente wie in der Inbox
|
||||||
|
- [x] **RVS-Brain-Proxy als Fundament**: Bridge implementiert generischen `brain_request` / `brain_response`-Channel — die App kann beliebige Brain-HTTP-Endpoints via RVS adressieren (GET/POST/PATCH/DELETE, JSON+Base64-Body, base64-encoded Binär-Antworten). `services/brainApi.ts` als Promise-basierter Client mit Request-ID-Routing, Timeout, automatischem Listener-Setup
|
||||||
|
- [x] **App-Crash-Reporting via RVS**: ErrorBoundary-Komponente fängt React-Render-Fehler, `installGlobalCrashReporter` haengt sich an `ErrorUtils.setGlobalHandler` + `HermesInternal.enablePromiseRejectionTracker`. Crashes wandern als `app_log`-Event durch RVS, Bridge schreibt JSONL in `/shared/logs/app.log`. Diagnostic-Server liefert GET `/api/app-log[?limit=N]` + POST `/api/app-log/clear`. **`tools/fetch-app-logs.sh`** holt die Logs auf die Dev-Maschine (über `ARIA_DIAG_URL` aus `.claude/aria-vm.env`), speichert in `.aria-debug/` (gitignored), zeigt Stack-Trace kompakt auf stdout
|
||||||
|
- [x] **`memory_search` + `memory_update` Tools**: ARIA kann die DB jetzt aktiv durchsuchen (Volltext/Semantic) und existierende Einträge per ID patchen statt fragmentierende neue anzulegen. Tool-Description sagt explizit „Memory ist Truth über Conversation-Window" — wenn der User korrigiert hat, gilt das was im Memory steht. Wichtig nach Diagnostic-Edits damit ARIA die neue Wahrheit sieht statt aus dem Window zu raten
|
||||||
|
- [x] **App-Bugfixes**: (a) URLSearchParams crasht in Hermes — durch Mini-Query-Builder ersetzt (`brainApi._qs()`). (b) Cache leer + Datei-Tap → Auto-Re-Download via file_request statt Toast-Sackgasse, plus State-Cleanup (uri/localUri auf undefined). (c) Memory-Liste in Settings scrollt jetzt (nestedScrollEnabled auf FlatList + äußere ScrollView). (d) Modal-im-Modal auf Android gefixt — MemoryBrowser nimmt optionalen `onOpenMemory`-Callback, kein verschachteltes DetailModal mehr. (e) Alert.prompt (iOS-only) durch eigenes Text-Input-Modal ersetzt fuer „Neue Memory anlegen"
|
||||||
|
|
||||||
### Memory-Anhaenge mit Vision (Stufe A-E + attach_paths)
|
### Memory-Anhaenge mit Vision (Stufe A-E + attach_paths)
|
||||||
|
|
||||||
- [x] **Anhaenge an Memory-Eintraege** — Bilder/PDFs/beliebige Dateien koennen an jede Memory gehaengt werden, liegen physisch unter `/shared/memory-attachments/<memory-id>/`. Cleanup beim Memory-Delete automatisch. Limit 20 MB pro Datei
|
- [x] **Anhaenge an Memory-Eintraege** — Bilder/PDFs/beliebige Dateien koennen an jede Memory gehaengt werden, liegen physisch unter `/shared/memory-attachments/<memory-id>/`. Cleanup beim Memory-Delete automatisch. Limit 20 MB pro Datei
|
||||||
@@ -324,14 +341,48 @@ Skills mit Tool-Use.
|
|||||||
- [x] Info-Buttons mit Modal-Erklaerungen im Gehirn-Tab
|
- [x] Info-Buttons mit Modal-Erklaerungen im Gehirn-Tab
|
||||||
- [x] Token/Call-Metrics + Subscription-Quota-Tracking: pro Claude-Call ein Log-Eintrag mit Token-Schaetzung (chars/4). Gehirn-Tab zeigt 1h/5h/24h/30d-Aggregat + Progress-Bar gegen Plan-Limit (Pro=45/5h, Max 5x=225/5h, Max 20x=900/5h, Custom). Warn-Schwelle 80%, kritisch 90%.
|
- [x] Token/Call-Metrics + Subscription-Quota-Tracking: pro Claude-Call ein Log-Eintrag mit Token-Schaetzung (chars/4). Gehirn-Tab zeigt 1h/5h/24h/30d-Aggregat + Progress-Bar gegen Plan-Limit (Pro=45/5h, Max 5x=225/5h, Max 20x=900/5h, Custom). Warn-Schwelle 80%, kritisch 90%.
|
||||||
|
|
||||||
|
### Chat-Stabilitaet: Such-Scroll, Stuck-Watchdog, Delivery-Handshake
|
||||||
|
|
||||||
|
- [x] **Such-Scroll springt nicht mehr permanent**: `onScrollToIndexFailed` hatte 3 cascading `setTimeout`s (120/320/600 ms) — jeder failed Retry triggerte den Handler wieder → 3, 9, 27 Scrolls in der Pipeline. Plus `invertedMessages` war in den useEffect-Deps: jede neue ARIA-Nachricht re-triggerte den Such-Scroll. Fix: nur EIN Retry nach 300 ms, in einer Ref-getrackten Timer-Variable; bei neuem Such-Hit wird der pending Retry gecancelt. `invertedMessages`-Snapshot via Ref statt Dep
|
||||||
|
- [x] **Jump-to-Bottom-Button** rechts unten in der Chat-Liste — taucht ab ~250 px Scroll-Weg auf, scrollt zur neuesten Nachricht (bei inverted FlatList `scrollToOffset(0)`)
|
||||||
|
- [x] **AsyncStorage-Init-Race**: zwischen Mount und „Verlauf aus AsyncStorage geladen" konnte eine User-Nachricht oder ein WS-Event ankommen — `setMessages(parsed)` ueberschrieb's mit dem alten Stand und die frische Nachricht war spurlos weg. Fix: Merge per `id` (frischere `prev`-Eintraege schlagen Gespeichertes), sortiert nach `timestamp`. `messageIdCounter` wird nur noch erhoeht, nie zurueckgesetzt
|
||||||
|
- [x] **Stuck-Thinking-Watchdog**: „ARIA denkt..." blieb gelegentlich kleben (Brain-Crash, WS-Disconnect ohne idle-Event, Cancel mit Race). Fix: jeder `agent_activity != idle` armiert einen 180s-Timer; ohne neues Lebenszeichen geht's auto-idle + Bubble „⚠ Habe gerade keine Verbindung zurueck bekommen". Watchdog wird beim ARIA-Reply, beim Cancel/Barge-In und beim Screen-Unmount gecleart
|
||||||
|
- [x] **Delivery-Handshake (WhatsApp-Style)**: pro User-Bubble ein lokaler `clientMsgId` + `deliveryStatus` (queued/sending/sent/delivered/failed). Bridge sendet `chat_ack` zurueck (✓ sent) und schreibt die ID ins `chat_backup.jsonl`. ARIA-Reply markiert alle vorigen User-Bubbles als delivered (✓✓). LRU-Idempotenz auf der Bridge (200 cmids) verhindert Doppelte beim Retry. Offline-Queue: Nachrichten im Flugmodus bleiben lokal als ⏱-queued, beim Reconnect feuert `flushQueuedMessages`. ACK-Timeout 30 s, bis zu 3 Retries, danach ⚠ + Tap-fuer-Retry
|
||||||
|
- [x] **Offline-Bubble verschwand nach Reconnect (Race)**: parallel laufen `chat_history_request` und `flushQueuedMessages` beim Reconnect; die History-Antwort kam an bevor die Bridge die Bubble persistiert hatte → Merge ersetzte den lokalen Stand → Bubble weg (war aber in Diagnostic drin). Fix: Bridge spiegelt `clientMsgId` im `chat_backup.jsonl`, App-Merge dedupt per cmid und behaelt lokale Bubbles deren ID der Server noch nicht kennt
|
||||||
|
- [x] **Doppel-Bubble nach Retry**: Backup-Eintraege von vor dem cmid-Patch hatten keine `clientMsgId` — Server-Bubble (ohne cmid) und lokale failed-Bubble (mit cmid) standen beide im Merge. Plus ACK-Timer lief gelegentlich weiter obwohl die Bubble schon `delivered` war → Retry pushte den Status zurueck auf `sending`. Fix: Merge faellt zusaetzlich auf `text+timestamp`-Heuristik im 5-Min-Fenster zurueck; `dispatchWithAck` prueft per Ref ob die Bubble inzwischen `delivered` ist und cancelt dann; bei ARIA-Reply werden alle laufenden ACK-Timer gecleart
|
||||||
|
- [x] **chat_backup ts war Container-Uptime statt UNIX-ms**: `_append_chat_backup` nutzte `asyncio.get_event_loop().time()` (Monotonic, bei jedem Restart wieder 0) statt `time.time()`. Folge: Server-Bubbles mit ts wie 394M (6 min Uptime) wurden in der App-History neben App-side Bubbles mit Date.now() (1.778e12) sortiert — Hello-Kitty-Konversation von gestern landete chronologisch nach heutigen Karten-Routen, neue Nachrichten verschwanden unter dem 500er-Cap. Plus: Doppelpost-Schutz griff nicht weil das 5-Min-ts-Fenster bei 1.7 Bio ms Diff nie zutraf. Fix: Bridge schreibt jetzt UNIX-ms, Migration-Script `tools/migrate_chat_backup_ts.py` repariert vorhandene jsonl (284/299 ts umgeschrieben auf der VM, Datei-Reihenfolge bleibt). App-Merge dedupt zusaetzlich per blossem Text-Match (ohne ts-Diff) — schuetzt auch gegen vorhandene lokale Duplikate
|
||||||
|
- [x] **User-Bubble ⏳→failed bei langsamen ARIA-Antworten**: ACK-Timer (30 s × 3 Retries) lief durch obwohl Brain laengst arbeitete — wenn `chat_ack` aus irgendwelchen Gruenden nicht durchkam (RVS-Frame verloren etc.), wurde die Bubble nach 90 s auf failed gesetzt obwohl die Antwort gleich danach kam. Fix: jedes `agent_activity != idle`-Event ist impliziter ACK — Brain wuerde nicht arbeiten wenn es die Nachricht nicht haette. Beim ersten non-idle Event werden alle laufenden ACK-Timer gecanceled und sending-Bubbles auf 'sent' gesetzt. ACK_TIMEOUT_MS zusaetzlich von 30 s auf 60 s als Backup
|
||||||
|
- [x] **Gedanken-Stream Modal scrollte nicht**: innerer `TouchableOpacity` (eigentlich nur fuer close-on-tap-outside-Schutz) hat alle Touch-Events konsumiert. Fix: durch `View` mit `onStartShouldSetResponder={true}` + `onResponderTerminationRequest={false}` ersetzt — blockt Tap-Propagation ohne Scrolls der Children zu verschlucken
|
||||||
|
|
||||||
|
### Brain-Hang: Multi-Tool-Timeouts + RVS-Block + Skill-Aggressivitaet
|
||||||
|
|
||||||
|
- [x] **Skill-Erstellung aggressiver als gewollt**: Prompt sagte „Harte Regel — IMMER Skill anlegen wenn pip-Library noetig". ARIA hat das wortwoertlich genommen und bei einer simplen pdf-extract-Frage sofort `skill_create` aufgerufen → Brain 12 Min blockiert (venv 2 min + pip install 10 min Timeout in `skills.py`). App zeigt „ARIA denkt", Bridge emitted nach 5 Min Timeout idle, User ohne Antwort. Fix in `prompts.py`: „Goldene Regel: NIE ungefragt Skills anlegen" + nur bei expliziter Anfrage („mach daraus einen Skill") und auch dann nur wenn die 4 Kriterien (wiederkehrend / nicht-trivial / parametrisierbar / wiederverwendbar) zutreffen. Greift auf der VM nach `docker compose restart aria-brain` ohne Re-Build
|
||||||
|
- [x] **Brain-Timeouts 5 Min → 20 Min**: drei verkettete 5-Min-Timeouts (Bridge `urlopen`, Brain `proxy_client`, Proxy `DEFAULT_TIMEOUT` im claude-max-api-proxy npm-Modul) feuerten exakt gleichzeitig. Live in den Logs nachvollzogen: ein Proxy-Call brauchte 4m51s und wurde von der Bridge auf den Sekundenbruchteil genau gekappt. Aufgabenstellungen wie Karten-Rekonstruktion mit 10+ curl-Calls oder PDF-Verarbeitung brauchen aber locker 8–15 Min. Fix: alle drei Timeouts auf 1200 s, plus dritter sed-Patch im docker-compose proxy-Service (`DEFAULT_TIMEOUT = 300000 → 1200000`). App-Stuck-Watchdog auf 1260 s (21 Min, knapp drueber)
|
||||||
|
- [x] **RVS-Block waehrend Brain-Call** (mobil.hacker-net.de:444 droppt nach 4 Min idle): `async for raw_message in ws: await _handle_rvs_message(...)` — das await blockierte den recv-Loop solange `send_to_core` lief. Die websockets-Lib beantwortete Pings im Hintergrund, aber der RVS-Server zaehlt nur echte App-Frames und droppt sonst die Verbindung. Symptom: App+Diagnostic zeigten „abgebrochen" obwohl Brain noch arbeitete. Fix: `send_to_core` als `asyncio.create_task` statt `await` — RVS-recv-Loop bleibt frei, neue Messages werden weiter verarbeitet, Verbindung bleibt lebendig
|
||||||
|
|
||||||
|
### Gedanken-Stream + Live-Tool-Events
|
||||||
|
|
||||||
|
- [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
|
## Offen
|
||||||
|
|
||||||
### App Features
|
### App Features
|
||||||
- [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition)
|
|
||||||
- [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild)
|
- [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild)
|
||||||
|
|
||||||
### Architektur
|
### Architektur
|
||||||
- [ ] Bilder: Claude Vision direkt nutzen (aktuell nur Dateipfad an ARIA)
|
|
||||||
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
|
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
|
||||||
- [ ] RVS Zombie-Connections endgueltig loesen
|
- [ ] RVS Zombie-Connections endgueltig loesen
|
||||||
- [ ] Gamebox: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push
|
- [ ] Gamebox: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
/**
|
||||||
|
* ARIA-patched API Route Handlers
|
||||||
|
*
|
||||||
|
* Erweiterung der npm-Version von claude-max-api-proxy:
|
||||||
|
* - Bei jedem Claude-CLI-`assistant`-Event mit tool_use-Block (Bash, Read,
|
||||||
|
* Edit, Grep, …) wird ein HTTP-POST an die Bridge gefeuert
|
||||||
|
* (ARIA_TOOL_HOOK_URL, default http://aria-bridge:8090/internal/agent-activity).
|
||||||
|
* Bridge spiegelt das als RVS `agent_activity` an App+Diagnostic →
|
||||||
|
* Gedanken-Stream zeigt live was ARIA gerade tool-maessig macht.
|
||||||
|
* - Fire-and-forget, fail-open. Wenn die Bridge nicht antwortet, bricht
|
||||||
|
* der Brain-Call NICHT ab.
|
||||||
|
*
|
||||||
|
* Wird zur Container-Startzeit ueber die npm-Version geschrieben
|
||||||
|
* (siehe docker-compose.yml proxy-Block).
|
||||||
|
*/
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import http from "http";
|
||||||
|
import { ClaudeSubprocess } from "../subprocess/manager.js";
|
||||||
|
import { openaiToCli } from "../adapter/openai-to-cli.js";
|
||||||
|
import { cliResultToOpenai, createDoneChunk, } from "../adapter/cli-to-openai.js";
|
||||||
|
|
||||||
|
const TOOL_HOOK_URL = process.env.ARIA_TOOL_HOOK_URL
|
||||||
|
|| "http://aria-bridge:8090/internal/agent-activity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pusht einen Tool-Use-Event an die Bridge. Fire-and-forget — keine Awaits,
|
||||||
|
* keine Fehler nach oben. Logged Fehler still.
|
||||||
|
*/
|
||||||
|
function _emitToolEvent(toolName) {
|
||||||
|
if (!toolName) return;
|
||||||
|
try {
|
||||||
|
const u = new URL(TOOL_HOOK_URL);
|
||||||
|
const body = JSON.stringify({ tool: String(toolName) });
|
||||||
|
const req = http.request({
|
||||||
|
method: "POST",
|
||||||
|
hostname: u.hostname,
|
||||||
|
port: u.port || 80,
|
||||||
|
path: u.pathname,
|
||||||
|
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
|
||||||
|
timeout: 2000,
|
||||||
|
}, (res) => { res.resume(); });
|
||||||
|
req.on("error", () => {});
|
||||||
|
req.on("timeout", () => req.destroy());
|
||||||
|
req.write(body);
|
||||||
|
req.end();
|
||||||
|
} catch (_) { /* niemals weiterwerfen */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hookt die `assistant`-Events des Subprozesses. Jedes assistant-Message
|
||||||
|
* kann mehrere content-Bloecke haben — tool_use-Bloecke pushen wir live.
|
||||||
|
*/
|
||||||
|
function _attachToolHook(subprocess) {
|
||||||
|
subprocess.on("assistant", (message) => {
|
||||||
|
try {
|
||||||
|
const blocks = message?.message?.content || [];
|
||||||
|
for (const b of blocks) {
|
||||||
|
if (b && b.type === "tool_use" && b.name) {
|
||||||
|
_emitToolEvent(b.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) { /* fail-open */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Handle POST /v1/chat/completions
|
||||||
|
*
|
||||||
|
* Main endpoint for chat requests, supports both streaming and non-streaming
|
||||||
|
*/
|
||||||
|
export async function handleChatCompletions(req, res) {
|
||||||
|
const requestId = uuidv4().replace(/-/g, "").slice(0, 24);
|
||||||
|
const body = req.body;
|
||||||
|
const stream = body.stream === true;
|
||||||
|
try {
|
||||||
|
// Validate request
|
||||||
|
if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: {
|
||||||
|
message: "messages is required and must be a non-empty array",
|
||||||
|
type: "invalid_request_error",
|
||||||
|
code: "invalid_messages",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Convert to CLI input format
|
||||||
|
const cliInput = openaiToCli(body);
|
||||||
|
const subprocess = new ClaudeSubprocess();
|
||||||
|
// ARIA-Patch: Tool-Use-Events live an die Bridge weiterleiten.
|
||||||
|
// Greift fuer beide Branches (stream + non-stream).
|
||||||
|
_attachToolHook(subprocess);
|
||||||
|
if (stream) {
|
||||||
|
await handleStreamingResponse(req, res, subprocess, cliInput, requestId);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await handleNonStreamingResponse(res, subprocess, cliInput, requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
console.error("[handleChatCompletions] Error:", message);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
type: "server_error",
|
||||||
|
code: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Handle streaming response (SSE)
|
||||||
|
*
|
||||||
|
* IMPORTANT: The Express req.on("close") event fires when the request body
|
||||||
|
* is fully received, NOT when the client disconnects. For SSE connections,
|
||||||
|
* we use res.on("close") to detect actual client disconnection.
|
||||||
|
*/
|
||||||
|
async function handleStreamingResponse(req, res, subprocess, cliInput, requestId) {
|
||||||
|
// Set SSE headers
|
||||||
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
|
res.setHeader("Connection", "keep-alive");
|
||||||
|
res.setHeader("X-Request-Id", requestId);
|
||||||
|
// CRITICAL: Flush headers immediately to establish SSE connection
|
||||||
|
// Without this, headers are buffered and client times out waiting
|
||||||
|
res.flushHeaders();
|
||||||
|
// Send initial comment to confirm connection is alive
|
||||||
|
res.write(":ok\n\n");
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let isFirst = true;
|
||||||
|
let lastModel = "claude-sonnet-4";
|
||||||
|
let isComplete = false;
|
||||||
|
// Handle actual client disconnect (response stream closed)
|
||||||
|
res.on("close", () => {
|
||||||
|
if (!isComplete) {
|
||||||
|
// Client disconnected before response completed - kill subprocess
|
||||||
|
subprocess.kill();
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
// Handle streaming content deltas
|
||||||
|
subprocess.on("content_delta", (event) => {
|
||||||
|
const text = event.event.delta?.text || "";
|
||||||
|
if (text && !res.writableEnded) {
|
||||||
|
const chunk = {
|
||||||
|
id: `chatcmpl-${requestId}`,
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: lastModel,
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
delta: {
|
||||||
|
role: isFirst ? "assistant" : undefined,
|
||||||
|
content: text,
|
||||||
|
},
|
||||||
|
finish_reason: null,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||||
|
isFirst = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Handle final assistant message (for model name)
|
||||||
|
subprocess.on("assistant", (message) => {
|
||||||
|
lastModel = message.message.model;
|
||||||
|
});
|
||||||
|
subprocess.on("result", (_result) => {
|
||||||
|
isComplete = true;
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
// Send final done chunk with finish_reason
|
||||||
|
const doneChunk = createDoneChunk(requestId, lastModel);
|
||||||
|
res.write(`data: ${JSON.stringify(doneChunk)}\n\n`);
|
||||||
|
res.write("data: [DONE]\n\n");
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
subprocess.on("error", (error) => {
|
||||||
|
console.error("[Streaming] Error:", error.message);
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
res.write(`data: ${JSON.stringify({
|
||||||
|
error: { message: error.message, type: "server_error", code: null },
|
||||||
|
})}\n\n`);
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
subprocess.on("close", (code) => {
|
||||||
|
// Subprocess exited - ensure response is closed
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
if (code !== 0 && !isComplete) {
|
||||||
|
// Abnormal exit without result - send error
|
||||||
|
res.write(`data: ${JSON.stringify({
|
||||||
|
error: { message: `Process exited with code ${code}`, type: "server_error", code: null },
|
||||||
|
})}\n\n`);
|
||||||
|
}
|
||||||
|
res.write("data: [DONE]\n\n");
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
// Start the subprocess
|
||||||
|
subprocess.start(cliInput.prompt, {
|
||||||
|
model: cliInput.model,
|
||||||
|
sessionId: cliInput.sessionId,
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error("[Streaming] Subprocess start error:", err);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Handle non-streaming response
|
||||||
|
*/
|
||||||
|
async function handleNonStreamingResponse(res, subprocess, cliInput, requestId) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let finalResult = null;
|
||||||
|
subprocess.on("result", (result) => {
|
||||||
|
finalResult = result;
|
||||||
|
});
|
||||||
|
subprocess.on("error", (error) => {
|
||||||
|
console.error("[NonStreaming] Error:", error.message);
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: error.message,
|
||||||
|
type: "server_error",
|
||||||
|
code: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
subprocess.on("close", (code) => {
|
||||||
|
if (finalResult) {
|
||||||
|
res.json(cliResultToOpenai(finalResult, requestId));
|
||||||
|
}
|
||||||
|
else if (!res.headersSent) {
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: `Claude CLI exited with code ${code} without response`,
|
||||||
|
type: "server_error",
|
||||||
|
code: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
// Start the subprocess
|
||||||
|
subprocess
|
||||||
|
.start(cliInput.prompt, {
|
||||||
|
model: cliInput.model,
|
||||||
|
sessionId: cliInput.sessionId,
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: error.message,
|
||||||
|
type: "server_error",
|
||||||
|
code: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Handle GET /v1/models
|
||||||
|
*
|
||||||
|
* Returns available models
|
||||||
|
*/
|
||||||
|
export function handleModels(_req, res) {
|
||||||
|
res.json({
|
||||||
|
object: "list",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "claude-opus-4",
|
||||||
|
object: "model",
|
||||||
|
owned_by: "anthropic",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-sonnet-4",
|
||||||
|
object: "model",
|
||||||
|
owned_by: "anthropic",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-haiku-4",
|
||||||
|
object: "model",
|
||||||
|
owned_by: "anthropic",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Handle GET /health
|
||||||
|
*
|
||||||
|
* Health check endpoint
|
||||||
|
*/
|
||||||
|
export function handleHealth(_req, res) {
|
||||||
|
res.json({
|
||||||
|
status: "ok",
|
||||||
|
provider: "claude-code-cli",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=routes.js.map
|
||||||
+7
-3
@@ -71,10 +71,14 @@ function cleanupRooms() {
|
|||||||
|
|
||||||
// ── WebSocket-Server starten ────────────────────────────────────────
|
// ── 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.
|
// audio_pcm Chunks koennen die ws-Library Default 1MB ueberschreiten.
|
||||||
// Default-Limit war der Killer fuer die voice_upload Pipeline.
|
// Plus: file_request/file_response fuer Re-Download von Anhaengen.
|
||||||
const wss = new WebSocketServer({ port: PORT, maxPayload: 50 * 1024 * 1024 });
|
// 40 MB MP4 → ~53 MB base64 → vorher mit 50 MB Limit zerschossen
|
||||||
|
// (Code 1009 message too big, Bridge crashed im cleanup). 100 MB
|
||||||
|
// deckt bis ~70 MB binaer ab; groessere Files werden Bridge-seitig
|
||||||
|
// abgewiesen (siehe file_request-Handler) bevor die WS abreisst.
|
||||||
|
const wss = new WebSocketServer({ port: PORT, maxPayload: 100 * 1024 * 1024 });
|
||||||
|
|
||||||
wss.on("listening", () => {
|
wss.on("listening", () => {
|
||||||
log(`RVS läuft auf Port ${PORT} | Max Sessions: ${MAX_SESSIONS}`);
|
log(`RVS läuft auf Port ${PORT} | Max Sessions: ${MAX_SESSIONS}`);
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# tools/
|
||||||
|
|
||||||
|
Hilfsskripte für die Dev-Maschine. Brauchen `.claude/aria-vm.env` (aus
|
||||||
|
`.example` kopieren + lokale VM-IP eintragen).
|
||||||
|
|
||||||
|
## fetch-app-logs.sh
|
||||||
|
|
||||||
|
Holt App-Crash-Logs von der VM und speichert sie unter `.aria-debug/`
|
||||||
|
(gitignored). Die App schickt JS-Errors und ungefangene Promise-
|
||||||
|
Rejections via RVS an die Bridge — Bridge sammelt in
|
||||||
|
`/shared/logs/app.log`, Diagnostic-Server gibt sie via
|
||||||
|
`GET /api/app-log` raus.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/fetch-app-logs.sh # 200 neueste Eintraege
|
||||||
|
tools/fetch-app-logs.sh --limit 50 # weniger
|
||||||
|
tools/fetch-app-logs.sh --watch # alle 5s pollen, neue rausgeben
|
||||||
|
tools/fetch-app-logs.sh --clear # nach Abholen Log auf VM leeren
|
||||||
|
```
|
||||||
|
|
||||||
|
Ausgabe enthaelt pro Eintrag: Uhrzeit, Level (error/warn/info), Scope
|
||||||
|
(z.B. `ChatScreen.InboxModal` oder `global-fatal`), Message, und die
|
||||||
|
ersten ~8 Stack-Frames. Die kompletten Daten liegen als JSON in
|
||||||
|
`.aria-debug/app-log-<timestamp>.json`.
|
||||||
|
|
||||||
|
Workflow nach einem Crash:
|
||||||
|
|
||||||
|
1. App rebuilden mit Crash-Reporting (passiert automatisch ab dem
|
||||||
|
`21a315c`-Commit)
|
||||||
|
2. Crash in der App ausloesen
|
||||||
|
3. `tools/fetch-app-logs.sh` auf der Dev-Maschine
|
||||||
|
4. Stacktrace lesen / Claude geben
|
||||||
|
5. Fix bauen
|
||||||
|
6. `tools/fetch-app-logs.sh --clear` damit der Log wieder sauber ist
|
||||||
Executable
+105
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# fetch-app-logs.sh — App-Crash-Logs von der VM holen
|
||||||
|
#
|
||||||
|
# Nutzt .claude/aria-vm.env als Quelle fuer $ARIA_DIAG_URL und ruft
|
||||||
|
# GET /api/app-log?limit=N. Speichert die Roh-Response unter
|
||||||
|
# .aria-debug/app-log-<timestamp>.json und gibt eine kompakte
|
||||||
|
# Zusammenfassung auf stdout aus (letzte Eintraege mit Stack-Trace).
|
||||||
|
#
|
||||||
|
# Verwendung:
|
||||||
|
# tools/fetch-app-logs.sh # Default limit=200
|
||||||
|
# tools/fetch-app-logs.sh --limit 50 # nur 50 holen
|
||||||
|
# tools/fetch-app-logs.sh --clear # nach Abholen Log loeschen
|
||||||
|
# tools/fetch-app-logs.sh --watch # alle 5s pollen, neue Eintraege ausgeben
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LIMIT=200
|
||||||
|
CLEAR=0
|
||||||
|
WATCH=0
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--limit) LIMIT="$2"; shift 2 ;;
|
||||||
|
--limit=*) LIMIT="${1#*=}"; shift ;;
|
||||||
|
--clear) CLEAR=1; shift ;;
|
||||||
|
--watch) WATCH=1; shift ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '1,/^set/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'
|
||||||
|
exit 0 ;;
|
||||||
|
*) echo "Unbekannte Option: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
ENV_FILE="$ROOT/.claude/aria-vm.env"
|
||||||
|
OUT_DIR="$ROOT/.aria-debug"
|
||||||
|
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
echo "FEHLER: $ENV_FILE nicht vorhanden. Aus .example kopieren und IP anpassen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$ENV_FILE"
|
||||||
|
|
||||||
|
if [[ -z "${ARIA_DIAG_URL:-}" ]]; then
|
||||||
|
echo "FEHLER: ARIA_DIAG_URL nicht gesetzt in $ENV_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
fetch_once() {
|
||||||
|
local ts json file
|
||||||
|
ts="$(date +%Y%m%d_%H%M%S)"
|
||||||
|
json="$(curl -s --max-time 10 "${ARIA_DIAG_URL%/}/api/app-log?limit=$LIMIT")" || {
|
||||||
|
echo "FEHLER: curl gegen $ARIA_DIAG_URL fehlgeschlagen" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
file="$OUT_DIR/app-log-$ts.json"
|
||||||
|
echo "$json" > "$file"
|
||||||
|
python3 - "$file" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
from pathlib import Path
|
||||||
|
data = json.loads(Path(sys.argv[1]).read_text())
|
||||||
|
entries = data.get("entries") or []
|
||||||
|
print(f"=== {len(entries)} Eintrag{'e' if len(entries)!=1 else ''} (gespeichert unter {sys.argv[1]}) ===")
|
||||||
|
for e in entries[-20:]:
|
||||||
|
ts = e.get("ts") or 0
|
||||||
|
from datetime import datetime
|
||||||
|
when = datetime.fromtimestamp(ts/1000).strftime("%H:%M:%S") if ts else "?"
|
||||||
|
lvl = e.get("level","?")
|
||||||
|
scope = e.get("scope","?")
|
||||||
|
msg = (e.get("message") or "").splitlines()[0][:200]
|
||||||
|
print(f"\n[{when}] {lvl:5} {scope}: {msg}")
|
||||||
|
stack = (e.get("stack") or "").strip()
|
||||||
|
if stack:
|
||||||
|
for line in stack.splitlines()[:8]:
|
||||||
|
print(f" {line}")
|
||||||
|
if len(stack.splitlines()) > 8:
|
||||||
|
print(f" ... ({len(stack.splitlines())-8} weitere Zeilen — siehe JSON)")
|
||||||
|
PY
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$WATCH" == "1" ]]; then
|
||||||
|
echo "Watching $ARIA_DIAG_URL/api/app-log — Ctrl+C zum Beenden"
|
||||||
|
SEEN=""
|
||||||
|
while true; do
|
||||||
|
cur=$(curl -s --max-time 10 "${ARIA_DIAG_URL%/}/api/app-log?limit=$LIMIT") || cur=""
|
||||||
|
hash=$(echo "$cur" | md5sum | awk '{print $1}')
|
||||||
|
if [[ "$hash" != "$SEEN" && -n "$cur" ]]; then
|
||||||
|
SEEN="$hash"
|
||||||
|
fetch_once
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
else
|
||||||
|
fetch_once
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$CLEAR" == "1" ]]; then
|
||||||
|
echo
|
||||||
|
echo "→ Log auf der VM leeren..."
|
||||||
|
curl -s --max-time 5 -X POST "${ARIA_DIAG_URL%/}/api/app-log/clear" | python3 -m json.tool || true
|
||||||
|
fi
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration: chat_backup.jsonl ts-Werte von Container-Uptime-ms auf UNIX-ms umstellen.
|
||||||
|
|
||||||
|
Hintergrund: vor dem Fix nutzte _append_chat_backup() `asyncio.get_event_loop().time()`,
|
||||||
|
was Container-Monotonic ist (bei Restart wieder 0). Mischte sich mit App-side
|
||||||
|
`Date.now()` (echtes UNIX-ms) → falsche Sortierung in der App-History.
|
||||||
|
|
||||||
|
Strategie: ts < 1e12 (keine UNIX-ms) werden umgeschrieben. Anker = file-mtime,
|
||||||
|
decay 60 Sekunden pro Eintrag rueckwaerts. Datei-Reihenfolge bleibt erhalten
|
||||||
|
(append-only war chronologisch korrekt, nur ts-Werte waren Unsinn).
|
||||||
|
|
||||||
|
Vorhandene UNIX-ms-Eintraege (file_deleted-Marker, neue Eintraege ab Bridge-Fix)
|
||||||
|
werden unveraendert gelassen.
|
||||||
|
|
||||||
|
Idempotent: zweimal laufen lassen ist sicher — beim zweiten Mal sind alle ts
|
||||||
|
schon UNIX-ms und werden nicht angefasst.
|
||||||
|
|
||||||
|
Backup: schreibt erst chat_backup.jsonl.bak, dann atomar replace.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
UNIX_MS_THRESHOLD = 10 ** 12 # < 1e12 ms = vor 2001 = unrealistisch fuer UNIX
|
||||||
|
GAP_SECONDS = 60 # 1 Eintrag pro Minute rueckwaerts ab mtime
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(path: Path) -> None:
|
||||||
|
if not path.exists():
|
||||||
|
print(f"Datei nicht da: {path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
raw = path.read_text(encoding="utf-8").splitlines()
|
||||||
|
entries = []
|
||||||
|
for raw_line in raw:
|
||||||
|
s = raw_line.strip()
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entries.append(json.loads(s))
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ueberspringe kaputte Zeile: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
print("Datei leer")
|
||||||
|
return
|
||||||
|
|
||||||
|
file_mtime_ms = int(os.path.getmtime(path) * 1000)
|
||||||
|
n = len(entries)
|
||||||
|
fixed = 0
|
||||||
|
|
||||||
|
# Wir bauen einen Ersatz-ts (file_mtime - gap*minutes_back) nur fuer
|
||||||
|
# Eintraege deren ts < UNIX_MS_THRESHOLD. file_deleted etc. mit echtem
|
||||||
|
# UNIX-ms bleiben unangetastet.
|
||||||
|
for i, entry in enumerate(entries):
|
||||||
|
ts = entry.get("ts", 0)
|
||||||
|
if not isinstance(ts, (int, float)) or ts < UNIX_MS_THRESHOLD:
|
||||||
|
# Synth-ts vergeben: aelteste = mtime - n*gap, neueste = mtime
|
||||||
|
new_ts = file_mtime_ms - (n - 1 - i) * GAP_SECONDS * 1000
|
||||||
|
entry["ts"] = new_ts
|
||||||
|
fixed += 1
|
||||||
|
|
||||||
|
if fixed == 0:
|
||||||
|
print(f"Nichts zu migrieren ({n} Eintraege, alle ts schon UNIX-ms)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Backup
|
||||||
|
bak = path.with_suffix(path.suffix + ".bak")
|
||||||
|
shutil.copy2(path, bak)
|
||||||
|
print(f"Backup: {bak}")
|
||||||
|
|
||||||
|
# Atomic rewrite
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
for entry in entries:
|
||||||
|
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
print(f"Migration fertig: {fixed}/{n} ts umgeschrieben")
|
||||||
|
print(f" aelteste neu : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(entries[0]['ts'] / 1000))}")
|
||||||
|
print(f" neueste neu : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(entries[-1]['ts'] / 1000))}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
default = Path("/var/lib/docker/volumes/aria-agent_aria-shared/_data/config/chat_backup.jsonl")
|
||||||
|
path = Path(sys.argv[1]) if len(sys.argv) > 1 else default
|
||||||
|
migrate(path)
|
||||||
Reference in New Issue
Block a user