Compare commits

..

10 Commits

Author SHA1 Message Date
duffyduck d8b05082d6 release: bump version to 0.1.3.4 2026-05-14 14:58:47 +02:00
duffyduck de91073b2e release: bump version to 0.1.3.3 2026-05-14 14:08:03 +02:00
duffyduck e88b5f57bf fix(memory): Inbox-Crash auf Android — Modal-Stacking + Alert.prompt
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>
2026-05-14 14:05:42 +02:00
duffyduck 64a17c8c19 release: bump version to 0.1.3.2 2026-05-14 13:59:09 +02:00
duffyduck ebeacba8b5 fix(chat): Spezial-Bubbles raus aus Chat → Inbox + Emoji-Bug behoben
Zwei Bugs aus Stefans Screenshot:

1. memorySaved/triggerCreated/skillCreated bleiben permanent unten im
   Chat-Verlauf statt mit den anderen Bubbles zu scrollen — sieht aus
   wie Werbe-Bumper. Fix: chatVisibleMessages-Filter raus aus
   FlatList-Source, diese Bubbles werden im Chat ueberhaupt nicht mehr
   gerendert.

   Stefans urspruengliche Idee war ja "trigger und gedächtnis bubble
   in ein extra modal fenster" — genau das ist die Inbox jetzt.

2. Inbox-Emoji 🗂️ wurde als Literal "🗂️"-Text
   gerendert. Letztes Edit hat es ohne JSX-String-Literal-Schutz
   eingefuegt. Fix: {'🗂️'} statt direktes Emoji-Token.
   Modal-Header analog.

Inbox-Modal erweitert:
- Neue Section "AUS DIESEM CHAT" oben: kompakte Liste der Spezial-
  Bubbles aus messages (chronologisch neueste oben). Memory-Eintraege
  oeffnen MemoryDetailModal (mit Tap auf den Pfeil). Trigger/Skills
  zeigen nur Title+Meta — keine Edit-UI, dafuer gibt's die jeweiligen
  Tabs im Diagnostic.
- Darunter wie bisher der volle MemoryBrowser mit allen DB-Memories.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:56:39 +02:00
duffyduck 58251b26a2 release: bump version to 0.1.3.1 2026-05-14 13:31:59 +02:00
duffyduck 5c10990cbc 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>
2026-05-14 13:29:43 +02:00
duffyduck f71936da86 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>
2026-05-14 13:26:02 +02:00
duffyduck 62f394b2aa release: bump version to 0.1.3.0 2026-05-14 13:18:55 +02:00
duffyduck 6239037fa7 feat(memory): Bubble-Header zeigt jetzt Aktion (angelegt/geaendert/geloescht)
Etappe 1 von Stefans App-Memory-UX-Wunsch:

Brain agent.py: memory_save Dispatcher pushed jetzt action="created",
memory_update Dispatcher pushed action="updated" mit demselben
memory_saved-Event-Typ. Bridge reicht das action-Feld im Payload mit
durch (in beiden Side-Channel-Pfaden — send_to_core + trigger-fired).

App ChatScreen: ChatMessage.memorySaved.action ('created' | 'updated'
| 'deleted'). Bubble-Header je nach Aktion:
- created → "🧠 ARIA hat etwas gemerkt" (gelb)
- updated → "🧠 ARIA hat eine Notiz geändert" (gelb)
- deleted → "🧠 ARIA hat eine Notiz gelöscht" (rot)

