Compare commits

..

8 Commits

23 changed files with 776 additions and 95 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.
--- ---
+1 -1
View File
@@ -80,7 +80,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 1
versionName "1.0" versionName "0.0.1.6"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
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 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
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.1.0", "version": "0.0.1.6",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+286 -47
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,36 +101,110 @@ 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().then(async () => {
// Auto-Scroll nach Laden des Verlaufs // Auto-Re-Download: fehlende Anhänge vom Server nachladen (wenn aktiviert)
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: false }), 200); const autoDownload = await AsyncStorage.getItem('aria_auto_download');
if (autoDownload === 'false') return;
setTimeout(() => {
setMessages(prev => {
const missing: {id: string, serverPath: string}[] = [];
for (const msg of prev) {
for (const att of msg.attachments || []) {
if (att.serverPath && !att.uri) {
missing.push({ id: msg.id, serverPath: att.serverPath });
}
}
}
if (missing.length > 0) {
console.log(`[Chat] ${missing.length} fehlende Anhaenge — lade nach...`);
for (const m of missing) {
rvs.send('file_request' as any, { serverPath: m.serverPath, requestId: m.id });
}
}
return prev;
});
}, 2000); // Warten bis RVS verbunden ist
}); });
}, []); }, []);
// 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;
}
if (message.type === 'chat') { if (message.type === 'chat') {
// Nur Nachrichten von ARIA anzeigen — eigene Nachrichten werden lokal hinzugefuegt
const sender = (message.payload.sender as string) || ''; const sender = (message.payload.sender as string) || '';
// STT-Ergebnis: Transkribierten Text in die Sprach-Bubble schreiben
if (sender === 'stt') {
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: `\uD83C\uDFA4 ${sttText}` }
: m
));
}
return;
}
// Eigene Nachrichten ignorieren (werden lokal hinzugefuegt)
if (sender === 'user' || sender === 'diagnostic') return; if (sender === 'user' || sender === 'diagnostic') return;
const text = (message.payload.text as string) || ''; const text = (message.payload.text as string) || '';
@@ -195,23 +301,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> => {
@@ -283,15 +418,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 +460,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 +507,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 +597,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 +741,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)',
+198 -1
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,11 @@ 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 [autoDownload, setAutoDownload] = useState(true);
const [storageSize, setStorageSize] = useState('...');
const [editingPath, setEditingPath] = useState(false);
const [tempPath, setTempPath] = useState('');
let logIdCounter = 0; let logIdCounter = 0;
@@ -73,8 +84,107 @@ const SettingsScreen: React.FC = () => {
setManualPort(String(config.port)); setManualPort(String(config.port));
setManualToken(config.token); setManualToken(config.token);
} }
// Speicherpfad + Auto-Download laden
AsyncStorage.getItem(STORAGE_PATH_KEY).then(saved => {
if (saved) setStoragePath(saved);
});
AsyncStorage.getItem('aria_auto_download').then(saved => {
if (saved !== null) setAutoDownload(saved === 'true');
});
}, []); }, []);
// 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 +442,81 @@ const SettingsScreen: React.FC = () => {
</View> </View>
</View> </View>
{/* === Speicher === */}
<Text style={styles.sectionTitle}>Anhang-Speicher</Text>
<View style={styles.card}>
<View style={styles.toggleRow}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Auto-Download</Text>
<Text style={styles.toggleHint}>
Fehlende Anhaenge beim App-Start automatisch vom Server laden
</Text>
</View>
<Switch
value={autoDownload}
onValueChange={(val) => {
setAutoDownload(val);
AsyncStorage.setItem('aria_auto_download', String(val));
}}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={autoDownload ? '#FFFFFF' : '#666680'}
/>
</View>
<View style={{height: 16}} />
<Text style={styles.toggleLabel}>Lokaler Speicherort</Text>
<Text style={styles.toggleHint}>
Hier werden Bilder und Dateien aus dem Chat gespeichert.
{autoDownload ? ' Fehlende Dateien werden automatisch nachgeladen.' : ' Fehlende Dateien koennen per Tippen geladen werden.'}
</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}>
@@ -416,7 +601,7 @@ const SettingsScreen: React.FC = () => {
<Text style={styles.sectionTitle}>{'\u00DC'}ber</Text> <Text style={styles.sectionTitle}>{'\u00DC'}ber</Text>
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.aboutTitle}>ARIA Cockpit</Text> <Text style={styles.aboutTitle}>ARIA Cockpit</Text>
<Text style={styles.aboutVersion}>Version 0.1.0 (Alpha)</Text> <Text style={styles.aboutVersion}>Version 0.0.1.6 </Text>
<Text style={styles.aboutInfo}> <Text style={styles.aboutInfo}>
Stefans Kommandozentrale f{'\u00FC'}r ARIA.{'\n'} Stefans Kommandozentrale f{'\u00FC'}r ARIA.{'\n'}
Gebaut mit React Native + TypeScript. Gebaut mit React Native + TypeScript.
@@ -559,6 +744,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',
+95 -21
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",
@@ -925,7 +925,10 @@ class ARIABridge:
payload = message.get("payload", {}) payload = message.get("payload", {})
if msg_type == "chat": if msg_type == "chat":
# Text von der App → an aria-core # Nur User-Nachrichten weiterleiten — ARIA/Diagnostic-Antworten ignorieren (sonst Loop!)
sender = payload.get("sender", "")
if sender in ("aria", "diagnostic", "stt"):
return
text = payload.get("text", "") text = payload.get("text", "")
if text: if text:
logger.info("[rvs] App-Chat: '%s'", text[:80]) logger.info("[rvs] App-Chat: '%s'", text[:80])
@@ -964,31 +967,88 @@ 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.") # 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") 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: 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}]") # 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") 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: 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,7 +1107,21 @@ class ARIABridge:
if text.strip(): if text.strip():
logger.info("[rvs] STT Ergebnis: '%s'", text[:80]) logger.info("[rvs] STT Ergebnis: '%s'", text[:80])
# ERST an aria-core senden (wichtigster Schritt)
await self.send_to_core(text, source="app-voice") await self.send_to_core(text, source="app-voice")
# STT-Text an RVS senden (fuer Anzeige in App + Diagnostic)
# sender="stt" damit Bridge es ignoriert (kein Loop)
try:
await self._send_to_rvs({
"type": "chat",
"payload": {
"text": text,
"sender": "stt",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] STT-Text konnte nicht an RVS gesendet werden: %s", e)
else: else:
logger.info("[rvs] Keine Sprache erkannt — ignoriert") logger.info("[rvs] Keine Sprache erkannt — ignoriert")
@@ -1064,13 +1138,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 ─────────────────────────────
+73 -8
View File
@@ -196,7 +196,10 @@
<!-- Chat Test --> <!-- Chat Test -->
<div class="grid"> <div class="grid">
<div class="card full"> <div class="card full">
<h2>Chat Test</h2> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<h2 style="margin:0;">Chat Test</h2>
<button class="btn secondary" onclick="toggleChatFullscreen()" id="btn-chat-fs" style="padding:4px 10px;font-size:11px;">Vollbild</button>
</div>
<div class="chat-box" id="chat-box"></div> <div class="chat-box" id="chat-box"></div>
<div class="input-row"> <div class="input-row">
<input type="text" id="chat-input" placeholder="Nachricht an ARIA..."> <input type="text" id="chat-input" placeholder="Nachricht an ARIA...">
@@ -206,6 +209,20 @@
</div> </div>
</div> </div>
<!-- Chat Vollbild Modal -->
<div id="chat-fullscreen" style="display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;background:#0D0D1A;z-index:1000;padding:16px;flex-direction:column;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
<h2 style="margin:0;color:#0096FF;">ARIA Chat</h2>
<button class="btn secondary" onclick="toggleChatFullscreen()" style="padding:6px 14px;">Schliessen</button>
</div>
<div id="chat-box-fs" class="chat-box" style="flex:1;max-height:none;min-height:0;overflow-y:auto;"></div>
<div class="input-row" style="margin-top:8px;">
<input type="text" id="chat-input-fs" placeholder="Nachricht an ARIA..." onkeydown="if(event.key==='Enter'){testGatewayFS();event.preventDefault();}">
<button class="btn" onclick="testGatewayFS()">Gateway senden</button>
<button class="btn" onclick="testRVSFS()">Via RVS senden</button>
</div>
</div>
<!-- Session + Brain Viewer --> <!-- Session + Brain Viewer -->
<div class="grid" style="grid-template-columns: 1fr 1fr;"> <div class="grid" style="grid-template-columns: 1fr 1fr;">
<div class="card"> <div class="card">
@@ -501,7 +518,10 @@
} }
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';
const label = sender === 'stt' ? '\uD83C\uDFA4 Spracheingabe' : `via RVS (${sender})`;
addChat(chatType, p.text || '?', label);
return; return;
} }
if (msg.type === 'proxy_result') { if (msg.type === 'proxy_result') {
@@ -854,15 +874,60 @@
} }
function addChat(type, text, meta) { function addChat(type, text, meta) {
const el = document.createElement('div');
el.className = `chat-msg ${type}`;
const escaped = escapeHtml(text); const escaped = escapeHtml(text);
const linked = linkifyText(escaped); let linked = linkifyText(escaped);
el.innerHTML = `${linked}<div class="meta">${escapeHtml(meta)}${new Date().toLocaleTimeString('de-DE')}</div>`; // /shared/uploads/ Pfade als Inline-Bilder anzeigen
chatBox.appendChild(el); linked = linked.replace(/\/shared\/uploads\/[^\s<"]+\.(jpg|jpeg|png|gif)/gi, (match) => {
chatBox.scrollTop = chatBox.scrollHeight; return `<a href="${match}" target="_blank">${match}</a><img src="${match}" class="chat-media" onclick="openLightbox('image','${match}')" onerror="this.style.display='none'">`;
});
const html = `${linked}<div class="meta">${escapeHtml(meta)}${new Date().toLocaleTimeString('de-DE')}</div>`;
// In beide Chat-Boxen schreiben (normal + Vollbild)
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
if (!box) continue;
const el = document.createElement('div');
el.className = `chat-msg ${type}`;
el.innerHTML = html;
box.appendChild(el);
box.scrollTop = box.scrollHeight;
}
} }
let chatFullscreen = false;
function toggleChatFullscreen() {
const modal = document.getElementById('chat-fullscreen');
chatFullscreen = !chatFullscreen;
if (chatFullscreen) {
modal.style.display = 'flex';
// Chat-Inhalt synchronisieren
const fsBox = document.getElementById('chat-box-fs');
fsBox.innerHTML = chatBox.innerHTML;
fsBox.scrollTop = fsBox.scrollHeight;
document.getElementById('chat-input-fs').focus();
} else {
modal.style.display = 'none';
}
}
function testGatewayFS() {
const input = document.getElementById('chat-input-fs');
const text = input.value.trim();
if (!text) return;
addChat('sent', text, 'Gateway direkt');
send({ action: 'test_gateway', text });
input.value = '';
}
function testRVSFS() {
const input = document.getElementById('chat-input-fs');
const text = input.value.trim();
if (!text) return;
addChat('sent', text, 'via RVS');
send({ action: 'test_rvs', text });
input.value = '';
}
// Escape schliesst Vollbild-Chat
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && chatFullscreen) toggleChatFullscreen();
});
function openLightbox(mediaType, url) { function openLightbox(mediaType, url) {
const lb = document.getElementById('lightbox'); const lb = document.getElementById('lightbox');
if (mediaType === 'video') { if (mediaType === 'video') {
+30
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;
} }
@@ -937,6 +945,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:
+31 -5
View File
@@ -51,8 +51,30 @@ fi
echo -e " ${GREEN}${NC} Login erfolgreich" echo -e " ${GREEN}${NC} Login erfolgreich"
echo "" echo ""
# ── Versionsnummern aktualisieren ─────────────
echo -e "${GREEN}[1/5] Versionsnummern auf $VERSION setzen...${NC}"
# package.json
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" android/package.json
echo -e " ${GREEN}${NC} package.json → $VERSION"
# build.gradle: versionName + versionCode (aus Major.Minor.Patch berechnen)
MAJOR=$(echo "$VERSION" | cut -d. -f1)
MINOR=$(echo "$VERSION" | cut -d. -f2)
PATCH=$(echo "$VERSION" | cut -d. -f3)
VERSION_CODE=$((MAJOR * 10000 + MINOR * 100 + PATCH))
sed -i "s/versionName \"[^\"]*\"/versionName \"$VERSION\"/" android/android/app/build.gradle
sed -i "s/versionCode [0-9]*/versionCode $VERSION_CODE/" android/android/app/build.gradle
echo -e " ${GREEN}${NC} build.gradle → versionName $VERSION, versionCode $VERSION_CODE"
# SettingsScreen: Anzeige-Version
sed -i "s/Version [0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]* [^<]*/Version $VERSION /" android/src/screens/SettingsScreen.tsx
echo -e " ${GREEN}${NC} SettingsScreen → Version $VERSION"
echo ""
# ── APK bauen ───────────────────────────────── # ── APK bauen ─────────────────────────────────
echo -e "${GREEN}[1/4] APK bauen...${NC}" echo -e "${GREEN}[2/5] APK bauen...${NC}"
cd android cd android
./build.sh release ./build.sh release
cd .. cd ..
@@ -70,7 +92,11 @@ echo -e " ${GREEN}✓${NC} APK gebaut ($APK_SIZE)"
echo "" echo ""
# ── Git Tag ─────────────────────────────────── # ── Git Tag ───────────────────────────────────
echo -e "${GREEN}[2/4] Git Tag $TAG...${NC}" echo -e "${GREEN}[3/5] Git Tag $TAG...${NC}"
# Versions-Aenderungen committen
git add android/package.json android/android/app/build.gradle android/src/screens/SettingsScreen.tsx
git commit -m "release: bump version to $VERSION" 2>/dev/null || echo -e " ${YELLOW}Keine Aenderungen zum Committen${NC}"
if git rev-parse "$TAG" &>/dev/null; then if git rev-parse "$TAG" &>/dev/null; then
echo -e " ${YELLOW}Tag $TAG existiert bereits — überspringe${NC}" echo -e " ${YELLOW}Tag $TAG existiert bereits — überspringe${NC}"
@@ -79,7 +105,7 @@ else
echo -e " ${GREEN}${NC} Tag $TAG erstellt" echo -e " ${GREEN}${NC} Tag $TAG erstellt"
fi fi
git push origin "$TAG" git push origin main "$TAG"
echo -e " ${GREEN}${NC} Tag gepusht" echo -e " ${GREEN}${NC} Tag gepusht"
echo "" echo ""
@@ -102,7 +128,7 @@ fi
RELEASE_BODY_ESCAPED=$(printf '%s' "$RELEASE_BODY" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))' 2>/dev/null || printf '"%s"' "$RELEASE_BODY" | sed 's/"/\\"/g') RELEASE_BODY_ESCAPED=$(printf '%s' "$RELEASE_BODY" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))' 2>/dev/null || printf '"%s"' "$RELEASE_BODY" | sed 's/"/\\"/g')
# ── Gitea Release erstellen ─────────────────── # ── Gitea Release erstellen ───────────────────
echo -e "${GREEN}[3/4] Gitea Release erstellen...${NC}" echo -e "${GREEN}[4/5] Gitea Release erstellen...${NC}"
RELEASE_RESPONSE=$(curl -s -X POST \ RELEASE_RESPONSE=$(curl -s -X POST \
"$GITEA_URL/api/v1/repos/$GITEA_REPO/releases" \ "$GITEA_URL/api/v1/repos/$GITEA_REPO/releases" \
@@ -127,7 +153,7 @@ echo -e " ${GREEN}✓${NC} Release #$RELEASE_ID erstellt"
echo "" echo ""
# ── APK hochladen ───────────────────────────── # ── APK hochladen ─────────────────────────────
echo -e "${GREEN}[4/4] APK hochladen...${NC}" echo -e "${GREEN}[5/5] APK hochladen...${NC}"
UPLOAD_RESPONSE=$(curl -s -X POST \ UPLOAD_RESPONSE=$(curl -s -X POST \
"$GITEA_URL/api/v1/repos/$GITEA_REPO/releases/$RELEASE_ID/assets?name=$APK_NAME" \ "$GITEA_URL/api/v1/repos/$GITEA_REPO/releases/$RELEASE_ID/assets?name=$APK_NAME" \
+1
View File
@@ -9,6 +9,7 @@ const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "10", 10);
// Erlaubte Nachrichtentypen — alles andere wird verworfen // Erlaubte Nachrichtentypen — alles andere wird verworfen
const ALLOWED_TYPES = new Set([ const ALLOWED_TYPES = new Set([
"chat", "audio", "file", "location", "mode", "log", "event", "heartbeat", "chat", "audio", "file", "location", "mode", "log", "event", "heartbeat",
"file_request", "file_response", "file_saved", "stt_result",
]); ]);
// Token-Raum: token -> { clients: Set<ws> } // Token-Raum: token -> { clients: Set<ws> }