Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c758727345 | |||
| cb0e879118 | |||
| ce6f5b551e | |||
| b6a68b7658 | |||
| 03edee8881 | |||
| 7093ebaf0b | |||
| b4923bc221 | |||
| 7a66752655 | |||
| b510ccd93a | |||
| bbd51406a9 | |||
| 2cd436f6e9 | |||
| 22adc91c1e | |||
| 61cf8e3bcc | |||
| 3e38f1dad3 | |||
| 635944299e | |||
| b2ac013765 | |||
| 93db6a3156 |
@@ -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,7 +320,7 @@ 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`, …). **Drei GPS-Funktionen** mit unterschiedlicher Semantik:
|
- **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:
|
||||||
@@ -362,10 +366,11 @@ 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 — Highlight + Next/Prev springt zum Treffer (Bubble landet am Text-Anfang oben am Viewport)
|
- **Chat-Suche**: Lupe in der Statusleiste — Highlight + Next/Prev springt zum Treffer (Bubble landet am Text-Anfang oben am Viewport). Reihenfolge **neueste zuerst** (analog WhatsApp), „Naechster" geht in die Vergangenheit. Item-Hoehen werden per `onLayout` gecached fuer praezisen Pre-Scroll auch bei langen Listen
|
||||||
- **Jump-to-Bottom-Button**: erscheint rechts unten sobald man weg von der neuesten Nachricht scrollt, ein Tap fuehrt zurueck
|
- **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
|
- **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)
|
- **🗂️ 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
|
- **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
|
- **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
|
||||||
@@ -375,7 +380,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
|||||||
- **Einstellungen**: TTS-aktiv, F5-TTS-Voice, Pre-Roll-Buffer, Stille-Toleranz, Speicherort, Auto-Download, GPS, Verbose-Logging
|
- **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
|
||||||
|
|||||||
+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 10409
|
versionCode 10505
|
||||||
versionName "0.1.4.9"
|
versionName "0.1.5.5"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.1.4.9",
|
"version": "0.1.5.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1041,11 +1041,20 @@ const ChatScreen: React.FC = () => {
|
|||||||
for (const cmid of Array.from(ackTimers.current.keys())) {
|
for (const cmid of Array.from(ackTimers.current.keys())) {
|
||||||
clearAckTimer(cmid);
|
clearAckTimer(cmid);
|
||||||
}
|
}
|
||||||
setMessages(prev => prev.map(m =>
|
// Reference-stable: wenn keine Bubble zu aendern ist, geben wir
|
||||||
m.sender === 'user' && m.deliveryStatus === 'sending'
|
// prev unveraendert zurueck. Sonst triggert .map() ein neues
|
||||||
? { ...m, deliveryStatus: 'sent' }
|
// Array + Re-Render, was waehrend einer aktiven Such-Scroll-
|
||||||
: m
|
// Sequenz die FlatList-Layouts invalidiert → permanenter
|
||||||
));
|
// onScrollToIndexFailed-Loop.
|
||||||
|
setMessages(prev => {
|
||||||
|
const needs = prev.some(m => m.sender === 'user' && m.deliveryStatus === 'sending');
|
||||||
|
if (!needs) return prev;
|
||||||
|
return prev.map(m =>
|
||||||
|
m.sender === 'user' && m.deliveryStatus === 'sending'
|
||||||
|
? { ...m, deliveryStatus: 'sent' }
|
||||||
|
: m,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// In den Gedanken-Stream einfuegen. Dedup gegen identische Folge-
|
// In den Gedanken-Stream einfuegen. Dedup gegen identische Folge-
|
||||||
// Events (z.B. zwei mal 'thinking' direkt hintereinander). Tool-
|
// Events (z.B. zwei mal 'thinking' direkt hintereinander). Tool-
|
||||||
@@ -1372,15 +1381,22 @@ const ChatScreen: React.FC = () => {
|
|||||||
);
|
);
|
||||||
const invertedMessages = useMemo(() => [...chatVisibleMessages].reverse(), [chatVisibleMessages]);
|
const invertedMessages = useMemo(() => [...chatVisibleMessages].reverse(), [chatVisibleMessages]);
|
||||||
|
|
||||||
// Such-Treffer: alle Message-IDs die zur Query passen, in chronologischer
|
// Such-Treffer: alle Message-IDs die zur Query passen. NEUESTE ZUERST —
|
||||||
// Reihenfolge (aelteste zuerst). Bei Query-Change resetten wir den Index.
|
// analog zu WhatsApp/Telegram: User ist visuell unten im Chat, der erste
|
||||||
|
// Treffer ist meist schon im Viewport (kein weiter Pre-Scroll, kein
|
||||||
|
// Cold-Start-Sprung-Fail). „Naechster" geht in die Vergangenheit.
|
||||||
|
// WICHTIG: nur in chatVisibleMessages suchen — Spezial-Bubbles (Memory/
|
||||||
|
// Skill/Trigger) sind im Chat-Stream nicht sichtbar und Treffer auf die
|
||||||
|
// wuerden zu „ID nicht im FlatList → findIndex=-1 → kein Scroll"-Fail
|
||||||
|
// fuehren.
|
||||||
const searchMatchIds = useMemo(() => {
|
const searchMatchIds = useMemo(() => {
|
||||||
const q = searchQuery.trim().toLowerCase();
|
const q = searchQuery.trim().toLowerCase();
|
||||||
if (!q) return [] as string[];
|
if (!q) return [] as string[];
|
||||||
return messages
|
return chatVisibleMessages
|
||||||
.filter(m => (m.text || '').toLowerCase().includes(q))
|
.filter(m => (m.text || '').toLowerCase().includes(q))
|
||||||
.map(m => m.id);
|
.map(m => m.id)
|
||||||
}, [messages, searchQuery]);
|
.reverse();
|
||||||
|
}, [chatVisibleMessages, searchQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearchIndex(0);
|
setSearchIndex(0);
|
||||||
@@ -1394,11 +1410,21 @@ const ChatScreen: React.FC = () => {
|
|||||||
// ein neuer Search-Hit kommt, damit alte Retries nicht den neuen
|
// ein neuer Search-Hit kommt, damit alte Retries nicht den neuen
|
||||||
// Scroll-Versuch durcheinanderbringen ("permanent springen"-Bug).
|
// Scroll-Versuch durcheinanderbringen ("permanent springen"-Bug).
|
||||||
const pendingScrollRetry = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const pendingScrollRetry = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
// Zaehler fuer fehlgeschlagene Scroll-Retries. Hartes Limit gegen
|
||||||
|
// Endlos-Loops wenn das Item-Layout aus irgendwelchen Gruenden nie
|
||||||
|
// verfuegbar wird (z.B. weil setMessages mitten in der Sequenz die
|
||||||
|
// FlatList re-rendert).
|
||||||
|
const scrollRetryCount = useRef<number>(0);
|
||||||
|
// 6 Retries: bei weiten Spruengen (Suche auf Bubble #150 von Position 0)
|
||||||
|
// kann FlatList mehrere Iterationen brauchen bis die Items in der Naehe
|
||||||
|
// gemessen sind. Vorher 3 = vorzeitig aufgegeben.
|
||||||
|
const MAX_SCROLL_RETRIES = 6;
|
||||||
const clearPendingScrollRetry = () => {
|
const clearPendingScrollRetry = () => {
|
||||||
if (pendingScrollRetry.current) {
|
if (pendingScrollRetry.current) {
|
||||||
clearTimeout(pendingScrollRetry.current);
|
clearTimeout(pendingScrollRetry.current);
|
||||||
pendingScrollRetry.current = null;
|
pendingScrollRetry.current = null;
|
||||||
}
|
}
|
||||||
|
scrollRetryCount.current = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Bei Search-Index-Wechsel zur entsprechenden Bubble scrollen.
|
// Bei Search-Index-Wechsel zur entsprechenden Bubble scrollen.
|
||||||
@@ -1409,6 +1435,11 @@ const ChatScreen: React.FC = () => {
|
|||||||
// Den aktuellen Snapshot von invertedMessages holen wir via Ref.
|
// Den aktuellen Snapshot von invertedMessages holen wir via Ref.
|
||||||
const invertedMessagesRef = useRef(invertedMessages);
|
const invertedMessagesRef = useRef(invertedMessages);
|
||||||
invertedMessagesRef.current = invertedMessages;
|
invertedMessagesRef.current = invertedMessages;
|
||||||
|
// Cache fuer echte Bubble-Hoehen, gefuettert per onLayout in
|
||||||
|
// renderMessage. Wird beim Pre-Scroll genutzt damit der grobe Sprung
|
||||||
|
// praezise landet (statt mit dem 150-px-Pauschalwert weit daneben).
|
||||||
|
const itemHeights = useRef<Map<string, number>>(new Map());
|
||||||
|
const AVG_BUBBLE_HEIGHT = 150; // Fallback fuer noch nicht gemessene Items
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchMatchIds.length) {
|
if (!searchMatchIds.length) {
|
||||||
lastSearchScrollKey.current = '';
|
lastSearchScrollKey.current = '';
|
||||||
@@ -1426,12 +1457,42 @@ const ChatScreen: React.FC = () => {
|
|||||||
clearPendingScrollRetry();
|
clearPendingScrollRetry();
|
||||||
const idx = invertedMessagesRef.current.findIndex(m => m.id === id);
|
const idx = invertedMessagesRef.current.findIndex(m => m.id === id);
|
||||||
if (idx < 0 || !flatListRef.current) return;
|
if (idx < 0 || !flatListRef.current) return;
|
||||||
|
// Pre-Scroll: erst grob in die Naehe springen, damit FlatList die
|
||||||
|
// Bubbles in der Umgebung ueberhaupt rendert (sonst basiert
|
||||||
|
// averageItemLength im Failed-Handler nur auf den ersten ~10 Items
|
||||||
|
// und liefert einen voellig falschen Sprung).
|
||||||
|
// Offset = Summe echter Hoehen (aus itemHeights-Cache, gefuettert per
|
||||||
|
// onLayout) + dynamischer Fallback aus dem Mittel der bisher
|
||||||
|
// gemessenen Items. Beim Cold-Start gibt's nur 10 Messungen (die
|
||||||
|
// neuesten unten in der invertierten Liste) — der Mittel daraus ist
|
||||||
|
// immer noch besser als die Pauschal-150.
|
||||||
|
const measured = Array.from(itemHeights.current.values());
|
||||||
|
const dynamicAvg = measured.length >= 5
|
||||||
|
? measured.reduce((a, b) => a + b, 0) / measured.length
|
||||||
|
: AVG_BUBBLE_HEIGHT;
|
||||||
|
let preOffset = 0;
|
||||||
|
const inv = invertedMessagesRef.current;
|
||||||
|
for (let i = 0; i < idx; i++) {
|
||||||
|
preOffset += itemHeights.current.get(inv[i].id) || dynamicAvg;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flatListRef.current?.scrollToOffset({
|
||||||
|
offset: preOffset,
|
||||||
|
animated: false,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
// Nach Render-Pause praezise nachsetzen. 350 ms — bei weiten Spruengen
|
||||||
|
// (Pre-Scroll 5000+ px) braucht FlatList Zeit die Items dort zu
|
||||||
|
// mounten und onLayout zu feuern. Zu kurz → averageItemLength im
|
||||||
|
// Failed-Handler basiert noch auf den falschen Items.
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
try {
|
setTimeout(() => {
|
||||||
flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0 });
|
try {
|
||||||
} catch {
|
flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0 });
|
||||||
// onScrollToIndexFailed-Handler uebernimmt den Fallback
|
} catch {
|
||||||
}
|
// onScrollToIndexFailed-Handler uebernimmt den Fallback
|
||||||
|
}
|
||||||
|
}, 350);
|
||||||
});
|
});
|
||||||
}, [searchIndex, searchMatchIds]);
|
}, [searchIndex, searchMatchIds]);
|
||||||
|
|
||||||
@@ -1849,7 +1910,15 @@ const ChatScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble, searchHighlightStyle]}>
|
<View
|
||||||
|
style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble, searchHighlightStyle]}
|
||||||
|
onLayout={e => {
|
||||||
|
// Echte Hoehe in Cache speichern — Pre-Scroll der Suche nutzt
|
||||||
|
// die summierten Cache-Werte fuer praezisen Sprung. Bei
|
||||||
|
// unbekannten Items faellt's auf AVG_BUBBLE_HEIGHT zurueck.
|
||||||
|
itemHeights.current.set(item.id, e.nativeEvent.layout.height);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Anhang-Vorschau */}
|
{/* Anhang-Vorschau */}
|
||||||
{item.attachments?.map((att, idx) => (
|
{item.attachments?.map((att, idx) => (
|
||||||
<View key={idx}>
|
<View key={idx}>
|
||||||
@@ -2143,6 +2212,13 @@ const ChatScreen: React.FC = () => {
|
|||||||
ref={flatListRef}
|
ref={flatListRef}
|
||||||
inverted
|
inverted
|
||||||
data={invertedMessages}
|
data={invertedMessages}
|
||||||
|
// Mehr Items beim Mount messen → bessere averageItemLength fuer
|
||||||
|
// Such-Sprung gleich nach App-Start. Default sind 10 Items, das
|
||||||
|
// ist bei 300+ Bubbles im Backup viel zu wenig.
|
||||||
|
initialNumToRender={30}
|
||||||
|
// Mehr Items im Speicher halten (Default 21 = 10 oben + 10 unten).
|
||||||
|
// Macht scroll-to-far-away weniger anfaellig fuer Layout-Holes.
|
||||||
|
windowSize={41}
|
||||||
onScroll={(e) => {
|
onScroll={(e) => {
|
||||||
// Bei inverted FlatList: contentOffset.y > 0 = weg von "unten"
|
// Bei inverted FlatList: contentOffset.y > 0 = weg von "unten"
|
||||||
// (= aelter scrollen). Wir zeigen den Jump-Down-Button ab ~250px.
|
// (= aelter scrollen). Wir zeigen den Jump-Down-Button ab ~250px.
|
||||||
@@ -2152,13 +2228,24 @@ const ChatScreen: React.FC = () => {
|
|||||||
scrollEventThrottle={120}
|
scrollEventThrottle={120}
|
||||||
onScrollToIndexFailed={(info) => {
|
onScrollToIndexFailed={(info) => {
|
||||||
// FlatList kennt das Item-Layout noch nicht. Wir scrollen grob in
|
// FlatList kennt das Item-Layout noch nicht. Wir scrollen grob in
|
||||||
// die Naehe (Average-Item-Hoehe-Schaetzung) und versuchen EINMAL
|
// die Naehe (Average-Item-Hoehe-Schaetzung) und versuchen bis zu
|
||||||
// nach 300ms praezise nachzusetzen. Mehr Retries → Endlos-Cascade
|
// MAX_SCROLL_RETRIES mal praezise nachzusetzen. Danach geben wir
|
||||||
// (jeder failed Retry triggert wieder den Handler → 3, 9, 27 ...
|
// auf — User sieht die Bubble in der ungefaehren Naehe und kann
|
||||||
// Scrolls in der Pipeline = der "permanent springen"-Bug).
|
// selber finetunen. Frueher: jeder failed Retry triggerte einen
|
||||||
|
// neuen Retry ohne Limit → "permanent springen"-Bug, vor allem
|
||||||
|
// wenn waehrenddessen setMessages die Layouts invalidierte.
|
||||||
const offset = info.averageItemLength * info.index;
|
const offset = info.averageItemLength * info.index;
|
||||||
try { flatListRef.current?.scrollToOffset({ offset, animated: false }); } catch {}
|
try { flatListRef.current?.scrollToOffset({ offset, animated: false }); } catch {}
|
||||||
clearPendingScrollRetry();
|
if (pendingScrollRetry.current) {
|
||||||
|
clearTimeout(pendingScrollRetry.current);
|
||||||
|
pendingScrollRetry.current = null;
|
||||||
|
}
|
||||||
|
if (scrollRetryCount.current >= MAX_SCROLL_RETRIES) {
|
||||||
|
// Aufgeben — Item ist offenbar nicht stabil renderbar
|
||||||
|
scrollRetryCount.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scrollRetryCount.current += 1;
|
||||||
pendingScrollRetry.current = setTimeout(() => {
|
pendingScrollRetry.current = setTimeout(() => {
|
||||||
pendingScrollRetry.current = null;
|
pendingScrollRetry.current = null;
|
||||||
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0 }); } catch {}
|
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0 }); } catch {}
|
||||||
@@ -2333,12 +2420,20 @@ const ChatScreen: React.FC = () => {
|
|||||||
transparent
|
transparent
|
||||||
onRequestClose={() => setThoughtsVisible(false)}
|
onRequestClose={() => setThoughtsVisible(false)}
|
||||||
>
|
>
|
||||||
<TouchableOpacity
|
<View style={{flex:1, backgroundColor:'rgba(0,0,0,0.5)', justifyContent:'flex-end'}}>
|
||||||
style={{flex:1, backgroundColor:'rgba(0,0,0,0.5)', justifyContent:'flex-end'}}
|
{/* Tap-Outside-Bereich oberhalb des Sheets — separater Touchable
|
||||||
activeOpacity={1}
|
damit das Sheet-View NICHT als Responder den FlatList-Scroll
|
||||||
onPress={() => setThoughtsVisible(false)}
|
blockiert. Frueher hatten wir den ganzen Hintergrund als
|
||||||
>
|
TouchableOpacity + inneren View mit onStartShouldSetResponder
|
||||||
<TouchableOpacity activeOpacity={1} style={{height:'60%', backgroundColor:'#0D0D1A', borderTopLeftRadius:16, borderTopRightRadius:16}}>
|
= das hat alle Touch-Events kassiert. */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{flex:1}}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setThoughtsVisible(false)}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{height:'60%', backgroundColor:'#0D0D1A', borderTopLeftRadius:16, borderTopRightRadius:16}}
|
||||||
|
>
|
||||||
{/* Drag-Indicator */}
|
{/* Drag-Indicator */}
|
||||||
<View style={{alignItems:'center', paddingTop:8, paddingBottom:4}}>
|
<View style={{alignItems:'center', paddingTop:8, paddingBottom:4}}>
|
||||||
<View style={{width:40, height:4, borderRadius:2, backgroundColor:'#2A2A3E'}} />
|
<View style={{width:40, height:4, borderRadius:2, backgroundColor:'#2A2A3E'}} />
|
||||||
@@ -2417,8 +2512,8 @@ const ChatScreen: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Notizen-Inbox — Listet alle Memories aus dem aktuellen Chat (Special-Bubbles).
|
{/* Notizen-Inbox — Listet alle Memories aus dem aktuellen Chat (Special-Bubbles).
|
||||||
@@ -2453,7 +2548,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
<Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:8, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
|
<Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:8, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
|
||||||
Aus diesem Chat
|
Aus diesem Chat
|
||||||
</Text>
|
</Text>
|
||||||
<ScrollView style={{paddingHorizontal:8}}>
|
<ScrollView style={{paddingHorizontal:8}} nestedScrollEnabled={true}>
|
||||||
{specials.map(m => {
|
{specials.map(m => {
|
||||||
if (m.memorySaved) {
|
if (m.memorySaved) {
|
||||||
const ms = m.memorySaved;
|
const ms = m.memorySaved;
|
||||||
@@ -2509,7 +2604,12 @@ const ChatScreen: React.FC = () => {
|
|||||||
<Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:10, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
|
<Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:10, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
|
||||||
Alle Memories aus der DB
|
Alle Memories aus der DB
|
||||||
</Text>
|
</Text>
|
||||||
<MemoryBrowser onOpenMemory={(id) => { setInboxVisible(false); setMemoryDetailId(id); }} />
|
{/* flex:1 Wrapper damit MemoryBrowser den verbleibenden Platz
|
||||||
|
bekommt (sonst rendert die FlatList intern mit 0 Hoehe und
|
||||||
|
nimmt nur was der Inhalt sagt → Scroll-Gestures verschwinden). */}
|
||||||
|
<View style={{flex:1}}>
|
||||||
|
<MemoryBrowser onOpenMemory={(id) => { setInboxVisible(false); setMemoryDetailId(id); }} />
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -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,7 @@ 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 [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 +198,10 @@ 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');
|
||||||
|
});
|
||||||
// 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 +585,37 @@ 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);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// --- XTTS Voice ---
|
// --- XTTS Voice ---
|
||||||
|
|
||||||
const selectVoice = useCallback((voiceName: string) => {
|
const selectVoice = useCallback((voiceName: string) => {
|
||||||
@@ -868,7 +909,15 @@ const SettingsScreen: React.FC = () => {
|
|||||||
})()}
|
})()}
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content} nestedScrollEnabled={true}>
|
<ScrollView
|
||||||
|
style={styles.container}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
nestedScrollEnabled={true}
|
||||||
|
// Wenn eine Section eine eigene voll-hoch-scrollende Sub-Liste hat
|
||||||
|
// (Memory, Trigger), den outer Scroll deaktivieren — Android-nested-
|
||||||
|
// scrolling laesst sonst nur in eine Richtung scrollen.
|
||||||
|
scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers'}
|
||||||
|
>
|
||||||
|
|
||||||
{currentSection === null && (
|
{currentSection === null && (
|
||||||
<>
|
<>
|
||||||
@@ -1053,6 +1102,33 @@ const SettingsScreen: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</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 +1758,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 +1886,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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -121,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 = {
|
||||||
@@ -215,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();
|
||||||
|
|||||||
+98
-5
@@ -968,11 +968,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;">
|
||||||
@@ -986,8 +986,16 @@
|
|||||||
|
|
||||||
<!-- Timer-spezifisch -->
|
<!-- Timer-spezifisch -->
|
||||||
<div id="trigger-timer-fields">
|
<div id="trigger-timer-fields">
|
||||||
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">In wievielen Minuten?</label>
|
<!-- Create-mode: relativ („in X Minuten ab jetzt") -->
|
||||||
<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 id="trigger-timer-create-fields">
|
||||||
|
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">In wievielen Minuten?</label>
|
||||||
|
<input type="number" id="trigger-timer-minutes" min="1" max="10080" value="10" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
|
||||||
|
</div>
|
||||||
|
<!-- Edit-mode: absoluter ISO-Timestamp (UTC) -->
|
||||||
|
<div id="trigger-timer-edit-fields" style="display:none;">
|
||||||
|
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Feuert am (ISO, UTC)</label>
|
||||||
|
<input type="text" id="trigger-timer-fires-at" placeholder="2026-05-15T20:00:00+00:00" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:monospace;margin-bottom:10px;">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Watcher-spezifisch -->
|
<!-- Watcher-spezifisch -->
|
||||||
@@ -1008,7 +1016,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>
|
||||||
@@ -3101,6 +3109,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>
|
||||||
@@ -3138,10 +3147,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';
|
||||||
@@ -3170,6 +3190,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() {
|
||||||
@@ -3181,6 +3247,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;
|
||||||
|
|||||||
@@ -350,6 +350,32 @@ Skills mit Tool-Use.
|
|||||||
- [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] **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] **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] **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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user