Naechste Etappen folgen (Detail-Modal beim Tap, Edit + Anhang-Upload,
Notizen-Inbox neben Lupe, Memory-Editor in Settings).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:44:05 +02:00
11 changed files with 1094 additions and 11 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10209
versionName "0.1.2.9"
versionCode 10304
versionName "0.1.3.4"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.1.2.9",
"version": "0.1.3.4",
"private": true,
"scripts": {
"android": "react-native run-android",
+266
View File
@@ -0,0 +1,266 @@
/**
* 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;
@@ -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;
+158 -7
View File
@@ -28,6 +28,8 @@ 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 MemoryBrowser from '../components/MemoryBrowser';
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio';
import wakeWordService from '../services/wakeword';
@@ -95,6 +97,9 @@ interface ChatMessage {
category?: string;
pinned: boolean;
preview?: string;
/** Was passiert ist: angelegt / geaendert / geloescht. Default created
* fuer Rueckwaerts-Kompatibilitaet mit aelteren Events. */
action?: 'created' | 'updated' | 'deleted';
attachments?: Array<{
name: string;
mime?: string;
@@ -228,6 +233,8 @@ 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 [inboxVisible, setInboxVisible] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchVisible, setSearchVisible] = useState(false);
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
@@ -560,6 +567,7 @@ const ChatScreen: React.FC = () => {
category: p.category ? String(p.category) : undefined,
pinned: !!p.pinned,
preview: p.content_preview ? String(p.content_preview) : undefined,
action: (p.action === 'updated' || p.action === 'deleted') ? p.action : 'created',
attachments: atts.length ? atts : undefined,
},
};
@@ -1018,7 +1026,15 @@ const ChatScreen: React.FC = () => {
}, [messages]);
// Inverted FlatList: neueste Nachrichten unten, kein manuelles Scrollen noetig
const invertedMessages = useMemo(() => [...messages].reverse(), [messages]);
// Spezial-Bubbles (memorySaved/triggerCreated/skillCreated) sollen im Chat
// NICHT mehr erscheinen — sie werden in der Notizen-Inbox angezeigt.
// Das verhindert dass sie chronologisch unten im Chat haengen und der
// eigentliche Chat-Verlauf darunter verschwindet.
const chatVisibleMessages = useMemo(
() => messages.filter(m => !m.memorySaved && !m.triggerCreated && !m.skillCreated),
[messages],
);
const invertedMessages = useMemo(() => [...chatVisibleMessages].reverse(), [chatVisibleMessages]);
// Such-Treffer: alle Message-IDs die zur Query passen, in chronologischer
// Reihenfolge (aelteste zuerst). Bei Query-Change resetten wir den Index.
@@ -1326,10 +1342,22 @@ const ChatScreen: React.FC = () => {
const m = item.memorySaved;
const catPart = m.category ? ` · [${m.category}]` : '';
const atts = m.attachments || [];
const action = m.action || 'created';
const headline =
action === 'updated' ? '🧠 ARIA hat eine Notiz geändert' :
action === 'deleted' ? '🧠 ARIA hat eine Notiz gelöscht' :
'🧠 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: '#FFD60A'}, searchHighlightStyle]}>
<Text style={{color: '#FFD60A', fontWeight: 'bold', fontSize: 14}}>
{'🧠 ARIA hat etwas gemerkt'}
<Wrapper {...wrapperProps} style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: borderColor}, searchHighlightStyle]}>
<Text style={{color: headlineColor, fontWeight: 'bold', fontSize: 14}}>
{headline}
</Text>
<Text style={{color: '#E0E0F0', marginTop: 4, fontSize: 14}}>
<Text style={{fontWeight: 'bold'}}>{m.title}</Text>
@@ -1367,8 +1395,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>
);
}
@@ -1574,7 +1604,10 @@ const ChatScreen: React.FC = () => {
{connectionState === 'connected' ? 'Verbunden' :
connectionState === 'connecting' ? 'Verbinde...' : 'Getrennt'}
</Text>
<TouchableOpacity onPress={() => setSearchVisible(!searchVisible)} style={{marginLeft: 'auto', paddingHorizontal: 8}}>
<TouchableOpacity onPress={() => setInboxVisible(true)} style={{marginLeft: 'auto', paddingHorizontal: 6}} hitSlop={{top:8,bottom:8,left:6,right:6}}>
<Text style={{fontSize: 18}}>{'\uD83D\uDDC2\uFE0F'}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => setSearchVisible(!searchVisible)} style={{paddingHorizontal: 6}} hitSlop={{top:8,bottom:8,left:6,right:6}}>
<Text style={{fontSize: 16}}>{'\uD83D\uDD0D'}</Text>
</TouchableOpacity>
</View>
@@ -1805,6 +1838,105 @@ 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)}
/>
{/* Notizen-Inbox — Listet alle Memories aus dem aktuellen Chat (Special-Bubbles).
Bestes-Aus-beiden-Welten: nur die Memory-IDs aus den memorySaved-Bubbles
des aktuellen Chats, plus den vollen Browser darunter wenn der User mehr will. */}
<Modal visible={inboxVisible} animationType="slide" onRequestClose={() => setInboxVisible(false)}>
<View style={{flex:1, backgroundColor:'#0D0D1A'}}>
<View style={{flexDirection:'row', alignItems:'center', padding:14, borderBottomWidth:1, borderBottomColor:'#1E1E2E'}}>
<Text style={{color:'#FFD60A', fontWeight:'bold', fontSize:16, flex:1}}>{'🗂️'} Notizen-Inbox</Text>
<TouchableOpacity onPress={() => setInboxVisible(false)} hitSlop={{top:8,bottom:8,left:8,right:8}}>
<Text style={{color:'#8888AA', fontSize:24}}>×</Text>
</TouchableOpacity>
</View>
{/* Aus aktuellem Chat: Spezial-Bubbles (memory/trigger/skill) kompakt
auflisten — neueste oben. Klick auf Memory oeffnet Detail-Modal. */}
{(() => {
const specials = messages
.filter(m => m.memorySaved || m.triggerCreated || m.skillCreated)
.slice().reverse();
if (specials.length === 0) {
return (
<View style={{padding:14, borderBottomWidth:1, borderBottomColor:'#1E1E2E'}}>
<Text style={{color:'#555570', fontSize:11, fontStyle:'italic'}}>
(keine Notizen-Bubbles im aktuellen Chat)
</Text>
</View>
);
}
return (
<View style={{maxHeight:260, borderBottomWidth:1, borderBottomColor:'#1E1E2E'}}>
<Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:8, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
Aus diesem Chat
</Text>
<ScrollView style={{paddingHorizontal:8}}>
{specials.map(m => {
if (m.memorySaved) {
const ms = m.memorySaved;
const action = ms.action || 'created';
const verb = action === 'updated' ? 'geändert' : action === 'deleted' ? 'gelöscht' : 'angelegt';
const dotColor = action === 'deleted' ? '#FF6B6B' : '#FFD60A';
return (
<TouchableOpacity
key={m.id}
style={styles.inboxRow}
onPress={() => { if (ms.id && action !== 'deleted') { setInboxVisible(false); setMemoryDetailId(ms.id); } }}
disabled={!ms.id || action === 'deleted'}
>
<Text style={{fontSize:16}}>{'🧠'}</Text>
<View style={{flex:1}}>
<Text style={styles.inboxRowTitle} numberOfLines={1}>{ms.title}</Text>
<Text style={[styles.inboxRowMeta, {color: dotColor}]}>Memory · {verb} · {ms.type}</Text>
</View>
{ms.id && action !== 'deleted' ? <Text style={{color:'#0096FF', fontSize:14}}></Text> : null}
</TouchableOpacity>
);
}
if (m.triggerCreated) {
const t = m.triggerCreated;
return (
<View key={m.id} style={styles.inboxRow}>
<Text style={{fontSize:16}}>{'⏰'}</Text>
<View style={{flex:1}}>
<Text style={styles.inboxRowTitle} numberOfLines={1}>{t.name}</Text>
<Text style={styles.inboxRowMeta}>Trigger · {t.type}{t.fires_at ? ` · ${t.fires_at.slice(0,16).replace('T',' ')}` : ''}</Text>
</View>
</View>
);
}
if (m.skillCreated) {
const sk = m.skillCreated;
return (
<View key={m.id} style={styles.inboxRow}>
<Text style={{fontSize:16}}>{'🛠'}</Text>
<View style={{flex:1}}>
<Text style={styles.inboxRowTitle} numberOfLines={1}>{sk.name}</Text>
<Text style={styles.inboxRowMeta}>Skill · {sk.execution}</Text>
</View>
</View>
);
}
return null;
})}
</ScrollView>
</View>
);
})()}
<Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:10, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
Alle Memories aus der DB
</Text>
<MemoryBrowser onOpenMemory={(id) => { setInboxVisible(false); setMemoryDetailId(id); }} />
</View>
</Modal>
{/* Bild-Vollbild Modal */}
<Modal visible={!!fullscreenImage} transparent animationType="fade" onRequestClose={() => setFullscreenImage(null)}>
<View style={styles.fullscreenOverlay}>
@@ -2133,6 +2265,25 @@ const styles = StyleSheet.create({
playButtonText: {
fontSize: 16,
},
inboxRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: '#1E1E2E',
padding: 10,
borderRadius: 6,
marginBottom: 4,
},
inboxRowTitle: {
color: '#E0E0F0',
fontSize: 13,
fontWeight: '600',
},
inboxRowMeta: {
color: '#8888AA',
fontSize: 11,
marginTop: 1,
},
memoryAttachmentRow: {
flexDirection: 'row',
alignItems: 'center',
+14
View File
@@ -52,6 +52,7 @@ import {
} from '../services/audio';
import audioService from '../services/audio';
import gpsTrackingService from '../services/gpsTracking';
import MemoryBrowser from '../components/MemoryBrowser';
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
import {
isWakeReadySoundEnabled,
@@ -100,6 +101,7 @@ const SETTINGS_SECTIONS = [
{ id: 'voice_output', icon: '🔊', label: 'Sprachausgabe', desc: 'Stimmen, Pre-Roll, Geschwindigkeit' },
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
{ id: 'memory', icon: '🧠', label: 'Gedächtnis', desc: 'ARIA-Memories durchsuchen, anlegen, bearbeiten, löschen' },
{ id: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' },
{ id: 'about', icon: '️', label: 'Ueber', desc: 'App-Version, Update' },
] as const;
@@ -1673,6 +1675,18 @@ const SettingsScreen: React.FC = () => {
</View>
</>)}
{/* === Gedaechtnis === */}
{currentSection === 'memory' && (<>
<Text style={styles.sectionTitle}>Gedächtnis</Text>
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
Alle Memory-Einträge aus ARIAs Vector-DB. Tippen zum Bearbeiten mit Anhängen, pinned-Status,
Tags. Neue Einträge anlegen via "+ Neu".
</Text>
<View style={{height: 600, marginBottom: 8}}>
<MemoryBrowser />
</View>
</>)}
{/* === Logs === */}
{currentSection === 'protocol' && (<>
<Text style={styles.sectionTitle}>Protokoll</Text>
+206
View File
@@ -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;
+2
View File
@@ -672,6 +672,7 @@ class Agent:
saved = self.store.get(pid)
self._pending_events.append({
"type": "memory_saved",
"action": "updated",
"memory": {
"id": saved.id, "type": saved.type, "title": saved.title,
"content_preview": (saved.content or "")[:140],
@@ -734,6 +735,7 @@ class Agent:
saved = self.store.get(pid)
self._pending_events.append({
"type": "memory_saved",
"action": "created",
"memory": {
"id": saved.id, "type": saved.type, "title": saved.title,
"content_preview": (saved.content or "")[:140],
+10
View File
@@ -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()
+70 -1
View File
@@ -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")
@@ -2647,9 +2713,12 @@ class ARIABridge:
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
elif etype == "memory_saved":
mem = event.get("memory", {})
if event.get("action"):
mem = {**mem, "action": event.get("action")}
await self._send_to_rvs({
"type": "memory_saved",
"payload": event.get("memory", {}),
"payload": mem,
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception:
+1
View File
@@ -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",