feat(memory): Notizen-Inbox + Settings-Editor (Etappen 4+5)
Etappe 4 — 🗂️ Notizen-Inbox-Button neben der Lupe: - Statusleiste hat jetzt zwei Icons: 🗂️ Inbox + 🔍 Suche - Tap auf Inbox-Icon oeffnet ein Vollbild-Modal mit MemoryBrowser- Komponente. User sieht alle Memories aus der DB, kann suchen, filtern, neu anlegen, und in den Detail/Edit-Modus springen. Etappe 5 — Memory-Editor in App-Settings: - SETTINGS_SECTIONS um Eintrag 🧠 "Gedächtnis" erweitert - Sektion rendert MemoryBrowser (selbe Komponente wie Inbox) in einer 600px-Box — vom Diagnostic-Gehirn-Tab inspiriert, aber fuer's Handy optimiert - Beide Stellen recyclen MemoryBrowser+MemoryDetailModal aus Etappe 2/3 — kein doppelter Code MemoryBrowser (neue Komponente components/MemoryBrowser.tsx): - Lazy-Load aller Memories via brainApi.listMemories - Client-side Filter: Volltext-Suche (Title+Content+Category+Tags), Type-Dropdown, Pinned/Cold/Alle-Toggle - "+ Neu" Knopf mit Alert.prompt fuer Titel, automatisch type=fact, oeffnet danach den DetailModal zum Editieren des Contents - Item-Render mit Pinned-Marker, Anhang-Badge 📎N, Type-Label, Category, 2-Zeilen-Content-Preview - Tap auf Item oeffnet MemoryDetailModal → CRUD weiter dort Damit sind alle 5 Etappen aus Stefans Wunsch-Trio durch: - Bubble-Header dynamic (Etappe 1, committed gestern) - Tap-Modal mit Detail (Etappe 2) - Edit + Anhang-Upload im Modal (Etappe 3) - Notizen-Inbox-Button (Etappe 4) - Memory-Editor in Settings (Etappe 5) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<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;
|
||||
}
|
||||
|
||||
export const MemoryBrowser: React.FC<Props> = ({ restrictToIds, title, flatStyle }) => {
|
||||
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 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 (
|
||||
<TouchableOpacity style={s.row} onPress={() => 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>
|
||||
|
||||
<MemoryDetailModal
|
||||
memoryId={openId}
|
||||
visible={!!openId}
|
||||
onClose={() => { setOpenId(null); load(); }}
|
||||
onDeleted={() => { setOpenId(null); load(); }}
|
||||
/>
|
||||
</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;
|
||||
Reference in New Issue
Block a user