diff --git a/android/src/components/MemoryBrowser.tsx b/android/src/components/MemoryBrowser.tsx new file mode 100644 index 0000000..f57ba1b --- /dev/null +++ b/android/src/components/MemoryBrowser.tsx @@ -0,0 +1,237 @@ +/** + * Memory-Browser — Liste mit Suche + Filter, Tap oeffnet MemoryDetailModal. + * + * Eingesetzt von: + * - SettingsScreen → Sektion "Gedächtnis" (kompletter Editor) + * - Inbox-Modal (Notizen-Button neben Lupe) — kann aber auch Bubbles + * aus dem Chat als zusaetzlichen Filter zeigen + */ + +import React, { useEffect, useState, useCallback } from 'react'; +import { + ActivityIndicator, + FlatList, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, + Alert, + Modal, +} from 'react-native'; + +import brainApi, { Memory } from '../services/brainApi'; +import MemoryDetailModal from './MemoryDetailModal'; + +const TYPE_LABELS: Record = { + identity: 'Identität', rule: 'Regeln', preference: 'Präferenzen', + tool: 'Tools', skill: 'Skills', fact: 'Fakten', + conversation: 'Konversation', reminder: 'Reminder', +}; +const TYPE_OPTIONS = ['', 'identity', 'rule', 'preference', 'tool', 'skill', 'fact', 'conversation', 'reminder']; + +interface Props { + /** Wenn gesetzt: nur diese IDs anzeigen (z.B. Inbox-Modal mit Chat-Bubbles-Filter). */ + restrictToIds?: string[]; + /** Headline ueber der Liste. */ + title?: string; + /** Style-Erweiterung fuer den Container. */ + flatStyle?: boolean; +} + +export const MemoryBrowser: React.FC = ({ restrictToIds, title, flatStyle }) => { + const [items, setItems] = useState([]); + const [filtered, setFiltered] = useState([]); + const [loading, setLoading] = useState(false); + const [err, setErr] = useState(null); + const [q, setQ] = useState(''); + const [typeFilter, setTypeFilter] = useState(''); + const [pinnedFilter, setPinnedFilter] = useState<'all' | 'pinned' | 'cold'>('all'); + const [showTypeMenu, setShowTypeMenu] = useState(false); + const [openId, setOpenId] = useState(null); + + const load = useCallback(() => { + setLoading(true); setErr(null); + brainApi.listMemories({ limit: 500 }) + .then(setItems) + .catch(e => setErr(String(e?.message || e))) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { load(); }, [load]); + + // Filter clientseitig — bei kleiner DB (<1000) easy + useEffect(() => { + let out = items; + if (restrictToIds && restrictToIds.length) { + const set = new Set(restrictToIds); + out = out.filter(m => set.has(m.id)); + } + if (typeFilter) out = out.filter(m => m.type === typeFilter); + if (pinnedFilter === 'pinned') out = out.filter(m => m.pinned); + else if (pinnedFilter === 'cold') out = out.filter(m => !m.pinned); + if (q.trim()) { + const needle = q.toLowerCase(); + out = out.filter(m => + (m.title || '').toLowerCase().includes(needle) || + (m.content || '').toLowerCase().includes(needle) || + (m.category || '').toLowerCase().includes(needle) || + (m.tags || []).some(t => t.toLowerCase().includes(needle)) + ); + } + setFiltered(out); + }, [items, q, typeFilter, pinnedFilter, restrictToIds]); + + const onAddNew = () => { + Alert.prompt( + 'Neue Memory', + 'Titel:', + [ + { text: 'Abbrechen', style: 'cancel' }, + { + text: 'Anlegen', + onPress: async (title?: string) => { + const t = (title || '').trim(); + if (!t) return; + try { + const m = await brainApi.saveMemory({ + type: 'fact', title: t, + content: '(noch leer — bitte editieren)', + }); + load(); + setOpenId(m.id); + } catch (e: any) { + Alert.alert('Fehler', String(e?.message || e)); + } + }, + }, + ], + 'plain-text', + ); + }; + + const renderItem = ({ item }: { item: Memory }) => { + const attCount = (item.attachments || []).length; + return ( + setOpenId(item.id)}> + + + {item.pinned ? '📌 ' : ''}{item.title || '(ohne Titel)'} + {attCount > 0 ? {` 📎${attCount}`} : null} + + + {TYPE_LABELS[item.type] || item.type} + {item.category ? ` · [${item.category}]` : ''} + + {item.content} + + + ); + }; + + return ( + + {title ? {title} : null} + + + + + + + + + setShowTypeMenu(true)}> + {typeFilter ? (TYPE_LABELS[typeFilter] || typeFilter) : 'Alle Typen'} ▾ + + { + setPinnedFilter(pinnedFilter === 'all' ? 'pinned' : pinnedFilter === 'pinned' ? 'cold' : 'all'); + }}> + + {pinnedFilter === 'pinned' ? '📌 Nur Pinned' : pinnedFilter === 'cold' ? 'Nur Cold' : 'Alle'} + + + + + Neu + + + + {err ? {err} : null} + + {loading && items.length === 0 ? ( + + ) : ( + m.id} + renderItem={renderItem} + ListEmptyComponent={ + + {items.length === 0 ? '(keine Memories in der DB)' : '(keine Treffer für diese Filter)'} + + } + contentContainerStyle={{paddingBottom:20}} + /> + )} + + + {filtered.length}/{items.length} Memories + + + {/* Type-Filter-Auswahl */} + setShowTypeMenu(false)}> + setShowTypeMenu(false)}> + + {TYPE_OPTIONS.map(t => ( + { setTypeFilter(t); setShowTypeMenu(false); }} + > + + {t ? (TYPE_LABELS[t] || t) : 'Alle Typen'} + + + ))} + + + + + { setOpenId(null); load(); }} + onDeleted={() => { setOpenId(null); load(); }} + /> + + ); +}; + +const s = StyleSheet.create({ + container: { flex:1, padding:8, backgroundColor:'#0D0D1A' }, + heading: { color:'#0096FF', fontWeight:'bold', fontSize:14, marginBottom:8 }, + searchRow: { flexDirection:'row', gap:6, marginBottom:6 }, + search: { flex:1, backgroundColor:'#1E1E2E', color:'#E0E0F0', padding:8, borderRadius:6, fontSize:13 }, + iconBtn: { paddingHorizontal:12, justifyContent:'center', backgroundColor:'#1E1E2E', borderRadius:6 }, + filterRow: { flexDirection:'row', gap:6, marginBottom:8 }, + filterBtn: { backgroundColor:'#1E1E2E', paddingHorizontal:10, paddingVertical:6, borderRadius:6 }, + filterText: { color:'#E0E0F0', fontSize:12 }, + err: { color:'#FF6B6B', fontSize:12, marginVertical:6 }, + row: { backgroundColor:'#1E1E2E', padding:10, borderRadius:6, marginBottom:6 }, + rowTitle: { color:'#E0E0F0', fontWeight:'600', fontSize:13 }, + attBadge: { color:'#34C759', fontWeight:'normal', fontSize:11 }, + rowMeta: { color:'#8888AA', fontSize:11, marginTop:2 }, + rowPreview: { color:'#666680', fontSize:11, marginTop:4 }, + footer: { color:'#555570', fontSize:10, textAlign:'center', paddingVertical:6 }, + menuBack: { flex:1, backgroundColor:'rgba(0,0,0,0.7)', justifyContent:'center', alignItems:'center' }, + menuBox: { backgroundColor:'#0D0D1A', borderRadius:8, paddingVertical:4, minWidth:200 }, + menuItem: { paddingVertical:10, paddingHorizontal:14 }, + menuItemText: { color:'#E0E0F0', fontSize:13 }, +}); + +export default MemoryBrowser; diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index ea46116..ccce338 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -29,6 +29,7 @@ import { SvgUri } from 'react-native-svg'; import { Dimensions } from 'react-native'; import ZoomableImage from '../components/ZoomableImage'; import MemoryDetailModal from '../components/MemoryDetailModal'; +import MemoryBrowser from '../components/MemoryBrowser'; import rvs, { RVSMessage, ConnectionState } from '../services/rvs'; import audioService from '../services/audio'; import wakeWordService from '../services/wakeword'; @@ -233,6 +234,7 @@ const ChatScreen: React.FC = () => { const [wakeWordState, setWakeWordState] = useState<'off' | 'armed' | 'conversing'>('off'); const [fullscreenImage, setFullscreenImage] = useState(null); const [memoryDetailId, setMemoryDetailId] = useState(null); + const [inboxVisible, setInboxVisible] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [searchVisible, setSearchVisible] = useState(false); const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist @@ -1594,7 +1596,10 @@ const ChatScreen: React.FC = () => { {connectionState === 'connected' ? 'Verbunden' : connectionState === 'connecting' ? 'Verbinde...' : 'Getrennt'} - setSearchVisible(!searchVisible)} style={{marginLeft: 'auto', paddingHorizontal: 8}}> + setInboxVisible(true)} style={{marginLeft: 'auto', paddingHorizontal: 6}} hitSlop={{top:8,bottom:8,left:6,right:6}}> + \uD83D\uDDC2\uFE0F + + setSearchVisible(!searchVisible)} style={{paddingHorizontal: 6}} hitSlop={{top:8,bottom:8,left:6,right:6}}> {'\uD83D\uDD0D'} @@ -1833,6 +1838,21 @@ const ChatScreen: React.FC = () => { onDeleted={() => setMemoryDetailId(null)} /> + {/* Notizen-Inbox — Listet alle Memories aus dem aktuellen Chat (Special-Bubbles). + Bestes-Aus-beiden-Welten: nur die Memory-IDs aus den memorySaved-Bubbles + des aktuellen Chats, plus den vollen Browser darunter wenn der User mehr will. */} + setInboxVisible(false)}> + + + 🗂️ Notizen-Inbox + setInboxVisible(false)} hitSlop={{top:8,bottom:8,left:8,right:8}}> + × + + + + + + {/* Bild-Vollbild Modal */} setFullscreenImage(null)}> diff --git a/android/src/screens/SettingsScreen.tsx b/android/src/screens/SettingsScreen.tsx index f4d2f32..5eee245 100644 --- a/android/src/screens/SettingsScreen.tsx +++ b/android/src/screens/SettingsScreen.tsx @@ -52,6 +52,7 @@ import { } from '../services/audio'; import audioService from '../services/audio'; import gpsTrackingService from '../services/gpsTracking'; +import MemoryBrowser from '../components/MemoryBrowser'; import { isVerboseLogging, setVerboseLogging } from '../services/logger'; import { isWakeReadySoundEnabled, @@ -100,6 +101,7 @@ const SETTINGS_SECTIONS = [ { id: 'voice_output', icon: '🔊', label: 'Sprachausgabe', desc: 'Stimmen, Pre-Roll, Geschwindigkeit' }, { 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: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' }, { id: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' }, ] as const; @@ -1673,6 +1675,18 @@ const SettingsScreen: React.FC = () => { )} + {/* === Gedaechtnis === */} + {currentSection === 'memory' && (<> + Gedächtnis + + Alle Memory-Einträge aus ARIAs Vector-DB. Tippen zum Bearbeiten — mit Anhängen, pinned-Status, + Tags. Neue Einträge anlegen via "+ Neu". + + + + + )} + {/* === Logs === */} {currentSection === 'protocol' && (<> Protokoll