Compare commits

...

7 Commits

20 changed files with 664 additions and 70 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:40:22 CEST 2026 #Sun Mar 29 13:21:34 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
+7 -4
View File
@@ -17,6 +17,7 @@ import {
import DocumentPicker, { import DocumentPicker, {
DocumentPickerResponse, DocumentPickerResponse,
} from 'react-native-document-picker'; } from 'react-native-document-picker';
import RNFS from 'react-native-fs';
// --- Typen --- // --- Typen ---
@@ -74,15 +75,17 @@ const FileUpload: React.FC<FileUploadProps> = ({ onFileSelected, onCancel }) =>
setLoading(true); setLoading(true);
try { try {
// In Produktion: Datei lesen und zu Base64 konvertieren // Datei lesen und zu Base64 konvertieren
// const base64 = await RNFS.readFile(selectedFile.fileCopyUri || selectedFile.uri, 'base64'); const filePath = selectedFile.fileCopyUri || selectedFile.uri;
const base64Placeholder = ''; // URI-Schema entfernen fuer RNFS (file:// → absoluter Pfad)
const cleanPath = filePath.replace('file://', '');
const base64 = await RNFS.readFile(cleanPath, 'base64');
const fileData: FileData = { const fileData: FileData = {
name: selectedFile.name || 'unbenannt', name: selectedFile.name || 'unbenannt',
type: selectedFile.type || 'application/octet-stream', type: selectedFile.type || 'application/octet-stream',
size: selectedFile.size || 0, size: selectedFile.size || 0,
base64: base64Placeholder, base64,
uri: selectedFile.uri, uri: selectedFile.uri,
}; };
+261 -46
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,22 +101,28 @@ 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(); loadMessages();
@@ -93,6 +131,51 @@ 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: Transkribierten Text unter den Placeholder schreiben
if (message.type === 'stt_result') {
const sttText = (message.payload.text as string) || '';
setMessages(prev => prev.map(m =>
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
? { ...m, text: sttText ? `\uD83C\uDFA4 ${sttText}` : '\uD83C\uDFA4 (nicht erkannt)' }
: m
));
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) || '';
@@ -159,7 +242,7 @@ const ChatScreen: React.FC = () => {
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: nextId(), id: nextId(),
sender: 'user', sender: 'user',
text: '[Sprachnachricht]', text: '🎙 Spracheingabe wird verarbeitet...',
timestamp: Date.now(), timestamp: Date.now(),
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }], attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
}; };
@@ -192,23 +275,52 @@ const ChatScreen: React.FC = () => {
} }
}, [wakeWordActive]); }, [wakeWordActive]);
// Chat-Verlauf in AsyncStorage speichern (letzte N Nachrichten) // Chat-Verlauf in AsyncStorage speichern (debounced, nur nach initialem Laden)
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
if (messages.length === 0) return; if (messages.length === 0 || isInitialLoad.current) return;
const toStore = messages.slice(-MAX_STORED_MESSAGES); // Debounce: 1s warten damit persistAttachment fertig werden kann
AsyncStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(toStore)).catch(err => if (saveTimer.current) clearTimeout(saveTimer.current);
console.error('[Chat] Fehler beim Speichern:', err), saveTimer.current = setTimeout(() => {
); const toStore = messages.slice(-MAX_STORED_MESSAGES).map(msg => ({
...msg,
attachments: msg.attachments?.map(att => ({
...att,
// Nur file:// URIs speichern, data: URIs rausfiltern (zu gross fuer AsyncStorage)
uri: att.uri?.startsWith('file://') ? att.uri : undefined,
})),
}));
const json = JSON.stringify(toStore);
// Sicherheitscheck: nicht speichern wenn >4MB (AsyncStorage Limit)
if (json.length > 4 * 1024 * 1024) {
console.warn('[Chat] Speicher zu gross, kuerze auf 100 Nachrichten');
const shortened = JSON.stringify(toStore.slice(-100));
AsyncStorage.setItem(CHAT_STORAGE_KEY, shortened).catch(() => {});
} else {
AsyncStorage.setItem(CHAT_STORAGE_KEY, json).catch(err =>
console.error('[Chat] Speichern fehlgeschlagen:', err),
);
}
}, 1000);
return () => { if (saveTimer.current) clearTimeout(saveTimer.current); };
}, [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> => {
@@ -262,9 +374,8 @@ const ChatScreen: React.FC = () => {
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: nextId(), id: nextId(),
sender: 'user', sender: 'user',
text: '[Sprachnachricht]', text: '🎙 Spracheingabe wird verarbeitet...',
timestamp: Date.now(), timestamp: Date.now(),
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
}; };
setMessages(prev => [...prev, userMsg]); setMessages(prev => [...prev, userMsg]);
@@ -281,15 +392,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,
@@ -304,15 +434,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,
@@ -334,16 +481,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>
); );
@@ -376,6 +571,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>
@@ -517,17 +715,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)',
+174
View File
@@ -16,10 +16,16 @@ 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 DocumentPicker from 'react-native-document-picker';
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 +68,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 +83,104 @@ 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 showPathPicker = useCallback(() => {
Alert.alert(
'Speicherort waehlen',
'Wo sollen Anhaenge gespeichert werden?',
[
{
text: 'Ordner auswaehlen...',
onPress: async () => {
try {
const result = await DocumentPicker.pickDirectory();
if (result?.uri) {
// SAF URI decodieren (content://com.android.externalstorage...)
const decoded = decodeURIComponent(result.uri);
// Versuche einen lesbaren Pfad zu extrahieren
const match = decoded.match(/primary[:%]3A(.+)/);
const readablePath = match
? `/storage/emulated/0/${match[1].replace(/%2F|%3A/g, '/')}`
: decoded;
saveStoragePath(readablePath);
}
} catch (e: any) {
if (!DocumentPicker.isCancel(e)) {
Alert.alert('Fehler', 'Ordnerauswahl fehlgeschlagen');
}
}
},
},
{
text: 'App-intern (Standard)',
onPress: () => saveStoragePath(DEFAULT_STORAGE_PATH),
},
{
text: 'Pfad manuell 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 +438,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 +721,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',
+119 -7
View File
@@ -896,7 +896,7 @@ class ARIABridge:
"""Sendet Heartbeats an den RVS damit die Verbindung offen bleibt.""" """Sendet Heartbeats an den RVS damit die Verbindung offen bleibt."""
while True: while True:
await asyncio.sleep(25) await asyncio.sleep(25)
if self.ws_rvs and self.ws_rvs.open: if self.ws_rvs:
try: try:
await self.ws_rvs.send(json.dumps({ await self.ws_rvs.send(json.dumps({
"type": "heartbeat", "type": "heartbeat",
@@ -954,10 +954,97 @@ class ARIABridge:
await self.ws_core.send(raw_message) await self.ws_core.send(raw_message)
elif msg_type == "file": elif msg_type == "file":
# Datei von der App → an aria-core # Datei von der App → als Text-Nachricht an aria-core
logger.info("[rvs] Datei empfangen: %s", payload.get("name", "?")) file_name = payload.get("name", "unbekannt")
if self.ws_core: file_type = payload.get("type", "")
await self.ws_core.send(raw_message) file_b64 = payload.get("base64", "")
file_size = payload.get("size", 0)
width = payload.get("width", 0)
height = payload.get("height", 0)
logger.info("[rvs] Datei empfangen: %s (%s, %dKB)",
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/"):
# Bild in Shared Volume speichern
ext = ".jpg" if "jpeg" in file_type or "jpg" in file_type else ".png"
safe_name = f"img_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}"
file_path = os.path.join(SHARED_DIR, safe_name if safe_name.endswith(ext) else safe_name + ext)
with open(file_path, "wb") as f:
f.write(base64.b64decode(file_b64))
size_kb = len(file_b64) // 1365
logger.info("[rvs] Bild gespeichert: %s (%dKB)", file_path, size_kb)
# ERST an aria-core senden (wichtigster Schritt)
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")
# Dann App informieren (optional, darf nicht crashen)
try:
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),
})
except Exception as e:
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
elif file_b64:
# Andere Datei in Shared Volume speichern
safe_name = f"file_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}"
file_path = os.path.join(SHARED_DIR, safe_name)
with open(file_path, "wb") as f:
f.write(base64.b64decode(file_b64))
size_kb = len(file_b64) // 1365
logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb)
# ERST an aria-core senden
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")
try:
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),
})
except Exception as e:
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
else:
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")
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
@@ -1017,9 +1104,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")
@@ -1034,13 +1146,13 @@ class ARIABridge:
async def _send_to_rvs(self, message: dict) -> None: async def _send_to_rvs(self, message: dict) -> None:
"""Sendet eine Nachricht an die App (via RVS).""" """Sendet eine Nachricht an die App (via RVS)."""
if self.ws_rvs is None or not self.ws_rvs.open: if self.ws_rvs is None:
return return
try: try:
await self.ws_rvs.send(json.dumps(message)) await self.ws_rvs.send(json.dumps(message))
except Exception: except Exception:
logger.exception("[rvs] Sendefehler") logger.warning("[rvs] Sendefehler — RVS nicht erreichbar")
# ── Log-Streaming an die App ───────────────────────────── # ── Log-Streaming an die App ─────────────────────────────
+8 -2
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') {
@@ -857,7 +859,11 @@
const el = document.createElement('div'); const el = document.createElement('div');
el.className = `chat-msg ${type}`; el.className = `chat-msg ${type}`;
const escaped = escapeHtml(text); const escaped = escapeHtml(text);
const linked = linkifyText(escaped); let linked = linkifyText(escaped);
// /shared/uploads/ Pfade als Inline-Bilder anzeigen
linked = linked.replace(/\/shared\/uploads\/[^\s<"]+\.(jpg|jpeg|png|gif)/gi, (match) => {
return `<a href="${match}" target="_blank">${match}</a><img src="${match}" class="chat-media" onclick="openLightbox('image','${match}')" onerror="this.style.display='none'">`;
});
el.innerHTML = `${linked}<div class="meta">${escapeHtml(meta)}${new Date().toLocaleTimeString('de-DE')}</div>`; el.innerHTML = `${linked}<div class="meta">${escapeHtml(meta)}${new Date().toLocaleTimeString('de-DE')}</div>`;
chatBox.appendChild(el); chatBox.appendChild(el);
chatBox.scrollTop = chatBox.scrollHeight; chatBox.scrollTop = chatBox.scrollHeight;
+35
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;
} }
@@ -454,6 +462,11 @@ function connectRVS(forcePlain) {
pipelineEnd(true, `Antwort via RVS von ${sender}: "${(msg.payload.text || "").slice(0, 120)}"`); pipelineEnd(true, `Antwort via RVS von ${sender}: "${(msg.payload.text || "").slice(0, 120)}"`);
} }
broadcast({ type: "rvs_chat", msg }); broadcast({ type: "rvs_chat", msg });
} else if (msg.type === "stt_result" && msg.payload) {
const text = msg.payload.text || "(nicht erkannt)";
log("info", "rvs", `STT: "${text.slice(0, 100)}"`);
// Im Chat als User-Nachricht anzeigen (zur Info, wurde schon an ARIA gesendet)
broadcast({ type: "rvs_chat", msg: { type: "chat", payload: { text: `\uD83C\uDFA4 ${text}`, sender: "user" } } });
} else if (msg.type === "heartbeat") { } else if (msg.type === "heartbeat") {
// ignorieren // ignorieren
} else { } else {
@@ -937,6 +950,28 @@ const server = http.createServer((req, res) => {
} else if (req.url === "/api/session") { } else if (req.url === "/api/session") {
res.writeHead(200, { "Content-Type": "application/json" }); res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ sessionKey: activeSessionKey })); res.end(JSON.stringify({ sessionKey: activeSessionKey }));
} else if (req.url.startsWith("/shared/")) {
// Dateien aus Shared Volume ausliefern (Bilder, Uploads)
const filePath = decodeURIComponent(req.url);
const safePath = path.resolve(filePath);
if (!safePath.startsWith("/shared/")) {
res.writeHead(403);
res.end("Forbidden");
return;
}
try {
if (!fs.existsSync(safePath)) { res.writeHead(404); res.end("Not Found"); return; }
const ext = path.extname(safePath).toLowerCase();
const mimeTypes = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif",
".pdf": "application/pdf", ".txt": "text/plain", ".json": "application/json" };
const contentType = mimeTypes[ext] || "application/octet-stream";
const data = fs.readFileSync(safePath);
res.writeHead(200, { "Content-Type": contentType, "Content-Length": data.length });
res.end(data);
} catch (err) {
res.writeHead(500);
res.end("Error");
}
} else { } else {
res.writeHead(404); res.writeHead(404);
res.end("Not Found"); res.end("Not Found");
+4
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
@@ -97,6 +99,7 @@ services:
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
- ./aria-data/config/diag-state:/data # Persistenter State (aktive Session etc.) - ./aria-data/config/diag-state:/data # Persistenter State (aktive Session etc.)
- aria-shared:/shared:ro # Shared Volume (Uploads lesen fuer Vorschau)
environment: environment:
- ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-} - ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-}
- PROXY_URL=http://proxy:3456 - PROXY_URL=http://proxy:3456
@@ -110,6 +113,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: