fixed, long chats not loading to end, saved attachments in local folder on android., if file missing redownload over shared folde via rvs server, andord app added settingss for local storage path, updated readme

This commit is contained in:
duffyduck 2026-03-29 12:51:38 +02:00
parent db053c2dbd
commit 5c8d11824e
16 changed files with 325 additions and 10 deletions

View File

@ -409,6 +409,25 @@ ARIA: Kann Datei per Bash/Read-Tool oeffnen und analysieren
**Unterstuetzte Formate:** Bilder (JPG, PNG), Dokumente (PDF, DOCX, TXT), beliebige Dateien. **Unterstuetzte Formate:** Bilder (JPG, PNG), Dokumente (PDF, DOCX, TXT), beliebige Dateien.
Bilder werden in der App inline angezeigt, andere Dateien als Icon + Dateiname. Bilder werden in der App inline angezeigt, andere Dateien als Icon + Dateiname.
**Re-Download:** Wird der lokale Cache in der App geleert (Einstellungen → Anhang-Speicher → Cache leeren),
werden fehlende Anhaenge automatisch ueber RVS vom Server neu geladen. Der Speicherort
ist in den App-Einstellungen konfigurierbar.
> **Tipp Speicherplatz:** Das Docker Volume `aria-shared` liegt standardmaessig auf ARIAs VM-Disk.
> Bei vielen Uploads kann das den Speicher der VM belasten (dort laufen auch alle Container).
> Empfehlung: Das Volume auf ein Netzwerk-Filesystem mounten (CephFS, NFS, GlusterFS):
> ```yaml
> # docker-compose.yml
> volumes:
> aria-shared:
> driver: local
> driver_opts:
> type: nfs
> o: addr=nas.local,rw
> device: ":/exports/aria-uploads"
> ```
> So bleibt ARIAs VM-Disk sauber und die Uploads liegen auf dediziertem Storage.
--- ---
## Datenverzeichnis — aria-data/ ## Datenverzeichnis — aria-data/

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
#Sun Mar 29 12:31:28 CEST 2026 #Sun Mar 29 12:50:35 CEST 2026
base.2=/home/duffy/Dokumente/programmierung/ARIA-AGENT/android/android/app/build/intermediates/dex/release/mergeDexRelease/classes2.dex base.2=/home/duffy/Dokumente/programmierung/ARIA-AGENT/android/android/app/build/intermediates/dex/release/mergeDexRelease/classes2.dex
path.2=classes2.dex path.2=classes2.dex
base.1=/home/duffy/Dokumente/programmierung/ARIA-AGENT/android/android/app/build/intermediates/global_synthetics_dex/release/classes.dex base.1=/home/duffy/Dokumente/programmierung/ARIA-AGENT/android/android/app/build/intermediates/global_synthetics_dex/release/classes.dex

View File

