feat(memory): Tap auf Memory-Bubble oeffnet Detail+Edit-Modal in der App (Etappen 2+3)
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>
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -28,6 +28,7 @@ import RNFS from 'react-native-fs';
|
||||
import { SvgUri } from 'react-native-svg';
|
||||
import { Dimensions } from 'react-native';
|
||||
import ZoomableImage from '../components/ZoomableImage';
|
||||
import MemoryDetailModal from '../components/MemoryDetailModal';
|
||||
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
||||
import audioService from '../services/audio';
|
||||
import wakeWordService from '../services/wakeword';
|
||||
@@ -231,6 +232,7 @@ const ChatScreen: React.FC = () => {
|
||||
// Genauer State (off/armed/conversing) fuer UI-Feedback am Button
|
||||
const [wakeWordState, setWakeWordState] = useState<'off' | 'armed' | 'conversing'>('off');
|
||||
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
|
||||
const [memoryDetailId, setMemoryDetailId] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchVisible, setSearchVisible] = useState(false);
|
||||
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
|
||||
@@ -1337,8 +1339,13 @@ const ChatScreen: React.FC = () => {
|
||||
'🧠 ARIA hat etwas gemerkt';
|
||||
const headlineColor = action === 'deleted' ? '#FF6B6B' : '#FFD60A';
|
||||
const borderColor = action === 'deleted' ? '#FF6B6B' : '#FFD60A';
|
||||
const openable = !!m.id && action !== 'deleted';
|
||||
const Wrapper: any = openable ? TouchableOpacity : View;
|
||||
const wrapperProps = openable
|
||||
? { onPress: () => setMemoryDetailId(m.id || null), activeOpacity: 0.7 }
|
||||
: {};
|
||||
return (
|
||||
<View style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: borderColor}, searchHighlightStyle]}>
|
||||
<Wrapper {...wrapperProps} style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: borderColor}, searchHighlightStyle]}>
|
||||
<Text style={{color: headlineColor, fontWeight: 'bold', fontSize: 14}}>
|
||||
{headline}
|
||||
</Text>
|
||||
@@ -1378,8 +1385,10 @@ const ChatScreen: React.FC = () => {
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
<Text style={{color: '#555570', fontSize: 10, marginTop: 6}}>ARIA-Memory · {time}</Text>
|
||||
</View>
|
||||
<Text style={{color: '#555570', fontSize: 10, marginTop: 6}}>
|
||||
ARIA-Memory · {time}{openable ? ' · tippen für Details' : ''}
|
||||
</Text>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1816,6 +1825,14 @@ const ChatScreen: React.FC = () => {
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Memory-Detail/Edit-Modal — wird durch Tap auf eine memorySaved-Bubble geoeffnet */}
|
||||
<MemoryDetailModal
|
||||
memoryId={memoryDetailId}
|
||||
visible={!!memoryDetailId}
|
||||
onClose={() => setMemoryDetailId(null)}
|
||||
onDeleted={() => setMemoryDetailId(null)}
|
||||
/>
|
||||
|
||||
{/* Bild-Vollbild Modal */}
|
||||
<Modal visible={!!fullscreenImage} transparent animationType="fade" onRequestClose={() => setFullscreenImage(null)}>
|
||||
<View style={styles.fullscreenOverlay}>
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Brain-API-Client fuer die App.
|
||||
*
|
||||
* Die App hat keinen direkten HTTP-Zugriff aufs Brain (nur via RVS). Wir
|
||||
* tunneln alle Memory-Operationen ueber den generischen brain_request /
|
||||
* brain_response RVS-Channel den die Bridge implementiert.
|
||||
*
|
||||
* Pattern: pro Call eine eindeutige requestId, Listener wartet auf passende
|
||||
* brain_response, Promise loest auf / wird abgelehnt bei status>=400.
|
||||
*/
|
||||
|
||||
import rvs from './rvs';
|
||||
|
||||
type AnyJson = any;
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (data: AnyJson) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
expectBinary?: boolean;
|
||||
}
|
||||
|
||||
const pending = new Map<string, PendingRequest>();
|
||||
let installed = false;
|
||||
|
||||
function _ensureListener() {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
rvs.onMessage((msg: any) => {
|
||||
if (!msg || msg.type !== 'brain_response') return;
|
||||
const p = msg.payload || {};
|
||||
const reqId: string = p.requestId || '';
|
||||
const handler = pending.get(reqId);
|
||||
if (!handler) return;
|
||||
pending.delete(reqId);
|
||||
clearTimeout(handler.timer);
|
||||
const status: number = Number(p.status || 0);
|
||||
if (status >= 200 && status < 300) {
|
||||
if (handler.expectBinary) {
|
||||
handler.resolve({ base64: p.base64 || '', contentType: p.contentType || '' });
|
||||
} else {
|
||||
handler.resolve(p.json !== undefined ? p.json : (p.text !== undefined ? p.text : null));
|
||||
}
|
||||
} else {
|
||||
const detail = (p.json && p.json.detail) || p.text || `HTTP ${status}`;
|
||||
handler.reject(new Error(`Brain ${status}: ${detail}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let _nextId = 0;
|
||||
function _newRequestId(): string {
|
||||
_nextId += 1;
|
||||
return `brain_${Date.now().toString(36)}_${_nextId}`;
|
||||
}
|
||||
|
||||
interface SendOpts {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
body?: AnyJson;
|
||||
bodyBase64?: string;
|
||||
contentType?: string;
|
||||
expectBinary?: boolean;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
function _send(path: string, opts: SendOpts = {}): Promise<AnyJson> {
|
||||
_ensureListener();
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestId = _newRequestId();
|
||||
const timer = setTimeout(() => {
|
||||
if (pending.delete(requestId)) {
|
||||
reject(new Error(`Brain-Timeout fuer ${path}`));
|
||||
}
|
||||
}, opts.timeoutMs || 30000);
|
||||
pending.set(requestId, { resolve, reject, timer, expectBinary: opts.expectBinary });
|
||||
rvs.send('brain_request' as any, {
|
||||
requestId,
|
||||
method: opts.method || 'GET',
|
||||
path,
|
||||
...(opts.body !== undefined ? { body: opts.body } : {}),
|
||||
...(opts.bodyBase64 ? { bodyBase64: opts.bodyBase64 } : {}),
|
||||
...(opts.contentType ? { contentType: opts.contentType } : {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Typen ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MemoryAttachment {
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface Memory {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
pinned: boolean;
|
||||
category: string;
|
||||
source: string;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
conversation_id?: string | null;
|
||||
score?: number | null;
|
||||
attachments?: MemoryAttachment[];
|
||||
}
|
||||
|
||||
// ── Memory CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
export const brainApi = {
|
||||
/** Einzelne Memory holen (mit allen Feldern inkl. Anhaenge) */
|
||||
getMemory(id: string): Promise<Memory> {
|
||||
return _send(`/memory/get/${encodeURIComponent(id)}`);
|
||||
},
|
||||
|
||||
/** Liste aller Memories, optional nach Type gefiltert. */
|
||||
listMemories(opts: { type?: string; limit?: number } = {}): Promise<Memory[]> {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts.type) qs.set('type', opts.type);
|
||||
qs.set('limit', String(opts.limit || 500));
|
||||
return _send(`/memory/list?${qs.toString()}`);
|
||||
},
|
||||
|
||||
/** Volltext-Substring-Suche. */
|
||||
searchText(q: string, opts: { type?: string; includePinned?: boolean; k?: number } = {}): Promise<Memory[]> {
|
||||
const qs = new URLSearchParams({ q });
|
||||
if (opts.type) qs.set('type', opts.type);
|
||||
qs.set('include_pinned', String(opts.includePinned !== false));
|
||||
qs.set('k', String(opts.k || 50));
|
||||
return _send(`/memory/search-text?${qs.toString()}`);
|
||||
},
|
||||
|
||||
/** Semantische Suche (Embedder). */
|
||||
searchSemantic(q: string, opts: { type?: string; includePinned?: boolean; k?: number; threshold?: number } = {}): Promise<Memory[]> {
|
||||
const qs = new URLSearchParams({ q });
|
||||
if (opts.type) qs.set('type', opts.type);
|
||||
qs.set('include_pinned', String(opts.includePinned !== false));
|
||||
qs.set('k', String(opts.k || 10));
|
||||
qs.set('score_threshold', String(opts.threshold ?? 0.30));
|
||||
return _send(`/memory/search?${qs.toString()}`);
|
||||
},
|
||||
|
||||
/** Memory anlegen. */
|
||||
saveMemory(body: {
|
||||
type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
pinned?: boolean;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
}): Promise<Memory> {
|
||||
return _send('/memory/save', {
|
||||
method: 'POST',
|
||||
body: { source: 'app', ...body },
|
||||
});
|
||||
},
|
||||
|
||||
/** Memory aktualisieren (Patch — nur uebergebene Felder werden geaendert). */
|
||||
updateMemory(id: string, body: Partial<Pick<Memory, 'title' | 'content' | 'pinned' | 'category' | 'tags'>>): Promise<Memory> {
|
||||
return _send(`/memory/update/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/** Memory loeschen. */
|
||||
deleteMemory(id: string): Promise<{ deleted: string }> {
|
||||
return _send(`/memory/delete/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
|
||||
// ── Anhaenge ────────────────────────────────────────────────────────
|
||||
|
||||
/** Datei als Anhang an die Memory haengen (Base64-Upload). */
|
||||
uploadAttachment(memoryId: string, name: string, base64: string): Promise<Memory> {
|
||||
return _send(`/memory/${encodeURIComponent(memoryId)}/attachments`, {
|
||||
method: 'POST',
|
||||
body: { name, data_base64: base64 },
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
},
|
||||
|
||||
/** Anhang loeschen. */
|
||||
deleteAttachment(memoryId: string, filename: string): Promise<Memory> {
|
||||
return _send(
|
||||
`/memory/${encodeURIComponent(memoryId)}/attachments/${encodeURIComponent(filename)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
},
|
||||
|
||||
/** Anhang-Bytes holen (fuer Vorschau / Download). Liefert Base64. */
|
||||
getAttachmentBytes(memoryId: string, filename: string): Promise<{ base64: string; contentType: string }> {
|
||||
return _send(
|
||||
`/memory/${encodeURIComponent(memoryId)}/attachments/${encodeURIComponent(filename)}`,
|
||||
{ expectBinary: true, timeoutMs: 60000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default brainApi;
|
||||
@@ -167,6 +167,16 @@ def health():
|
||||
|
||||
# ─── Memory-Endpoints ─────────────────────────────────────────────────
|
||||
|
||||
@app.get("/memory/get/{point_id}", response_model=MemoryOut)
|
||||
def memory_get(point_id: str):
|
||||
"""Einzelner Memory mit allen Feldern (inkl. Anhaengen).
|
||||
Pfad-Prefix /memory/get/ vermeidet Konflikt mit /memory/list, /memory/save etc."""
|
||||
m = store().get(point_id)
|
||||
if not m:
|
||||
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
|
||||
return MemoryOut.from_point(m)
|
||||
|
||||
|
||||
@app.get("/memory/stats")
|
||||
def memory_stats():
|
||||
s = store()
|
||||
|
||||
@@ -1824,6 +1824,72 @@ class ARIABridge:
|
||||
logger.warning("[rvs] delete_message fehlgeschlagen: %s", result.get("error"))
|
||||
return
|
||||
|
||||
elif msg_type == "brain_request":
|
||||
# Generischer RVS-Proxy fuer die Brain-HTTP-API.
|
||||
# payload: {requestId, method, path, body?, bodyBase64?, contentType?}
|
||||
# - method: GET | POST | PATCH | DELETE
|
||||
# - path: z.B. "/memory/list" oder "/memory/get/<id>"
|
||||
# - body: JSON-Objekt (wird als JSON encoded)
|
||||
# - bodyBase64: rohe Bytes als Base64 (fuer Upload mit contentType)
|
||||
# - contentType: default application/json
|
||||
# Antwort als brain_response {requestId, status, json?, base64?}.
|
||||
req_id = payload.get("requestId") or ""
|
||||
method = (payload.get("method") or "GET").upper()
|
||||
path = payload.get("path") or ""
|
||||
if not req_id or not path or not path.startswith("/"):
|
||||
logger.warning("[rvs] brain_request ungueltig: %r", payload)
|
||||
return
|
||||
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
|
||||
url = brain_url.rstrip("/") + path
|
||||
headers: dict[str, str] = {}
|
||||
data: Optional[bytes] = None
|
||||
ct = payload.get("contentType") or "application/json"
|
||||
if payload.get("bodyBase64"):
|
||||
try:
|
||||
data = base64.b64decode(payload["bodyBase64"])
|
||||
except Exception:
|
||||
data = None
|
||||
if data is not None:
|
||||
headers["Content-Type"] = ct
|
||||
elif payload.get("body") is not None:
|
||||
data = json.dumps(payload["body"]).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
logger.info("[rvs] brain_request %s %s (%d Byte)", method, path, len(data or b""))
|
||||
|
||||
def _do_call():
|
||||
try:
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
return r.status, r.read(), r.headers.get("Content-Type", "")
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
body = e.read()
|
||||
except Exception:
|
||||
body = b""
|
||||
return e.code, body, e.headers.get("Content-Type", "") if e.headers else ""
|
||||
except Exception as exc:
|
||||
return None, str(exc).encode("utf-8"), "text/plain"
|
||||
|
||||
status, body_bytes, response_ct = await asyncio.get_event_loop().run_in_executor(None, _do_call)
|
||||
out: dict = {"requestId": req_id, "status": status or 0}
|
||||
if response_ct and "json" in response_ct:
|
||||
try:
|
||||
out["json"] = json.loads(body_bytes.decode("utf-8", errors="ignore"))
|
||||
except Exception:
|
||||
out["text"] = body_bytes.decode("utf-8", errors="ignore")[:2000]
|
||||
elif response_ct and "text" in response_ct:
|
||||
out["text"] = body_bytes.decode("utf-8", errors="ignore")[:4000]
|
||||
else:
|
||||
# Binaer (z.B. attachment-download) → base64 zurueck
|
||||
out["base64"] = base64.b64encode(body_bytes).decode("ascii")
|
||||
out["contentType"] = response_ct or "application/octet-stream"
|
||||
await self._send_to_rvs({
|
||||
"type": "brain_response",
|
||||
"payload": out,
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
return
|
||||
|
||||
elif msg_type == "file_list_request":
|
||||
# App fragt die Liste aller /shared/uploads/-Dateien an.
|
||||
logger.info("[rvs] file_list_request von App")
|
||||
|
||||
@@ -30,6 +30,7 @@ const ALLOWED_TYPES = new Set([
|
||||
"location_update", "location_tracking",
|
||||
"chat_history_request", "chat_history_response", "chat_cleared",
|
||||
"delete_message_request", "chat_message_deleted",
|
||||
"brain_request", "brain_response",
|
||||
"file_delete_batch_request", "file_delete_batch_response",
|
||||
"file_zip_request", "file_zip_response",
|
||||
"xtts_delete_voice",
|
||||
|
||||
Reference in New Issue
Block a user