Compare commits

..

9 Commits

24 changed files with 803 additions and 87 deletions
+52 -7
View File
@@ -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.
---
+1 -1
View File
@@ -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'
}
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
#Sun Mar 29 11:40:22 CEST 2026
#Sun Mar 29 13:21:34 CEST 2026
base.2=/home/duffy/Dokumente/programmierung/ARIA-AGENT/android/android/app/build/intermediates/dex/release/mergeDexRelease/classes2.dex
path.2=classes2.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",
"version": "0.1.0",
"version": "0.0.1.6",
"private": true,
"scripts": {
"android": "react-native run-android",
+7 -4
View File
@@ -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,
};
+289 -48
View File
@@ -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)',
+198 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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') {
+30
View File
@@ -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");
+4
View File
@@ -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
View File
@@ -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" \
+1
View File
@@ -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> }