@ -19,6 +19,7 @@ import {
Modal, Modal,
} from 'react-native'; } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs';
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';
@ -34,8 +35,9 @@ interface Attachment {
type: 'image' | 'file' | 'audio'; type: 'image' | 'file' | 'audio';
name: string; name: string;
size?: number; size?: number;
uri?: string; // Lokaler Pfad oder data URI fuer Anzeige uri?: string; // Lokaler Pfad (file://) fuer Anzeige
mimeType?: string; mimeType?: string;
serverPath?: string; // Pfad auf dem Server (/shared/uploads/...) fuer Re-Download
} }
interface ChatMessage { interface ChatMessage {
@ -50,6 +52,33 @@ interface ChatMessage {
const CHAT_STORAGE_KEY = 'aria_chat_messages'; const CHAT_STORAGE_KEY = 'aria_chat_messages';
const MAX_STORED_MESSAGES = 500; const MAX_STORED_MESSAGES = 500;
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
async function getAttachmentDir(): Promise<string> {
try {
const saved = await AsyncStorage.getItem(STORAGE_PATH_KEY);
return saved || DEFAULT_ATTACHMENT_DIR;
} catch { return DEFAULT_ATTACHMENT_DIR; }
}
/** Speichert Base64-Daten als Datei, gibt file:// Pfad zurueck */
async function persistAttachment(base64Data: string, msgId: string, fileName: string): Promise<string> {
const cacheDir = await getAttachmentDir();
await RNFS.mkdir(cacheDir);
// Dateiendung aus originalem Dateinamen oder Fallback
const ext = fileName.includes('.') ? fileName.split('.').pop() : 'bin';
const safeName = `${msgId}_${fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
const filePath = `${cacheDir}/${safeName}`;
await RNFS.writeFile(filePath, base64Data, 'base64');
return `file://${filePath}`;
}
/** Prueft ob eine lokale Datei noch existiert */
async function checkFileExists(uri: string): Promise<boolean> {
if (!uri || !uri.startsWith('file://')) return false;
return RNFS.exists(uri.replace('file://', ''));
}
// --- Komponente --- // --- Komponente ---
@ -96,6 +125,40 @@ const ChatScreen: React.FC = () => {
// RVS-Nachrichten abonnieren // RVS-Nachrichten abonnieren
useEffect(() => { useEffect(() => {
const unsubMessage = rvs.onMessage((message: RVSMessage) => { const unsubMessage = rvs.onMessage((message: RVSMessage) => {
// file_saved: Bridge meldet Server-Pfad — in Attachment merken fuer Re-Download
if (message.type === 'file_saved') {
const serverPath = (message.payload.serverPath as string) || '';
const name = (message.payload.name as string) || '';
if (serverPath) {
setMessages(prev => prev.map(m => ({
...m,
attachments: m.attachments?.map(a =>
a.name === name && !a.serverPath ? { ...a, serverPath } : a
),
})));
}
return;
}
// file_response: Re-Download von Server — lokal speichern
if (message.type === 'file_response') {
const reqId = (message.payload.requestId as string) || '';
const b64 = (message.payload.base64 as string) || '';
const serverPath = (message.payload.serverPath as string) || '';
if (b64 && reqId) {
const fileName = (message.payload.name as string) || 'download';
persistAttachment(b64, reqId, fileName).then(filePath => {
setMessages(prev => prev.map(m => ({
...m,
attachments: m.attachments?.map(a =>
a.serverPath === serverPath ? { ...a, uri: filePath } : a
),
})));
}).catch(() => {});
}
return;
}
// STT-Ergebnis: Spracheingabe-Placeholder mit transkribiertem Text ersetzen // STT-Ergebnis: Spracheingabe-Placeholder mit transkribiertem Text ersetzen
if (message.type === 'stt_result') { if (message.type === 'stt_result') {
const sttText = (message.payload.text as string) || ''; const sttText = (message.payload.text as string) || '';
@ -216,7 +279,14 @@ const ChatScreen: React.FC = () => {
// Chat-Verlauf in AsyncStorage speichern (letzte N Nachrichten) // Chat-Verlauf in AsyncStorage speichern (letzte N Nachrichten)
useEffect(() => { useEffect(() => {
if (messages.length === 0) return; if (messages.length === 0) return;
const toStore = messages.slice(-MAX_STORED_MESSAGES); // Nur file:// URIs speichern, data: URIs rausfiltern (zu gross)
const toStore = messages.slice(-MAX_STORED_MESSAGES).map(msg => ({
...msg,
attachments: msg.attachments?.map(att => ({
...att,
uri: att.uri?.startsWith('file://') ? att.uri : undefined,
})),
}));
AsyncStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(toStore)).catch(err => AsyncStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(toStore)).catch(err =>
console.error('[Chat] Fehler beim Speichern:', err), console.error('[Chat] Fehler beim Speichern:', err),
); );
@ -310,8 +380,11 @@ const ChatScreen: React.FC = () => {
const location = await getCurrentLocation(); const location = await getCurrentLocation();
const isImage = file.type.startsWith('image/'); const isImage = file.type.startsWith('image/');
const msgId = nextId();
let imageUri = isImage && file.base64 ? `data:${file.type};base64,${file.base64}` : file.uri;
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: nextId(), id: msgId,
sender: 'user', sender: 'user',
text: 'Anhang empfangen', text: 'Anhang empfangen',
timestamp: Date.now(), timestamp: Date.now(),
@ -319,12 +392,21 @@ const ChatScreen: React.FC = () => {
type: isImage ? 'image' : 'file', type: isImage ? 'image' : 'file',
name: file.name, name: file.name,
size: file.size, size: file.size,
uri: isImage && file.base64 ? `data:${file.type};base64,${file.base64}` : file.uri, uri: imageUri,
mimeType: file.type, mimeType: file.type,
}], }],
}; };
setMessages(prev => [...prev, userMsg]); setMessages(prev => [...prev, userMsg]);
// Anhang auf Disk speichern fuer Persistenz
if (file.base64) {
persistAttachment(file.base64, msgId, file.name).then(filePath => {
setMessages(prev => prev.map(m =>
m.id === msgId ? { ...m, attachments: m.attachments?.map(a => ({ ...a, uri: filePath })) } : m
));
}).catch(() => {});
}
rvs.send('file', { rvs.send('file', {
name: file.name, name: file.name,
type: file.type, type: file.type,
@ -339,20 +421,32 @@ const ChatScreen: React.FC = () => {
setShowCameraUpload(false); setShowCameraUpload(false);
const location = await getCurrentLocation(); const location = await getCurrentLocation();
const msgId = nextId();
const dataUri = photo.base64 ? `data:${photo.type};base64,${photo.base64}` : undefined;
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: nextId(), id: msgId,
sender: 'user', sender: 'user',
text: 'Anhang empfangen', text: 'Anhang empfangen',
timestamp: Date.now(), timestamp: Date.now(),
attachments: [{ attachments: [{
type: 'image', type: 'image',
name: photo.fileName, name: photo.fileName,
uri: photo.base64 ? `data:${photo.type};base64,${photo.base64}` : undefined, uri: dataUri,
mimeType: photo.type, mimeType: photo.type,
}], }],
}; };
setMessages(prev => [...prev, userMsg]); setMessages(prev => [...prev, userMsg]);
// Foto auf Disk speichern fuer Persistenz
if (photo.base64) {
persistAttachment(photo.base64, msgId, photo.fileName).then(filePath => {
setMessages(prev => prev.map(m =>
m.id === msgId ? { ...m, attachments: m.attachments?.map(a => ({ ...a, uri: filePath })) } : m
));
}).catch(() => {});
}
rvs.send('file', { rvs.send('file', {
name: photo.fileName, name: photo.fileName,
type: photo.type, type: photo.type,
@ -382,7 +476,30 @@ const ChatScreen: React.FC = () => {
source={{ uri: att.uri }} source={{ uri: att.uri }}
style={styles.attachmentImage} style={styles.attachmentImage}
resizeMode="contain" resizeMode="contain"
onError={() => {
// Bild nicht mehr verfuegbar — Placeholder setzen
setMessages(prev => prev.map(m =>
m.id === item.id ? { ...m, attachments: m.attachments?.map((a, i) =>
i === idx ? { ...a, uri: undefined } : a
)} : m
));
}}
/> />
) : att.type === 'image' && !att.uri ? (
<TouchableOpacity
style={styles.attachmentFile}
onPress={() => {
if (att.serverPath) {
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
}
}}
>
<Text style={styles.attachmentFileIcon}>{'\uD83D\uDDBC\uFE0F'}</Text>
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
<Text style={styles.attachmentFileSize}>
{att.serverPath ? '(tippen zum Laden)' : '(nicht verfuegbar)'}
</Text>
</TouchableOpacity>
) : ( ) : (
<View style={styles.attachmentFile}> <View style={styles.attachmentFile}>
<Text style={styles.attachmentFileIcon}> <Text style={styles.attachmentFileIcon}>
@ -393,6 +510,12 @@ const ChatScreen: React.FC = () => {
</Text> </Text>
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text> <Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
{att.size ? <Text style={styles.attachmentFileSize}>{Math.round(att.size / 1024)}KB</Text> : null} {att.size ? <Text style={styles.attachmentFileSize}>{Math.round(att.size / 1024)}KB</Text> : null}
{!att.uri && att.serverPath && (
<TouchableOpacity onPress={() => rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id })}>
<Text style={[styles.attachmentFileSize, {color: '#0096FF'}]}>(laden)</Text>
</TouchableOpacity>
)}
{!att.uri && !att.serverPath && <Text style={styles.attachmentFileSize}>(nicht verfuegbar)</Text>}
</View> </View>
)} )}
</View> </View>

View File

@ -16,10 +16,15 @@ import {
Alert, Alert,
Platform, Platform,
} from 'react-native'; } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs';
import rvs, { ConnectionState, RVSMessage, ConnectionConfig, ConnectionLogEntry } from '../services/rvs'; import rvs, { ConnectionState, RVSMessage, ConnectionConfig, ConnectionLogEntry } from '../services/rvs';
import ModeSelector from '../components/ModeSelector'; import ModeSelector from '../components/ModeSelector';
import QRScanner from '../components/QRScanner'; import QRScanner from '../components/QRScanner';
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
const DEFAULT_STORAGE_PATH = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
// --- Typen --- // --- Typen ---
interface LogEntry { interface LogEntry {
@ -62,6 +67,10 @@ const SettingsScreen: React.FC = () => {
const [logs, setLogs] = useState<LogEntry[]>([]); const [logs, setLogs] = useState<LogEntry[]>([]);
const [events, setEvents] = useState<EventEntry[]>([]); const [events, setEvents] = useState<EventEntry[]>([]);
const [connLog, setConnLog] = useState<ConnectionLogEntry[]>(rvs.getConnectionLog()); const [connLog, setConnLog] = useState<ConnectionLogEntry[]>(rvs.getConnectionLog());
const [storagePath, setStoragePath] = useState(DEFAULT_STORAGE_PATH);
const [storageSize, setStorageSize] = useState('...');
const [editingPath, setEditingPath] = useState(false);
const [tempPath, setTempPath] = useState('');
let logIdCounter = 0; let logIdCounter = 0;
@ -73,8 +82,64 @@ const SettingsScreen: React.FC = () => {
setManualPort(String(config.port)); setManualPort(String(config.port));
setManualToken(config.token); setManualToken(config.token);
} }
// Speicherpfad laden
AsyncStorage.getItem(STORAGE_PATH_KEY).then(saved => {
if (saved) setStoragePath(saved);
});
}, []); }, []);
// Speichergroesse berechnen
useEffect(() => {
const calcSize = async () => {
try {
const exists = await RNFS.exists(storagePath);
if (!exists) { setStorageSize('0 KB'); return; }
const items = await RNFS.readDir(storagePath);
const totalBytes = items.reduce((sum, f) => sum + (f.size || 0), 0);
if (totalBytes > 1024 * 1024) {
setStorageSize(`${(totalBytes / 1024 / 1024).toFixed(1)} MB (${items.length} Dateien)`);
} else {
setStorageSize(`${Math.round(totalBytes / 1024)} KB (${items.length} Dateien)`);
}
} catch { setStorageSize('nicht verfuegbar'); }
};
calcSize();
}, [storagePath]);
const saveStoragePath = useCallback(async (newPath: string) => {
const clean = newPath.trim();
if (!clean) return;
await AsyncStorage.setItem(STORAGE_PATH_KEY, clean);
setStoragePath(clean);
setEditingPath(false);
Alert.alert('Gespeichert', `Neuer Speicherort:\n${clean}\n\nWird ab der naechsten Nachricht verwendet.`);
}, []);
const clearStorageCache = useCallback(async () => {
Alert.alert(
'Cache loeschen',
`Alle lokalen Anhaenge in\n${storagePath}\nloeschen?\n\nDateien koennen ueber RVS erneut heruntergeladen werden.`,
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Loeschen',
style: 'destructive',
onPress: async () => {
try {
const exists = await RNFS.exists(storagePath);
if (exists) await RNFS.unlink(storagePath);
await RNFS.mkdir(storagePath);
setStorageSize('0 KB (0 Dateien)');
Alert.alert('Erledigt', 'Cache geleert. Anhaenge werden bei Bedarf neu geladen.');
} catch (e: any) {
Alert.alert('Fehler', e.message);
}
},
},
],
);
}, [storagePath]);
// RVS-Nachrichten und Verbindungslog abonnieren // RVS-Nachrichten und Verbindungslog abonnieren
useEffect(() => { useEffect(() => {
const unsubState = rvs.onStateChange(setConnectionState); const unsubState = rvs.onStateChange(setConnectionState);
@ -332,6 +397,62 @@ const SettingsScreen: React.FC = () => {
</View> </View>
</View> </View>
{/* === Speicher === */}
<Text style={styles.sectionTitle}>Anhang-Speicher</Text>
<View style={styles.card}>
<Text style={styles.toggleLabel}>Lokaler Speicherort</Text>
<Text style={styles.toggleHint}>
Hier werden Bilder und Dateien aus dem Chat gespeichert.
Bei geloeschtem Cache werden Anhaenge automatisch ueber RVS neu geladen.
</Text>
{editingPath ? (
<View style={{marginTop: 10}}>
<TextInput
style={styles.input}
value={tempPath}
onChangeText={setTempPath}
placeholder="z.B. /storage/emulated/0/ARIA/attachments"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
<View style={{flexDirection: 'row', gap: 8}}>
<TouchableOpacity
style={[styles.connectButton, {flex: 1}]}
onPress={() => saveStoragePath(tempPath)}
>
<Text style={styles.connectButtonText}>Speichern</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.clearButton, {flex: 1, marginTop: 0}]}
onPress={() => setEditingPath(false)}
>
<Text style={styles.clearButtonText}>Abbrechen</Text>
</TouchableOpacity>
</View>
</View>
) : (
<View style={{marginTop: 10}}>
<Text style={styles.storagePathText} numberOfLines={2}>{storagePath}</Text>
<Text style={styles.storageSizeText}>{storageSize}</Text>
<View style={{flexDirection: 'row', gap: 8, marginTop: 8}}>
<TouchableOpacity
style={[styles.clearButton, {flex: 1, marginTop: 0}]}
onPress={() => { setTempPath(storagePath); setEditingPath(true); }}
>
<Text style={styles.clearButtonText}>Pfad aendern</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.clearButton, {flex: 1, marginTop: 0, backgroundColor: 'rgba(255,59,48,0.15)'}]}
onPress={clearStorageCache}
>
<Text style={[styles.clearButtonText, {color: '#FF3B30'}]}>Cache leeren</Text>
</TouchableOpacity>
</View>
</View>
)}
</View>
{/* === Logs === */} {/* === Logs === */}
<Text style={styles.sectionTitle}>Protokoll</Text> <Text style={styles.sectionTitle}>Protokoll</Text>
<View style={styles.card}> <View style={styles.card}>
@ -559,6 +680,18 @@ const styles = StyleSheet.create({
marginTop: 2, marginTop: 2,
}, },
// Speicher
storagePathText: {
color: '#0096FF',
fontSize: 12,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
},
storageSizeText: {
color: '#8888AA',
fontSize: 12,
marginTop: 4,
},
// Logs // Logs
tabRow: { tabRow: {
flexDirection: 'row', flexDirection: 'row',

View File

@ -977,6 +977,12 @@ class ARIABridge:
f.write(base64.b64decode(file_b64)) f.write(base64.b64decode(file_b64))
size_kb = len(file_b64) // 1365 size_kb = len(file_b64) // 1365
logger.info("[rvs] Bild gespeichert: %s (%dKB)", file_path, size_kb) logger.info("[rvs] Bild gespeichert: %s (%dKB)", file_path, size_kb)
# App informieren wo die Datei liegt (fuer Re-Download)
await self._send_to_rvs({
"type": "file_saved",
"payload": {"name": file_name, "serverPath": file_path, "mimeType": file_type},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
text = (f"Stefan hat dir ein Bild geschickt: {file_name}" text = (f"Stefan hat dir ein Bild geschickt: {file_name}"
f"{f' ({width}x{height}px)' if width else ''}" f"{f' ({width}x{height}px)' if width else ''}"
f", {size_kb}KB." f", {size_kb}KB."
@ -990,6 +996,11 @@ class ARIABridge:
f.write(base64.b64decode(file_b64)) f.write(base64.b64decode(file_b64))
size_kb = len(file_b64) // 1365 size_kb = len(file_b64) // 1365
logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb) logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb)
await self._send_to_rvs({
"type": "file_saved",
"payload": {"name": file_name, "serverPath": file_path, "mimeType": file_type},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
text = (f"Stefan hat dir eine Datei geschickt: {file_name}" text = (f"Stefan hat dir eine Datei geschickt: {file_name}"
f" ({file_type}, {size_kb}KB)." f" ({file_type}, {size_kb}KB)."
f" Die Datei liegt unter: {file_path}") f" Die Datei liegt unter: {file_path}")
@ -998,6 +1009,35 @@ class ARIABridge:
text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen." text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen."
await self.send_to_core(text, source="app-file") await self.send_to_core(text, source="app-file")
elif msg_type == "file_request":
# App fordert eine Datei an (Re-Download nach Cache-Leerung)
server_path = payload.get("serverPath", "")
req_id = payload.get("requestId", "")
if not server_path or not server_path.startswith("/shared/"):
logger.warning("[rvs] Ungueltiger file_request: %s", server_path)
return
if not os.path.isfile(server_path):
logger.warning("[rvs] Datei nicht gefunden: %s", server_path)
await self._send_to_rvs({
"type": "file_response",
"payload": {"requestId": req_id, "error": "Datei nicht gefunden"},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
return
with open(server_path, "rb") as f:
file_b64 = base64.b64encode(f.read()).decode("ascii")
logger.info("[rvs] Re-Download: %s (%dKB)", server_path, len(file_b64) // 1365)
await self._send_to_rvs({
"type": "file_response",
"payload": {
"requestId": req_id,
"serverPath": server_path,
"base64": file_b64,
"name": os.path.basename(server_path),
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
elif msg_type == "audio": elif msg_type == "audio":
# Audio von der App → decodieren → STT → an aria-core # Audio von der App → decodieren → STT → an aria-core
audio_b64 = payload.get("base64", "") audio_b64 = payload.get("base64", "")