Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f11f28448 | |||
| 21a315ca71 | |||
| d8b05082d6 | |||
| de91073b2e | |||
| e88b5f57bf | |||
| 64a17c8c19 | |||
| ebeacba8b5 | |||
| 58251b26a2 | |||
| 5c10990cbc | |||
| f71936da86 |
+4
-1
@@ -13,7 +13,7 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
|||||||
import ChatScreen from './src/screens/ChatScreen';
|
import ChatScreen from './src/screens/ChatScreen';
|
||||||
import SettingsScreen from './src/screens/SettingsScreen';
|
import SettingsScreen from './src/screens/SettingsScreen';
|
||||||
import rvs from './src/services/rvs';
|
import rvs from './src/services/rvs';
|
||||||
import { initLogger } from './src/services/logger';
|
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
||||||
|
|
||||||
// --- Navigation ---
|
// --- Navigation ---
|
||||||
|
|
||||||
@@ -49,6 +49,9 @@ const App: React.FC = () => {
|
|||||||
// initLogger ist async aber blockt nichts — solange er noch laueft,
|
// initLogger ist async aber blockt nichts — solange er noch laueft,
|
||||||
// loggen wir normal (Default an), danach respektiert console.log das Setting.
|
// loggen wir normal (Default an), danach respektiert console.log das Setting.
|
||||||
initLogger().catch(() => {});
|
initLogger().catch(() => {});
|
||||||
|
// Crash-Reporter installieren — ungefangene JS-Errors landen via RVS
|
||||||
|
// bei der Bridge (sichtbar in /shared/logs/app.log + Diagnostic-API)
|
||||||
|
installGlobalCrashReporter();
|
||||||
const initConnection = async () => {
|
const initConnection = async () => {
|
||||||
const config = await rvs.loadConfig();
|
const config = await rvs.loadConfig();
|
||||||
if (config) {
|
if (config) {
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 10300
|
versionCode 10305
|
||||||
versionName "0.1.3.0"
|
versionName "0.1.3.5"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.1.3.0",
|
"version": "0.1.3.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* ErrorBoundary — fängt React-Render-Fehler und zeigt eine Error-Box
|
||||||
|
* statt White-Screen-of-Death. Plus: Crash wird zum logger geschickt,
|
||||||
|
* der das ueber RVS an die Bridge weiterleitet.
|
||||||
|
*
|
||||||
|
* Einsatz: kritische Komponenten/Modals damit ein Bug nicht die ganze
|
||||||
|
* App killt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
import { reportAppError } from '../services/logger';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Optional: Bezeichnung der eingegrenzten Section fuer's Log. */
|
||||||
|
scope?: string;
|
||||||
|
/** Optional: Reset-Callback (z.B. Modal schliessen) — Button ist dann sichtbar. */
|
||||||
|
onReset?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
err: Error | null;
|
||||||
|
info: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends React.Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { err: null, info: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(err: Error): Partial<State> {
|
||||||
|
return { err };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(err: Error, info: any) {
|
||||||
|
const stack = info?.componentStack || '';
|
||||||
|
this.setState({ info: stack });
|
||||||
|
reportAppError({
|
||||||
|
scope: this.props.scope || 'ErrorBoundary',
|
||||||
|
message: err?.message || String(err),
|
||||||
|
stack: (err?.stack || '') + '\n--- componentStack ---\n' + stack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.err) {
|
||||||
|
return (
|
||||||
|
<View style={s.box}>
|
||||||
|
<Text style={s.title}>⚠️ Etwas ist schiefgegangen</Text>
|
||||||
|
<Text style={s.scope}>{this.props.scope || 'unbekannte Komponente'}</Text>
|
||||||
|
<ScrollView style={s.scroll}>
|
||||||
|
<Text style={s.msg}>{this.state.err.message || String(this.state.err)}</Text>
|
||||||
|
{this.state.info ? <Text style={s.stack}>{this.state.info}</Text> : null}
|
||||||
|
</ScrollView>
|
||||||
|
{this.props.onReset ? (
|
||||||
|
<TouchableOpacity style={s.btn} onPress={() => { this.setState({err:null,info:''}); this.props.onReset?.(); }}>
|
||||||
|
<Text style={s.btnText}>Schliessen + zurueck</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity style={s.btn} onPress={() => this.setState({err:null,info:''})}>
|
||||||
|
<Text style={s.btnText}>Erneut versuchen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<Text style={s.hint}>
|
||||||
|
Crash wurde an die Bridge gemeldet — sichtbar in der Diagnostic-Web-UI unter /api/app-log
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = StyleSheet.create({
|
||||||
|
box: { flex:1, padding:16, backgroundColor:'#1A0A0A' },
|
||||||
|
title: { color:'#FF6B6B', fontWeight:'bold', fontSize:16, marginBottom:6 },
|
||||||
|
scope: { color:'#FF9500', fontSize:12, marginBottom:10 },
|
||||||
|
scroll: { flex:1, backgroundColor:'#0D0D1A', borderRadius:6, padding:10, marginBottom:10 },
|
||||||
|
msg: { color:'#FF6B6B', fontSize:13, marginBottom:8 },
|
||||||
|
stack: { color:'#8888AA', fontSize:11, fontFamily:'monospace' },
|
||||||
|
btn: { backgroundColor:'#0096FF', paddingVertical:10, borderRadius:6, alignItems:'center' },
|
||||||
|
btnText: { color:'#fff', fontWeight:'600' },
|
||||||
|
hint: { color:'#555570', fontSize:10, marginTop:8, textAlign:'center' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
@@ -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;
|
||||||
@@ -28,6 +28,9 @@ import RNFS from 'react-native-fs';
|
|||||||
import { SvgUri } from 'react-native-svg';
|
import { SvgUri } from 'react-native-svg';
|
||||||
import { Dimensions } from 'react-native';
|
import { Dimensions } from 'react-native';
|
||||||
import ZoomableImage from '../components/ZoomableImage';
|
import ZoomableImage from '../components/ZoomableImage';
|
||||||
|
import MemoryDetailModal from '../components/MemoryDetailModal';
|
||||||
|
import MemoryBrowser from '../components/MemoryBrowser';
|
||||||
|
import ErrorBoundary from '../components/ErrorBoundary';
|
||||||
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
||||||
import audioService from '../services/audio';
|
import audioService from '../services/audio';
|
||||||
import wakeWordService from '../services/wakeword';
|
import wakeWordService from '../services/wakeword';
|
||||||
@@ -231,6 +234,8 @@ const ChatScreen: React.FC = () => {
|
|||||||
// Genauer State (off/armed/conversing) fuer UI-Feedback am Button
|
// Genauer State (off/armed/conversing) fuer UI-Feedback am Button
|
||||||
const [wakeWordState, setWakeWordState] = useState<'off' | 'armed' | 'conversing'>('off');
|
const [wakeWordState, setWakeWordState] = useState<'off' | 'armed' | 'conversing'>('off');
|
||||||
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
|
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
|
||||||
|
const [memoryDetailId, setMemoryDetailId] = useState<string | null>(null);
|
||||||
|
const [inboxVisible, setInboxVisible] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchVisible, setSearchVisible] = useState(false);
|
const [searchVisible, setSearchVisible] = useState(false);
|
||||||
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
|
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
|
||||||
@@ -1022,7 +1027,15 @@ const ChatScreen: React.FC = () => {
|
|||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
// Inverted FlatList: neueste Nachrichten unten, kein manuelles Scrollen noetig
|
// 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
|
// Such-Treffer: alle Message-IDs die zur Query passen, in chronologischer
|
||||||
// Reihenfolge (aelteste zuerst). Bei Query-Change resetten wir den Index.
|
// Reihenfolge (aelteste zuerst). Bei Query-Change resetten wir den Index.
|
||||||
@@ -1337,8 +1350,13 @@ const ChatScreen: React.FC = () => {
|
|||||||
'🧠 ARIA hat etwas gemerkt';
|
'🧠 ARIA hat etwas gemerkt';
|
||||||
const headlineColor = action === 'deleted' ? '#FF6B6B' : '#FFD60A';
|
const headlineColor = action === 'deleted' ? '#FF6B6B' : '#FFD60A';
|
||||||
const borderColor = 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 (
|
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}}>
|
<Text style={{color: headlineColor, fontWeight: 'bold', fontSize: 14}}>
|
||||||
{headline}
|
{headline}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1378,8 +1396,10 @@ const ChatScreen: React.FC = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<Text style={{color: '#555570', fontSize: 10, marginTop: 6}}>ARIA-Memory · {time}</Text>
|
<Text style={{color: '#555570', fontSize: 10, marginTop: 6}}>
|
||||||
</View>
|
ARIA-Memory · {time}{openable ? ' · tippen für Details' : ''}
|
||||||
|
</Text>
|
||||||
|
</Wrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1585,7 +1605,10 @@ const ChatScreen: React.FC = () => {
|
|||||||
{connectionState === 'connected' ? 'Verbunden' :
|
{connectionState === 'connected' ? 'Verbunden' :
|
||||||
connectionState === 'connecting' ? 'Verbinde...' : 'Getrennt'}
|
connectionState === 'connecting' ? 'Verbinde...' : 'Getrennt'}
|
||||||
</Text>
|
</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>
|
<Text style={{fontSize: 16}}>{'\uD83D\uDD0D'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -1816,6 +1839,111 @@ const ChatScreen: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Memory-Detail/Edit-Modal — wird durch Tap auf eine memorySaved-Bubble geoeffnet */}
|
||||||
|
{memoryDetailId ? (
|
||||||
|
<ErrorBoundary scope="ChatScreen.MemoryDetailModal" onReset={() => setMemoryDetailId(null)}>
|
||||||
|
<MemoryDetailModal
|
||||||
|
memoryId={memoryDetailId}
|
||||||
|
visible={!!memoryDetailId}
|
||||||
|
onClose={() => setMemoryDetailId(null)}
|
||||||
|
onDeleted={() => setMemoryDetailId(null)}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
|
) : 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)}>
|
||||||
|
<ErrorBoundary scope="ChatScreen.InboxModal" onReset={() => 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>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Bild-Vollbild Modal */}
|
{/* Bild-Vollbild Modal */}
|
||||||
<Modal visible={!!fullscreenImage} transparent animationType="fade" onRequestClose={() => setFullscreenImage(null)}>
|
<Modal visible={!!fullscreenImage} transparent animationType="fade" onRequestClose={() => setFullscreenImage(null)}>
|
||||||
<View style={styles.fullscreenOverlay}>
|
<View style={styles.fullscreenOverlay}>
|
||||||
@@ -2144,6 +2272,25 @@ const styles = StyleSheet.create({
|
|||||||
playButtonText: {
|
playButtonText: {
|
||||||
fontSize: 16,
|
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: {
|
memoryAttachmentRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
} from '../services/audio';
|
} from '../services/audio';
|
||||||
import audioService from '../services/audio';
|
import audioService from '../services/audio';
|
||||||
import gpsTrackingService from '../services/gpsTracking';
|
import gpsTrackingService from '../services/gpsTracking';
|
||||||
|
import MemoryBrowser from '../components/MemoryBrowser';
|
||||||
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
|
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
|
||||||
import {
|
import {
|
||||||
isWakeReadySoundEnabled,
|
isWakeReadySoundEnabled,
|
||||||
@@ -100,6 +101,7 @@ const SETTINGS_SECTIONS = [
|
|||||||
{ id: 'voice_output', icon: '🔊', label: 'Sprachausgabe', desc: 'Stimmen, Pre-Roll, Geschwindigkeit' },
|
{ id: 'voice_output', icon: '🔊', label: 'Sprachausgabe', desc: 'Stimmen, Pre-Roll, Geschwindigkeit' },
|
||||||
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
|
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
|
||||||
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
|
{ 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: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' },
|
||||||
{ id: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' },
|
{ id: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -1673,6 +1675,18 @@ const SettingsScreen: React.FC = () => {
|
|||||||
</View>
|
</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 === */}
|
{/* === Logs === */}
|
||||||
{currentSection === 'protocol' && (<>
|
{currentSection === 'protocol' && (<>
|
||||||
<Text style={styles.sectionTitle}>Protokoll</Text>
|
<Text style={styles.sectionTitle}>Protokoll</Text>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import rvs from './rvs';
|
||||||
|
|
||||||
export const VERBOSE_LOGGING_KEY = 'aria_verbose_logging';
|
export const VERBOSE_LOGGING_KEY = 'aria_verbose_logging';
|
||||||
|
|
||||||
@@ -39,3 +41,77 @@ export function setVerboseLogging(verbose: boolean): void {
|
|||||||
applyState();
|
applyState();
|
||||||
AsyncStorage.setItem(VERBOSE_LOGGING_KEY, String(verbose)).catch(() => {});
|
AsyncStorage.setItem(VERBOSE_LOGGING_KEY, String(verbose)).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── App-Crash-Reporting via RVS ────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Wenn die App crasht — egal ob React-Render-Fehler (ErrorBoundary) oder
|
||||||
|
// ungefangener JS-Error (ErrorUtils-Handler) — schicken wir den Crash
|
||||||
|
// als RVS-Message vom Typ "app_log" an die Bridge. Die schreibt in
|
||||||
|
// /shared/logs/app.log, sodass wir/Diagnostic die Crashes mitlesen
|
||||||
|
// koennen ohne ADB.
|
||||||
|
|
||||||
|
interface AppErrorEvent {
|
||||||
|
scope: string;
|
||||||
|
message: string;
|
||||||
|
stack?: string;
|
||||||
|
level?: 'error' | 'warn' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
let _reportingInstalled = false;
|
||||||
|
|
||||||
|
/** Schickt einen App-Fehler via RVS an die Bridge. */
|
||||||
|
export function reportAppError(ev: AppErrorEvent): void {
|
||||||
|
try {
|
||||||
|
rvs.send('app_log' as any, {
|
||||||
|
ts: Date.now(),
|
||||||
|
platform: Platform.OS,
|
||||||
|
level: ev.level || 'error',
|
||||||
|
scope: ev.scope,
|
||||||
|
message: ev.message,
|
||||||
|
stack: (ev.stack || '').slice(0, 8000),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// RVS noch nicht connected — Fehler geht im console weiter.
|
||||||
|
}
|
||||||
|
// Plus lokal: console.error, damit Stefan's adb (wenn doch mal verfuegbar)
|
||||||
|
// den Crash sieht.
|
||||||
|
console.error(`[app-error scope=${ev.scope}]`, ev.message, '\n', ev.stack || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Installiert einen globalen JS-Error-Handler der ungefangene Errors via
|
||||||
|
* RVS an die Bridge schickt. Beim App-Start aufrufen. */
|
||||||
|
export function installGlobalCrashReporter(): void {
|
||||||
|
if (_reportingInstalled) return;
|
||||||
|
_reportingInstalled = true;
|
||||||
|
try {
|
||||||
|
const g: any = global as any;
|
||||||
|
const prev = g.ErrorUtils?.getGlobalHandler?.();
|
||||||
|
g.ErrorUtils?.setGlobalHandler?.((err: any, isFatal: boolean) => {
|
||||||
|
reportAppError({
|
||||||
|
scope: isFatal ? 'global-fatal' : 'global-nonfatal',
|
||||||
|
message: (err && err.message) || String(err),
|
||||||
|
stack: err && err.stack,
|
||||||
|
});
|
||||||
|
// Original-Handler weiterhin aufrufen damit React-Native das System-
|
||||||
|
// Crash-Overlay zeigt (im Dev-Build) bzw. in Production sauber stirbt.
|
||||||
|
if (typeof prev === 'function') {
|
||||||
|
try { prev(err, isFatal); } catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// unhandled Promise-Rejections — manche RN-Versionen haben das nicht
|
||||||
|
// automatisch im ErrorUtils.
|
||||||
|
g.HermesInternal?.enablePromiseRejectionTracker?.({
|
||||||
|
allRejections: true,
|
||||||
|
onUnhandled: (id: number, err: any) => {
|
||||||
|
reportAppError({
|
||||||
|
scope: 'promise-unhandled',
|
||||||
|
level: 'warn',
|
||||||
|
message: (err && err.message) || String(err),
|
||||||
|
stack: err && err.stack,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ErrorUtils nicht da → nix machen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -167,6 +167,16 @@ def health():
|
|||||||
|
|
||||||
# ─── Memory-Endpoints ─────────────────────────────────────────────────
|
# ─── 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")
|
@app.get("/memory/stats")
|
||||||
def memory_stats():
|
def memory_stats():
|
||||||
s = store()
|
s = store()
|
||||||
|
|||||||
@@ -1824,6 +1824,95 @@ class ARIABridge:
|
|||||||
logger.warning("[rvs] delete_message fehlgeschlagen: %s", result.get("error"))
|
logger.warning("[rvs] delete_message fehlgeschlagen: %s", result.get("error"))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
elif msg_type == "app_log":
|
||||||
|
# App schickt Crash/Error/Info-Log via RVS — wir schreiben das
|
||||||
|
# in /shared/logs/app.log (JSONL) damit Diagnostic + Claude
|
||||||
|
# mitlesen koennen, auch ohne ADB-Zugriff aufs Handy.
|
||||||
|
try:
|
||||||
|
log_dir = Path("/shared/logs")
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
line = {
|
||||||
|
"ts": payload.get("ts") or int(time.time() * 1000),
|
||||||
|
"platform": payload.get("platform", "?"),
|
||||||
|
"level": payload.get("level", "info"),
|
||||||
|
"scope": payload.get("scope", ""),
|
||||||
|
"message": payload.get("message", ""),
|
||||||
|
"stack": payload.get("stack", ""),
|
||||||
|
}
|
||||||
|
with (log_dir / "app.log").open("a", encoding="utf-8") as f:
|
||||||
|
f.write(json.dumps(line, ensure_ascii=False) + "\n")
|
||||||
|
logger.info("[app-log] %s %s: %s",
|
||||||
|
line["level"], line["scope"], line["message"][:120])
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[app-log] schreiben fehlgeschlagen: %s", exc)
|
||||||
|
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":
|
elif msg_type == "file_list_request":
|
||||||
# App fragt die Liste aller /shared/uploads/-Dateien an.
|
# App fragt die Liste aller /shared/uploads/-Dateien an.
|
||||||
logger.info("[rvs] file_list_request von App")
|
logger.info("[rvs] file_list_request von App")
|
||||||
|
|||||||
@@ -1338,6 +1338,42 @@ const server = http.createServer((req, res) => {
|
|||||||
else broadcast({ type: "agent_activity", activity: "idle" });
|
else broadcast({ type: "agent_activity", activity: "idle" });
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
res.end(JSON.stringify({ ok: true }));
|
res.end(JSON.stringify({ ok: true }));
|
||||||
|
} else if (req.url.startsWith("/api/app-log") && req.method === "GET") {
|
||||||
|
// App-Crash-Reporting-Log lesen — die App schickt JS-Errors via RVS,
|
||||||
|
// Bridge schreibt JSONL nach /shared/logs/app.log. Wir liefern die
|
||||||
|
// letzten 200 Eintraege (oder ?limit=N).
|
||||||
|
const url = new URL(req.url, "http://x");
|
||||||
|
const limit = Math.max(1, Math.min(2000, parseInt(url.searchParams.get("limit") || "200", 10) || 200));
|
||||||
|
try {
|
||||||
|
const file = "/shared/logs/app.log";
|
||||||
|
if (!fs.existsSync(file)) {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ count: 0, entries: [] }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = fs.readFileSync(file, "utf-8");
|
||||||
|
const lines = raw.split("\n").filter(l => l.trim());
|
||||||
|
const tail = lines.slice(-limit);
|
||||||
|
const entries = tail.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ count: entries.length, entries }));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: err.message }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else if (req.url === "/api/app-log/clear" && req.method === "POST") {
|
||||||
|
// App-Log leeren — nach erfolgreichem Debug.
|
||||||
|
try {
|
||||||
|
const file = "/shared/logs/app.log";
|
||||||
|
if (fs.existsSync(file)) fs.unlinkSync(file);
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: true }));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: false, error: err.message }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
} else if (req.url === "/api/files-list" && req.method === "GET") {
|
} else if (req.url === "/api/files-list" && req.method === "GET") {
|
||||||
// Liste alle Dateien in /shared/uploads/ — die kommen entweder vom User
|
// Liste alle Dateien in /shared/uploads/ — die kommen entweder vom User
|
||||||
// (Upload aus App/Diagnostic) oder von ARIA (aria_<name>.<ext> Pattern).
|
// (Upload aus App/Diagnostic) oder von ARIA (aria_<name>.<ext> Pattern).
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const ALLOWED_TYPES = new Set([
|
|||||||
"location_update", "location_tracking",
|
"location_update", "location_tracking",
|
||||||
"chat_history_request", "chat_history_response", "chat_cleared",
|
"chat_history_request", "chat_history_response", "chat_cleared",
|
||||||
"delete_message_request", "chat_message_deleted",
|
"delete_message_request", "chat_message_deleted",
|
||||||
|
"brain_request", "brain_response",
|
||||||
|
"app_log",
|
||||||
"file_delete_batch_request", "file_delete_batch_response",
|
"file_delete_batch_request", "file_delete_batch_response",
|
||||||
"file_zip_request", "file_zip_response",
|
"file_zip_request", "file_zip_response",
|
||||||
"xtts_delete_voice",
|
"xtts_delete_voice",
|
||||||
|
|||||||
Reference in New Issue
Block a user