/** * 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; /** Wenn gesetzt: kein eigenes DetailModal mounten — Parent kuemmert sich. */ onOpenMemory?: (id: string) => void; } export const MemoryBrowser: React.FC = ({ restrictToIds, title, flatStyle, onOpenMemory }) => { 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 [showNewMemoryDialog, setShowNewMemoryDialog] = useState(false); const [newMemoryTitle, setNewMemoryTitle] = useState(''); const onAddNew = () => { setNewMemoryTitle(''); setShowNewMemoryDialog(true); }; const confirmAddNew = async () => { const t = newMemoryTitle.trim(); if (!t) { setShowNewMemoryDialog(false); return; } setShowNewMemoryDialog(false); try { const m = await brainApi.saveMemory({ type: 'fact', title: t, content: '(noch leer — bitte editieren)', }); load(); if (onOpenMemory) onOpenMemory(m.id); else setOpenId(m.id); } catch (e: any) { Alert.alert('Fehler', String(e?.message || e)); } }; const renderItem = ({ item }: { item: Memory }) => { const attCount = (item.attachments || []).length; return ( onOpenMemory ? onOpenMemory(item.id) : 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} // nestedScrollEnabled: notwendig damit die FlatList auf Android // scrollt wenn sie in einer aeusseren ScrollView haengt (Settings- // Screen ist ScrollView). Ohne das frisst der aeussere ScrollView // alle Gesten und die innere Liste ist tot. nestedScrollEnabled={true} keyboardShouldPersistTaps="handled" ListEmptyComponent={ {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'} ))} {/* Eigenes DetailModal nur wenn der Parent kein Callback uebergibt (vermeidet Modal-in-Modal-Stacking auf Android). */} {!onOpenMemory && ( { setOpenId(null); load(); }} onDeleted={() => { setOpenId(null); load(); }} /> )} {/* "Neue Memory"-Dialog (Alert.prompt ist iOS-only, daher eigenes Modal) */} setShowNewMemoryDialog(false)}> Neue Memory anlegen Titel: setShowNewMemoryDialog(false)} style={{padding:8}}> Abbrechen Anlegen ); }; 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;