Compare commits

...

14 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Zwei Ursachen:

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:42:08 +02:00
duffyduck 22adc91c1e release: bump version to 0.1.5.1 2026-05-15 12:12:17 +02:00
duffyduck 61cf8e3bcc fix(chat): Such-Sprung beim ersten Versuch nach App-Start
Symptom: Suchbegriff direkt nach App-Start eingegeben → springt an
falsche Stelle. Erst beim zweiten Versuch funktioniert es.

Ursache: FlatList rendert per Default nur 10 Items initial.
info.averageItemLength im onScrollToIndexFailed basiert nur auf diesen
10 — bei einem Suchtreffer auf Bubble 150 ist die Schaetzung katastrophal
falsch. Beim zweiten Versuch ist die FlatList „warm gelaufen" und mehr
Items sind gemessen → Schaetzung passt besser.

Drei kombinierte Fixes:

1) Pre-Scroll: vor dem scrollToIndex erst grob mit AVG_BUBBLE_HEIGHT=150
   per scrollToOffset(idx*150) in die Naehe springen. FlatList rendert
   die Bubbles in der Naehe, dann praezise nachsetzen nach 80ms.

2) initialNumToRender=30 (Default 10) — mehr Items beim Mount gemessen.

3) windowSize=41 (Default 21) — mehr Items im Speicher gehalten, weniger
   Layout-Holes beim Weit-Scroll.

