Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fac87474ec | |||
| 8227266aea | |||
| 5d24e01d4b | |||
| 4fe72cc4a8 | |||
| eeeb1d43f5 | |||
| 0044e222db | |||
| 048d231b60 | |||
| 2bac9c26ca | |||
| c758727345 | |||
| cb0e879118 | |||
| ce6f5b551e | |||
| b6a68b7658 | |||
| 03edee8881 | |||
| 7093ebaf0b | |||
| b4923bc221 | |||
| 7a66752655 | |||
| b510ccd93a | |||
| bbd51406a9 | |||
| 2cd436f6e9 |
@@ -366,7 +366,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
- **Lokale Voice-Wahl**: Pro Geraet eigene Stimme moeglich (in Settings). Diagnostic-Wechsel ueberschreibt alle App-Wahlen.
|
||||
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
|
||||
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
|
||||
- **Chat-Suche**: Lupe in der Statusleiste — Highlight + Next/Prev springt zum Treffer (Bubble landet am Text-Anfang oben am Viewport)
|
||||
- **Chat-Suche**: Lupe in der Statusleiste — Highlight + Next/Prev springt zum Treffer (Bubble landet am Text-Anfang oben am Viewport). Reihenfolge **neueste zuerst** (analog WhatsApp), „Naechster" geht in die Vergangenheit. Item-Hoehen werden per `onLayout` gecached fuer praezisen Pre-Scroll auch bei langen Listen
|
||||
- **Jump-to-Bottom-Button**: erscheint rechts unten sobald man weg von der neuesten Nachricht scrollt, ein Tap fuehrt zurueck
|
||||
- **Delivery-Status pro User-Bubble** (WhatsApp-Style): `⏱` (queued, wartet auf Verbindung) → `⏳` (sending) → `✓` (Bridge hat ACK gesendet) → `✓✓` (ARIA hat verarbeitet). Bei Netzausfall werden Nachrichten lokal als queued gehalten und beim Reconnect automatisch geflusht. Bei drei ACK-Timeouts → `⚠ tippen f. Retry`. Idempotenz auf der Bridge (LRU ueber `clientMsgId`) verhindert Doppelte beim Retry
|
||||
- **Mülltonne pro Bubble** (mit Confirm): gezielt eine Nachricht loeschen — geht nicht nur aus der UI weg, sondern auch aus `chat_backup.jsonl`, Brain-Conversation-Window und allen anderen Clients (RVS-Broadcast). Wichtig damit ARIA den Turn auch beim naechsten Prompt nicht mehr im Kontext hat
|
||||
@@ -380,7 +380,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
- **Einstellungen**: TTS-aktiv, F5-TTS-Voice, Pre-Roll-Buffer, Stille-Toleranz, Speicherort, Auto-Download, GPS, Verbose-Logging
|
||||
- **Auto-Update**: Prueft beim Start + per Button auf neue Version, Download + Installation ueber RVS (FileProvider)
|
||||
- GPS-Position (optional, mit Runtime-Permission-Request) — wird in jeden Chat/Audio-Payload mitgegeben und ist in Diagnostic als Debug-Block einblendbar
|
||||
- **GPS-Tracking (kontinuierlich)**: Toggle in Settings → Standort. Wenn aktiv, pushed die App alle ~15s bzw. ab 30m Bewegung ein `location_update` an die Bridge — Voraussetzung damit Watcher mit `near(lat, lon, m)` (z.B. Blitzer-Warner, Ankunft-Erinnerungen) ueberhaupt feuern koennen. ARIA selbst kann das Tracking via `request_location_tracking`-Tool an-/ausschalten und tut das automatisch wenn sie einen GPS-Watcher anlegt
|
||||
- **GPS-Tracking (kontinuierlich)**: Toggle in Settings → Standort. Wenn aktiv, pushed die App ab 30m Bewegung ein `location_update` an die Bridge — Voraussetzung damit Watcher mit `near(lat, lon, m)` (z.B. Blitzer-Warner, Ankunft-Erinnerungen) ueberhaupt feuern koennen. **Heartbeat alle 60 s**: auch ohne Bewegung wird die letzte bekannte Position erneut an die Bridge geschickt damit der Brain-State nicht nach 5 min (NEAR_MAX_AGE_SEC) veraltet — kein extra GPS-Wakeup, akkufreundlich. ARIA selbst kann das Tracking via `request_location_tracking`-Tool an-/ausschalten und tut das automatisch wenn sie einen GPS-Watcher anlegt
|
||||
- QR-Code Scanner fuer Token-Pairing
|
||||
- **ARIA-Dateien empfangen**: Wenn ARIA eine PDF/Bild/Markdown/ZIP fuer dich erstellt (Marker `[FILE: /shared/uploads/aria_*]` in der Antwort), erscheint sie als eigene Anhang-Bubble. Tippen → wird via RVS geladen + mit Android-Intent-Picker geoeffnet (PDF-Viewer, Bildbetrachter, Standard-App). Inline-Bilder aus Markdown-``-Syntax werden direkt unter dem Text gerendert (PNG/JPG via Image, SVG via react-native-svg)
|
||||
- **Vollbild mit Pinch-Zoom**: Bilder im Vollbild-Modal sind pinch-zoombar (1x..5x), 1-Finger-Pan wenn gezoomt, Doppel-Tap toggelt 1x↔2.5x — alles ohne externe Lib
|
||||
|
||||
+39
-1
@@ -6,7 +6,8 @@
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { StatusBar, StyleSheet } from 'react-native';
|
||||
import { PermissionsAndroid, Platform, StatusBar, StyleSheet } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
|
||||
@@ -14,6 +15,7 @@ import ChatScreen from './src/screens/ChatScreen';
|
||||
import SettingsScreen from './src/screens/SettingsScreen';
|
||||
import rvs from './src/services/rvs';
|
||||
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
||||
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
@@ -61,6 +63,42 @@ const App: React.FC = () => {
|
||||
};
|
||||
initConnection();
|
||||
|
||||
// Hintergrund-Modus: Foreground-Service starten damit JS-Engine +
|
||||
// WebSocket auch ueberleben wenn die App im Hintergrund ist.
|
||||
// Trigger-Replies, Reconnects, Timer-Erinnerungen kommen sonst nicht
|
||||
// durch weil Android nach ~30s die JS-Engine pausiert.
|
||||
//
|
||||
// Default an, kann in Settings → Hintergrund-Modus deaktiviert werden.
|
||||
// Braucht POST_NOTIFICATIONS Permission ab Android 13.
|
||||
const initBackground = async () => {
|
||||
const setting = await AsyncStorage.getItem('aria_background_mode');
|
||||
if (setting === 'false') {
|
||||
console.log('[App] Hintergrund-Modus deaktiviert (Settings)');
|
||||
return;
|
||||
}
|
||||
// Permission fuer die persistente Notification
|
||||
if (Platform.OS === 'android' && Platform.Version >= 33) {
|
||||
try {
|
||||
await PermissionsAndroid.request(
|
||||
'android.permission.POST_NOTIFICATIONS' as any,
|
||||
{
|
||||
title: 'Hintergrund-Modus',
|
||||
message: 'ARIA zeigt eine Notification damit Trigger und Reconnects auch laufen wenn die App im Hintergrund ist.',
|
||||
buttonPositive: 'Erlauben',
|
||||
buttonNegative: 'Spaeter',
|
||||
},
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
await acquireBackgroundAudio('background');
|
||||
console.log('[App] Hintergrund-Modus aktiv');
|
||||
} catch (err: any) {
|
||||
console.warn('[App] Hintergrund-Modus konnte nicht starten:', err?.message || err);
|
||||
}
|
||||
};
|
||||
initBackground();
|
||||
|
||||
// Beim Beenden: Verbindung sauber trennen
|
||||
return () => {
|
||||
rvs.disconnect();
|
||||
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 10501
|
||||
versionName "0.1.5.1"
|
||||
versionCode 10509
|
||||
versionName "0.1.5.9"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Lauscht auf Anruf-Statusaenderungen — wenn das Telefon klingelt oder ein
|
||||
@@ -35,6 +36,11 @@ class PhoneCallModule(reactContext: ReactApplicationContext) : ReactContextBaseJ
|
||||
private var legacyListener: PhoneStateListener? = null
|
||||
private var modernCallback: Any? = null // TelephonyCallback ab API 31
|
||||
private var lastState: Int = TelephonyManager.CALL_STATE_IDLE
|
||||
// Eigener Single-Thread-Executor statt mainExecutor — der wird bei
|
||||
// pausierter Activity verzoegert oder gar nicht abgearbeitet, der eigene
|
||||
// Thread laeuft unabhaengig solange der App-Prozess lebt (was er ja tut,
|
||||
// wir haben einen Foreground-Service der das garantiert).
|
||||
private val callbackExecutor = Executors.newSingleThreadExecutor()
|
||||
|
||||
@ReactMethod
|
||||
fun start(promise: Promise) {
|
||||
@@ -59,7 +65,7 @@ class PhoneCallModule(reactContext: ReactApplicationContext) : ReactContextBaseJ
|
||||
handleStateChange(state)
|
||||
}
|
||||
}
|
||||
tm.registerTelephonyCallback(reactApplicationContext.mainExecutor, cb)
|
||||
tm.registerTelephonyCallback(callbackExecutor, cb)
|
||||
modernCallback = cb
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.1.5.1",
|
||||
"version": "0.1.5.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"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;
|
||||
@@ -149,6 +149,22 @@ const MAX_THOUGHTS = 500;
|
||||
// im Gespraechsmodus bei sehr vielen Nachrichten.
|
||||
const capMessages = (msgs: ChatMessage[]): ChatMessage[] =>
|
||||
msgs.length > MAX_MEMORY_MESSAGES ? msgs.slice(-MAX_MEMORY_MESSAGES) : msgs;
|
||||
|
||||
// Bridge fuegt User-Texten Praefixe in eckigen Klammern hinzu damit Brain
|
||||
// Kontext hat (GPS-Position, Barge-In-Hint etc.). Diese sollen nicht in der
|
||||
// Bubble auftauchen — nur Brain sieht sie. Filtert alle aufeinanderfolgenden
|
||||
// [...]-Bloecke am Textanfang weg, inkl. der Trennleerzeichen dahinter.
|
||||
function stripSystemHints(text: string): string {
|
||||
if (!text) return text;
|
||||
let out = text;
|
||||
// Mehrere Hints koennen aneinanderhaengen — "[A] [B] Hallo" → "Hallo"
|
||||
while (true) {
|
||||
const m = out.match(/^\s*\[[^\]]*\]\s*/);
|
||||
if (!m) break;
|
||||
out = out.slice(m[0].length);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
||||
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
|
||||
|
||||
@@ -279,6 +295,12 @@ const ChatScreen: React.FC = () => {
|
||||
// Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button)
|
||||
const [ttsDeviceEnabled, setTtsDeviceEnabled] = useState(true);
|
||||
const [ttsMuted, setTtsMuted] = useState(false);
|
||||
// System-Hints in Bubble: Bridge fuegt User-Text Praefixe wie
|
||||
// "[Stefans aktuelle GPS-Position: ...]" oder "[Hinweis: Stefan hat
|
||||
// dich gerade unterbrochen...]" hinzu damit Brain Kontext hat. Die
|
||||
// App soll sie standardmaessig NICHT anzeigen — Stefan sieht sonst
|
||||
// jeden Hint mit. Toggle in Settings.
|
||||
const [showSystemHints, setShowSystemHints] = useState(false);
|
||||
// Gerätelokale XTTS-Voice-Wahl (bevorzugt gegenueber dem globalen Default)
|
||||
const localXttsVoiceRef = useRef<string>('');
|
||||
// Geraetelokale TTS-Wiedergabegeschwindigkeit (speed-Param an F5-TTS)
|
||||
@@ -446,6 +468,8 @@ const ChatScreen: React.FC = () => {
|
||||
ttsSpeedRef.current = await loadTtsSpeed();
|
||||
const gps = await AsyncStorage.getItem('aria_gps_enabled');
|
||||
setGpsEnabled(gps === 'true');
|
||||
const hints = await AsyncStorage.getItem('aria_show_hints');
|
||||
setShowSystemHints(hints === 'true'); // default false
|
||||
};
|
||||
loadSettings();
|
||||
const interval = setInterval(loadSettings, 2000);
|
||||
@@ -480,14 +504,40 @@ const ChatScreen: React.FC = () => {
|
||||
return () => { phoneCallService.stop().catch(() => {}); };
|
||||
}, []);
|
||||
|
||||
// App-Resume: kurzer Wake-Word-Cooldown — beim Wechsel Background→Foreground
|
||||
// gibt's haeufig Audio-Pegel-Spikes (AudioFocus-Switch, AudioTrack re-route)
|
||||
// die openWakeWord sonst faelschlich als Wake-Word interpretiert.
|
||||
// App-Resume: drei Schutzmaßnahmen gegen verirrte Wake-Word-Trigger
|
||||
// beim Wechsel Background→Foreground:
|
||||
// (a) Cooldown 3s — Audio-Pegel-Spikes (AudioFocus-Switch, AudioTrack
|
||||
// re-route) sollen openWakeWord nicht faelschlich triggern
|
||||
// (b) Wenn die App laenger im Hintergrund war und in 'conversing'
|
||||
// zurueckkommt: vermutlich false-positive durch ein Hintergrund-
|
||||
// Geraeusch (TV, Husten etc.) waehrend Stefan gar nicht da war.
|
||||
// Wir verwerfen den Trigger und gehen zurueck zu 'armed'.
|
||||
// (c) Aktuelle Aufnahme abbrechen falls sie aus dem false-positive
|
||||
// gerade gestartet wurde.
|
||||
useEffect(() => {
|
||||
let lastState: string = AppState.currentState;
|
||||
let lastBackgroundAt = 0;
|
||||
const sub = AppState.addEventListener('change', (next) => {
|
||||
if (lastState !== 'active' && next === 'active') {
|
||||
wakeWordService.setResumeCooldown(1500);
|
||||
if (next === 'background' || next === 'inactive') {
|
||||
lastBackgroundAt = Date.now();
|
||||
} else if (lastState !== 'active' && next === 'active') {
|
||||
wakeWordService.setResumeCooldown(3000);
|
||||
const bgDur = lastBackgroundAt > 0 ? Date.now() - lastBackgroundAt : 0;
|
||||
// Bei laengerer Hintergrund-Zeit (>30s): pruefen ob ein frisches
|
||||
// Wake-Word getriggert wurde wahrend die App weg war — wenn ja,
|
||||
// verwerfen + laufende Aufnahme stoppen.
|
||||
if (bgDur > 30_000) {
|
||||
wakeWordService.discardIfFreshlyTriggered(15_000).then(discarded => {
|
||||
if (discarded) {
|
||||
try { audioService.cancelRecording(); } catch {}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
// PhoneCall-Listener pruefen: kann passieren dass der nach laengerer
|
||||
// Hintergrund-Zeit verloren geht (Bridge-Context recreated). Refresh
|
||||
// versucht ihn neu zu attachen falls noetig — sonst kriegt die App
|
||||
// bei display-aus / minimized keine Anruf-Events mit.
|
||||
phoneCallService.refresh().catch(() => {});
|
||||
}
|
||||
lastState = next;
|
||||
});
|
||||
@@ -1381,15 +1431,22 @@ const ChatScreen: React.FC = () => {
|
||||
);
|
||||
const invertedMessages = useMemo(() => [...chatVisibleMessages].reverse(), [chatVisibleMessages]);
|
||||
|
||||
// Such-Treffer: alle Message-IDs die zur Query passen, in chronologischer
|
||||
// Reihenfolge (aelteste zuerst). Bei Query-Change resetten wir den Index.
|
||||
// Such-Treffer: alle Message-IDs die zur Query passen. NEUESTE ZUERST —
|
||||
// analog zu WhatsApp/Telegram: User ist visuell unten im Chat, der erste
|
||||
// Treffer ist meist schon im Viewport (kein weiter Pre-Scroll, kein
|
||||
// Cold-Start-Sprung-Fail). „Naechster" geht in die Vergangenheit.
|
||||
// WICHTIG: nur in chatVisibleMessages suchen — Spezial-Bubbles (Memory/
|
||||
// Skill/Trigger) sind im Chat-Stream nicht sichtbar und Treffer auf die
|
||||
// wuerden zu „ID nicht im FlatList → findIndex=-1 → kein Scroll"-Fail
|
||||
// fuehren.
|
||||
const searchMatchIds = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (!q) return [] as string[];
|
||||
return messages
|
||||
return chatVisibleMessages
|
||||
.filter(m => (m.text || '').toLowerCase().includes(q))
|
||||
.map(m => m.id);
|
||||
}, [messages, searchQuery]);
|
||||
.map(m => m.id)
|
||||
.reverse();
|
||||
}, [chatVisibleMessages, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchIndex(0);
|
||||
@@ -1408,7 +1465,10 @@ const ChatScreen: React.FC = () => {
|
||||
// verfuegbar wird (z.B. weil setMessages mitten in der Sequenz die
|
||||
// FlatList re-rendert).
|
||||
const scrollRetryCount = useRef<number>(0);
|
||||
const MAX_SCROLL_RETRIES = 3;
|
||||
// 6 Retries: bei weiten Spruengen (Suche auf Bubble #150 von Position 0)
|
||||
// kann FlatList mehrere Iterationen brauchen bis die Items in der Naehe
|
||||
// gemessen sind. Vorher 3 = vorzeitig aufgegeben.
|
||||
const MAX_SCROLL_RETRIES = 6;
|
||||
const clearPendingScrollRetry = () => {
|
||||
if (pendingScrollRetry.current) {
|
||||
clearTimeout(pendingScrollRetry.current);
|
||||
@@ -1425,11 +1485,11 @@ const ChatScreen: React.FC = () => {
|
||||
// Den aktuellen Snapshot von invertedMessages holen wir via Ref.
|
||||
const invertedMessagesRef = useRef(invertedMessages);
|
||||
invertedMessagesRef.current = invertedMessages;
|
||||
// Schaetzwert fuer durchschnittliche Bubble-Hoehe. Wird fuer den
|
||||
// ersten Pre-Scroll genutzt wenn FlatList noch keine Layouts hat.
|
||||
// 150 px ist Mittel zwischen kurzen Voice-Bubbles (~70) und langen
|
||||
// ARIA-Antworten (~250).
|
||||
const AVG_BUBBLE_HEIGHT = 150;
|
||||
// Cache fuer echte Bubble-Hoehen, gefuettert per onLayout in
|
||||
// renderMessage. Wird beim Pre-Scroll genutzt damit der grobe Sprung
|
||||
// praezise landet (statt mit dem 150-px-Pauschalwert weit daneben).
|
||||
const itemHeights = useRef<Map<string, number>>(new Map());
|
||||
const AVG_BUBBLE_HEIGHT = 150; // Fallback fuer noch nicht gemessene Items
|
||||
useEffect(() => {
|
||||
if (!searchMatchIds.length) {
|
||||
lastSearchScrollKey.current = '';
|
||||
@@ -1447,19 +1507,34 @@ const ChatScreen: React.FC = () => {
|
||||
clearPendingScrollRetry();
|
||||
const idx = invertedMessagesRef.current.findIndex(m => m.id === id);
|
||||
if (idx < 0 || !flatListRef.current) return;
|
||||
// Pre-Scroll: erst grob mit konstanter Hoehe in die Naehe springen.
|
||||
// Damit hat FlatList ueberhaupt die Chance die Layouts der Bubbles in
|
||||
// der Naehe zu rendern — sonst basiert info.averageItemLength im
|
||||
// Failed-Handler nur auf den ersten 10 Items (Default initialNumToRender)
|
||||
// und liefert beim ersten Such-Versuch nach App-Start einen voellig
|
||||
// falschen Sprung.
|
||||
// Pre-Scroll: erst grob in die Naehe springen, damit FlatList die
|
||||
// Bubbles in der Umgebung ueberhaupt rendert (sonst basiert
|
||||
// averageItemLength im Failed-Handler nur auf den ersten ~10 Items
|
||||
// und liefert einen voellig falschen Sprung).
|
||||
// Offset = Summe echter Hoehen (aus itemHeights-Cache, gefuettert per
|
||||
// onLayout) + dynamischer Fallback aus dem Mittel der bisher
|
||||
// gemessenen Items. Beim Cold-Start gibt's nur 10 Messungen (die
|
||||
// neuesten unten in der invertierten Liste) — der Mittel daraus ist
|
||||
// immer noch besser als die Pauschal-150.
|
||||
const measured = Array.from(itemHeights.current.values());
|
||||
const dynamicAvg = measured.length >= 5
|
||||
? measured.reduce((a, b) => a + b, 0) / measured.length
|
||||
: AVG_BUBBLE_HEIGHT;
|
||||
let preOffset = 0;
|
||||
const inv = invertedMessagesRef.current;
|
||||
for (let i = 0; i < idx; i++) {
|
||||
preOffset += itemHeights.current.get(inv[i].id) || dynamicAvg;
|
||||
}
|
||||
try {
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: idx * AVG_BUBBLE_HEIGHT,
|
||||
offset: preOffset,
|
||||
animated: false,
|
||||
});
|
||||
} catch {}
|
||||
// Nach kurzer Render-Pause praezise nachsetzen
|
||||
// Nach Render-Pause praezise nachsetzen. 350 ms — bei weiten Spruengen
|
||||
// (Pre-Scroll 5000+ px) braucht FlatList Zeit die Items dort zu
|
||||
// mounten und onLayout zu feuern. Zu kurz → averageItemLength im
|
||||
// Failed-Handler basiert noch auf den falschen Items.
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
@@ -1467,7 +1542,7 @@ const ChatScreen: React.FC = () => {
|
||||
} catch {
|
||||
// onScrollToIndexFailed-Handler uebernimmt den Fallback
|
||||
}
|
||||
}, 80);
|
||||
}, 350);
|
||||
});
|
||||
}, [searchIndex, searchMatchIds]);
|
||||
|
||||
@@ -1885,7 +1960,15 @@ const ChatScreen: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble, searchHighlightStyle]}>
|
||||
<View
|
||||
style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble, searchHighlightStyle]}
|
||||
onLayout={e => {
|
||||
// Echte Hoehe in Cache speichern — Pre-Scroll der Suche nutzt
|
||||
// die summierten Cache-Werte fuer praezisen Sprung. Bei
|
||||
// unbekannten Items faellt's auf AVG_BUBBLE_HEIGHT zurueck.
|
||||
itemHeights.current.set(item.id, e.nativeEvent.layout.height);
|
||||
}}
|
||||
>
|
||||
{/* Anhang-Vorschau */}
|
||||
{item.attachments?.map((att, idx) => (
|
||||
<View key={idx}>
|
||||
@@ -1973,7 +2056,7 @@ const ChatScreen: React.FC = () => {
|
||||
{/* Text (nicht anzeigen wenn nur "Anhang empfangen" und ein Bild da ist) */}
|
||||
{!(item.text === 'Anhang empfangen' && item.attachments?.some(a => a.type === 'image' && a.uri)) && (
|
||||
<MessageText
|
||||
text={item.text}
|
||||
text={showSystemHints ? item.text : stripSystemHints(item.text)}
|
||||
style={[styles.messageText, isUser ? styles.userText : styles.ariaText]}
|
||||
/>
|
||||
)}
|
||||
@@ -2387,19 +2470,19 @@ const ChatScreen: React.FC = () => {
|
||||
transparent
|
||||
onRequestClose={() => setThoughtsVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={{flex:1, backgroundColor:'rgba(0,0,0,0.5)', justifyContent:'flex-end'}}
|
||||
activeOpacity={1}
|
||||
onPress={() => setThoughtsVisible(false)}
|
||||
>
|
||||
{/* View statt TouchableOpacity, sonst konsumiert das die Touch-
|
||||
Events und die FlatList laesst sich nicht scrollen.
|
||||
onStartShouldSetResponder={true} blockt aber die Propagation
|
||||
an das aeussere TouchableOpacity (close-on-tap-outside). */}
|
||||
<View style={{flex:1, backgroundColor:'rgba(0,0,0,0.5)', justifyContent:'flex-end'}}>
|
||||
{/* Tap-Outside-Bereich oberhalb des Sheets — separater Touchable
|
||||
damit das Sheet-View NICHT als Responder den FlatList-Scroll
|
||||
blockiert. Frueher hatten wir den ganzen Hintergrund als
|
||||
TouchableOpacity + inneren View mit onStartShouldSetResponder
|
||||
= das hat alle Touch-Events kassiert. */}
|
||||
<TouchableOpacity
|
||||
style={{flex:1}}
|
||||
activeOpacity={1}
|
||||
onPress={() => setThoughtsVisible(false)}
|
||||
/>
|
||||
<View
|
||||
style={{height:'60%', backgroundColor:'#0D0D1A', borderTopLeftRadius:16, borderTopRightRadius:16}}
|
||||
onStartShouldSetResponder={() => true}
|
||||
onResponderTerminationRequest={() => false}
|
||||
>
|
||||
{/* Drag-Indicator */}
|
||||
<View style={{alignItems:'center', paddingTop:8, paddingBottom:4}}>
|
||||
@@ -2480,7 +2563,7 @@ const ChatScreen: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* Notizen-Inbox — Listet alle Memories aus dem aktuellen Chat (Special-Bubbles).
|
||||
@@ -2515,7 +2598,7 @@ const ChatScreen: React.FC = () => {
|
||||
<Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:8, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
|
||||
Aus diesem Chat
|
||||
</Text>
|
||||
<ScrollView style={{paddingHorizontal:8}}>
|
||||
<ScrollView style={{paddingHorizontal:8}} nestedScrollEnabled={true}>
|
||||
{specials.map(m => {
|
||||
if (m.memorySaved) {
|
||||
const ms = m.memorySaved;
|
||||
@@ -2571,7 +2654,12 @@ const ChatScreen: React.FC = () => {
|
||||
<Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:10, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
|
||||
Alle Memories aus der DB
|
||||
</Text>
|
||||
<MemoryBrowser onOpenMemory={(id) => { setInboxVisible(false); setMemoryDetailId(id); }} />
|
||||
{/* flex:1 Wrapper damit MemoryBrowser den verbleibenden Platz
|
||||
bekommt (sonst rendert die FlatList intern mit 0 Hoehe und
|
||||
nimmt nur was der Inhalt sagt → Scroll-Gestures verschwinden). */}
|
||||
<View style={{flex:1}}>
|
||||
<MemoryBrowser onOpenMemory={(id) => { setInboxVisible(false); setMemoryDetailId(id); }} />
|
||||
</View>
|
||||
</View>
|
||||
</ErrorBoundary>
|
||||
</Modal>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
PermissionsAndroid,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import RNFS from 'react-native-fs';
|
||||
@@ -52,7 +53,9 @@ import {
|
||||
} from '../services/audio';
|
||||
import audioService from '../services/audio';
|
||||
import gpsTrackingService from '../services/gpsTracking';
|
||||
import { acquireBackgroundAudio, releaseBackgroundAudio } from '../services/backgroundAudio';
|
||||
import MemoryBrowser from '../components/MemoryBrowser';
|
||||
import TriggerBrowser from '../components/TriggerBrowser';
|
||||
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
|
||||
import {
|
||||
isWakeReadySoundEnabled,
|
||||
@@ -102,6 +105,7 @@ const SETTINGS_SECTIONS = [
|
||||
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
|
||||
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
|
||||
{ id: 'memory', icon: '🧠', label: 'Gedächtnis', desc: 'ARIA-Memories durchsuchen, anlegen, bearbeiten, löschen' },
|
||||
{ id: 'triggers', icon: '⏰', label: 'Trigger', desc: 'Timer + Watcher anlegen, bearbeiten, löschen' },
|
||||
{ id: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' },
|
||||
{ id: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' },
|
||||
] as const;
|
||||
@@ -118,6 +122,7 @@ const SOURCE_COLORS: Record<string, string> = {
|
||||
// --- Komponente ---
|
||||
|
||||
const SettingsScreen: React.FC = () => {
|
||||
const winDims = useWindowDimensions();
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
||||
const [manualToken, setManualToken] = useState('');
|
||||
const [manualHost, setManualHost] = useState('');
|
||||
@@ -125,6 +130,8 @@ const SettingsScreen: React.FC = () => {
|
||||
const [currentMode, setCurrentMode] = useState('normal');
|
||||
const [gpsEnabled, setGpsEnabled] = useState(false);
|
||||
const [gpsTracking, setGpsTracking] = useState(gpsTrackingService.isActive());
|
||||
const [backgroundMode, setBackgroundMode] = useState(true); // Default an
|
||||
const [showSystemHints, setShowSystemHints] = useState(false); // Default aus
|
||||
const [scannerVisible, setScannerVisible] = useState(false);
|
||||
const [logTab, setLogTab] = useState<LogTab>('live');
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
@@ -192,6 +199,14 @@ const SettingsScreen: React.FC = () => {
|
||||
AsyncStorage.getItem('aria_gps_enabled').then(saved => {
|
||||
if (saved !== null) setGpsEnabled(saved === 'true');
|
||||
});
|
||||
AsyncStorage.getItem('aria_background_mode').then(saved => {
|
||||
// Default ist an — nur explicit 'false' deaktiviert
|
||||
setBackgroundMode(saved !== 'false');
|
||||
});
|
||||
AsyncStorage.getItem('aria_show_hints').then(saved => {
|
||||
// Default ist aus — nur explicit 'true' aktiviert
|
||||
setShowSystemHints(saved === 'true');
|
||||
});
|
||||
// gpsTrackingService status syncen + auf Aenderungen lauschen
|
||||
setGpsTracking(gpsTrackingService.isActive());
|
||||
const offGps = gpsTrackingService.onChange(setGpsTracking);
|
||||
@@ -575,6 +590,44 @@ const SettingsScreen: React.FC = () => {
|
||||
AsyncStorage.setItem('aria_gps_enabled', String(value)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// --- Hintergrund-Modus Toggle ---
|
||||
|
||||
const handleBackgroundModeToggle = useCallback(async (value: boolean) => {
|
||||
setBackgroundMode(value);
|
||||
AsyncStorage.setItem('aria_background_mode', String(value)).catch(() => {});
|
||||
try {
|
||||
if (value) {
|
||||
// Permission fuer Notification (Android 13+) — sonst sieht der User
|
||||
// den Hintergrund-Modus nicht und wundert sich
|
||||
if (Platform.OS === 'android' && Platform.Version >= 33) {
|
||||
await PermissionsAndroid.request(
|
||||
'android.permission.POST_NOTIFICATIONS' as any,
|
||||
{
|
||||
title: 'Hintergrund-Modus',
|
||||
message: 'ARIA zeigt eine Notification damit die App im Hintergrund laufen darf.',
|
||||
buttonPositive: 'Erlauben',
|
||||
buttonNegative: 'Spaeter',
|
||||
},
|
||||
);
|
||||
}
|
||||
await acquireBackgroundAudio('background');
|
||||
ToastAndroid.show('Hintergrund-Modus aktiv', ToastAndroid.SHORT);
|
||||
} else {
|
||||
await releaseBackgroundAudio('background');
|
||||
ToastAndroid.show('Hintergrund-Modus aus', ToastAndroid.SHORT);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn('[Settings] Background-Toggle gescheitert:', err?.message || err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// --- System-Hints Toggle ---
|
||||
|
||||
const handleShowSystemHintsToggle = useCallback((value: boolean) => {
|
||||
setShowSystemHints(value);
|
||||
AsyncStorage.setItem('aria_show_hints', String(value)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// --- XTTS Voice ---
|
||||
|
||||
const selectVoice = useCallback((voiceName: string) => {
|
||||
@@ -868,7 +921,15 @@ const SettingsScreen: React.FC = () => {
|
||||
})()}
|
||||
</View>
|
||||
</Modal>
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content} nestedScrollEnabled={true}>
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={styles.content}
|
||||
nestedScrollEnabled={true}
|
||||
// Wenn eine Section eine eigene voll-hoch-scrollende Sub-Liste hat
|
||||
// (Memory, Trigger), den outer Scroll deaktivieren — Android-nested-
|
||||
// scrolling laesst sonst nur in eine Richtung scrollen.
|
||||
scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers'}
|
||||
>
|
||||
|
||||
{currentSection === null && (
|
||||
<>
|
||||
@@ -1053,6 +1114,55 @@ const SettingsScreen: React.FC = () => {
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* === Bubble-Anzeige === */}
|
||||
<Text style={styles.sectionTitle}>Chat-Bubbles</Text>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.toggleRow}>
|
||||
<View style={styles.toggleInfo}>
|
||||
<Text style={styles.toggleLabel}>System-Hints in Bubbles anzeigen</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Wenn aktiviert: GPS-Position, Barge-In-Hinweise und andere
|
||||
System-Praefixe in eckigen Klammern bleiben in der User-Bubble
|
||||
sichtbar (Debug). Standardmaessig versteckt — Brain bekommt sie
|
||||
trotzdem, sie sind nur fuer dich nicht relevant.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={showSystemHints}
|
||||
onValueChange={handleShowSystemHintsToggle}
|
||||
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
|
||||
thumbColor={showSystemHints ? '#FFFFFF' : '#666680'}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* === Hintergrund-Modus === */}
|
||||
<Text style={styles.sectionTitle}>Hintergrund-Modus</Text>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.toggleRow}>
|
||||
<View style={styles.toggleInfo}>
|
||||
<Text style={styles.toggleLabel}>App im Hintergrund weiterlaufen</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Haelt die Verbindung zu ARIA auch dann offen wenn die App minimiert
|
||||
ist. Sonst pausiert Android nach ~30s die JS-Engine und Timer-/Watcher-
|
||||
Trigger kommen nicht durch. Notification "ARIA aktiv" bleibt sichtbar
|
||||
waehrend der Modus laeuft (das ist Android-Vorschrift fuer Foreground-
|
||||
Services). Akku-Mehrverbrauch minimal solange ARIA nichts tut.
|
||||
{'\n\n'}
|
||||
Wenn nach Akku-Optimierung Trigger trotzdem nicht durchkommen:
|
||||
Android-Einstellungen → Apps → ARIA Cockpit → Akku → "Uneingeschraenkt"
|
||||
setzen.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={backgroundMode}
|
||||
onValueChange={handleBackgroundModeToggle}
|
||||
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
|
||||
thumbColor={backgroundMode ? '#FFFFFF' : '#666680'}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</>)}
|
||||
|
||||
{/* === Spracheingabe (geraetelokal) === */}
|
||||
@@ -1682,11 +1792,23 @@ const SettingsScreen: React.FC = () => {
|
||||
Alle Memory-Einträge aus ARIAs Vector-DB. Tippen zum Bearbeiten — mit Anhängen, pinned-Status,
|
||||
Tags. Neue Einträge anlegen via "+ Neu".
|
||||
</Text>
|
||||
<View style={{height: 600, marginBottom: 8}}>
|
||||
<View style={{height: winDims.height - 220, marginBottom: 8}}>
|
||||
<MemoryBrowser />
|
||||
</View>
|
||||
</>)}
|
||||
|
||||
{/* === Trigger === */}
|
||||
{currentSection === 'triggers' && (<>
|
||||
<Text style={styles.sectionTitle}>Trigger</Text>
|
||||
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
|
||||
Timer (einmalige Erinnerung) + Watcher (recurring mit Condition, z.B. GPS-near). Toggle aktiv/inaktiv,
|
||||
Tap zum Bearbeiten, "+ Neu" zum Anlegen.
|
||||
</Text>
|
||||
<View style={{height: winDims.height - 220, marginBottom: 8}}>
|
||||
<TriggerBrowser />
|
||||
</View>
|
||||
</>)}
|
||||
|
||||
{/* === Logs === */}
|
||||
{currentSection === 'protocol' && (<>
|
||||
<Text style={styles.sectionTitle}>Protokoll</Text>
|
||||
@@ -1798,7 +1920,7 @@ const SettingsScreen: React.FC = () => {
|
||||
<Text style={styles.aboutTitle}>ARIA Cockpit</Text>
|
||||
<Text style={styles.aboutVersion}>Version {require('../../package.json').version}</Text>
|
||||
<Text style={styles.aboutInfo}>
|
||||
ARIA \u2014 Autonomous Reasoning & Intelligence Assistant.{'\n'}
|
||||
ARIA {'\u2014'} Autonomous Reasoning & Intelligence Assistant.{'\n'}
|
||||
Stefans Kommandozentrale.{'\n'}
|
||||
Gebaut mit React Native + TypeScript.
|
||||
</Text>
|
||||
|
||||
@@ -727,6 +727,31 @@ class AudioService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Aufnahme abbrechen ohne RecordingResult zu emittieren — z.B. bei
|
||||
* Wake-Word-False-Positive beim App-Resume aus laengerem Hintergrund.
|
||||
* Aufgenommene Datei wird sofort verworfen. */
|
||||
async cancelRecording(): Promise<void> {
|
||||
if (this.recordingState !== 'recording') return;
|
||||
console.log('[Audio] Aufnahme abgebrochen (cancel)');
|
||||
this.vadEnabled = false;
|
||||
if (this.vadTimer) { clearInterval(this.vadTimer); this.vadTimer = null; }
|
||||
if (this.maxDurationTimer) { clearTimeout(this.maxDurationTimer); this.maxDurationTimer = null; }
|
||||
if (this.noSpeechTimer) { clearTimeout(this.noSpeechTimer); this.noSpeechTimer = null; }
|
||||
try {
|
||||
const path = await this.recorder.stopRecorder();
|
||||
this.recorder.removeRecordBackListener();
|
||||
// Datei loeschen wenn da
|
||||
if (path && path !== 'Already stopped') {
|
||||
const local = path.replace(/^file:\/\//, '');
|
||||
try { await RNFS.unlink(local); } catch {}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Audio] cancelRecording stop fehlgeschlagen:', err);
|
||||
}
|
||||
this._releaseFocusDeferred();
|
||||
this.setState('idle');
|
||||
}
|
||||
|
||||
/** Aufnahme stoppen und Ergebnis zurueckgeben */
|
||||
async stopRecording(): Promise<RecordingResult | null> {
|
||||
if (this.recordingState !== 'recording') {
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
/**
|
||||
* Background-Audio: ARIAs TTS, Mic-Aufnahme und Wake-Word-Lauschen sollen
|
||||
* auch bei minimierter App weiterlaufen. Wir starten dafuer einen Foreground-
|
||||
* Background-Audio + Hintergrund-Persistenz: ARIAs TTS, Mic-Aufnahme,
|
||||
* Wake-Word-Lauschen UND der allgemeine Hintergrund-Modus laufen
|
||||
* weiter wenn die App minimiert ist. Wir starten dafuer einen Foreground-
|
||||
* Service mit foregroundServiceType=mediaPlayback|microphone, der eine
|
||||
* persistente Notification zeigt waehrend irgendein Audio-Slot aktiv ist.
|
||||
* persistente Notification zeigt solange irgendein Slot aktiv ist.
|
||||
*
|
||||
* Mehrere Komponenten koennen den Service unabhaengig "halten":
|
||||
* - 'tts' : ARIA spricht
|
||||
* - 'rec' : Aufnahme laeuft
|
||||
* - 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
|
||||
* - 'tts' : ARIA spricht
|
||||
* - 'rec' : Aufnahme laeuft
|
||||
* - 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
|
||||
* - 'background' : Persistenter Hintergrund-Modus (Settings-Toggle).
|
||||
* Haelt JS-Engine + WebSocket auch ohne Audio am Leben
|
||||
* → Trigger-Replies, Reconnects, Push-Reaktionen.
|
||||
*
|
||||
* Solange mindestens ein Slot aktiv ist, laeuft der Service. Wenn alle
|
||||
* Slots leer sind, wird er gestoppt. Der Notification-Text passt sich an
|
||||
* den hoechstprioren Slot an (tts > rec > wake).
|
||||
* den hoechstprioren Slot an (tts > rec > wake > background).
|
||||
*/
|
||||
|
||||
import { NativeModules } from 'react-native';
|
||||
@@ -23,12 +27,13 @@ interface BackgroundAudioNative {
|
||||
|
||||
const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative };
|
||||
|
||||
type Slot = 'tts' | 'rec' | 'wake';
|
||||
type Slot = 'tts' | 'rec' | 'wake' | 'background';
|
||||
|
||||
const slots = new Set<Slot>();
|
||||
|
||||
// Prioritaet fuer den Notification-Text — hoechste zuerst.
|
||||
const PRIORITY: Slot[] = ['tts', 'rec', 'wake'];
|
||||
// Prioritaet fuer den Notification-Text — hoechste zuerst. 'background'
|
||||
// ist die fallback-Anzeige wenn nichts anderes laeuft.
|
||||
const PRIORITY: Slot[] = ['tts', 'rec', 'wake', 'background'];
|
||||
|
||||
function topReason(): string {
|
||||
for (const s of PRIORITY) {
|
||||
|
||||
@@ -121,6 +121,24 @@ export interface Memory {
|
||||
attachments?: MemoryAttachment[];
|
||||
}
|
||||
|
||||
/** Trigger-Manifest wie aus Brain `/triggers/list` zurueckkommt. */
|
||||
export interface Trigger {
|
||||
name: string;
|
||||
type: 'timer' | 'watcher' | string;
|
||||
active: boolean;
|
||||
author?: string;
|
||||
message: string;
|
||||
fires_at?: string; // ISO, nur timer
|
||||
condition?: string; // nur watcher
|
||||
check_interval_sec?: number; // nur watcher
|
||||
throttle_sec?: number; // nur watcher
|
||||
fire_count?: number;
|
||||
last_fired_at?: string | null;
|
||||
last_checked_at?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// ── Memory CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
export const brainApi = {
|
||||
@@ -215,6 +233,74 @@ export const brainApi = {
|
||||
{ expectBinary: true, timeoutMs: 60000 },
|
||||
);
|
||||
},
|
||||
|
||||
// ── Triggers ────────────────────────────────────────────────────────
|
||||
|
||||
/** Liste aller Trigger (aktive + inaktive). */
|
||||
listTriggers(): Promise<Trigger[]> {
|
||||
return _send('/triggers/list');
|
||||
},
|
||||
|
||||
/** Einzelnen Trigger holen (inkl. fire_count, last_fired_at, ...). */
|
||||
getTrigger(name: string): Promise<Trigger> {
|
||||
return _send(`/triggers/${encodeURIComponent(name)}`);
|
||||
},
|
||||
|
||||
/** Verfuegbare Condition-Variablen + Funktionen (fuer Watcher-Editor). */
|
||||
getTriggerConditions(): Promise<{ variables: any[]; functions: any[] }> {
|
||||
return _send('/triggers/conditions');
|
||||
},
|
||||
|
||||
/** Trigger-Logs (last N Feuerungen). */
|
||||
getTriggerLogs(name: string, limit: number = 50): Promise<any[]> {
|
||||
return _send(`/triggers/${encodeURIComponent(name)}/logs?limit=${limit}`);
|
||||
},
|
||||
|
||||
/** Timer anlegen. fires_at = ISO timestamp (UTC). */
|
||||
createTimer(body: { name: string; fires_at: string; message: string; author?: string }): Promise<Trigger> {
|
||||
return _send('/triggers/timer', {
|
||||
method: 'POST',
|
||||
body: { author: 'app', ...body },
|
||||
});
|
||||
},
|
||||
|
||||
/** Watcher anlegen. */
|
||||
createWatcher(body: {
|
||||
name: string;
|
||||
condition: string;
|
||||
message: string;
|
||||
check_interval_sec?: number;
|
||||
throttle_sec?: number;
|
||||
author?: string;
|
||||
}): Promise<Trigger> {
|
||||
return _send('/triggers/watcher', {
|
||||
method: 'POST',
|
||||
body: { author: 'app', ...body },
|
||||
});
|
||||
},
|
||||
|
||||
/** Trigger patchen (active/message/condition/throttle/interval/fires_at). */
|
||||
updateTrigger(name: string, body: Partial<{
|
||||
active: boolean;
|
||||
message: string;
|
||||
condition: string;
|
||||
throttle_sec: number;
|
||||
check_interval_sec: number;
|
||||
fires_at: string;
|
||||
}>): Promise<Trigger> {
|
||||
return _send(`/triggers/${encodeURIComponent(name)}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/** Trigger loeschen. */
|
||||
deleteTrigger(name: string): Promise<{ deleted: string }> {
|
||||
return _send(`/triggers/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default brainApi;
|
||||
|
||||
@@ -26,6 +26,13 @@ class GpsTrackingService {
|
||||
private listeners: Set<Listener> = new Set();
|
||||
// Defensive: nicht zu schnell oeffentlich togglen
|
||||
private lastChangeAt = 0;
|
||||
// Letzte bekannte Position — wird vom Heartbeat-Timer alle 60s erneut
|
||||
// an die Bridge gesendet, sonst veraltet near() im Brain (NEAR_MAX_AGE_SEC
|
||||
// = 5 min) wenn der User stationaer ist und distanceFilter keine Updates
|
||||
// mehr triggert.
|
||||
private lastLat: number | null = null;
|
||||
private lastLon: number | null = null;
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
@@ -84,6 +91,8 @@ class GpsTrackingService {
|
||||
(pos) => {
|
||||
const lat = pos.coords.latitude;
|
||||
const lon = pos.coords.longitude;
|
||||
this.lastLat = lat;
|
||||
this.lastLon = lon;
|
||||
rvs.send('location_update' as any, { lat, lon });
|
||||
},
|
||||
(err) => {
|
||||
@@ -96,6 +105,17 @@ class GpsTrackingService {
|
||||
fastestInterval: 10000, // (Android) max Frequenz
|
||||
} as any,
|
||||
);
|
||||
// Heartbeat: alle 60s die letzte bekannte Position erneut senden.
|
||||
// Sonst bleibt der Brain-State stale wenn der User stationaer ist
|
||||
// (distanceFilter blockt watchPosition-Updates) → near()-Watcher
|
||||
// verwerfen die Position als veraltet (NEAR_MAX_AGE_SEC = 300s).
|
||||
// Kein neuer GPS-Wakeup, nur Re-Send der letzten Werte → akkufreundlich.
|
||||
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (this.lastLat != null && this.lastLon != null) {
|
||||
rvs.send('location_update' as any, { lat: this.lastLat, lon: this.lastLon });
|
||||
}
|
||||
}, 60_000);
|
||||
this.active = true;
|
||||
this.lastChangeAt = Date.now();
|
||||
this.notify();
|
||||
@@ -118,6 +138,10 @@ class GpsTrackingService {
|
||||
try { Geolocation.clearWatch(this.watchId); } catch {}
|
||||
this.watchId = null;
|
||||
}
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
this.active = false;
|
||||
this.lastChangeAt = Date.now();
|
||||
this.notify();
|
||||
|
||||
@@ -43,6 +43,42 @@ class PhoneCallService {
|
||||
/** Damit Resume nach VoIP-Loss nicht doppelt feuert wenn auch
|
||||
* TelephonyManager-IDLE-Event kommt. */
|
||||
private interruptedByFocus: boolean = false;
|
||||
/** True wenn der TelephonyManager-Listener (Pfad 1) wirklich registriert
|
||||
* ist. False wenn READ_PHONE_STATE abgelehnt wurde oder Native nicht ging. */
|
||||
private telephonyAttached: boolean = false;
|
||||
|
||||
/** Status fuer Diagnose: laeuft die Anruf-Erkennung tatsaechlich? */
|
||||
status(): { focusAttached: boolean; telephonyAttached: boolean } {
|
||||
return {
|
||||
focusAttached: this.focusSubscription !== null,
|
||||
telephonyAttached: this.telephonyAttached,
|
||||
};
|
||||
}
|
||||
|
||||
/** Nach App-Resume: pruefen ob die Listener noch leben. Wenn der
|
||||
* TelephonyManager-Listener verloren ging (kann passieren wenn der
|
||||
* React-Bridge-Context recreated wurde), neu attachen. */
|
||||
async refresh(): Promise<void> {
|
||||
if (!this.started) return;
|
||||
if (this.telephonyAttached) return; // alles ok
|
||||
if (!PhoneCall) return;
|
||||
try {
|
||||
const ok = await PhoneCall.start();
|
||||
if (ok) {
|
||||
if (!this.subscription) {
|
||||
const emitter = new NativeEventEmitter(NativeModules.PhoneCall as any);
|
||||
this.subscription = emitter.addListener(
|
||||
'PhoneCallStateChanged',
|
||||
(e: { state: PhoneState }) => this._onStateChanged(e.state),
|
||||
);
|
||||
}
|
||||
this.telephonyAttached = true;
|
||||
console.log('[PhoneCall] refresh: TelephonyManager-Listener re-attached');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn('[PhoneCall] refresh fehlgeschlagen:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<boolean> {
|
||||
if (this.started || Platform.OS !== 'android') return false;
|
||||
@@ -82,7 +118,10 @@ class PhoneCallService {
|
||||
'PhoneCallStateChanged',
|
||||
(e: { state: PhoneState }) => this._onStateChanged(e.state),
|
||||
);
|
||||
this.telephonyAttached = true;
|
||||
console.log('[PhoneCall] TelephonyManager-Listener aktiv');
|
||||
} else {
|
||||
console.warn('[PhoneCall] PhoneCall.start() lieferte false — Native-Listener nicht aktiv');
|
||||
}
|
||||
} else {
|
||||
console.warn('[PhoneCall] READ_PHONE_STATE abgelehnt — VoIP-Calls werden trotzdem ueber AudioFocus erkannt');
|
||||
@@ -108,6 +147,7 @@ class PhoneCallService {
|
||||
this.started = false;
|
||||
this.lastState = 'idle';
|
||||
this.interruptedByFocus = false;
|
||||
this.telephonyAttached = false;
|
||||
}
|
||||
|
||||
private _onStateChanged(state: PhoneState): void {
|
||||
|
||||
@@ -86,6 +86,11 @@ class WakeWordService {
|
||||
* oft einen Audio-Pegel-Spike (AudioFocus-Switch, AudioTrack re-route),
|
||||
* der openWakeWord faelschlich triggern kann. */
|
||||
private cooldownUntilMs: number = 0;
|
||||
/** Zeitpunkt des letzten echten Wake-Word-Triggers — gebraucht damit
|
||||
* ChatScreen entscheiden kann ob ein 'conversing'-State bei App-Resume
|
||||
* ein false-positive war (Wake-Word im Hintergrund getriggert waehrend
|
||||
* Stefan gar nicht in der App war). */
|
||||
private lastTriggerAt: number = 0;
|
||||
|
||||
private keyword: WakeKeyword = DEFAULT_KEYWORD;
|
||||
private nativeReady: boolean = false;
|
||||
@@ -231,6 +236,7 @@ class WakeWordService {
|
||||
}
|
||||
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
|
||||
this.keyword, this.state, this.bargeListening);
|
||||
this.lastTriggerAt = now;
|
||||
if (this.nativeReady && OpenWakeWord) {
|
||||
try { await OpenWakeWord.stop(); } catch {}
|
||||
}
|
||||
@@ -341,6 +347,33 @@ class WakeWordService {
|
||||
this.setState('off');
|
||||
}
|
||||
|
||||
/** Wenn ein conversing-State auf einem Wake-Word-Trigger juenger als
|
||||
* maxAgeMs basiert: false-positive verwerfen, zurueck zu armed.
|
||||
* Wird vom ChatScreen aufgerufen wenn die App aus laengerem Hintergrund
|
||||
* zurueck kommt — dann ist ein „gerade getriggertes" Wake-Word sehr
|
||||
* wahrscheinlich ein TV-Spike, Husten, ARIAs eigene TTS-Aufnahme etc.
|
||||
* Returnt true wenn verworfen wurde. */
|
||||
async discardIfFreshlyTriggered(maxAgeMs: number = 10_000): Promise<boolean> {
|
||||
if (this.state !== 'conversing') return false;
|
||||
if (this.lastTriggerAt === 0) return false;
|
||||
const age = Date.now() - this.lastTriggerAt;
|
||||
if (age > maxAgeMs) return false;
|
||||
console.log('[WakeWord] Resume: verwerfe verdaechtiges conversing (age=%dms)', age);
|
||||
this.lastTriggerAt = 0;
|
||||
if (this.nativeReady && OpenWakeWord) {
|
||||
try {
|
||||
await OpenWakeWord.start();
|
||||
ToastAndroid.show('Hintergrund-Trigger verworfen — lausche wieder', ToastAndroid.SHORT);
|
||||
this.setState('armed');
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[WakeWord] re-arm nach discard fehlgeschlagen:', err);
|
||||
}
|
||||
}
|
||||
this.setState('off');
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten */
|
||||
async resume(): Promise<void> {
|
||||
if (this.state !== 'conversing') return;
|
||||
|
||||
+106
-37
@@ -320,8 +320,7 @@
|
||||
<input type="file" id="diag-file-input" multiple accept="image/*,application/pdf,.doc,.docx,.txt" style="display:none;" onchange="handleDiagFileSelect(this.files)">
|
||||
</label>
|
||||
<textarea id="chat-input" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" onpaste="handleDiagPaste(event)" oninput="autoResizeTextarea(this)"></textarea>
|
||||
<button class="btn" id="btn-gw" onclick="testGateway()">Gateway senden</button>
|
||||
<button class="btn" id="btn-rvs" onclick="testRVS()">Via RVS senden</button>
|
||||
<button class="btn" id="btn-rvs" onclick="testRVS()">Senden</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -338,8 +337,7 @@
|
||||
</div>
|
||||
<div class="input-row" style="margin-top:8px;">
|
||||
<textarea id="chat-input-fs" placeholder="Nachricht an ARIA... (Enter sendet, Shift+Enter neue Zeile)" rows="2" oninput="autoResizeTextarea(this)"></textarea>
|
||||
<button class="btn" onclick="testGatewayFS()">Gateway senden</button>
|
||||
<button class="btn" onclick="testRVSFS()">Via RVS senden</button>
|
||||
<button class="btn" onclick="testRVSFS()">Senden</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -367,7 +365,6 @@
|
||||
<div style="padding: 0 12px;">
|
||||
<div class="tab-bar">
|
||||
<button class="tab-btn active" data-tab="all" onclick="switchTab('all')">Alle <span class="tab-count" id="count-all">0</span></button>
|
||||
<button class="tab-btn" data-tab="gateway" onclick="switchTab('gateway')">Gateway <span class="tab-count" id="count-gateway">0</span></button>
|
||||
<button class="tab-btn" data-tab="rvs" onclick="switchTab('rvs')">RVS <span class="tab-count" id="count-rvs">0</span></button>
|
||||
<button class="tab-btn" data-tab="proxy" onclick="switchTab('proxy')">Proxy <span class="tab-count" id="count-proxy">0</span></button>
|
||||
<button class="tab-btn" data-tab="bridge" onclick="switchTab('bridge')">Bridge <span class="tab-count" id="count-bridge">0</span></button>
|
||||
@@ -386,7 +383,6 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="log-box" id="log-all"></div>
|
||||
<div class="log-box hidden" id="log-gateway"></div>
|
||||
<div class="log-box hidden" id="log-rvs"></div>
|
||||
<div class="log-box hidden" id="log-proxy"></div>
|
||||
<div class="log-box hidden" id="log-bridge"></div>
|
||||
@@ -968,11 +964,11 @@
|
||||
</div>
|
||||
</div><!-- /tab-triggers -->
|
||||
|
||||
<!-- Trigger-Create Modal -->
|
||||
<!-- Trigger-Create/Edit Modal -->
|
||||
<div class="modal-overlay" id="trigger-modal">
|
||||
<div class="modal-box" style="max-width:600px;">
|
||||
<div class="modal-header">
|
||||
<h3>Neuer Trigger</h3>
|
||||
<h3 id="trigger-modal-title">Neuer Trigger</h3>
|
||||
<button class="modal-close" onclick="closeTriggerModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding:16px;">
|
||||
@@ -986,8 +982,16 @@
|
||||
|
||||
<!-- Timer-spezifisch -->
|
||||
<div id="trigger-timer-fields">
|
||||
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">In wievielen Minuten?</label>
|
||||
<input type="number" id="trigger-timer-minutes" min="1" max="10080" value="10" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
|
||||
<!-- Create-mode: relativ („in X Minuten ab jetzt") -->
|
||||
<div id="trigger-timer-create-fields">
|
||||
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">In wievielen Minuten?</label>
|
||||
<input type="number" id="trigger-timer-minutes" min="1" max="10080" value="10" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
|
||||
</div>
|
||||
<!-- Edit-mode: absoluter ISO-Timestamp (UTC) -->
|
||||
<div id="trigger-timer-edit-fields" style="display:none;">
|
||||
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Feuert am (ISO, UTC)</label>
|
||||
<input type="text" id="trigger-timer-fires-at" placeholder="2026-05-15T20:00:00+00:00" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:monospace;margin-bottom:10px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Watcher-spezifisch -->
|
||||
@@ -1008,7 +1012,7 @@
|
||||
</div>
|
||||
<div class="modal-footer" style="padding:10px 16px;border-top:1px solid #1E1E2E;display:flex;justify-content:flex-end;gap:8px;">
|
||||
<button class="btn secondary" onclick="closeTriggerModal()">Abbrechen</button>
|
||||
<button class="btn" onclick="saveTrigger()">Anlegen</button>
|
||||
<button class="btn" id="trigger-modal-save-btn" onclick="saveTrigger()">Anlegen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1110,13 +1114,12 @@
|
||||
const btnScroll = document.getElementById('btn-scroll');
|
||||
let ws;
|
||||
let activeTab = 'all';
|
||||
const DOCKER_TABS = ['gateway', 'proxy', 'bridge'];
|
||||
const autoScroll = { all: true, gateway: true, rvs: true, proxy: true, bridge: true, server: true, trace: true };
|
||||
const logCounts = { all: 0, gateway: 0, rvs: 0, proxy: 0, bridge: 0, server: 0, trace: 0 };
|
||||
const DOCKER_TABS = ['proxy', 'bridge'];
|
||||
const autoScroll = { all: true, rvs: true, proxy: true, bridge: true, server: true, trace: true };
|
||||
const logCounts = { all: 0, rvs: 0, proxy: 0, bridge: 0, server: 0, trace: 0 };
|
||||
|
||||
const logBoxes = {
|
||||
all: document.getElementById('log-all'),
|
||||
gateway: document.getElementById('log-gateway'),
|
||||
rvs: document.getElementById('log-rvs'),
|
||||
proxy: document.getElementById('log-proxy'),
|
||||
bridge: document.getElementById('log-bridge'),
|
||||
@@ -1170,7 +1173,9 @@
|
||||
}
|
||||
|
||||
function mapSourceToTab(source) {
|
||||
if (source === 'gateway') return 'gateway';
|
||||
// Gateway-Source: deprecated — falls noch was reinkommt zeigen wir's
|
||||
// einfach unter 'server'. Spart einen toten Tab.
|
||||
if (source === 'gateway') return 'server';
|
||||
if (source === 'rvs') return 'rvs';
|
||||
if (source === 'proxy') return 'proxy';
|
||||
if (source === 'bridge') return 'bridge';
|
||||
@@ -1612,18 +1617,6 @@
|
||||
renderDiagPending();
|
||||
}
|
||||
|
||||
function testGateway() {
|
||||
const input = document.getElementById('chat-input');
|
||||
const text = input.value.trim();
|
||||
if (!text && diagPendingFiles.length === 0) return;
|
||||
if (diagPendingFiles.length > 0) sendDiagAttachments();
|
||||
if (text) {
|
||||
addChat('sent', text, 'Gateway direkt');
|
||||
send({ action: 'test_gateway', text });
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function testRVS() {
|
||||
const input = document.getElementById('chat-input');
|
||||
const text = input.value.trim();
|
||||
@@ -1763,7 +1756,6 @@
|
||||
if (proxy.models && proxy.models.length) showProxyModels(proxy.models);
|
||||
|
||||
// Buttons
|
||||
document.getElementById('btn-gw').disabled = gw.status !== 'connected';
|
||||
document.getElementById('btn-rvs').disabled = rvs.status !== 'connected';
|
||||
}
|
||||
|
||||
@@ -2086,14 +2078,6 @@
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
function testGatewayFS() {
|
||||
const input = document.getElementById('chat-input-fs');
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
addChat('sent', text, 'Gateway direkt');
|
||||
send({ action: 'test_gateway', text });
|
||||
input.value = '';
|
||||
}
|
||||
function testRVSFS() {
|
||||
const input = document.getElementById('chat-input-fs');
|
||||
const text = input.value.trim();
|
||||
@@ -3101,6 +3085,7 @@
|
||||
<div style="color:#8888AA;font-size:11px;margin-top:4px;">${detailLine}</div>
|
||||
<div style="color:#888;font-size:12px;margin-top:2px;">"${escapeHtml(t.message || '')}"</div>
|
||||
<div style="margin-top:6px;display:flex;gap:6px;">
|
||||
<button class="btn secondary" onclick="openTriggerEdit('${escapeHtml(t.name)}')" style="padding:2px 10px;font-size:10px;color:#0096FF;border-color:#0096FF;">✎ Bearbeiten</button>
|
||||
<button class="btn secondary" onclick="toggleTriggerActive('${escapeHtml(t.name)}', ${!active})" style="padding:2px 10px;font-size:10px;color:#FF9500;border-color:#FF9500;">${active ? '⏸ Deaktivieren' : '▶ Aktivieren'}</button>
|
||||
<button class="btn secondary" onclick="deleteTrigger('${escapeHtml(t.name)}')" style="padding:2px 10px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;">🗑 Löschen</button>
|
||||
</div>
|
||||
@@ -3138,10 +3123,21 @@
|
||||
document.getElementById('trigger-watcher-fields').style.display = t === 'watcher' ? '' : 'none';
|
||||
}
|
||||
|
||||
// null = Create-Modus, string = Edit-Modus (Name der bearbeiteten Bubble)
|
||||
let editingTriggerName = null;
|
||||
|
||||
async function openTriggerCreate() {
|
||||
editingTriggerName = null;
|
||||
document.getElementById('trigger-modal-title').textContent = 'Neuer Trigger';
|
||||
document.getElementById('trigger-modal-save-btn').textContent = 'Anlegen';
|
||||
document.getElementById('trigger-type').disabled = false;
|
||||
document.getElementById('trigger-name').disabled = false;
|
||||
document.getElementById('trigger-timer-create-fields').style.display = '';
|
||||
document.getElementById('trigger-timer-edit-fields').style.display = 'none';
|
||||
document.getElementById('trigger-type').value = 'timer';
|
||||
document.getElementById('trigger-name').value = '';
|
||||
document.getElementById('trigger-timer-minutes').value = '10';
|
||||
document.getElementById('trigger-timer-fires-at').value = '';
|
||||
document.getElementById('trigger-condition').value = '';
|
||||
document.getElementById('trigger-check-interval').value = '300';
|
||||
document.getElementById('trigger-throttle').value = '3600';
|
||||
@@ -3170,6 +3166,52 @@
|
||||
|
||||
function closeTriggerModal() {
|
||||
document.getElementById('trigger-modal').classList.remove('open');
|
||||
editingTriggerName = null;
|
||||
}
|
||||
|
||||
/** Edit-Modus: Modal mit existierenden Trigger-Werten fuellen. */
|
||||
async function openTriggerEdit(name) {
|
||||
const t = triggersCache.find(x => x.name === name);
|
||||
if (!t) { alert('Trigger nicht in cache, lade neu...'); loadTriggers(); return; }
|
||||
editingTriggerName = name;
|
||||
document.getElementById('trigger-modal-title').textContent = 'Trigger bearbeiten — ' + name;
|
||||
document.getElementById('trigger-modal-save-btn').textContent = 'Speichern';
|
||||
// Type + Name sind im Edit-Modus nicht aenderbar
|
||||
document.getElementById('trigger-type').value = t.type;
|
||||
document.getElementById('trigger-type').disabled = true;
|
||||
document.getElementById('trigger-name').value = t.name;
|
||||
document.getElementById('trigger-name').disabled = true;
|
||||
// Timer: relative-Minutes-Feld aus, absoluter ISO-Feld an
|
||||
document.getElementById('trigger-timer-create-fields').style.display = 'none';
|
||||
document.getElementById('trigger-timer-edit-fields').style.display = '';
|
||||
document.getElementById('trigger-timer-fires-at').value = t.fires_at || '';
|
||||
// Watcher-Felder vorbefuellen
|
||||
document.getElementById('trigger-condition').value = t.condition || '';
|
||||
document.getElementById('trigger-check-interval').value = String(t.check_interval_sec || 300);
|
||||
document.getElementById('trigger-throttle').value = String(t.throttle_sec || 3600);
|
||||
document.getElementById('trigger-message').value = t.message || '';
|
||||
document.getElementById('trigger-modal-error').style.display = 'none';
|
||||
onTriggerTypeChange();
|
||||
// Variablen-Hinweis fuer Watcher auch im Edit-Modus
|
||||
if (t.type === 'watcher') {
|
||||
try {
|
||||
const r = await fetch('/api/brain/triggers/conditions');
|
||||
const d = await r.json();
|
||||
const info = document.getElementById('trigger-vars-info');
|
||||
if (info) {
|
||||
const vars = (d.variables || []).map(v =>
|
||||
`<code>${escapeHtml(v.name)}</code>=${escapeHtml(String(d.current[v.name]))} <span style="color:#444;">(${escapeHtml(v.desc)})</span>`
|
||||
).join(' · ');
|
||||
const fns = (d.functions || []).map(f =>
|
||||
`<code>${escapeHtml(f.signature)}</code> — ${escapeHtml(f.desc)}`
|
||||
).join('<br>');
|
||||
info.innerHTML =
|
||||
'<strong>Variablen:</strong> ' + vars +
|
||||
(fns ? '<br><br><strong>Funktionen:</strong><br>' + fns : '');
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
document.getElementById('trigger-modal').classList.add('open');
|
||||
}
|
||||
|
||||
async function saveTrigger() {
|
||||
@@ -3181,6 +3223,33 @@
|
||||
if (!name) { errEl.textContent = 'Name fehlt.'; errEl.style.display = 'block'; return; }
|
||||
if (!message) { errEl.textContent = 'Nachricht fehlt.'; errEl.style.display = 'block'; return; }
|
||||
try {
|
||||
// ── EDIT-MODUS ──────────────────────────────────────────
|
||||
if (editingTriggerName) {
|
||||
const patch = { message };
|
||||
if (ttype === 'watcher') {
|
||||
const condition = document.getElementById('trigger-condition').value.trim();
|
||||
if (!condition) { errEl.textContent = 'Condition fehlt.'; errEl.style.display = 'block'; return; }
|
||||
patch.condition = condition;
|
||||
patch.check_interval_sec = parseInt(document.getElementById('trigger-check-interval').value, 10) || 300;
|
||||
patch.throttle_sec = parseInt(document.getElementById('trigger-throttle').value, 10) || 3600;
|
||||
} else if (ttype === 'timer') {
|
||||
const fa = document.getElementById('trigger-timer-fires-at').value.trim();
|
||||
if (fa) patch.fires_at = fa;
|
||||
}
|
||||
const r = await fetch('/api/brain/triggers/' + encodeURIComponent(editingTriggerName), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const txt = await r.text();
|
||||
throw new Error('HTTP ' + r.status + ': ' + txt.slice(0, 200));
|
||||
}
|
||||
closeTriggerModal();
|
||||
loadTriggers();
|
||||
return;
|
||||
}
|
||||
// ── CREATE-MODUS ────────────────────────────────────────
|
||||
let url, body;
|
||||
if (ttype === 'timer') {
|
||||
const mins = parseInt(document.getElementById('trigger-timer-minutes').value, 10) || 10;
|
||||
|
||||
+17
-15
@@ -492,9 +492,10 @@ function handleGatewayMessage(msg) {
|
||||
}
|
||||
|
||||
function sendToGateway(text, isTrace) {
|
||||
// OpenClaw-Gateway ist raus — Brain via Bridge via RVS ist die einzige
|
||||
// Route. Wir loggen nichts mehr; alte Trace-Aufrufe schliessen wir clean.
|
||||
if (!gatewayWs || gatewayWs.readyState !== WebSocket.OPEN) {
|
||||
log("error", "gateway", "Nicht verbunden — kann nicht senden");
|
||||
if (isTrace) traceEnd(false, "Gateway nicht verbunden");
|
||||
if (isTrace) traceEnd(false, "Gateway deprecated — nutze RVS");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -757,22 +758,20 @@ function sendToRVS_raw(msgObj) {
|
||||
}
|
||||
|
||||
function sendToRVS(text, isTrace) {
|
||||
// Ueber Gateway senden (zuverlaessig) UND an RVS fuer App-Sichtbarkeit
|
||||
// Die Bridge empfaengt RVS-Nachrichten von der App zuverlaessig,
|
||||
// aber die Diagnostic→RVS→Bridge Route hat Zombie-Probleme.
|
||||
// Deshalb: Gateway fuer ARIA, RVS nur fuer App-Anzeige.
|
||||
|
||||
// 1. An Gateway senden (damit ARIA antwortet)
|
||||
const gatewayOk = sendToGateway(text, isTrace);
|
||||
|
||||
// 2. An RVS senden (damit die App die Nachricht sieht)
|
||||
// Brain-Pipeline: Diagnostic → RVS → Bridge → Brain (HTTP). OpenClaw-
|
||||
// Gateway-Pfad ist abgeschaltet. Sender 'diagnostic' damit die Bridge
|
||||
// den Text als User-Nachricht ans Brain weiterleitet und die App +
|
||||
// Diagnostic die Bubble live spiegeln koennen.
|
||||
if (!rvsWs || rvsWs.readyState !== WebSocket.OPEN) {
|
||||
if (isTrace) traceEnd(false, "RVS nicht verbunden");
|
||||
return false;
|
||||
}
|
||||
sendToRVS_raw({
|
||||
type: "chat",
|
||||
payload: { text, sender: "diagnostic" },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return gatewayOk;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Claude Proxy Test ────────────────────────────────────
|
||||
@@ -1836,8 +1835,11 @@ wss.on("connection", (ws) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
|
||||
if (msg.action === "test_gateway") {
|
||||
traceStart("Gateway", msg.text || "aria lebst du noch?");
|
||||
sendToGateway(msg.text || "aria lebst du noch?", true);
|
||||
// Deprecated — Gateway-Pfad ist raus. Wir leiten an RVS um damit
|
||||
// alte Browser-Sessions die noch den Button anzeigen nicht stumm
|
||||
// ins Leere klicken. Neue Versionen kennen den Button nicht mehr.
|
||||
traceStart("RVS", msg.text || "aria lebst du noch?");
|
||||
sendToRVS(msg.text || "aria lebst du noch?", true);
|
||||
} else if (msg.action === "test_rvs") {
|
||||
traceStart("RVS", msg.text || "aria lebst du noch?");
|
||||
sendToRVS(msg.text || "aria lebst du noch?", true);
|
||||
|
||||
@@ -365,6 +365,18 @@ Skills mit Tool-Use.
|
||||
- [x] **Gedanken-Stream in App + Diagnostic**: chronologisches Log was ARIA intern macht, gefuettert aus `agent_activity`-Events (thinking/tool/assistant/idle). Bleibt zwischen Denk-Phasen stehen, lange Pausen sichtbar als Trennlinie mit Minuten-Hint. App: 💭-Icon in der Statusleiste oeffnet Bottom-Sheet mit chronologischer Liste, 🗑-Confirm zum Leeren. Diagnostic: 💭 Gedanken-Button im Chat-Test-Header oeffnet zentrales Modal, Live-Update wenn neue Eintraege kommen (autoscroll ans Ende). Persistierung in AsyncStorage / localStorage, capped auf 500 Eintraege
|
||||
- [x] **Live-Tool-Events vom Proxy**: dritter Proxy-Patch (`proxy-patches/routes.js`) hookt Claude-CLI `assistant`-Events — bei jedem `tool_use`-Block (Bash, Read, Edit, Grep, ...) wird per HTTP-POST an die Bridge gemeldet. Bridge spiegelt das als `agent_activity tool=<name>` an RVS-Clients. Vorher kam pro Brain-Call nur EIN „💭 denkt" am Anfang und EIN „✓ fertig" am Ende — jetzt sieht man **live** in beiden UIs wie ARIA durch die Tools haengt. Hook ist fire-and-forget (ARIA_TOOL_HOOK_URL Env-Variable, default http://aria-bridge:8090/internal/agent-activity)
|
||||
|
||||
### Such-Sprung-Praezision + Such-Reihenfolge
|
||||
|
||||
- [x] **Such-Sprung kalt nach App-Start**: scrollToIndex landete bei langen Listen weit daneben (Cessna-Treffer → Sprung zur Oberhausen-Bubble 15 Stellen daneben). `info.averageItemLength` aus `onScrollToIndexFailed` basierte auf den ersten ~10 gerenderten Items — bei sehr unterschiedlichen Bubble-Hoehen (Voice ~70 px, lange ARIA-Antworten 400+ px) eine grottige Schaetzung. Fix: `itemHeights`-Ref-Map wird per `onLayout` in `renderMessage` gefuettert; Pre-Scroll summiert echte gemessene Hoehen (Fallback `AVG_BUBBLE_HEIGHT=150` fuer noch nicht gerenderte). Plus `initialNumToRender: 30` (Default 10) und `windowSize: 41` (Default 21) → mehr Items beim Mount gemessen
|
||||
- [x] **Such-Scroll Endlos-Loop (Wiederkehr)**: `onScrollToIndexFailed` retried unbegrenzt — jeder failed Retry rief den Handler erneut auf → neuer Timer → fail → loop. Plus: `setMessages` im `agent_activity`-Handler rief `prev.map()` auch wenn nichts zu aendern war → neues Array bei jedem Tool-Event → FlatList-Layouts invalidiert mitten in der Scroll-Sequenz. Fix: hartes `MAX_SCROLL_RETRIES = 3` plus `prev.some()`-Check vor `.map()` damit reference-stable bei No-Op
|
||||
- [x] **Such-Treffer in Spezial-Bubbles**: `searchMatchIds` suchte in `messages` (alle Bubbles inkl. Memory/Skill/Trigger), aber gescrollt wird in `invertedMessages` die diese filtert → `findIndex=-1` → kein Scroll, alter Pre-Scroll-Stand bleibt sichtbar. Fix: `searchMatchIds` aus `chatVisibleMessages`. Memory-Inhalte sind weiterhin ueber die 🗂️-Inbox erreichbar
|
||||
- [x] **Such-Reihenfolge: neueste zuerst** (WhatsApp/Telegram-analog): User ist visuell unten im Chat, der erste Treffer ist meist schon im Viewport ohne weiten Pre-Scroll. „Naechster" geht in die Vergangenheit. Plus Pre-Scroll-Wartezeit 80→200 ms damit FlatList beim ersten Versuch Render-Zeit hat
|
||||
|
||||
### Misc App-Polish
|
||||
|
||||
- [x] **About-Text rendete `—` literal**: JSX-Text-Knoten interpretieren keine JS-String-Escapes — `—` blieb als Backslash-u-Sequenz sichtbar. Fix: `{'—'}` als JS-Expression-Block
|
||||
- [x] **GPS-Heartbeat fuer stationaere User**: `watchPosition` mit `distanceFilter: 30` sendet keine Updates ohne 30 m Bewegung. Stefan stationaer → nach initialer Position keine weiteren Updates → Brain verwirft Position nach `NEAR_MAX_AGE_SEC=300` als veraltet → `near()`-Watcher feuern nie. Fix: zusaetzlich zum watchPosition laeuft ein `setInterval(60s)` Heartbeat der die zuletzt empfangene Position erneut sendet. Kein extra GPS-Wakeup, akkufreundlich — und Brain-State bleibt frisch auch ohne Bewegung
|
||||
|
||||
## Offen
|
||||
|
||||
### App Features
|
||||
|
||||
Reference in New Issue
Block a user