f71936da86
Stefans naechste Wunsch-Etappe — komplettes Edit eines Memory-Eintrags
aus der App heraus, inkl. Anhang-Upload, ohne Diagnostic-Browser
auszuklappen.
Backend-Fundament (Phase A):
- Brain bekommt GET /memory/get/{id} fuer Einzel-Lookup mit allen Feldern
- RVS ALLOWED_TYPES um brain_request + brain_response erweitert
- Bridge implementiert generischen RVS-Brain-Proxy:
payload {requestId, method, path, body|bodyBase64, contentType}
→ ruft Brain-HTTP-API → broadcastet brain_response {requestId,
status, json|text|base64+contentType}. Damit kann die App
beliebige Brain-Endpoints ueber RVS adressieren — nicht nur Memory.
App-Service (Phase B):
- services/brainApi.ts: Promise-basierter Client. _send() schickt
brain_request mit requestId, _ensureListener() filtert die passende
brain_response. Methoden: getMemory, listMemories, searchText,
searchSemantic, saveMemory, updateMemory, deleteMemory,
uploadAttachment (Base64), deleteAttachment, getAttachmentBytes.
App-UI (Phasen C+D):
- components/MemoryDetailModal.tsx: Modal mit zwei Modi.
- Read: Titel, Type, Category, Tags, voller Content, Anhang-Liste
(Tap = Bild im Vollbild oder Datei-Info), Stift-Icon → Edit.
- Edit: Titel/Content/Category/Tags/Pinned editierbar, Save via
brainApi.updateMemory.
- DocumentPicker + RNFS.readFile(base64) → uploadAttachment(...).
- Anhang loeschen, kompletter Memory loeschen (mit Alert-confirm).
- ChatScreen: TouchableOpacity-Wrapper um die memorySaved-Bubble,
Tap setzt memoryDetailId → Modal oeffnet. Hint im Footer
"tippen für Details" wenn die Bubble eine ID hat.
Etappen 4 (Notizen-Inbox neben Lupe) + 5 (Memory-Editor in App-
Settings) folgen — diese nutzen die gleiche MemoryDetailModal-
Komponente, sind also schnell aufgesetzt sobald 2+3 verifiziert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
365 lines
15 KiB
TypeScript
365 lines
15 KiB
TypeScript
/**
|
||
* Memory-Detail-Modal — Anzeige + Edit eines einzelnen Memory-Eintrags.
|
||
*
|
||
* Zwei Modi:
|
||
* - read-only: zeigt alle Felder + Anhang-Vorschau (Klick auf Bild = Vollbild)
|
||
* - edit: Form mit Save/Delete/Anhang-hochladen
|
||
*
|
||
* Memory-Daten werden beim Oeffnen aus dem Brain (via brainApi → RVS) frisch
|
||
* gezogen. Optimistic Updates sind explizit nicht da — der DB-Stand ist die
|
||
* Truth.
|
||
*/
|
||
|
||
import React, { useEffect, useState } from 'react';
|
||
import {
|
||
ActivityIndicator,
|
||
Alert,
|
||
Image,
|
||
Modal,
|
||
ScrollView,
|
||
StyleSheet,
|
||
Switch,
|
||
Text,
|
||
TextInput,
|
||
TouchableOpacity,
|
||
View,
|
||
} from 'react-native';
|
||
import DocumentPicker, { DocumentPickerResponse } from 'react-native-document-picker';
|
||
import RNFS from 'react-native-fs';
|
||
|
||
import brainApi, { Memory, MemoryAttachment } from '../services/brainApi';
|
||
|
||
interface Props {
|
||
memoryId: string | null;
|
||
visible: boolean;
|
||
onClose: () => void;
|
||
onDeleted?: (id: string) => void;
|
||
}
|
||
|
||
const TYPE_OPTIONS = [
|
||
{ value: 'identity', label: 'identity (FEST)' },
|
||
{ value: 'rule', label: 'rule (FEST)' },
|
||
{ value: 'preference', label: 'preference (FEST)' },
|
||
{ value: 'tool', label: 'tool (FEST)' },
|
||
{ value: 'skill', label: 'skill (FEST)' },
|
||
{ value: 'fact', label: 'fact (Cold)' },
|
||
{ value: 'conversation', label: 'conversation (Cold)' },
|
||
{ value: 'reminder', label: 'reminder (Cold)' },
|
||
];
|
||
|
||
export const MemoryDetailModal: React.FC<Props> = ({ memoryId, visible, onClose, onDeleted }) => {
|
||
const [memory, setMemory] = useState<Memory | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [editing, setEditing] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
const [busy, setBusy] = useState<string | null>(null);
|
||
|
||
// Edit-Felder
|
||
const [eTitle, setETitle] = useState('');
|
||
const [eContent, setEContent] = useState('');
|
||
const [eCategory, setECategory] = useState('');
|
||
const [eTags, setETags] = useState('');
|
||
const [ePinned, setEPinned] = useState(false);
|
||
|
||
// Bild-Vollbild
|
||
const [fullscreen, setFullscreen] = useState<string | null>(null);
|
||
|
||
// Memory laden beim Oeffnen
|
||
useEffect(() => {
|
||
if (!visible || !memoryId) {
|
||
setMemory(null); setEditing(false); setErr(null); return;
|
||
}
|
||
setLoading(true); setErr(null);
|
||
brainApi.getMemory(memoryId)
|
||
.then(m => {
|
||
setMemory(m);
|
||
setETitle(m.title || '');
|
||
setEContent(m.content || '');
|
||
setECategory(m.category || '');
|
||
setETags((m.tags || []).join(', '));
|
||
setEPinned(!!m.pinned);
|
||
})
|
||
.catch(e => setErr(String(e?.message || e)))
|
||
.finally(() => setLoading(false));
|
||
}, [visible, memoryId]);
|
||
|
||
const reload = () => {
|
||
if (!memoryId) return;
|
||
setLoading(true);
|
||
brainApi.getMemory(memoryId)
|
||
.then(m => setMemory(m))
|
||
.catch(e => setErr(String(e?.message || e)))
|
||
.finally(() => setLoading(false));
|
||
};
|
||
|
||
const onSave = async () => {
|
||
if (!memoryId) return;
|
||
setSaving(true); setErr(null);
|
||
try {
|
||
const tags = eTags.split(',').map(t => t.trim()).filter(Boolean);
|
||
const m = await brainApi.updateMemory(memoryId, {
|
||
title: eTitle.trim(),
|
||
content: eContent.trim(),
|
||
category: eCategory.trim(),
|
||
tags,
|
||
pinned: ePinned,
|
||
});
|
||
setMemory(m);
|
||
setEditing(false);
|
||
} catch (e: any) {
|
||
setErr(String(e?.message || e));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const onDelete = () => {
|
||
if (!memoryId || !memory) return;
|
||
Alert.alert(
|
||
'Memory loeschen?',
|
||
`"${memory.title}"\n\nWird permanent aus der DB entfernt, inkl. aller Anhaenge.`,
|
||
[
|
||
{ text: 'Abbrechen', style: 'cancel' },
|
||
{
|
||
text: 'Loeschen',
|
||
style: 'destructive',
|
||
onPress: async () => {
|
||
try {
|
||
await brainApi.deleteMemory(memoryId);
|
||
if (onDeleted) onDeleted(memoryId);
|
||
onClose();
|
||
} catch (e: any) {
|
||
Alert.alert('Fehler', String(e?.message || e));
|
||
}
|
||
},
|
||
},
|
||
],
|
||
);
|
||
};
|
||
|
||
const onPickAndUpload = async () => {
|
||
if (!memoryId) return;
|
||
try {
|
||
const picked: DocumentPickerResponse[] = await DocumentPicker.pick({
|
||
type: [DocumentPicker.types.images, DocumentPicker.types.pdf, DocumentPicker.types.allFiles],
|
||
copyTo: 'cachesDirectory',
|
||
});
|
||
for (const f of picked) {
|
||
setBusy(`Lade ${f.name}…`);
|
||
// RNFS lesen → base64 → API
|
||
const localPath = (f.fileCopyUri || f.uri).replace(/^file:\/\//, '');
|
||
const b64 = await RNFS.readFile(localPath, 'base64');
|
||
await brainApi.uploadAttachment(memoryId, f.name || 'datei', b64);
|
||
}
|
||
setBusy(null);
|
||
reload();
|
||
} catch (e: any) {
|
||
setBusy(null);
|
||
if (DocumentPicker.isCancel(e)) return;
|
||
Alert.alert('Upload-Fehler', String(e?.message || e));
|
||
}
|
||
};
|
||
|
||
const onDeleteAttachment = (att: MemoryAttachment) => {
|
||
if (!memoryId) return;
|
||
Alert.alert(
|
||
'Anhang loeschen?',
|
||
`"${att.name}"`,
|
||
[
|
||
{ text: 'Abbrechen', style: 'cancel' },
|
||
{
|
||
text: 'Loeschen',
|
||
style: 'destructive',
|
||
onPress: async () => {
|
||
try {
|
||
const m = await brainApi.deleteAttachment(memoryId, att.name);
|
||
setMemory(m);
|
||
} catch (e: any) {
|
||
Alert.alert('Fehler', String(e?.message || e));
|
||
}
|
||
},
|
||
},
|
||
],
|
||
);
|
||
};
|
||
|
||
const onTapAttachment = async (att: MemoryAttachment) => {
|
||
if (!memoryId) return;
|
||
if ((att.mime || '').startsWith('image/')) {
|
||
try {
|
||
setBusy('Lade Bild…');
|
||
const data = await brainApi.getAttachmentBytes(memoryId, att.name);
|
||
// Temp-File schreiben damit <Image source={uri: file://...}> es zeigen kann
|
||
const safe = att.name.replace(/[^A-Za-z0-9._-]/g, '_');
|
||
const localPath = `${RNFS.CachesDirectoryPath}/memory_${memoryId}_${safe}`;
|
||
await RNFS.writeFile(localPath, data.base64, 'base64');
|
||
setBusy(null);
|
||
setFullscreen('file://' + localPath);
|
||
} catch (e: any) {
|
||
setBusy(null);
|
||
Alert.alert('Fehler', String(e?.message || e));
|
||
}
|
||
} else {
|
||
Alert.alert('Anhang', `${att.name}\n${att.mime}\n${att.size} Byte\n\nPfad: ${att.path}`);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
|
||
<View style={s.backdrop}>
|
||
<View style={s.box}>
|
||
<View style={s.header}>
|
||
<Text style={s.title}>{editing ? 'Memory bearbeiten' : 'Memory-Detail'}</Text>
|
||
<TouchableOpacity onPress={onClose} hitSlop={{top:8,bottom:8,left:8,right:8}}>
|
||
<Text style={s.closeX}>×</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
<ScrollView style={s.body} contentContainerStyle={{paddingBottom:20}}>
|
||
{loading ? (
|
||
<ActivityIndicator color="#0096FF" style={{marginTop:30}} />
|
||
) : err && !memory ? (
|
||
<Text style={s.err}>{err}</Text>
|
||
) : memory ? (
|
||
editing ? (
|
||
<View>
|
||
<Text style={s.label}>Typ</Text>
|
||
<Text style={{color:'#888',fontSize:12,marginBottom:8}}>{memory.type} (kann hier nicht geaendert werden)</Text>
|
||
|
||
<Text style={s.label}>Titel</Text>
|
||
<TextInput style={s.input} value={eTitle} onChangeText={setETitle} />
|
||
|
||
<Text style={s.label}>Inhalt</Text>
|
||
<TextInput
|
||
style={[s.input, {minHeight:120, textAlignVertical:'top'}]}
|
||
value={eContent}
|
||
onChangeText={setEContent}
|
||
multiline
|
||
/>
|
||
|
||
<Text style={s.label}>Kategorie</Text>
|
||
<TextInput style={s.input} value={eCategory} onChangeText={setECategory} />
|
||
|
||
<Text style={s.label}>Tags (komma-getrennt)</Text>
|
||
<TextInput style={s.input} value={eTags} onChangeText={setETags} />
|
||
|
||
<View style={{flexDirection:'row',alignItems:'center',marginTop:10,gap:8}}>
|
||
<Switch value={ePinned} onValueChange={setEPinned} />
|
||
<Text style={{color:'#E0E0F0'}}>📌 Pinned (immer im System-Prompt)</Text>
|
||
</View>
|
||
|
||
{err ? <Text style={s.err}>{err}</Text> : null}
|
||
|
||
<View style={{flexDirection:'row',gap:8,marginTop:14}}>
|
||
<TouchableOpacity style={[s.btn,s.btnSecondary]} onPress={() => setEditing(false)} disabled={saving}>
|
||
<Text style={s.btnText}>Abbrechen</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity style={[s.btn,s.btnPrimary,{flex:1}]} onPress={onSave} disabled={saving}>
|
||
<Text style={s.btnText}>{saving ? 'Speichere…' : 'Speichern'}</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
) : (
|
||
<View>
|
||
<View style={{flexDirection:'row',alignItems:'flex-start',justifyContent:'space-between'}}>
|
||
<Text style={s.bigTitle}>{memory.pinned ? '📌 ' : ''}{memory.title}</Text>
|
||
<TouchableOpacity onPress={() => setEditing(true)} style={s.iconBtn}>
|
||
<Text style={s.iconBtnText}>✎</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
<Text style={s.meta}>
|
||
{memory.type}{memory.category ? ` · [${memory.category}]` : ''}
|
||
</Text>
|
||
{(memory.tags || []).length > 0 ? (
|
||
<View style={s.tagsRow}>
|
||
{memory.tags.map(t => <Text key={t} style={s.tag}>{t}</Text>)}
|
||
</View>
|
||
) : null}
|
||
|
||
<Text style={s.contentBlock}>{memory.content}</Text>
|
||
|
||
<Text style={s.sectionHead}>📎 Anhaenge</Text>
|
||
{(memory.attachments || []).length === 0 ? (
|
||
<Text style={{color:'#555570',fontStyle:'italic',fontSize:12}}>(keine)</Text>
|
||
) : (
|
||
(memory.attachments || []).map((a) => {
|
||
const isImage = (a.mime || '').startsWith('image/');
|
||
return (
|
||
<View key={a.name} style={s.attRow}>
|
||
<TouchableOpacity style={{flexDirection:'row',alignItems:'center',gap:8,flex:1}} onPress={() => onTapAttachment(a)}>
|
||
<Text style={{fontSize:18}}>{isImage ? '🖼️' : '📄'}</Text>
|
||
<View style={{flex:1}}>
|
||
<Text style={{color:'#E0E0F0',fontSize:12}} numberOfLines={1}>{a.name}</Text>
|
||
<Text style={{color:'#555570',fontSize:10}}>{a.mime} · {Math.round(a.size/1024)} KB</Text>
|
||
</View>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity onPress={() => onDeleteAttachment(a)} style={s.attDelete}>
|
||
<Text style={{color:'#FF6B6B',fontSize:12}}>🗑</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
);
|
||
})
|
||
)}
|
||
<TouchableOpacity style={[s.btn,s.btnSecondary,{marginTop:8}]} onPress={onPickAndUpload}>
|
||
<Text style={s.btnText}>⬆ Datei anhaengen</Text>
|
||
</TouchableOpacity>
|
||
{busy ? <Text style={{color:'#8888AA',fontSize:11,marginTop:4}}>{busy}</Text> : null}
|
||
|
||
<Text style={s.timestamps}>
|
||
angelegt: {(memory.created_at || '').slice(0,16).replace('T',' ')}{'\n'}
|
||
geaendert: {(memory.updated_at || '').slice(0,16).replace('T',' ')}{'\n'}
|
||
id: {memory.id}
|
||
</Text>
|
||
|
||
<TouchableOpacity style={[s.btn,s.btnDanger,{marginTop:14}]} onPress={onDelete}>
|
||
<Text style={s.btnText}>🗑 Memory komplett loeschen</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
)
|
||
) : null}
|
||
</ScrollView>
|
||
</View>
|
||
</View>
|
||
|
||
<Modal visible={!!fullscreen} transparent onRequestClose={() => setFullscreen(null)}>
|
||
<TouchableOpacity style={s.fsBack} onPress={() => setFullscreen(null)}>
|
||
{fullscreen ? <Image source={{uri:fullscreen}} style={s.fsImg} resizeMode="contain" /> : null}
|
||
</TouchableOpacity>
|
||
</Modal>
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
const s = StyleSheet.create({
|
||
backdrop: { flex:1, backgroundColor:'rgba(0,0,0,0.75)', justifyContent:'flex-end' },
|
||
box: { backgroundColor:'#0D0D1A', borderTopLeftRadius:12, borderTopRightRadius:12, maxHeight:'92%' },
|
||
header: { flexDirection:'row', justifyContent:'space-between', alignItems:'center', padding:14, borderBottomColor:'#1E1E2E', borderBottomWidth:1 },
|
||
title: { color:'#FFD60A', fontWeight:'bold', fontSize:15 },
|
||
closeX: { color:'#8888AA', fontSize:24, paddingHorizontal:6 },
|
||
body: { padding:14 },
|
||
err: { color:'#FF6B6B', fontSize:12, marginTop:8 },
|
||
label: { color:'#8888AA', fontSize:11, marginBottom:3, marginTop:8 },
|
||
input: { backgroundColor:'#080810', borderColor:'#1E1E2E', borderWidth:1, borderRadius:4, padding:8, color:'#E0E0F0', fontSize:13 },
|
||
bigTitle: { color:'#E0E0F0', fontWeight:'bold', fontSize:16, flex:1, marginRight:6 },
|
||
iconBtn: { padding:6, backgroundColor:'#1E1E2E', borderRadius:6 },
|
||
iconBtnText: { color:'#0096FF', fontSize:14 },
|
||
meta: { color:'#8888AA', fontSize:11, marginTop:4 },
|
||
tagsRow: { flexDirection:'row', flexWrap:'wrap', gap:4, marginTop:6 },
|
||
tag: { backgroundColor:'#1E1E2E', color:'#8888AA', fontSize:10, paddingHorizontal:6, paddingVertical:2, borderRadius:8 },
|
||
contentBlock: { color:'#E0E0F0', fontSize:13, marginTop:12, lineHeight:18 },
|
||
sectionHead: { color:'#0096FF', fontSize:11, marginTop:14, marginBottom:6, textTransform:'uppercase', letterSpacing:0.5 },
|
||
attRow: { flexDirection:'row', alignItems:'center', backgroundColor:'#080810', padding:8, borderRadius:6, marginBottom:4, gap:6 },
|
||
attDelete: { padding:4 },
|
||
timestamps: { color:'#555570', fontSize:10, marginTop:12, fontFamily:'monospace' },
|
||
btn: { paddingVertical:10, paddingHorizontal:14, borderRadius:6, alignItems:'center' },
|
||
btnPrimary: { backgroundColor:'#0096FF' },
|
||
btnSecondary: { backgroundColor:'#1E1E2E' },
|
||
btnDanger: { backgroundColor:'#3B1010', borderWidth:1, borderColor:'#FF6B6B' },
|
||
btnText: { color:'#fff', fontSize:13, fontWeight:'600' },
|
||
fsBack: { flex:1, backgroundColor:'rgba(0,0,0,0.95)', justifyContent:'center', alignItems:'center' },
|
||
fsImg: { width:'95%', height:'85%' },
|
||
});
|
||
|
||
export default MemoryDetailModal;
|