Kosten: minimal hoehere Mount-Zeit. Bei 300+ Bubbles im Backup macht
sich der UX-Gewinn lohnt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:10:13 +02:00
12 changed files with 1044 additions and 50 deletions
+2 -2
View File
@@ -366,7 +366,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- **Lokale Voice-Wahl**: Pro Geraet eigene Stimme moeglich (in Settings). Diagnostic-Wechsel ueberschreibt alle App-Wahlen.
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
- **Chat-Suche**: Lupe in der Statusleiste — Highlight + Next/Prev springt zum Treffer (Bubble landet am Text-Anfang oben am Viewport)
- **Chat-Suche**: Lupe in der Statusleiste — Highlight + Next/Prev springt zum Treffer (Bubble landet am Text-Anfang oben am Viewport). Reihenfolge **neueste zuerst** (analog WhatsApp), „Naechster" geht in die Vergangenheit. Item-Hoehen werden per `onLayout` gecached fuer praezisen Pre-Scroll auch bei langen Listen
- **Jump-to-Bottom-Button**: erscheint rechts unten sobald man weg von der neuesten Nachricht scrollt, ein Tap fuehrt zurueck
- **Delivery-Status pro User-Bubble** (WhatsApp-Style): `⏱` (queued, wartet auf Verbindung) → `⏳` (sending) → `✓` (Bridge hat ACK gesendet) → `✓✓` (ARIA hat verarbeitet). Bei Netzausfall werden Nachrichten lokal als queued gehalten und beim Reconnect automatisch geflusht. Bei drei ACK-Timeouts → `⚠ tippen f. Retry`. Idempotenz auf der Bridge (LRU ueber `clientMsgId`) verhindert Doppelte beim Retry
- **Mülltonne pro Bubble** (mit Confirm): gezielt eine Nachricht loeschen — geht nicht nur aus der UI weg, sondern auch aus `chat_backup.jsonl`, Brain-Conversation-Window und allen anderen Clients (RVS-Broadcast). Wichtig damit ARIA den Turn auch beim naechsten Prompt nicht mehr im Kontext hat
@@ -380,7 +380,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- **Einstellungen**: TTS-aktiv, F5-TTS-Voice, Pre-Roll-Buffer, Stille-Toleranz, Speicherort, Auto-Download, GPS, Verbose-Logging
- **Auto-Update**: Prueft beim Start + per Button auf neue Version, Download + Installation ueber RVS (FileProvider)
- GPS-Position (optional, mit Runtime-Permission-Request) — wird in jeden Chat/Audio-Payload mitgegeben und ist in Diagnostic als Debug-Block einblendbar
- **GPS-Tracking (kontinuierlich)**: Toggle in Settings → Standort. Wenn aktiv, pushed die App alle ~15s bzw. ab 30m Bewegung ein `location_update` an die Bridge — Voraussetzung damit Watcher mit `near(lat, lon, m)` (z.B. Blitzer-Warner, Ankunft-Erinnerungen) ueberhaupt feuern koennen. ARIA selbst kann das Tracking via `request_location_tracking`-Tool an-/ausschalten und tut das automatisch wenn sie einen GPS-Watcher anlegt
- **GPS-Tracking (kontinuierlich)**: Toggle in Settings → Standort. Wenn aktiv, pushed die App ab 30m Bewegung ein `location_update` an die Bridge — Voraussetzung damit Watcher mit `near(lat, lon, m)` (z.B. Blitzer-Warner, Ankunft-Erinnerungen) ueberhaupt feuern koennen. **Heartbeat alle 60 s**: auch ohne Bewegung wird die letzte bekannte Position erneut an die Bridge geschickt damit der Brain-State nicht nach 5 min (NEAR_MAX_AGE_SEC) veraltet — kein extra GPS-Wakeup, akkufreundlich. ARIA selbst kann das Tracking via `request_location_tracking`-Tool an-/ausschalten und tut das automatisch wenn sie einen GPS-Watcher anlegt
- QR-Code Scanner fuer Token-Pairing
- **ARIA-Dateien empfangen**: Wenn ARIA eine PDF/Bild/Markdown/ZIP fuer dich erstellt (Marker `[FILE: /shared/uploads/aria_*]` in der Antwort), erscheint sie als eigene Anhang-Bubble. Tippen → wird via RVS geladen + mit Android-Intent-Picker geoeffnet (PDF-Viewer, Bildbetrachter, Standard-App). Inline-Bilder aus Markdown-`![alt](url)`-Syntax werden direkt unter dem Text gerendert (PNG/JPG via Image, SVG via react-native-svg)
- **Vollbild mit Pinch-Zoom**: Bilder im Vollbild-Modal sind pinch-zoombar (1x..5x), 1-Finger-Pan wenn gezoomt, Doppel-Tap toggelt 1x↔2.5x — alles ohne externe Lib
+39 -1
View File
@@ -6,7 +6,8 @@
*/
import React, { useEffect } from 'react';
import { StatusBar, StyleSheet } from 'react-native';
import { PermissionsAndroid, Platform, StatusBar, StyleSheet } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
@@ -14,6 +15,7 @@ import ChatScreen from './src/screens/ChatScreen';
import SettingsScreen from './src/screens/SettingsScreen';
import rvs from './src/services/rvs';
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
// --- Navigation ---
@@ -61,6 +63,42 @@ const App: React.FC = () => {
};
initConnection();
// Hintergrund-Modus: Foreground-Service starten damit JS-Engine +
// WebSocket auch ueberleben wenn die App im Hintergrund ist.
// Trigger-Replies, Reconnects, Timer-Erinnerungen kommen sonst nicht
// durch weil Android nach ~30s die JS-Engine pausiert.
//
// Default an, kann in Settings → Hintergrund-Modus deaktiviert werden.
// Braucht POST_NOTIFICATIONS Permission ab Android 13.
const initBackground = async () => {
const setting = await AsyncStorage.getItem('aria_background_mode');
if (setting === 'false') {
console.log('[App] Hintergrund-Modus deaktiviert (Settings)');
return;
}
// Permission fuer die persistente Notification
if (Platform.OS === 'android' && Platform.Version >= 33) {
try {
await PermissionsAndroid.request(
'android.permission.POST_NOTIFICATIONS' as any,
{
title: 'Hintergrund-Modus',
message: 'ARIA zeigt eine Notification damit Trigger und Reconnects auch laufen wenn die App im Hintergrund ist.',
buttonPositive: 'Erlauben',
buttonNegative: 'Spaeter',
},
);
} catch {}
}
try {
await acquireBackgroundAudio('background');
console.log('[App] Hintergrund-Modus aktiv');
} catch (err: any) {
console.warn('[App] Hintergrund-Modus konnte nicht starten:', err?.message || err);
}
};
initBackground();
// Beim Beenden: Verbindung sauber trennen
return () => {
rvs.disconnect();
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10500
versionName "0.1.5.0"
versionCode 10506
versionName "0.1.5.6"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.1.5.0",
"version": "0.1.5.6",
"private": true,
"scripts": {
"android": "react-native run-android",
+583
View File
@@ -0,0 +1,583 @@
/**
* Trigger-Browser — Liste aller Trigger (timer + watcher) mit Toggle,
* Tap-zum-Bearbeiten und "+ Neu"-Knopf.
*
* Eingesetzt von SettingsScreen → Sektion "Trigger".
*
* Brain-API ueber brainApi (RVS-Brain-Proxy).
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Modal,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import brainApi, { Trigger } from '../services/brainApi';
const COL_ACTIVE = '#34C759';
const COL_INACTIVE = '#555570';
const COL_TIMER = '#0096FF';
const COL_WATCHER = '#FFD60A';
function relTime(iso: string | null | undefined): string {
if (!iso) return '—';
const t = new Date(iso).getTime();
if (!t) return '—';
const diffSec = Math.floor((Date.now() - t) / 1000);
if (diffSec < 60) return `vor ${diffSec}s`;
if (diffSec < 3600) return `vor ${Math.floor(diffSec / 60)}min`;
if (diffSec < 86400) return `vor ${Math.floor(diffSec / 3600)}h`;
return `vor ${Math.floor(diffSec / 86400)}d`;
}
export const TriggerBrowser: React.FC = () => {
const [items, setItems] = useState<Trigger[]>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [filter, setFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [editTrigger, setEditTrigger] = useState<Trigger | null>(null);
const [showNew, setShowNew] = useState(false);
const load = useCallback(() => {
setLoading(true); setErr(null);
brainApi.listTriggers()
.then(t => {
// Sortierung: aktive zuerst, dann nach Name
t.sort((a, b) => {
if (a.active !== b.active) return a.active ? -1 : 1;
return (a.name || '').localeCompare(b.name || '');
});
setItems(t);
})
.catch(e => setErr(String(e?.message || e)))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const visible = items.filter(t => {
if (filter === 'active') return t.active;
if (filter === 'inactive') return !t.active;
return true;
});
const toggleActive = (t: Trigger) => {
brainApi.updateTrigger(t.name, { active: !t.active })
.then(() => load())
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
};
const deleteTrigger = (t: Trigger) => {
Alert.alert(
'Trigger löschen?',
`"${t.name}" — diese Aktion ist nicht rückgängig zu machen.`,
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Löschen',
style: 'destructive',
onPress: () => {
brainApi.deleteTrigger(t.name)
.then(() => { setEditTrigger(null); load(); })
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
},
},
],
);
};
const renderItem = ({ item }: { item: Trigger }) => {
const typeColor = item.type === 'timer' ? COL_TIMER : COL_WATCHER;
const typeLabel = item.type === 'timer' ? '⏰ Timer' : '👁 Watcher';
return (
<TouchableOpacity style={s.row} onPress={() => setEditTrigger(item)}>
<View style={{flex: 1, marginRight: 8}}>
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 4}}>
<Text style={{color: typeColor, fontSize: 11, fontWeight: '700'}}>{typeLabel}</Text>
<Text style={{color: '#E0E0F0', fontWeight: '600', flex: 1}} numberOfLines={1}>{item.name}</Text>
</View>
<Text style={{color: '#8888AA', fontSize: 12}} numberOfLines={2}>{item.message}</Text>
{item.type === 'watcher' && item.condition ? (
<Text style={{color: '#555570', fontSize: 11, marginTop: 4, fontFamily: 'monospace'}} numberOfLines={1}>
{item.condition}
</Text>
) : null}
{item.type === 'timer' && item.fires_at ? (
<Text style={{color: '#555570', fontSize: 11, marginTop: 4}}>
feuert: {new Date(item.fires_at).toLocaleString('de-DE')}
</Text>
) : null}
<Text style={{color: '#444460', fontSize: 10, marginTop: 4}}>
{item.fire_count || 0}× gefeuert · zuletzt: {relTime(item.last_fired_at)}
</Text>
</View>
<Switch
value={item.active}
onValueChange={() => toggleActive(item)}
trackColor={{ false: '#1E1E2E', true: COL_ACTIVE }}
thumbColor="#E0E0F0"
/>
</TouchableOpacity>
);
};
return (
<View style={{flex: 1}}>
{/* Filter-Leiste + Reload + Neu */}
<View style={s.toolbar}>
{(['all', 'active', 'inactive'] as const).map(f => (
<TouchableOpacity
key={f}
style={[s.chip, filter === f && s.chipActive]}
onPress={() => setFilter(f)}
>
<Text style={{color: filter === f ? '#0D0D1A' : '#8888AA', fontSize: 12, fontWeight: '600'}}>
{f === 'all' ? 'Alle' : f === 'active' ? 'Aktive' : 'Inaktive'}
</Text>
</TouchableOpacity>
))}
<View style={{flex: 1}} />
<TouchableOpacity onPress={load} style={s.iconBtn}>
<Text style={{fontSize: 16}}>{'↻'}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => setShowNew(true)} style={[s.iconBtn, {backgroundColor: '#0096FF'}]}>
<Text style={{fontSize: 14, color: '#fff', fontWeight: '700'}}>+ Neu</Text>
</TouchableOpacity>
</View>
{err ? <Text style={s.err}>{err}</Text> : null}
{loading && items.length === 0 ? (
<ActivityIndicator color="#0096FF" style={{marginTop: 20}} />
) : (
<FlatList
data={visible}
keyExtractor={t => t.name}
renderItem={renderItem}
nestedScrollEnabled={true}
ListEmptyComponent={
<Text style={{color: '#555570', textAlign: 'center', padding: 20, fontStyle: 'italic'}}>
{items.length === 0 ? '(keine Trigger angelegt)' : '(keine Treffer für diesen Filter)'}
</Text>
}
contentContainerStyle={{paddingBottom: 20}}
/>
)}
{editTrigger ? (
<TriggerEditModal
trigger={editTrigger}
onClose={() => setEditTrigger(null)}
onSaved={() => { setEditTrigger(null); load(); }}
onDelete={() => deleteTrigger(editTrigger)}
/>
) : null}
{showNew ? (
<TriggerNewModal
onClose={() => setShowNew(false)}
onCreated={() => { setShowNew(false); load(); }}
/>
) : null}
</View>
);
};
// ── Edit-Modal ─────────────────────────────────────────────────────────
interface EditProps {
trigger: Trigger;
onClose: () => void;
onSaved: () => void;
onDelete: () => void;
}
const TriggerEditModal: React.FC<EditProps> = ({ trigger, onClose, onSaved, onDelete }) => {
const [message, setMessage] = useState(trigger.message || '');
const [condition, setCondition] = useState(trigger.condition || '');
const [firesAt, setFiresAt] = useState(trigger.fires_at || '');
const [checkInterval, setCheckInterval] = useState(String(trigger.check_interval_sec || 300));
const [throttle, setThrottle] = useState(String(trigger.throttle_sec || 3600));
const [saving, setSaving] = useState(false);
const save = () => {
setSaving(true);
const patch: any = { message };
if (trigger.type === 'watcher') {
patch.condition = condition;
patch.check_interval_sec = parseInt(checkInterval, 10) || 300;
patch.throttle_sec = parseInt(throttle, 10) || 3600;
} else if (trigger.type === 'timer') {
patch.fires_at = firesAt;
}
brainApi.updateTrigger(trigger.name, patch)
.then(onSaved)
.catch(e => Alert.alert('Fehler beim Speichern', String(e?.message || e)))
.finally(() => setSaving(false));
};
return (
<Modal visible animationType="slide" onRequestClose={onClose} transparent>
<View style={s.modalBg}>
<View style={s.modal}>
<View style={s.modalHeader}>
<Text style={{color: trigger.type === 'timer' ? COL_TIMER : COL_WATCHER, fontWeight: '700', fontSize: 16, flex: 1}}>
{trigger.type === 'timer' ? '⏰' : '👁'} {trigger.name}
</Text>
<TouchableOpacity onPress={onClose}>
<Text style={{color: '#8888AA', fontSize: 24}}>×</Text>
</TouchableOpacity>
</View>
<ScrollView style={{padding: 14}} nestedScrollEnabled>
<Text style={s.label}>Nachricht</Text>
<TextInput
style={s.input}
value={message}
onChangeText={setMessage}
multiline
placeholder="Was soll ARIA sagen wenn der Trigger feuert?"
placeholderTextColor="#555570"
/>
{trigger.type === 'watcher' ? (
<>
<Text style={s.label}>Condition</Text>
<TextInput
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
value={condition}
onChangeText={setCondition}
placeholder="z.B. near(53.0, 8.5, 300)"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
<View style={{flexDirection: 'row', gap: 8}}>
<View style={{flex: 1}}>
<Text style={s.label}>Check-Intervall (s)</Text>
<TextInput
style={s.input}
value={checkInterval}
onChangeText={setCheckInterval}
keyboardType="number-pad"
/>
</View>
<View style={{flex: 1}}>
<Text style={s.label}>Throttle (s)</Text>
<TextInput
style={s.input}
value={throttle}
onChangeText={setThrottle}
keyboardType="number-pad"
/>
</View>
</View>
</>
) : (
<>
<Text style={s.label}>Feuert am (ISO, UTC)</Text>
<TextInput
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
value={firesAt}
onChangeText={setFiresAt}
placeholder="2026-05-15T20:00:00+00:00"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
</>
)}
<View style={s.metaBox}>
<Text style={s.meta}>Status: {trigger.active ? '🟢 aktiv' : '⚪ inaktiv'}</Text>
<Text style={s.meta}>Gefeuert: {trigger.fire_count || 0}×</Text>
<Text style={s.meta}>Zuletzt gefeuert: {relTime(trigger.last_fired_at)}</Text>
<Text style={s.meta}>Zuletzt geprüft: {relTime(trigger.last_checked_at)}</Text>
{trigger.author ? <Text style={s.meta}>Angelegt von: {trigger.author}</Text> : null}
</View>
</ScrollView>
<View style={s.modalFooter}>
<TouchableOpacity onPress={onDelete} style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: '#FF3B30'}]}>
<Text style={{color: '#FF3B30', fontWeight: '700'}}>🗑 Löschen</Text>
</TouchableOpacity>
<View style={{flex: 1}} />
<TouchableOpacity onPress={save} disabled={saving} style={[s.btn, {backgroundColor: '#0096FF', opacity: saving ? 0.5 : 1}]}>
<Text style={{color: '#fff', fontWeight: '700'}}>{saving ? 'Speichert...' : 'Speichern'}</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
// ── Neu-Modal ──────────────────────────────────────────────────────────
interface NewProps {
onClose: () => void;
onCreated: () => void;
}
const TriggerNewModal: React.FC<NewProps> = ({ onClose, onCreated }) => {
const [ttype, setTtype] = useState<'timer' | 'watcher'>('watcher');
const [name, setName] = useState('');
const [message, setMessage] = useState('');
const [condition, setCondition] = useState('');
const [firesAt, setFiresAt] = useState('');
const [checkInterval, setCheckInterval] = useState('300');
const [throttle, setThrottle] = useState('3600');
const [saving, setSaving] = useState(false);
const create = () => {
if (!name.trim() || !message.trim()) {
Alert.alert('Name und Nachricht erforderlich');
return;
}
setSaving(true);
const promise = ttype === 'timer'
? brainApi.createTimer({
name: name.trim(),
fires_at: firesAt.trim(),
message: message.trim(),
})
: brainApi.createWatcher({
name: name.trim(),
condition: condition.trim(),
message: message.trim(),
check_interval_sec: parseInt(checkInterval, 10) || 300,
throttle_sec: parseInt(throttle, 10) || 3600,
});
promise
.then(onCreated)
.catch(e => Alert.alert('Fehler beim Anlegen', String(e?.message || e)))
.finally(() => setSaving(false));
};
return (
<Modal visible animationType="slide" onRequestClose={onClose} transparent>
<View style={s.modalBg}>
<View style={s.modal}>
<View style={s.modalHeader}>
<Text style={{color: '#FFD60A', fontWeight: '700', fontSize: 16, flex: 1}}>+ Neuer Trigger</Text>
<TouchableOpacity onPress={onClose}>
<Text style={{color: '#8888AA', fontSize: 24}}>×</Text>
</TouchableOpacity>
</View>
<ScrollView style={{padding: 14}} nestedScrollEnabled>
<Text style={s.label}>Typ</Text>
<View style={{flexDirection: 'row', gap: 8, marginBottom: 12}}>
{(['watcher', 'timer'] as const).map(t => (
<TouchableOpacity
key={t}
onPress={() => setTtype(t)}
style={[s.chip, ttype === t && s.chipActive, {flex: 1, paddingVertical: 10}]}
>
<Text style={{color: ttype === t ? '#0D0D1A' : '#8888AA', fontWeight: '700', textAlign: 'center'}}>
{t === 'watcher' ? '👁 Watcher' : '⏰ Timer'}
</Text>
</TouchableOpacity>
))}
</View>
<Text style={s.label}>Name (kebab-case)</Text>
<TextInput
style={s.input}
value={name}
onChangeText={setName}
placeholder="z.B. drk-kreyenbrueck-warnung"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
<Text style={s.label}>Nachricht</Text>
<TextInput
style={s.input}
value={message}
onChangeText={setMessage}
multiline
placeholder="Was soll ARIA sagen?"
placeholderTextColor="#555570"
/>
{ttype === 'watcher' ? (
<>
<Text style={s.label}>Condition</Text>
<TextInput
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
value={condition}
onChangeText={setCondition}
placeholder="z.B. entered_near(53.0, 8.5, 300)"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
<Text style={s.hint}>
Funktionen: near() / entered_near() / left_near() · Variablen: disk_free_gb, hour_of_day, current_lat, current_lon, last_user_message_ago_sec
</Text>
<View style={{flexDirection: 'row', gap: 8}}>
<View style={{flex: 1}}>
<Text style={s.label}>Check-Intervall (s)</Text>
<TextInput
style={s.input}
value={checkInterval}
onChangeText={setCheckInterval}
keyboardType="number-pad"
/>
</View>
<View style={{flex: 1}}>
<Text style={s.label}>Throttle (s)</Text>
<TextInput
style={s.input}
value={throttle}
onChangeText={setThrottle}
keyboardType="number-pad"
/>
</View>
</View>
</>
) : (
<>
<Text style={s.label}>Feuert am (ISO, UTC)</Text>
<TextInput
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
value={firesAt}
onChangeText={setFiresAt}
placeholder="2026-05-15T20:00:00+00:00"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
<Text style={s.hint}>Beispiel oben: heute 20:00 UTC = 22:00 CEST</Text>
</>
)}
</ScrollView>
<View style={s.modalFooter}>
<View style={{flex: 1}} />
<TouchableOpacity onPress={create} disabled={saving} style={[s.btn, {backgroundColor: '#0096FF', opacity: saving ? 0.5 : 1}]}>
<Text style={{color: '#fff', fontWeight: '700'}}>{saving ? 'Legt an...' : 'Anlegen'}</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
const s = StyleSheet.create({
toolbar: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginBottom: 8,
},
chip: {
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
backgroundColor: '#1E1E2E',
},
chipActive: {
backgroundColor: '#FFD60A',
},
iconBtn: {
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
backgroundColor: '#1E1E2E',
},
err: {
color: '#FF3B30',
padding: 12,
fontSize: 12,
},
row: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
backgroundColor: '#1A1A2E',
borderRadius: 8,
marginBottom: 6,
},
modalBg: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
justifyContent: 'center',
alignItems: 'center',
padding: 16,
},
modal: {
backgroundColor: '#0D0D1A',
borderRadius: 12,
width: '100%',
maxWidth: 600,
maxHeight: '90%',
borderWidth: 1,
borderColor: '#1E1E2E',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
padding: 14,
borderBottomWidth: 1,
borderBottomColor: '#1E1E2E',
},
modalFooter: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderTopWidth: 1,
borderTopColor: '#1E1E2E',
gap: 8,
},
label: {
color: '#8888AA',
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginTop: 8,
marginBottom: 4,
},
input: {
backgroundColor: '#1A1A2E',
borderWidth: 1,
borderColor: '#1E1E2E',
borderRadius: 6,
color: '#E0E0F0',
padding: 10,
fontSize: 14,
marginBottom: 8,
},
hint: {
color: '#555570',
fontSize: 11,
fontStyle: 'italic',
marginTop: -4,
marginBottom: 10,
},
metaBox: {
backgroundColor: '#1A1A2E',
borderRadius: 6,
padding: 10,
marginTop: 10,
gap: 4,
},
meta: {
color: '#8888AA',
fontSize: 12,
},
btn: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 6,
borderWidth: 1,
borderColor: 'transparent',
},
});
export default TriggerBrowser;
+91 -26
View File
@@ -1381,15 +1381,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 +1415,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,6 +1435,11 @@ const ChatScreen: React.FC = () => {
// Den aktuellen Snapshot von invertedMessages holen wir via Ref.
const invertedMessagesRef = useRef(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(() => {
if (!searchMatchIds.length) {
lastSearchScrollKey.current = '';
@@ -1442,12 +1457,42 @@ const ChatScreen: React.FC = () => {
clearPendingScrollRetry();
const idx = invertedMessagesRef.current.findIndex(m => m.id === id);
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(() => {
try {
flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0 });
} catch {
// onScrollToIndexFailed-Handler uebernimmt den Fallback
}
setTimeout(() => {
try {
flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0 });
} catch {
// onScrollToIndexFailed-Handler uebernimmt den Fallback
}
}, 350);
});
}, [searchIndex, searchMatchIds]);
@@ -1865,7 +1910,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}>
@@ -2159,6 +2212,13 @@ const ChatScreen: React.FC = () => {
ref={flatListRef}
inverted
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) => {
// Bei inverted FlatList: contentOffset.y > 0 = weg von "unten"
// (= aelter scrollen). Wir zeigen den Jump-Down-Button ab ~250px.
@@ -2360,19 +2420,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}}>
@@ -2453,7 +2513,7 @@ const ChatScreen: React.FC = () => {
/>
)}
</View>
</TouchableOpacity>
</View>
</Modal>
{/* Notizen-Inbox — Listet alle Memories aus dem aktuellen Chat (Special-Bubbles).
@@ -2488,7 +2548,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;
@@ -2544,7 +2604,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>
+91 -3
View File
@@ -19,6 +19,7 @@ import {
ActivityIndicator,
Modal,
PermissionsAndroid,
useWindowDimensions,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs';
@@ -52,7 +53,9 @@ import {
} from '../services/audio';
import audioService from '../services/audio';
import gpsTrackingService from '../services/gpsTracking';
import { acquireBackgroundAudio, releaseBackgroundAudio } from '../services/backgroundAudio';
import MemoryBrowser from '../components/MemoryBrowser';
import TriggerBrowser from '../components/TriggerBrowser';
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
import {
isWakeReadySoundEnabled,
@@ -102,6 +105,7 @@ const SETTINGS_SECTIONS = [
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
{ id: 'memory', icon: '🧠', label: 'Gedächtnis', desc: 'ARIA-Memories durchsuchen, anlegen, bearbeiten, löschen' },
{ id: 'triggers', icon: '⏰', label: 'Trigger', desc: 'Timer + Watcher anlegen, bearbeiten, löschen' },
{ id: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' },
{ id: 'about', icon: '️', label: 'Ueber', desc: 'App-Version, Update' },
] as const;
@@ -118,6 +122,7 @@ const SOURCE_COLORS: Record<string, string> = {
// --- Komponente ---
const SettingsScreen: React.FC = () => {
const winDims = useWindowDimensions();
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
const [manualToken, setManualToken] = useState('');
const [manualHost, setManualHost] = useState('');
@@ -125,6 +130,7 @@ 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 [scannerVisible, setScannerVisible] = useState(false);
const [logTab, setLogTab] = useState<LogTab>('live');
const [logs, setLogs] = useState<LogEntry[]>([]);
@@ -192,6 +198,10 @@ 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');
});
// gpsTrackingService status syncen + auf Aenderungen lauschen
setGpsTracking(gpsTrackingService.isActive());
const offGps = gpsTrackingService.onChange(setGpsTracking);
@@ -575,6 +585,37 @@ 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);
}
}, []);
// --- XTTS Voice ---
const selectVoice = useCallback((voiceName: string) => {
@@ -868,7 +909,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 +1102,33 @@ const SettingsScreen: React.FC = () => {
/>
</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 +1758,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 +1886,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>
+15 -10
View File
@@ -1,17 +1,21 @@
/**
* Background-Audio: ARIAs TTS, Mic-Aufnahme und Wake-Word-Lauschen sollen
* auch bei minimierter App weiterlaufen. Wir starten dafuer einen Foreground-
* Background-Audio + Hintergrund-Persistenz: ARIAs TTS, Mic-Aufnahme,
* Wake-Word-Lauschen UND der allgemeine Hintergrund-Modus laufen
* weiter wenn die App minimiert ist. Wir starten dafuer einen Foreground-
* Service mit foregroundServiceType=mediaPlayback|microphone, der eine
* persistente Notification zeigt waehrend irgendein Audio-Slot aktiv ist.
* persistente Notification zeigt solange irgendein Slot aktiv ist.
*
* Mehrere Komponenten koennen den Service unabhaengig "halten":
* - 'tts' : ARIA spricht
* - 'rec' : Aufnahme laeuft
* - 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
* - 'tts' : ARIA spricht
* - 'rec' : Aufnahme laeuft
* - 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
* - 'background' : Persistenter Hintergrund-Modus (Settings-Toggle).
* Haelt JS-Engine + WebSocket auch ohne Audio am Leben
* → Trigger-Replies, Reconnects, Push-Reaktionen.
*
* Solange mindestens ein Slot aktiv ist, laeuft der Service. Wenn alle
* Slots leer sind, wird er gestoppt. Der Notification-Text passt sich an
* den hoechstprioren Slot an (tts > rec > wake).
* den hoechstprioren Slot an (tts > rec > wake > background).
*/
import { NativeModules } from 'react-native';
@@ -23,12 +27,13 @@ interface BackgroundAudioNative {
const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative };
type Slot = 'tts' | 'rec' | 'wake';
type Slot = 'tts' | 'rec' | 'wake' | 'background';
const slots = new Set<Slot>();
// Prioritaet fuer den Notification-Text — hoechste zuerst.
const PRIORITY: Slot[] = ['tts', 'rec', 'wake'];
// Prioritaet fuer den Notification-Text — hoechste zuerst. 'background'
// ist die fallback-Anzeige wenn nichts anderes laeuft.
const PRIORITY: Slot[] = ['tts', 'rec', 'wake', 'background'];
function topReason(): string {
for (const s of PRIORITY) {
+86
View File
@@ -121,6 +121,24 @@ export interface Memory {
attachments?: MemoryAttachment[];
}
/** Trigger-Manifest wie aus Brain `/triggers/list` zurueckkommt. */
export interface Trigger {
name: string;
type: 'timer' | 'watcher' | string;
active: boolean;
author?: string;
message: string;
fires_at?: string; // ISO, nur timer
condition?: string; // nur watcher
check_interval_sec?: number; // nur watcher
throttle_sec?: number; // nur watcher
fire_count?: number;
last_fired_at?: string | null;
last_checked_at?: string | null;
created_at?: string;
updated_at?: string;
}
// ── Memory CRUD ──────────────────────────────────────────────────────
export const brainApi = {
@@ -215,6 +233,74 @@ export const brainApi = {
{ expectBinary: true, timeoutMs: 60000 },
);
},
// ── Triggers ────────────────────────────────────────────────────────
/** Liste aller Trigger (aktive + inaktive). */
listTriggers(): Promise<Trigger[]> {
return _send('/triggers/list');
},
/** Einzelnen Trigger holen (inkl. fire_count, last_fired_at, ...). */
getTrigger(name: string): Promise<Trigger> {
return _send(`/triggers/${encodeURIComponent(name)}`);
},
/** Verfuegbare Condition-Variablen + Funktionen (fuer Watcher-Editor). */
getTriggerConditions(): Promise<{ variables: any[]; functions: any[] }> {
return _send('/triggers/conditions');
},
/** Trigger-Logs (last N Feuerungen). */
getTriggerLogs(name: string, limit: number = 50): Promise<any[]> {
return _send(`/triggers/${encodeURIComponent(name)}/logs?limit=${limit}`);
},
/** Timer anlegen. fires_at = ISO timestamp (UTC). */
createTimer(body: { name: string; fires_at: string; message: string; author?: string }): Promise<Trigger> {
return _send('/triggers/timer', {
method: 'POST',
body: { author: 'app', ...body },
});
},
/** Watcher anlegen. */
createWatcher(body: {
name: string;
condition: string;
message: string;
check_interval_sec?: number;
throttle_sec?: number;
author?: string;
}): Promise<Trigger> {
return _send('/triggers/watcher', {
method: 'POST',
body: { author: 'app', ...body },
});
},
/** Trigger patchen (active/message/condition/throttle/interval/fires_at). */
updateTrigger(name: string, body: Partial<{
active: boolean;
message: string;
condition: string;
throttle_sec: number;
check_interval_sec: number;
fires_at: string;
}>): Promise<Trigger> {
return _send(`/triggers/${encodeURIComponent(name)}`, {
method: 'PATCH',
body,
});
},
/** Trigger loeschen. */
deleteTrigger(name: string): Promise<{ deleted: string }> {
return _send(`/triggers/${encodeURIComponent(name)}`, {
method: 'DELETE',
timeoutMs: 15000,
});
},
};
export default brainApi;
+24
View File
@@ -26,6 +26,13 @@ class GpsTrackingService {
private listeners: Set<Listener> = new Set();
// Defensive: nicht zu schnell oeffentlich togglen
private lastChangeAt = 0;
// Letzte bekannte Position — wird vom Heartbeat-Timer alle 60s erneut
// an die Bridge gesendet, sonst veraltet near() im Brain (NEAR_MAX_AGE_SEC
// = 5 min) wenn der User stationaer ist und distanceFilter keine Updates
// mehr triggert.
private lastLat: number | null = null;
private lastLon: number | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
isActive(): boolean {
return this.active;
@@ -84,6 +91,8 @@ class GpsTrackingService {
(pos) => {
const lat = pos.coords.latitude;
const lon = pos.coords.longitude;
this.lastLat = lat;
this.lastLon = lon;
rvs.send('location_update' as any, { lat, lon });
},
(err) => {
@@ -96,6 +105,17 @@ class GpsTrackingService {
fastestInterval: 10000, // (Android) max Frequenz
} as any,
);
// Heartbeat: alle 60s die letzte bekannte Position erneut senden.
// Sonst bleibt der Brain-State stale wenn der User stationaer ist
// (distanceFilter blockt watchPosition-Updates) → near()-Watcher
// verwerfen die Position als veraltet (NEAR_MAX_AGE_SEC = 300s).
// Kein neuer GPS-Wakeup, nur Re-Send der letzten Werte → akkufreundlich.
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
this.heartbeatTimer = setInterval(() => {
if (this.lastLat != null && this.lastLon != null) {
rvs.send('location_update' as any, { lat: this.lastLat, lon: this.lastLon });
}
}, 60_000);
this.active = true;
this.lastChangeAt = Date.now();
this.notify();
@@ -118,6 +138,10 @@ class GpsTrackingService {
try { Geolocation.clearWatch(this.watchId); } catch {}
this.watchId = null;
}
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
this.active = false;
this.lastChangeAt = Date.now();
this.notify();
+98 -5
View File
@@ -968,11 +968,11 @@
</div>
</div><!-- /tab-triggers -->
<!-- Trigger-Create Modal -->
<!-- Trigger-Create/Edit Modal -->
<div class="modal-overlay" id="trigger-modal">
<div class="modal-box" style="max-width:600px;">
<div class="modal-header">
<h3>Neuer Trigger</h3>
<h3 id="trigger-modal-title">Neuer Trigger</h3>
<button class="modal-close" onclick="closeTriggerModal()">&times;</button>
</div>
<div class="modal-body" style="padding:16px;">
@@ -986,8 +986,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 +1016,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>
@@ -3101,6 +3109,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 +3147,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 +3190,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 +3247,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;
+12
View File
@@ -365,6 +365,18 @@ Skills mit Tool-Use.
- [x] **Gedanken-Stream in App + Diagnostic**: chronologisches Log was ARIA intern macht, gefuettert aus `agent_activity`-Events (thinking/tool/assistant/idle). Bleibt zwischen Denk-Phasen stehen, lange Pausen sichtbar als Trennlinie mit Minuten-Hint. App: 💭-Icon in der Statusleiste oeffnet Bottom-Sheet mit chronologischer Liste, 🗑-Confirm zum Leeren. Diagnostic: 💭 Gedanken-Button im Chat-Test-Header oeffnet zentrales Modal, Live-Update wenn neue Eintraege kommen (autoscroll ans Ende). Persistierung in AsyncStorage / localStorage, capped auf 500 Eintraege
- [x] **Live-Tool-Events vom Proxy**: dritter Proxy-Patch (`proxy-patches/routes.js`) hookt Claude-CLI `assistant`-Events — bei jedem `tool_use`-Block (Bash, Read, Edit, Grep, ...) wird per HTTP-POST an die Bridge gemeldet. Bridge spiegelt das als `agent_activity tool=<name>` an RVS-Clients. Vorher kam pro Brain-Call nur EIN „💭 denkt" am Anfang und EIN „✓ fertig" am Ende — jetzt sieht man **live** in beiden UIs wie ARIA durch die Tools haengt. Hook ist fire-and-forget (ARIA_TOOL_HOOK_URL Env-Variable, default http://aria-bridge:8090/internal/agent-activity)
### Such-Sprung-Praezision + Such-Reihenfolge
- [x] **Such-Sprung kalt nach App-Start**: scrollToIndex landete bei langen Listen weit daneben (Cessna-Treffer → Sprung zur Oberhausen-Bubble 15 Stellen daneben). `info.averageItemLength` aus `onScrollToIndexFailed` basierte auf den ersten ~10 gerenderten Items — bei sehr unterschiedlichen Bubble-Hoehen (Voice ~70 px, lange ARIA-Antworten 400+ px) eine grottige Schaetzung. Fix: `itemHeights`-Ref-Map wird per `onLayout` in `renderMessage` gefuettert; Pre-Scroll summiert echte gemessene Hoehen (Fallback `AVG_BUBBLE_HEIGHT=150` fuer noch nicht gerenderte). Plus `initialNumToRender: 30` (Default 10) und `windowSize: 41` (Default 21) → mehr Items beim Mount gemessen
- [x] **Such-Scroll Endlos-Loop (Wiederkehr)**: `onScrollToIndexFailed` retried unbegrenzt — jeder failed Retry rief den Handler erneut auf → neuer Timer → fail → loop. Plus: `setMessages` im `agent_activity`-Handler rief `prev.map()` auch wenn nichts zu aendern war → neues Array bei jedem Tool-Event → FlatList-Layouts invalidiert mitten in der Scroll-Sequenz. Fix: hartes `MAX_SCROLL_RETRIES = 3` plus `prev.some()`-Check vor `.map()` damit reference-stable bei No-Op
- [x] **Such-Treffer in Spezial-Bubbles**: `searchMatchIds` suchte in `messages` (alle Bubbles inkl. Memory/Skill/Trigger), aber gescrollt wird in `invertedMessages` die diese filtert → `findIndex=-1` → kein Scroll, alter Pre-Scroll-Stand bleibt sichtbar. Fix: `searchMatchIds` aus `chatVisibleMessages`. Memory-Inhalte sind weiterhin ueber die 🗂️-Inbox erreichbar
- [x] **Such-Reihenfolge: neueste zuerst** (WhatsApp/Telegram-analog): User ist visuell unten im Chat, der erste Treffer ist meist schon im Viewport ohne weiten Pre-Scroll. „Naechster" geht in die Vergangenheit. Plus Pre-Scroll-Wartezeit 80→200 ms damit FlatList beim ersten Versuch Render-Zeit hat
### Misc App-Polish
- [x] **About-Text rendete `—` literal**: JSX-Text-Knoten interpretieren keine JS-String-Escapes — `—` blieb als Backslash-u-Sequenz sichtbar. Fix: `{'—'}` als JS-Expression-Block
- [x] **GPS-Heartbeat fuer stationaere User**: `watchPosition` mit `distanceFilter: 30` sendet keine Updates ohne 30 m Bewegung. Stefan stationaer → nach initialer Position keine weiteren Updates → Brain verwirft Position nach `NEAR_MAX_AGE_SEC=300` als veraltet → `near()`-Watcher feuern nie. Fix: zusaetzlich zum watchPosition laeuft ein `setInterval(60s)` Heartbeat der die zuletzt empfangene Position erneut sendet. Kein extra GPS-Wakeup, akkufreundlich — und Brain-State bleibt frisch auch ohne Bewegung
## Offen
### App Features