e88b5f57bf
Stefan: App crasht beim Tap auf Inbox-Button. Zwei Ursachen: 1. Modal-in-Modal-Stacking (Inbox-Modal enthielt MemoryBrowser, der wiederum ein MemoryDetailModal gerendered hat). Android Modal hat damit Probleme — der Native-Layer mag nur eine Modal-Instance gleichzeitig zuverlaessig. 2. MemoryBrowser nutzte Alert.prompt fuer "Neue Memory anlegen" — das ist iOS-only, Android wirft eine Warnung oder crasht. Fix: - MemoryBrowser bekommt optionalen onOpenMemory-Callback. Wenn der Parent diesen liefert, mounted MemoryBrowser KEIN eigenes DetailModal mehr. ChatScreen mountet das DetailModal nur einmal auf seiner Ebene; Inbox-Modal schliesst sich beim Tap und delegiert die ID an memoryDetailId-State. Damit ist immer maximal ein Modal aktiv. - Alert.prompt durch eigenes kleines Dialog-Modal ersetzt: TextInput fuer Titel, Anlegen/Abbrechen-Buttons. Cross-platform stabil. SettingsScreen-Nutzung von MemoryBrowser bleibt unveraendert (kein Callback → eingebautes DetailModal, aber dort kein Modal-Stacking weil Settings kein Modal ist). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
267 lines
11 KiB
TypeScript
267 lines
11 KiB
TypeScript
/**
|
|
* 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<string, string> = {
|
|
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<Props> = ({ restrictToIds, title, flatStyle, onOpenMemory }) => {
|
|
const [items, setItems] = useState<Memory[]>([]);
|
|
const [filtered, setFiltered] = useState<Memory[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [err, setErr] = useState<string | null>(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<string | null>(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 (
|
|
<TouchableOpacity style={s.row} onPress={() => onOpenMemory ? onOpenMemory(item.id) : setOpenId(item.id)}>
|
|
<View style={{flex:1}}>
|
|
<Text style={s.rowTitle} numberOfLines={1}>
|
|
{item.pinned ? '📌 ' : ''}{item.title || '(ohne Titel)'}
|
|
{attCount > 0 ? <Text style={s.attBadge}>{` 📎${attCount}`}</Text> : null}
|
|
</Text>
|
|
<Text style={s.rowMeta} numberOfLines={1}>
|
|
{TYPE_LABELS[item.type] || item.type}
|
|
{item.category ? ` · [${item.category}]` : ''}
|
|
</Text>
|
|
<Text style={s.rowPreview} numberOfLines={2}>{item.content}</Text>
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<View style={[s.container, flatStyle && {padding:0,backgroundColor:'transparent'}]}>
|
|
{title ? <Text style={s.heading}>{title}</Text> : null}
|
|
|
|
<View style={s.searchRow}>
|
|
<TextInput
|
|
style={s.search}
|
|
value={q}
|
|
onChangeText={setQ}
|
|
placeholder="Suche in Titel, Inhalt, Tags…"
|
|
placeholderTextColor="#555570"
|
|
/>
|
|
<TouchableOpacity style={s.iconBtn} onPress={load}>
|
|
<Text style={{color:'#0096FF'}}>↻</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
<View style={s.filterRow}>
|
|
<TouchableOpacity style={s.filterBtn} onPress={() => setShowTypeMenu(true)}>
|
|
<Text style={s.filterText}>{typeFilter ? (TYPE_LABELS[typeFilter] || typeFilter) : 'Alle Typen'} ▾</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity style={s.filterBtn} onPress={() => {
|
|
setPinnedFilter(pinnedFilter === 'all' ? 'pinned' : pinnedFilter === 'pinned' ? 'cold' : 'all');
|
|
}}>
|
|
<Text style={s.filterText}>
|
|
{pinnedFilter === 'pinned' ? '📌 Nur Pinned' : pinnedFilter === 'cold' ? 'Nur Cold' : 'Alle'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity style={[s.filterBtn,{backgroundColor:'#0096FF'}]} onPress={onAddNew}>
|
|
<Text style={[s.filterText,{color:'#fff'}]}>+ Neu</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{err ? <Text style={s.err}>{err}</Text> : null}
|
|
|
|
{loading && items.length === 0 ? (
|
|
<ActivityIndicator color="#0096FF" style={{marginTop:20}} />
|
|
) : (
|
|
<FlatList
|
|
data={filtered}
|
|
keyExtractor={m => m.id}
|
|
renderItem={renderItem}
|
|
ListEmptyComponent={
|
|
<Text style={{color:'#555570',textAlign:'center',padding:20,fontStyle:'italic'}}>
|
|
{items.length === 0 ? '(keine Memories in der DB)' : '(keine Treffer für diese Filter)'}
|
|
</Text>
|
|
}
|
|
contentContainerStyle={{paddingBottom:20}}
|
|
/>
|
|
)}
|
|
|
|
<Text style={s.footer}>
|
|
{filtered.length}/{items.length} Memories
|
|
</Text>
|
|
|
|
{/* Type-Filter-Auswahl */}
|
|
<Modal visible={showTypeMenu} transparent animationType="fade" onRequestClose={() => setShowTypeMenu(false)}>
|
|
<TouchableOpacity style={s.menuBack} activeOpacity={1} onPress={() => setShowTypeMenu(false)}>
|
|
<View style={s.menuBox}>
|
|
{TYPE_OPTIONS.map(t => (
|
|
<TouchableOpacity
|
|
key={t || 'all'}
|
|
style={s.menuItem}
|
|
onPress={() => { setTypeFilter(t); setShowTypeMenu(false); }}
|
|
>
|
|
<Text style={s.menuItemText}>
|
|
{t ? (TYPE_LABELS[t] || t) : 'Alle Typen'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
</TouchableOpacity>
|
|
</Modal>
|
|
|
|
{/* Eigenes DetailModal nur wenn der Parent kein Callback uebergibt
|
|
(vermeidet Modal-in-Modal-Stacking auf Android). */}
|
|
{!onOpenMemory && (
|
|
<MemoryDetailModal
|
|
memoryId={openId}
|
|
visible={!!openId}
|
|
onClose={() => { setOpenId(null); load(); }}
|
|
onDeleted={() => { setOpenId(null); load(); }}
|
|
/>
|
|
)}
|
|
|
|
{/* "Neue Memory"-Dialog (Alert.prompt ist iOS-only, daher eigenes Modal) */}
|
|
<Modal visible={showNewMemoryDialog} transparent animationType="fade" onRequestClose={() => setShowNewMemoryDialog(false)}>
|
|
<View style={s.menuBack}>
|
|
<View style={[s.menuBox, {padding:16, minWidth:280}]}>
|
|
<Text style={{color:'#FFD60A', fontWeight:'bold', fontSize:14, marginBottom:10}}>Neue Memory anlegen</Text>
|
|
<Text style={{color:'#8888AA', fontSize:11, marginBottom:6}}>Titel:</Text>
|
|
<TextInput
|
|
value={newMemoryTitle}
|
|
onChangeText={setNewMemoryTitle}
|
|
autoFocus
|
|
placeholder="z.B. Stefans Auto"
|
|
placeholderTextColor="#555570"
|
|
style={{backgroundColor:'#1E1E2E', color:'#E0E0F0', padding:8, borderRadius:4, fontSize:13, marginBottom:12}}
|
|
/>
|
|
<View style={{flexDirection:'row', gap:8, justifyContent:'flex-end'}}>
|
|
<TouchableOpacity onPress={() => setShowNewMemoryDialog(false)} style={{padding:8}}>
|
|
<Text style={{color:'#8888AA'}}>Abbrechen</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity onPress={confirmAddNew} style={{backgroundColor:'#0096FF', paddingHorizontal:14, paddingVertical:8, borderRadius:4}}>
|
|
<Text style={{color:'#fff', fontWeight:'600'}}>Anlegen</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
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;
|