Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75752eefc0 | |||
| fbdd4274ac | |||
| 867b03aa1e | |||
| 457b469c96 | |||
| 94691f12ab | |||
| 5c8d11824e | |||
| db053c2dbd | |||
| 8c1dac86d5 | |||
| 8fb95b884f |
@@ -271,9 +271,13 @@ Die Bridge verbindet die Android App mit ARIA und bietet lokale Sprachverarbeitu
|
||||
|
||||
**Nachrichtenfluss:**
|
||||
```
|
||||
App → RVS → Bridge → aria-core
|
||||
aria-core → Bridge → RVS → App
|
||||
→ Lautsprecher (TTS)
|
||||
Text: App → RVS → Bridge → chat.send → aria-core
|
||||
Audio: App → RVS → Bridge → FFmpeg → Whisper STT → chat.send → aria-core
|
||||
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
|
||||
@@ -335,9 +339,11 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session`
|
||||
- Text-Chat mit ARIA
|
||||
- **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
|
||||
- **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)
|
||||
- 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)
|
||||
- QR-Code Scanner fuer Token-Pairing
|
||||
|
||||
@@ -381,15 +387,47 @@ GITEA_REPO=stefan/aria-agent
|
||||
GITEA_USER=stefan
|
||||
```
|
||||
|
||||
### Audio-Pipeline
|
||||
### Audio-Pipeline (Spracheingabe)
|
||||
|
||||
```
|
||||
App (Mikrofon) → AAC/MP4 Aufnahme → Base64 → RVS → Bridge
|
||||
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
|
||||
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/
|
||||
@@ -453,6 +491,8 @@ docker compose up -d
|
||||
| `./aria-data/ssh` (bind) | `/root/.ssh`, `/home/node/.ssh` | SSH Keys |
|
||||
| `./aria-data/brain` (bind) | `/home/node/.openclaw/workspace/memory` | Gedaechtnis |
|
||||
| `./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).
|
||||
- **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.
|
||||
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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -80,7 +80,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
versionName "0.0.1.6"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
File diff suppressed because one or more lines are too long
Binary file not shown.
BIN
Binary file not shown.
+1
-1
@@ -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
|
||||
path.2=classes2.dex
|
||||
base.1=/home/duffy/Dokumente/programmierung/ARIA-AGENT/android/android/app/build/intermediates/global_synthetics_dex/release/classes.dex
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.1.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import DocumentPicker, {
|
||||
DocumentPickerResponse,
|
||||
} from 'react-native-document-picker';
|
||||
import RNFS from 'react-native-fs';
|
||||
|
||||
// --- Typen ---
|
||||
|
||||
@@ -74,15 +75,17 @@ const FileUpload: React.FC<FileUploadProps> = ({ onFileSelected, onCancel }) =>
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// In Produktion: Datei lesen und zu Base64 konvertieren
|
||||
// const base64 = await RNFS.readFile(selectedFile.fileCopyUri || selectedFile.uri, 'base64');
|
||||
const base64Placeholder = '';
|
||||
// Datei lesen und zu Base64 konvertieren
|
||||
const filePath = selectedFile.fileCopyUri || selectedFile.uri;
|
||||
// URI-Schema entfernen fuer RNFS (file:// → absoluter Pfad)
|
||||
const cleanPath = filePath.replace('file://', '');
|
||||
const base64 = await RNFS.readFile(cleanPath, 'base64');
|
||||
|
||||
const fileData: FileData = {
|
||||
name: selectedFile.name || 'unbenannt',
|
||||
type: selectedFile.type || 'application/octet-stream',
|
||||
size: selectedFile.size || 0,
|
||||
base64: base64Placeholder,
|
||||
base64,
|
||||
uri: selectedFile.uri,
|
||||
};
|
||||
|
||||
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Image,
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import RNFS from 'react-native-fs';
|
||||
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
||||
import audioService from '../services/audio';
|
||||
import wakeWordService from '../services/wakeword';
|
||||
@@ -33,6 +35,9 @@ interface Attachment {
|
||||
type: 'image' | 'file' | 'audio';
|
||||
name: string;
|
||||
size?: number;
|
||||
uri?: string; // Lokaler Pfad (file://) fuer Anzeige
|
||||
mimeType?: string;
|
||||
serverPath?: string; // Pfad auf dem Server (/shared/uploads/...) fuer Re-Download
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
@@ -47,6 +52,33 @@ interface ChatMessage {
|
||||
|
||||
const CHAT_STORAGE_KEY = 'aria_chat_messages';
|
||||
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 ---
|
||||
|
||||
@@ -69,33 +101,110 @@ const ChatScreen: React.FC = () => {
|
||||
};
|
||||
|
||||
// Chat-Verlauf aus AsyncStorage laden
|
||||
const isInitialLoad = useRef(true);
|
||||
useEffect(() => {
|
||||
const loadMessages = async () => {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(CHAT_STORAGE_KEY);
|
||||
console.log('[Chat] AsyncStorage geladen:', stored ? `${stored.length} Bytes` : 'leer');
|
||||
if (stored) {
|
||||
const parsed: ChatMessage[] = JSON.parse(stored);
|
||||
setMessages(parsed);
|
||||
// ID-Counter auf hoechsten Wert setzen um Kollisionen zu vermeiden
|
||||
const maxId = parsed.reduce((max, msg) => {
|
||||
const num = parseInt(msg.id.split('_').pop() || '0', 10);
|
||||
return num > max ? num : max;
|
||||
}, 0);
|
||||
messageIdCounter.current = maxId;
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
console.log('[Chat] ${parsed.length} Nachrichten geladen');
|
||||
setMessages(parsed);
|
||||
const maxId = parsed.reduce((max, msg) => {
|
||||
const num = parseInt(msg.id.split('_').pop() || '0', 10);
|
||||
return num > max ? num : max;
|
||||
}, 0);
|
||||
messageIdCounter.current = maxId;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Chat] Fehler beim Laden des Verlaufs:', err);
|
||||
} finally {
|
||||
isInitialLoad.current = false;
|
||||
}
|
||||
};
|
||||
loadMessages();
|
||||
loadMessages().then(async () => {
|
||||
// Auto-Re-Download: fehlende Anhänge vom Server nachladen (wenn aktiviert)
|
||||
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
|
||||
useEffect(() => {
|
||||
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') {
|
||||
// Nur Nachrichten von ARIA anzeigen — eigene Nachrichten werden lokal hinzugefuegt
|
||||
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;
|
||||
|
||||
const text = (message.payload.text as string) || '';
|
||||
@@ -159,7 +268,7 @@ const ChatScreen: React.FC = () => {
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
sender: 'user',
|
||||
text: '[Sprachnachricht]',
|
||||
text: '🎙 Spracheingabe wird verarbeitet...',
|
||||
timestamp: Date.now(),
|
||||
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
|
||||
};
|
||||
@@ -192,23 +301,52 @@ const ChatScreen: React.FC = () => {
|
||||
}
|
||||
}, [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(() => {
|
||||
if (messages.length === 0) return;
|
||||
const toStore = messages.slice(-MAX_STORED_MESSAGES);
|
||||
AsyncStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(toStore)).catch(err =>
|
||||
console.error('[Chat] Fehler beim Speichern:', err),
|
||||
);
|
||||
if (messages.length === 0 || isInitialLoad.current) return;
|
||||
// Debounce: 1s warten damit persistAttachment fertig werden kann
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
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]);
|
||||
|
||||
// Auto-Scroll bei neuen Nachrichten
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
setTimeout(() => {
|
||||
flatListRef.current?.scrollToEnd({ animated: true });
|
||||
}, 100);
|
||||
// Auto-Scroll wird ueber onContentSizeChange der FlatList gesteuert
|
||||
const shouldAutoScroll = useRef(true);
|
||||
const handleContentSizeChange = useCallback(() => {
|
||||
if (shouldAutoScroll.current) {
|
||||
flatListRef.current?.scrollToEnd({ animated: false });
|
||||
}
|
||||
}, [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)
|
||||
const getCurrentLocation = useCallback((): Promise<{ lat: number; lon: number } | null> => {
|
||||
@@ -262,9 +400,8 @@ const ChatScreen: React.FC = () => {
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
sender: 'user',
|
||||
text: '[Sprachnachricht]',
|
||||
text: '🎙 Spracheingabe wird verarbeitet...',
|
||||
timestamp: Date.now(),
|
||||
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
|
||||
};
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
|
||||
@@ -281,15 +418,34 @@ const ChatScreen: React.FC = () => {
|
||||
setShowFileUpload(false);
|
||||
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 = {
|
||||
id: nextId(),
|
||||
id: msgId,
|
||||
sender: 'user',
|
||||
text: `[Datei: ${file.name}]`,
|
||||
text: 'Anhang empfangen',
|
||||
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]);
|
||||
|
||||
// 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', {
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
@@ -304,15 +460,32 @@ const ChatScreen: React.FC = () => {
|
||||
setShowCameraUpload(false);
|
||||
const location = await getCurrentLocation();
|
||||
|
||||
const msgId = nextId();
|
||||
const dataUri = photo.base64 ? `data:${photo.type};base64,${photo.base64}` : undefined;
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
id: msgId,
|
||||
sender: 'user',
|
||||
text: `[Foto: ${photo.fileName}]`,
|
||||
text: 'Anhang empfangen',
|
||||
timestamp: Date.now(),
|
||||
attachments: [{ type: 'image', name: photo.fileName }],
|
||||
attachments: [{
|
||||
type: 'image',
|
||||
name: photo.fileName,
|
||||
uri: dataUri,
|
||||
mimeType: photo.type,
|
||||
}],
|
||||
};
|
||||
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', {
|
||||
name: photo.fileName,
|
||||
type: photo.type,
|
||||
@@ -334,16 +507,64 @@ const ChatScreen: React.FC = () => {
|
||||
|
||||
return (
|
||||
<View style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble]}>
|
||||
<Text style={[styles.messageText, isUser ? styles.userText : styles.ariaText]}>
|
||||
{item.text}
|
||||
</Text>
|
||||
{/* Anhang-Vorschau */}
|
||||
{item.attachments?.map((att, idx) => (
|
||||
<View key={idx} style={styles.attachmentBadge}>
|
||||
<Text style={styles.attachmentText}>
|
||||
{att.type === 'image' ? '\uD83D\uDDBC\uFE0F' : att.type === 'audio' ? '\uD83C\uDFA4' : '\uD83D\uDCC4'} {att.name}
|
||||
</Text>
|
||||
<View key={idx}>
|
||||
{att.type === 'image' && att.uri ? (
|
||||
<Image
|
||||
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>
|
||||
))}
|
||||
{/* 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>
|
||||
</View>
|
||||
);
|
||||
@@ -376,6 +597,9 @@ const ChatScreen: React.FC = () => {
|
||||
renderItem={renderMessage}
|
||||
contentContainerStyle={styles.messageList}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
onScrollEndDrag={handleScrollEndDrag}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyIcon}>{'\uD83E\uDD16'}</Text>
|
||||
@@ -517,17 +741,34 @@ const styles = StyleSheet.create({
|
||||
ariaText: {
|
||||
color: '#E0E0F0',
|
||||
},
|
||||
attachmentBadge: {
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
marginTop: 6,
|
||||
alignSelf: 'flex-start',
|
||||
attachmentImage: {
|
||||
width: '100%',
|
||||
height: 200,
|
||||
borderRadius: 8,
|
||||
marginBottom: 6,
|
||||
backgroundColor: '#0D0D1A',
|
||||
},
|
||||
attachmentText: {
|
||||
color: '#CCCCDD',
|
||||
fontSize: 12,
|
||||
attachmentFile: {
|
||||
flexDirection: 'row',
|
||||
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: {
|
||||
color: 'rgba(255,255,255,0.4)',
|
||||
|
||||
@@ -16,10 +16,16 @@ import {
|
||||
Alert,
|
||||
Platform,
|
||||
} 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 ModeSelector from '../components/ModeSelector';
|
||||
import QRScanner from '../components/QRScanner';
|
||||
|
||||
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
|
||||
const DEFAULT_STORAGE_PATH = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
||||
|
||||
// --- Typen ---
|
||||
|
||||
interface LogEntry {
|
||||
@@ -62,6 +68,11 @@ const SettingsScreen: React.FC = () => {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [events, setEvents] = useState<EventEntry[]>([]);
|
||||
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;
|
||||
|
||||
@@ -73,8 +84,107 @@ const SettingsScreen: React.FC = () => {
|
||||
setManualPort(String(config.port));
|
||||
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
|
||||
useEffect(() => {
|
||||
const unsubState = rvs.onStateChange(setConnectionState);
|
||||
@@ -332,6 +442,81 @@ const SettingsScreen: React.FC = () => {
|
||||
</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 === */}
|
||||
<Text style={styles.sectionTitle}>Protokoll</Text>
|
||||
<View style={styles.card}>
|
||||
@@ -416,7 +601,7 @@ const SettingsScreen: React.FC = () => {
|
||||
<Text style={styles.sectionTitle}>{'\u00DC'}ber</Text>
|
||||
<View style={styles.card}>
|
||||
<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}>
|
||||
Stefans Kommandozentrale f{'\u00FC'}r ARIA.{'\n'}
|
||||
Gebaut mit React Native + TypeScript.
|
||||
@@ -559,6 +744,18 @@ const styles = StyleSheet.create({
|
||||
marginTop: 2,
|
||||
},
|
||||
|
||||
// Speicher
|
||||
storagePathText: {
|
||||
color: '#0096FF',
|
||||
fontSize: 12,
|
||||
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||
},
|
||||
storageSizeText: {
|
||||
color: '#8888AA',
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
|
||||
// Logs
|
||||
tabRow: {
|
||||
flexDirection: 'row',
|
||||
|
||||
+112
-8
@@ -896,7 +896,7 @@ class ARIABridge:
|
||||
"""Sendet Heartbeats an den RVS damit die Verbindung offen bleibt."""
|
||||
while True:
|
||||
await asyncio.sleep(25)
|
||||
if self.ws_rvs and self.ws_rvs.open:
|
||||
if self.ws_rvs:
|
||||
try:
|
||||
await self.ws_rvs.send(json.dumps({
|
||||
"type": "heartbeat",
|
||||
@@ -925,7 +925,10 @@ class ARIABridge:
|
||||
payload = message.get("payload", {})
|
||||
|
||||
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", "")
|
||||
if text:
|
||||
logger.info("[rvs] App-Chat: '%s'", text[:80])
|
||||
@@ -954,10 +957,97 @@ class ARIABridge:
|
||||
await self.ws_core.send(raw_message)
|
||||
|
||||
elif msg_type == "file":
|
||||
# Datei von der App → an aria-core
|
||||
logger.info("[rvs] Datei empfangen: %s", payload.get("name", "?"))
|
||||
if self.ws_core:
|
||||
await self.ws_core.send(raw_message)
|
||||
# Datei von der App → als Text-Nachricht an aria-core
|
||||
file_name = payload.get("name", "unbekannt")
|
||||
file_type = payload.get("type", "")
|
||||
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":
|
||||
# Audio von der App → decodieren → STT → an aria-core
|
||||
@@ -1017,7 +1107,21 @@ class ARIABridge:
|
||||
|
||||
if text.strip():
|
||||
logger.info("[rvs] STT Ergebnis: '%s'", text[:80])
|
||||
# ERST an aria-core senden (wichtigster Schritt)
|
||||
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:
|
||||
logger.info("[rvs] Keine Sprache erkannt — ignoriert")
|
||||
|
||||
@@ -1034,13 +1138,13 @@ class ARIABridge:
|
||||
|
||||
async def _send_to_rvs(self, message: dict) -> None:
|
||||
"""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
|
||||
|
||||
try:
|
||||
await self.ws_rvs.send(json.dumps(message))
|
||||
except Exception:
|
||||
logger.exception("[rvs] Sendefehler")
|
||||
logger.warning("[rvs] Sendefehler — RVS nicht erreichbar")
|
||||
|
||||
# ── Log-Streaming an die App ─────────────────────────────
|
||||
|
||||
|
||||
+73
-8
@@ -196,7 +196,10 @@
|
||||
<!-- Chat Test -->
|
||||
<div class="grid">
|
||||
<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="input-row">
|
||||
<input type="text" id="chat-input" placeholder="Nachricht an ARIA...">
|
||||
@@ -206,6 +209,20 @@
|
||||
</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 -->
|
||||
<div class="grid" style="grid-template-columns: 1fr 1fr;">
|
||||
<div class="card">
|
||||
@@ -501,7 +518,10 @@
|
||||
}
|
||||
if (msg.type === 'rvs_chat') {
|
||||
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;
|
||||
}
|
||||
if (msg.type === 'proxy_result') {
|
||||
@@ -854,15 +874,60 @@
|
||||
}
|
||||
|
||||
function addChat(type, text, meta) {
|
||||
const el = document.createElement('div');
|
||||
el.className = `chat-msg ${type}`;
|
||||
const escaped = escapeHtml(text);
|
||||
const linked = linkifyText(escaped);
|
||||
el.innerHTML = `${linked}<div class="meta">${escapeHtml(meta)} — ${new Date().toLocaleTimeString('de-DE')}</div>`;
|
||||
chatBox.appendChild(el);
|
||||
chatBox.scrollTop = chatBox.scrollHeight;
|
||||
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'">`;
|
||||
});
|
||||
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) {
|
||||
const lb = document.getElementById('lightbox');
|
||||
if (mediaType === 'video') {
|
||||
|
||||
@@ -338,6 +338,14 @@ function handleGatewayMessage(msg) {
|
||||
log("info", "gateway", `ANTWORT: "${text.slice(0, 200)}"`);
|
||||
if (pipelineActive) pipelineEnd(true, `"${text.slice(0, 120)}"`);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -937,6 +945,28 @@ const server = http.createServer((req, res) => {
|
||||
} else if (req.url === "/api/session") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
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 {
|
||||
res.writeHead(404);
|
||||
res.end("Not Found");
|
||||
|
||||
@@ -58,6 +58,7 @@ services:
|
||||
- ./aria-data/ssh:/home/node/.ssh # SSH Keys fuer VM-Zugriff
|
||||
- /tmp/.X11-unix:/tmp/.X11-unix
|
||||
- /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
|
||||
networks:
|
||||
- aria-net
|
||||
@@ -72,6 +73,7 @@ services:
|
||||
volumes:
|
||||
- ./aria-data/voices:/voices:ro # TTS Stimmen
|
||||
- ./aria-data/config/aria.env:/config/aria.env
|
||||
- aria-shared:/shared # Shared Volume fuer Datei-Austausch (Bridge <> Core)
|
||||
# Audio-Zugriff
|
||||
- /run/user/1000/pulse:/run/user/1000/pulse
|
||||
- /dev/snd:/dev/snd
|
||||
@@ -97,6 +99,7 @@ services:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./aria-data/config/diag-state:/data # Persistenter State (aktive Session etc.)
|
||||
- aria-shared:/shared:ro # Shared Volume (Uploads lesen fuer Vorschau)
|
||||
environment:
|
||||
- ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-}
|
||||
- PROXY_URL=http://proxy:3456
|
||||
@@ -110,6 +113,7 @@ services:
|
||||
volumes:
|
||||
openclaw-config: # Persistiert ~/.openclaw (Model, Auth, Sessions)
|
||||
claude-config: # Persistiert ~/.claude (Permissions, Settings)
|
||||
aria-shared: # Datei-Austausch zwischen Bridge und Core
|
||||
|
||||
networks:
|
||||
aria-net:
|
||||
|
||||
+31
-5
@@ -51,8 +51,30 @@ fi
|
||||
echo -e " ${GREEN}✓${NC} Login erfolgreich"
|
||||
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 ─────────────────────────────────
|
||||
echo -e "${GREEN}[1/4] APK bauen...${NC}"
|
||||
echo -e "${GREEN}[2/5] APK bauen...${NC}"
|
||||
cd android
|
||||
./build.sh release
|
||||
cd ..
|
||||
@@ -70,7 +92,11 @@ echo -e " ${GREEN}✓${NC} APK gebaut ($APK_SIZE)"
|
||||
echo ""
|
||||
|
||||
# ── 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
|
||||
echo -e " ${YELLOW}Tag $TAG existiert bereits — überspringe${NC}"
|
||||
@@ -79,7 +105,7 @@ else
|
||||
echo -e " ${GREEN}✓${NC} Tag $TAG erstellt"
|
||||
fi
|
||||
|
||||
git push origin "$TAG"
|
||||
git push origin main "$TAG"
|
||||
echo -e " ${GREEN}✓${NC} Tag gepusht"
|
||||
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')
|
||||
|
||||
# ── 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 \
|
||||
"$GITEA_URL/api/v1/repos/$GITEA_REPO/releases" \
|
||||
@@ -127,7 +153,7 @@ echo -e " ${GREEN}✓${NC} Release #$RELEASE_ID erstellt"
|
||||
echo ""
|
||||
|
||||
# ── APK hochladen ─────────────────────────────
|
||||
echo -e "${GREEN}[4/4] APK hochladen...${NC}"
|
||||
echo -e "${GREEN}[5/5] APK hochladen...${NC}"
|
||||
|
||||
UPLOAD_RESPONSE=$(curl -s -X POST \
|
||||
"$GITEA_URL/api/v1/repos/$GITEA_REPO/releases/$RELEASE_ID/assets?name=$APK_NAME" \
|
||||
|
||||
@@ -9,6 +9,7 @@ const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "10", 10);
|
||||
// Erlaubte Nachrichtentypen — alles andere wird verworfen
|
||||
const ALLOWED_TYPES = new Set([
|
||||
"chat", "audio", "file", "location", "mode", "log", "event", "heartbeat",
|
||||
"file_request", "file_response", "file_saved", "stt_result",
|
||||
]);
|
||||
|
||||
// Token-Raum: token -> { clients: Set<ws> }
|
||||
|
||||
Reference in New Issue
Block a user