Compare commits

...

4 Commits

19 changed files with 572 additions and 72 deletions
+52 -7
View File
@@ -271,9 +271,13 @@ Die Bridge verbindet die Android App mit ARIA und bietet lokale Sprachverarbeitu
**Nachrichtenfluss:** **Nachrichtenfluss:**
``` ```
App → RVS → Bridge → aria-core Text: App → RVS → Bridge → chat.send → aria-core
aria-core → Bridge → RVS → App Audio: App → RVS → Bridge → FFmpeg → Whisper STT → chat.send → aria-core
→ Lautsprecher (TTS) Datei: App → RVS → Bridge → /shared/uploads/ → chat.send (mit Pfad) → aria-core
aria-core → Antwort → Gateway → Diagnostic → RVS → App
→ Bridge → Piper TTS → RVS → App (Audio)
→ Bridge → Lautsprecher (lokal)
``` ```
### Features ### Features
@@ -335,9 +339,11 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session`
- Text-Chat mit ARIA - Text-Chat mit ARIA
- **Sprachaufnahme**: Push-to-Talk (halten) oder Tap-to-Talk (tippen, Auto-Stop bei Stille) - **Sprachaufnahme**: Push-to-Talk (halten) oder Tap-to-Talk (tippen, Auto-Stop bei Stille)
- **VAD (Voice Activity Detection)**: Erkennt 1.8s Stille und stoppt automatisch - **VAD (Voice Activity Detection)**: Erkennt 1.8s Stille und stoppt automatisch
- **Wake Word**: Toggle-Button aktiviert kontinuierliches Mikrofon-Monitoring - **STT (Speech-to-Text)**: Audio wird in der Bridge per Whisper transkribiert, transkribierter Text erscheint im Chat
- **Wake Word**: Toggle-Button (Ohr-Symbol) aktiviert kontinuierliches Mikrofon-Monitoring
- **TTS-Wiedergabe**: ARIA antwortet per Lautsprecher (Ramona/Thorsten) - **TTS-Wiedergabe**: ARIA antwortet per Lautsprecher (Ramona/Thorsten)
- Datei- und Kamera-Upload - **Datei- und Bild-Upload**: Bilder inline im Chat, Dateien mit Icon + Name + Groesse
- **Anhaenge**: Bridge speichert Dateien in Shared Volume (`/shared/uploads/`), ARIA kann darauf zugreifen
- GPS-Position (optional) - GPS-Position (optional)
- QR-Code Scanner fuer Token-Pairing - QR-Code Scanner fuer Token-Pairing
@@ -381,15 +387,47 @@ GITEA_REPO=stefan/aria-agent
GITEA_USER=stefan GITEA_USER=stefan
``` ```
### Audio-Pipeline ### Audio-Pipeline (Spracheingabe)
``` ```
App (Mikrofon) → AAC/MP4 Aufnahme → Base64 → RVS → Bridge App (Mikrofon) → AAC/MP4 Aufnahme → Base64 → RVS → Bridge
Bridge: FFmpeg (16kHz PCM) → Whisper STT → Text → aria-core Bridge: FFmpeg (16kHz PCM) → Whisper STT → Text → aria-core
Bridge: STT-Ergebnis → RVS → App (Placeholder wird durch transkribierten Text ersetzt)
aria-core → Antwort → Bridge → Piper TTS (WAV) → Base64 → RVS → App aria-core → Antwort → Bridge → Piper TTS (WAV) → Base64 → RVS → App
App: Base64 → WAV → Lautsprecher App: Base64 → WAV → Lautsprecher
``` ```
### Datei-Pipeline (Bilder & Anhaenge)
```
App (Kamera/Dateimanager) → Base64 → RVS → Bridge
Bridge: Speichert in /shared/uploads/ (Shared Volume, fuer aria-core sichtbar)
Bridge: chat.send → "Stefan hat ein Bild geschickt: foto.jpg — liegt unter /shared/uploads/..."
ARIA: Kann Datei per Bash/Read-Tool oeffnen und analysieren
```
**Unterstuetzte Formate:** Bilder (JPG, PNG), Dokumente (PDF, DOCX, TXT), beliebige Dateien.
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/
@@ -453,6 +491,8 @@ docker compose up -d
| `./aria-data/ssh` (bind) | `/root/.ssh`, `/home/node/.ssh` | SSH Keys | | `./aria-data/ssh` (bind) | `/root/.ssh`, `/home/node/.ssh` | SSH Keys |
| `./aria-data/brain` (bind) | `/home/node/.openclaw/workspace/memory` | Gedaechtnis | | `./aria-data/brain` (bind) | `/home/node/.openclaw/workspace/memory` | Gedaechtnis |
| `./aria-data/skills` (bind) | `/home/node/.openclaw/workspace/skills` | Skills | | `./aria-data/skills` (bind) | `/home/node/.openclaw/workspace/skills` | Skills |
| `aria-shared` | `/shared` (Core + Bridge) | Datei-Austausch (Uploads von App) |
| `./aria-data/config/diag-state` (bind) | `/data` (Diagnostic) | Persistenter State (aktive Session) |
--- ---
@@ -507,8 +547,13 @@ docker exec aria-core ssh aria-wohnung hostname
Dadurch ist ARIA langsamer als die direkte Claude CLI. Timeout ist auf 900s (15 Min). Dadurch ist ARIA langsamer als die direkte Claude CLI. Timeout ist auf 900s (15 Min).
- **Kein Streaming zur App**: Die App zeigt erst die fertige Antwort, keine Streaming-Tokens. - **Kein Streaming zur App**: Die App zeigt erst die fertige Antwort, keine Streaming-Tokens.
- **Wake Word nur auf VM**: Die Bridge hoert auf "ARIA" ueber das lokale Mikrofon der VM. - **Wake Word nur auf VM**: Die Bridge hoert auf "ARIA" ueber das lokale Mikrofon der VM.
In der App gibt es Energy-basierte Erkennung (Phase 1). In der App gibt es Energy-basierte Erkennung (Phase 1). On-device "ARIA"-Keyword (Porcupine) ist Phase 2.
- **Audio-Format**: App nimmt AAC/MP4 auf, Bridge konvertiert via FFmpeg zu 16kHz PCM. - **Audio-Format**: App nimmt AAC/MP4 auf, Bridge konvertiert via FFmpeg zu 16kHz PCM.
- **Bildanalyse eingeschraenkt**: Bilder werden in `/shared/uploads/` gespeichert. ARIA kann
sie per Bash/Read-Tool oeffnen, aber Claude Vision (direkte Bildanalyse) ist ueber den
Proxy-Pfad (`claude --print`) noch nicht moeglich. ARIA sieht den Dateipfad, nicht das Bild.
- **Dateigroesse**: Grosse Dateien (>5MB) koennen WebSocket-Limits ueberschreiten.
Bilder werden in der App auf max 1920x1920px @ 80% Qualitaet komprimiert.
--- ---
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
#Sun Mar 29 11:54:43 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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+249 -43
View File
@@ -15,9 +15,11 @@ import {
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
StyleSheet, StyleSheet,
Image,
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';
@@ -33,6 +35,9 @@ interface Attachment {
type: 'image' | 'file' | 'audio'; type: 'image' | 'file' | 'audio';
name: string; name: string;
size?: number; size?: number;
uri?: string; // Lokaler Pfad (file://) fuer Anzeige
mimeType?: string;
serverPath?: string; // Pfad auf dem Server (/shared/uploads/...) fuer Re-Download
} }
interface ChatMessage { interface ChatMessage {
@@ -47,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 ---
@@ -69,33 +101,88 @@ const ChatScreen: React.FC = () => {
}; };
// Chat-Verlauf aus AsyncStorage laden // Chat-Verlauf aus AsyncStorage laden
const isInitialLoad = useRef(true);
useEffect(() => { useEffect(() => {
const loadMessages = async () => { const loadMessages = async () => {
try { try {
const stored = await AsyncStorage.getItem(CHAT_STORAGE_KEY); const stored = await AsyncStorage.getItem(CHAT_STORAGE_KEY);
console.log('[Chat] AsyncStorage geladen:', stored ? `${stored.length} Bytes` : 'leer');
if (stored) { if (stored) {
const parsed: ChatMessage[] = JSON.parse(stored); const parsed: ChatMessage[] = JSON.parse(stored);
setMessages(parsed); if (Array.isArray(parsed) && parsed.length > 0) {
// ID-Counter auf hoechsten Wert setzen um Kollisionen zu vermeiden console.log('[Chat] ${parsed.length} Nachrichten geladen');
const maxId = parsed.reduce((max, msg) => { setMessages(parsed);
const num = parseInt(msg.id.split('_').pop() || '0', 10); const maxId = parsed.reduce((max, msg) => {
return num > max ? num : max; const num = parseInt(msg.id.split('_').pop() || '0', 10);
}, 0); return num > max ? num : max;
messageIdCounter.current = maxId; }, 0);
messageIdCounter.current = maxId;
}
} }
} catch (err) { } catch (err) {
console.error('[Chat] Fehler beim Laden des Verlaufs:', err); console.error('[Chat] Fehler beim Laden des Verlaufs:', err);
} finally {
isInitialLoad.current = false;
} }
}; };
loadMessages().then(() => { loadMessages();
// Auto-Scroll nach Laden des Verlaufs
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: false }), 200);
});
}, []); }, []);
// 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
if (message.type === 'stt_result') {
const sttText = (message.payload.text as string) || '';
if (sttText) {
setMessages(prev => prev.map(m =>
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
? { ...m, text: sttText }
: m
));
} else {
// Keine Sprache erkannt — Placeholder entfernen
setMessages(prev => prev.filter(m =>
!(m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet'))
));
}
return;
}
if (message.type === 'chat') { if (message.type === 'chat') {
// Nur Nachrichten von ARIA anzeigen — eigene Nachrichten werden lokal hinzugefuegt // Nur Nachrichten von ARIA anzeigen — eigene Nachrichten werden lokal hinzugefuegt
const sender = (message.payload.sender as string) || ''; const sender = (message.payload.sender as string) || '';
@@ -197,21 +284,36 @@ 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 || isInitialLoad.current) 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),
); );
}, [messages]); }, [messages]);
// Auto-Scroll bei neuen Nachrichten // Auto-Scroll wird ueber onContentSizeChange der FlatList gesteuert
useEffect(() => { const shouldAutoScroll = useRef(true);
if (messages.length > 0) { const handleContentSizeChange = useCallback(() => {
setTimeout(() => { if (shouldAutoScroll.current) {
flatListRef.current?.scrollToEnd({ animated: true }); flatListRef.current?.scrollToEnd({ animated: false });
}, 100);
} }
}, [messages]); }, []);
const handleScrollBeginDrag = useCallback(() => {
shouldAutoScroll.current = false;
}, []);
const handleScrollEndDrag = useCallback((e: any) => {
// Auto-Scroll wieder aktivieren wenn User ganz unten ist
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
const isAtBottom = contentOffset.y + layoutMeasurement.height >= contentSize.height - 50;
shouldAutoScroll.current = isAtBottom;
}, []);
// GPS-Position holen (optional) // GPS-Position holen (optional)
const getCurrentLocation = useCallback((): Promise<{ lat: number; lon: number } | null> => { const getCurrentLocation = useCallback((): Promise<{ lat: number; lon: number } | null> => {
@@ -283,15 +385,34 @@ const ChatScreen: React.FC = () => {
setShowFileUpload(false); setShowFileUpload(false);
const location = await getCurrentLocation(); const location = await getCurrentLocation();
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: `[Datei: ${file.name}]`, text: 'Anhang empfangen',
timestamp: Date.now(), timestamp: Date.now(),
attachments: [{ type: 'file', name: file.name, size: file.size }], attachments: [{
type: isImage ? 'image' : 'file',
name: file.name,
size: file.size,
uri: imageUri,
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,
@@ -306,15 +427,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: `[Foto: ${photo.fileName}]`, text: 'Anhang empfangen',
timestamp: Date.now(), timestamp: Date.now(),
attachments: [{ type: 'image', name: photo.fileName }], attachments: [{
type: 'image',
name: photo.fileName,
uri: dataUri,
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,
@@ -336,16 +474,64 @@ const ChatScreen: React.FC = () => {
return ( return (
<View style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble]}> <View style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble]}>
<Text style={[styles.messageText, isUser ? styles.userText : styles.ariaText]}> {/* Anhang-Vorschau */}
{item.text}
</Text>
{item.attachments?.map((att, idx) => ( {item.attachments?.map((att, idx) => (
<View key={idx} style={styles.attachmentBadge}> <View key={idx}>
<Text style={styles.attachmentText}> {att.type === 'image' && att.uri ? (
{att.type === 'image' ? '\uD83D\uDDBC\uFE0F' : att.type === 'audio' ? '\uD83C\uDFA4' : '\uD83D\uDCC4'} {att.name} <Image
</Text> source={{ uri: att.uri }}
style={styles.attachmentImage}
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}>
<Text style={styles.attachmentFileIcon}>
{att.mimeType?.includes('pdf') ? '\uD83D\uDCC4' :
att.mimeType?.includes('word') || att.mimeType?.includes('document') ? '\uD83D\uDCC3' :
att.mimeType?.includes('sheet') || att.mimeType?.includes('excel') ? '\uD83D\uDCC8' :
'\uD83D\uDCC1'}
</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.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>
))} ))}
{/* Text (nicht anzeigen wenn nur "Anhang empfangen" und ein Bild da ist) */}
{!(item.text === 'Anhang empfangen' && item.attachments?.some(a => a.type === 'image' && a.uri)) && (
<Text style={[styles.messageText, isUser ? styles.userText : styles.ariaText]}>
{item.text}
</Text>
)}
<Text style={styles.timestamp}>{time}</Text> <Text style={styles.timestamp}>{time}</Text>
</View> </View>
); );
@@ -378,6 +564,9 @@ const ChatScreen: React.FC = () => {
renderItem={renderMessage} renderItem={renderMessage}
contentContainerStyle={styles.messageList} contentContainerStyle={styles.messageList}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onContentSizeChange={handleContentSizeChange}
onScrollBeginDrag={handleScrollBeginDrag}
onScrollEndDrag={handleScrollEndDrag}
ListEmptyComponent={ ListEmptyComponent={
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<Text style={styles.emptyIcon}>{'\uD83E\uDD16'}</Text> <Text style={styles.emptyIcon}>{'\uD83E\uDD16'}</Text>
@@ -519,17 +708,34 @@ const styles = StyleSheet.create({
ariaText: { ariaText: {
color: '#E0E0F0', color: '#E0E0F0',
}, },
attachmentBadge: { attachmentImage: {
backgroundColor: 'rgba(255,255,255,0.1)', width: '100%',
borderRadius: 6, height: 200,
paddingHorizontal: 8, borderRadius: 8,
paddingVertical: 4, marginBottom: 6,
marginTop: 6, backgroundColor: '#0D0D1A',
alignSelf: 'flex-start',
}, },
attachmentText: { attachmentFile: {
color: '#CCCCDD', flexDirection: 'row',
fontSize: 12, alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.1)',
borderRadius: 8,
padding: 10,
marginBottom: 6,
},
attachmentFileIcon: {
fontSize: 24,
marginRight: 8,
},
attachmentFileName: {
flex: 1,
color: '#E0E0F0',
fontSize: 13,
},
attachmentFileSize: {
color: '#8888AA',
fontSize: 11,
marginLeft: 8,
}, },
timestamp: { timestamp: {
color: 'rgba(255,255,255,0.4)', color: 'rgba(255,255,255,0.4)',
+162
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,93 @@ 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 storagePaths = [
{ label: 'App-intern (Standard)', path: DEFAULT_STORAGE_PATH },
{ label: 'Externer Speicher', path: '/storage/emulated/0/ARIA/attachments' },
{ label: 'SD-Karte', path: '/storage/sdcard1/ARIA/attachments' },
{ label: 'Downloads', path: '/storage/emulated/0/Download/ARIA' },
];
const showPathPicker = useCallback(() => {
const options = storagePaths.map(p => p.label);
options.push('Eigenen Pfad eingeben');
options.push('Abbrechen');
Alert.alert(
'Speicherort waehlen',
'Wo sollen Anhaenge gespeichert werden?',
[
...storagePaths.map(p => ({
text: p.label,
onPress: () => saveStoragePath(p.path),
})),
{
text: 'Eigenen Pfad eingeben',
onPress: () => { setTempPath(storagePath); setEditingPath(true); },
},
{ text: 'Abbrechen', style: 'cancel' as const },
],
);
}, [storagePath]);
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 +426,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={showPathPicker}
>
<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 +709,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',
+91 -17
View File
@@ -964,31 +964,80 @@ class ARIABridge:
logger.info("[rvs] Datei empfangen: %s (%s, %dKB)", logger.info("[rvs] Datei empfangen: %s (%s, %dKB)",
file_name, file_type, len(file_b64) // 1365 if file_b64 else 0) file_name, file_type, len(file_b64) // 1365 if file_b64 else 0)
# Shared Volume: /shared/ ist in Bridge UND aria-core gemountet
SHARED_DIR = "/shared/uploads"
os.makedirs(SHARED_DIR, exist_ok=True)
if file_b64 and file_type.startswith("image/"): if file_b64 and file_type.startswith("image/"):
# Bild: als temporaere Datei speichern und Pfad an ARIA melden # Bild in Shared Volume speichern
ext = ".jpg" if "jpeg" in file_type or "jpg" in file_type else ".png" ext = ".jpg" if "jpeg" in file_type or "jpg" in file_type else ".png"
tmp = tempfile.NamedTemporaryFile(suffix=ext, dir="/tmp", delete=False, prefix="aria_img_") safe_name = f"img_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}"
tmp.write(base64.b64decode(file_b64)) file_path = os.path.join(SHARED_DIR, safe_name if safe_name.endswith(ext) else safe_name + ext)
tmp.close() with open(file_path, "wb") as f:
text = (f"[Bild von Stefan via App: {file_name}" f.write(base64.b64decode(file_b64))
f"{f', {width}x{height}px' if width else ''}" size_kb = len(file_b64) // 1365
f" — gespeichert als {tmp.name}]" logger.info("[rvs] Bild gespeichert: %s (%dKB)", file_path, size_kb)
f" Bitte analysiere das Bild.") # 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}"
f"{f' ({width}x{height}px)' if width else ''}"
f", {size_kb}KB."
f" Das Bild liegt unter: {file_path}")
await self.send_to_core(text, source="app-file") await self.send_to_core(text, source="app-file")
elif file_b64: elif file_b64:
# Andere Datei: speichern und Pfad melden # Andere Datei in Shared Volume speichern
ext = Path(file_name).suffix or ".bin" safe_name = f"file_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}"
tmp = tempfile.NamedTemporaryFile(suffix=ext, dir="/tmp", delete=False, prefix="aria_file_") file_path = os.path.join(SHARED_DIR, safe_name)
tmp.write(base64.b64decode(file_b64)) with open(file_path, "wb") as f:
tmp.close() f.write(base64.b64decode(file_b64))
text = (f"[Datei von Stefan via App: {file_name}" size_kb = len(file_b64) // 1365
f" ({file_type}, {file_size} bytes)" logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb)
f" — gespeichert als {tmp.name}]") 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}"
f" ({file_type}, {size_kb}KB)."
f" Die Datei liegt unter: {file_path}")
await self.send_to_core(text, source="app-file") await self.send_to_core(text, source="app-file")
else: else:
text = f"[Stefan hat eine Datei gesendet: {file_name} ({file_type}) aber keine Daten empfangen]" 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", "")
@@ -1047,9 +1096,34 @@ class ARIABridge:
if text.strip(): if text.strip():
logger.info("[rvs] STT Ergebnis: '%s'", text[:80]) logger.info("[rvs] STT Ergebnis: '%s'", text[:80])
# STT-Ergebnis zurueck an die App senden (zur Anzeige, nicht nochmal verarbeiten)
try:
await self._send_to_rvs({
"type": "stt_result",
"payload": {
"text": text,
"sender": "user",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] STT-Ergebnis konnte nicht an App gesendet werden: %s", e)
# Text trotzdem an aria-core senden
await self.send_to_core(text, source="app-voice") await self.send_to_core(text, source="app-voice")
else: else:
logger.info("[rvs] Keine Sprache erkannt — ignoriert") logger.info("[rvs] Keine Sprache erkannt — ignoriert")
try:
await self._send_to_rvs({
"type": "stt_result",
"payload": {
"text": "",
"error": "Keine Sprache erkannt",
"sender": "user",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] STT-Fehler konnte nicht an App gesendet werden: %s", e)
except Exception: except Exception:
logger.exception("[rvs] Audio-Verarbeitung fehlgeschlagen") logger.exception("[rvs] Audio-Verarbeitung fehlgeschlagen")
+3 -1
View File
@@ -501,7 +501,9 @@
} }
if (msg.type === 'rvs_chat') { if (msg.type === 'rvs_chat') {
const p = msg.msg.payload || {}; const p = msg.msg.payload || {};
addChat('received', p.text || '?', `via RVS (${p.sender || '?'})`); const sender = p.sender || '?';
const chatType = (sender === 'aria') ? 'received' : 'sent';
addChat(chatType, p.text || '?', `via RVS (${sender})`);
return; return;
} }
if (msg.type === 'proxy_result') { if (msg.type === 'proxy_result') {
+8
View File
@@ -338,6 +338,14 @@ function handleGatewayMessage(msg) {
log("info", "gateway", `ANTWORT: "${text.slice(0, 200)}"`); log("info", "gateway", `ANTWORT: "${text.slice(0, 200)}"`);
if (pipelineActive) pipelineEnd(true, `"${text.slice(0, 120)}"`); if (pipelineActive) pipelineEnd(true, `"${text.slice(0, 120)}"`);
broadcast({ type: "chat_final", text, payload }); broadcast({ type: "chat_final", text, payload });
// Antwort auch an RVS weiterleiten → App bekommt ARIAs Antworten
if (rvsWs && rvsWs.readyState === WebSocket.OPEN && text) {
rvsWs.send(JSON.stringify({
type: "chat",
payload: { text, sender: "aria" },
timestamp: Date.now(),
}));
}
return; return;
} }
+3
View File
@@ -58,6 +58,7 @@ services:
- ./aria-data/ssh:/home/node/.ssh # SSH Keys fuer VM-Zugriff - ./aria-data/ssh:/home/node/.ssh # SSH Keys fuer VM-Zugriff
- /tmp/.X11-unix:/tmp/.X11-unix - /tmp/.X11-unix:/tmp/.X11-unix
- /var/run/docker.sock:/var/run/docker.sock # VM von innen verwalten - /var/run/docker.sock:/var/run/docker.sock # VM von innen verwalten
- aria-shared:/shared # Shared Volume fuer Datei-Austausch (Bridge <> Core)
restart: unless-stopped restart: unless-stopped
networks: networks:
- aria-net - aria-net
@@ -72,6 +73,7 @@ services:
volumes: volumes:
- ./aria-data/voices:/voices:ro # TTS Stimmen - ./aria-data/voices:/voices:ro # TTS Stimmen
- ./aria-data/config/aria.env:/config/aria.env - ./aria-data/config/aria.env:/config/aria.env
- aria-shared:/shared # Shared Volume fuer Datei-Austausch (Bridge <> Core)
# Audio-Zugriff # Audio-Zugriff
- /run/user/1000/pulse:/run/user/1000/pulse - /run/user/1000/pulse:/run/user/1000/pulse
- /dev/snd:/dev/snd - /dev/snd:/dev/snd
@@ -110,6 +112,7 @@ services:
volumes: volumes:
openclaw-config: # Persistiert ~/.openclaw (Model, Auth, Sessions) openclaw-config: # Persistiert ~/.openclaw (Model, Auth, Sessions)
claude-config: # Persistiert ~/.claude (Permissions, Settings) claude-config: # Persistiert ~/.claude (Permissions, Settings)
aria-shared: # Datei-Austausch zwischen Bridge und Core
networks: networks:
aria-net: aria-net: