Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 867b03aa1e | |||
| 457b469c96 | |||
| 94691f12ab | |||
| 5c8d11824e |
@@ -409,6 +409,25 @@ ARIA: Kann Datei per Bash/Read-Tool oeffnen und analysieren
|
|||||||
**Unterstuetzte Formate:** Bilder (JPG, PNG), Dokumente (PDF, DOCX, TXT), beliebige Dateien.
|
**Unterstuetzte Formate:** Bilder (JPG, PNG), Dokumente (PDF, DOCX, TXT), beliebige Dateien.
|
||||||
Bilder werden in der App inline angezeigt, andere Dateien als Icon + Dateiname.
|
Bilder werden in der App inline angezeigt, andere Dateien als Icon + Dateiname.
|
||||||
|
|
||||||
|
**Re-Download:** Wird der lokale Cache in der App geleert (Einstellungen → Anhang-Speicher → Cache leeren),
|
||||||
|
werden fehlende Anhaenge automatisch ueber RVS vom Server neu geladen. Der Speicherort
|
||||||
|
ist in den App-Einstellungen konfigurierbar.
|
||||||
|
|
||||||
|
> **Tipp Speicherplatz:** Das Docker Volume `aria-shared` liegt standardmaessig auf ARIAs VM-Disk.
|
||||||
|
> Bei vielen Uploads kann das den Speicher der VM belasten (dort laufen auch alle Container).
|
||||||
|
> Empfehlung: Das Volume auf ein Netzwerk-Filesystem mounten (CephFS, NFS, GlusterFS):
|
||||||
|
> ```yaml
|
||||||
|
> # docker-compose.yml
|
||||||
|
> volumes:
|
||||||
|
> aria-shared:
|
||||||
|
> driver: local
|
||||||
|
> driver_opts:
|
||||||
|
> type: nfs
|
||||||
|
> o: addr=nas.local,rw
|
||||||
|
> device: ":/exports/aria-uploads"
|
||||||
|
> ```
|
||||||
|
> So bleibt ARIAs VM-Disk sauber und die Uploads liegen auf dediziertem Storage.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Datenverzeichnis — aria-data/
|
## Datenverzeichnis — aria-data/
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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 12:31:28 CEST 2026
|
#Sun Mar 29 13:21:34 CEST 2026
|
||||||
base.2=/home/duffy/Dokumente/programmierung/ARIA-AGENT/android/android/app/build/intermediates/dex/release/mergeDexRelease/classes2.dex
|
base.2=/home/duffy/Dokumente/programmierung/ARIA-AGENT/android/android/app/build/intermediates/dex/release/mergeDexRelease/classes2.dex
|
||||||
path.2=classes2.dex
|
path.2=classes2.dex
|
||||||
base.1=/home/duffy/Dokumente/programmierung/ARIA-AGENT/android/android/app/build/intermediates/global_synthetics_dex/release/classes.dex
|
base.1=/home/duffy/Dokumente/programmierung/ARIA-AGENT/android/android/app/build/intermediates/global_synthetics_dex/release/classes.dex
|
||||||
|
|||||||
+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
@@ -19,6 +19,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import RNFS from 'react-native-fs';
|
||||||
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
||||||
import audioService from '../services/audio';
|
import audioService from '../services/audio';
|
||||||
import wakeWordService from '../services/wakeword';
|
import wakeWordService from '../services/wakeword';
|
||||||
@@ -34,8 +35,9 @@ interface Attachment {
|
|||||||
type: 'image' | 'file' | 'audio';
|
type: 'image' | 'file' | 'audio';
|
||||||
name: string;
|
name: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
uri?: string; // Lokaler Pfad oder data URI fuer Anzeige
|
uri?: string; // Lokaler Pfad (file://) fuer Anzeige
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
|
serverPath?: string; // Pfad auf dem Server (/shared/uploads/...) fuer Re-Download
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
@@ -50,6 +52,33 @@ interface ChatMessage {
|
|||||||
|
|
||||||
const CHAT_STORAGE_KEY = 'aria_chat_messages';
|
const CHAT_STORAGE_KEY = 'aria_chat_messages';
|
||||||
const MAX_STORED_MESSAGES = 500;
|
const MAX_STORED_MESSAGES = 500;
|
||||||
|
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
||||||
|
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
|
||||||
|
|
||||||
|
async function getAttachmentDir(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const saved = await AsyncStorage.getItem(STORAGE_PATH_KEY);
|
||||||
|
return saved || DEFAULT_ATTACHMENT_DIR;
|
||||||
|
} catch { return DEFAULT_ATTACHMENT_DIR; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Speichert Base64-Daten als Datei, gibt file:// Pfad zurueck */
|
||||||
|
async function persistAttachment(base64Data: string, msgId: string, fileName: string): Promise<string> {
|
||||||
|
const cacheDir = await getAttachmentDir();
|
||||||
|
await RNFS.mkdir(cacheDir);
|
||||||
|
// Dateiendung aus originalem Dateinamen oder Fallback
|
||||||
|
const ext = fileName.includes('.') ? fileName.split('.').pop() : 'bin';
|
||||||
|
const safeName = `${msgId}_${fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
|
||||||
|
const filePath = `${cacheDir}/${safeName}`;
|
||||||
|
await RNFS.writeFile(filePath, base64Data, 'base64');
|
||||||
|
return `file://${filePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prueft ob eine lokale Datei noch existiert */
|
||||||
|
async function checkFileExists(uri: string): Promise<boolean> {
|
||||||
|
if (!uri || !uri.startsWith('file://')) return false;
|
||||||
|
return RNFS.exists(uri.replace('file://', ''));
|
||||||
|
}
|
||||||
|
|
||||||
// --- Komponente ---
|
// --- Komponente ---
|
||||||
|
|
||||||
@@ -72,22 +101,28 @@ const ChatScreen: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Chat-Verlauf aus AsyncStorage laden
|
// Chat-Verlauf aus AsyncStorage laden
|
||||||
|
const isInitialLoad = useRef(true);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadMessages = async () => {
|
const loadMessages = async () => {
|
||||||
try {
|
try {
|
||||||
const stored = await AsyncStorage.getItem(CHAT_STORAGE_KEY);
|
const stored = await AsyncStorage.getItem(CHAT_STORAGE_KEY);
|
||||||
|
console.log('[Chat] AsyncStorage geladen:', stored ? `${stored.length} Bytes` : 'leer');
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const parsed: ChatMessage[] = JSON.parse(stored);
|
const parsed: ChatMessage[] = JSON.parse(stored);
|
||||||
setMessages(parsed);
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
// ID-Counter auf hoechsten Wert setzen um Kollisionen zu vermeiden
|
console.log('[Chat] ${parsed.length} Nachrichten geladen');
|
||||||
const maxId = parsed.reduce((max, msg) => {
|
setMessages(parsed);
|
||||||
const num = parseInt(msg.id.split('_').pop() || '0', 10);
|
const maxId = parsed.reduce((max, msg) => {
|
||||||
return num > max ? num : max;
|
const num = parseInt(msg.id.split('_').pop() || '0', 10);
|
||||||
}, 0);
|
return num > max ? num : max;
|
||||||
messageIdCounter.current = maxId;
|
}, 0);
|
||||||
|
messageIdCounter.current = maxId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Chat] Fehler beim Laden des Verlaufs:', err);
|
console.error('[Chat] Fehler beim Laden des Verlaufs:', err);
|
||||||
|
} finally {
|
||||||
|
isInitialLoad.current = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadMessages();
|
loadMessages();
|
||||||
@@ -96,21 +131,48 @@ const ChatScreen: React.FC = () => {
|
|||||||
// RVS-Nachrichten abonnieren
|
// RVS-Nachrichten abonnieren
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubMessage = rvs.onMessage((message: RVSMessage) => {
|
const unsubMessage = rvs.onMessage((message: RVSMessage) => {
|
||||||
// STT-Ergebnis: Spracheingabe-Placeholder mit transkribiertem Text ersetzen
|
// file_saved: Bridge meldet Server-Pfad — in Attachment merken fuer Re-Download
|
||||||
|
if (message.type === 'file_saved') {
|
||||||
|
const serverPath = (message.payload.serverPath as string) || '';
|
||||||
|
const name = (message.payload.name as string) || '';
|
||||||
|
if (serverPath) {
|
||||||
|
setMessages(prev => prev.map(m => ({
|
||||||
|
...m,
|
||||||
|
attachments: m.attachments?.map(a =>
|
||||||
|
a.name === name && !a.serverPath ? { ...a, serverPath } : a
|
||||||
|
),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// file_response: Re-Download von Server — lokal speichern
|
||||||
|
if (message.type === 'file_response') {
|
||||||
|
const reqId = (message.payload.requestId as string) || '';
|
||||||
|
const b64 = (message.payload.base64 as string) || '';
|
||||||
|
const serverPath = (message.payload.serverPath as string) || '';
|
||||||
|
if (b64 && reqId) {
|
||||||
|
const fileName = (message.payload.name as string) || 'download';
|
||||||
|
persistAttachment(b64, reqId, fileName).then(filePath => {
|
||||||
|
setMessages(prev => prev.map(m => ({
|
||||||
|
...m,
|
||||||
|
attachments: m.attachments?.map(a =>
|
||||||
|
a.serverPath === serverPath ? { ...a, uri: filePath } : a
|
||||||
|
),
|
||||||
|
})));
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// STT-Ergebnis: Transkribierten Text unter den Placeholder schreiben
|
||||||
if (message.type === 'stt_result') {
|
if (message.type === 'stt_result') {
|
||||||
const sttText = (message.payload.text as string) || '';
|
const sttText = (message.payload.text as string) || '';
|
||||||
if (sttText) {
|
setMessages(prev => prev.map(m =>
|
||||||
setMessages(prev => prev.map(m =>
|
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
|
||||||
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
|
? { ...m, text: sttText ? `\uD83C\uDFA4 ${sttText}` : '\uD83C\uDFA4 (nicht erkannt)' }
|
||||||
? { ...m, text: sttText }
|
: m
|
||||||
: m
|
));
|
||||||
));
|
|
||||||
} else {
|
|
||||||
// Keine Sprache erkannt — Placeholder entfernen
|
|
||||||
setMessages(prev => prev.filter(m =>
|
|
||||||
!(m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet'))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,13 +275,34 @@ const ChatScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [wakeWordActive]);
|
}, [wakeWordActive]);
|
||||||
|
|
||||||
// Chat-Verlauf in AsyncStorage speichern (letzte N Nachrichten)
|
// Chat-Verlauf in AsyncStorage speichern (debounced, nur nach initialem Laden)
|
||||||
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length === 0) return;
|
if (messages.length === 0 || isInitialLoad.current) return;
|
||||||
const toStore = messages.slice(-MAX_STORED_MESSAGES);
|
// Debounce: 1s warten damit persistAttachment fertig werden kann
|
||||||
AsyncStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(toStore)).catch(err =>
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||||
console.error('[Chat] Fehler beim Speichern:', err),
|
saveTimer.current = setTimeout(() => {
|
||||||
);
|
const toStore = messages.slice(-MAX_STORED_MESSAGES).map(msg => ({
|
||||||
|
...msg,
|
||||||
|
attachments: msg.attachments?.map(att => ({
|
||||||
|
...att,
|
||||||
|
// Nur file:// URIs speichern, data: URIs rausfiltern (zu gross fuer AsyncStorage)
|
||||||
|
uri: att.uri?.startsWith('file://') ? att.uri : undefined,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
const json = JSON.stringify(toStore);
|
||||||
|
// Sicherheitscheck: nicht speichern wenn >4MB (AsyncStorage Limit)
|
||||||
|
if (json.length > 4 * 1024 * 1024) {
|
||||||
|
console.warn('[Chat] Speicher zu gross, kuerze auf 100 Nachrichten');
|
||||||
|
const shortened = JSON.stringify(toStore.slice(-100));
|
||||||
|
AsyncStorage.setItem(CHAT_STORAGE_KEY, shortened).catch(() => {});
|
||||||
|
} else {
|
||||||
|
AsyncStorage.setItem(CHAT_STORAGE_KEY, json).catch(err =>
|
||||||
|
console.error('[Chat] Speichern fehlgeschlagen:', err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
return () => { if (saveTimer.current) clearTimeout(saveTimer.current); };
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
// Auto-Scroll wird ueber onContentSizeChange der FlatList gesteuert
|
// Auto-Scroll wird ueber onContentSizeChange der FlatList gesteuert
|
||||||
@@ -310,8 +393,11 @@ const ChatScreen: React.FC = () => {
|
|||||||
const location = await getCurrentLocation();
|
const location = await getCurrentLocation();
|
||||||
|
|
||||||
const isImage = file.type.startsWith('image/');
|
const isImage = file.type.startsWith('image/');
|
||||||
|
const msgId = nextId();
|
||||||
|
let imageUri = isImage && file.base64 ? `data:${file.type};base64,${file.base64}` : file.uri;
|
||||||
|
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
id: nextId(),
|
id: msgId,
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
text: 'Anhang empfangen',
|
text: 'Anhang empfangen',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -319,12 +405,21 @@ const ChatScreen: React.FC = () => {
|
|||||||
type: isImage ? 'image' : 'file',
|
type: isImage ? 'image' : 'file',
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
uri: isImage && file.base64 ? `data:${file.type};base64,${file.base64}` : file.uri,
|
uri: imageUri,
|
||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
setMessages(prev => [...prev, userMsg]);
|
setMessages(prev => [...prev, userMsg]);
|
||||||
|
|
||||||
|
// Anhang auf Disk speichern fuer Persistenz
|
||||||
|
if (file.base64) {
|
||||||
|
persistAttachment(file.base64, msgId, file.name).then(filePath => {
|
||||||
|
setMessages(prev => prev.map(m =>
|
||||||
|
m.id === msgId ? { ...m, attachments: m.attachments?.map(a => ({ ...a, uri: filePath })) } : m
|
||||||
|
));
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
rvs.send('file', {
|
rvs.send('file', {
|
||||||
name: file.name,
|
name: file.name,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
@@ -339,20 +434,32 @@ const ChatScreen: React.FC = () => {
|
|||||||
setShowCameraUpload(false);
|
setShowCameraUpload(false);
|
||||||
const location = await getCurrentLocation();
|
const location = await getCurrentLocation();
|
||||||
|
|
||||||
|
const msgId = nextId();
|
||||||
|
const dataUri = photo.base64 ? `data:${photo.type};base64,${photo.base64}` : undefined;
|
||||||
|
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
id: nextId(),
|
id: msgId,
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
text: 'Anhang empfangen',
|
text: 'Anhang empfangen',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
attachments: [{
|
attachments: [{
|
||||||
type: 'image',
|
type: 'image',
|
||||||
name: photo.fileName,
|
name: photo.fileName,
|
||||||
uri: photo.base64 ? `data:${photo.type};base64,${photo.base64}` : undefined,
|
uri: dataUri,
|
||||||
mimeType: photo.type,
|
mimeType: photo.type,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
setMessages(prev => [...prev, userMsg]);
|
setMessages(prev => [...prev, userMsg]);
|
||||||
|
|
||||||
|
// Foto auf Disk speichern fuer Persistenz
|
||||||
|
if (photo.base64) {
|
||||||
|
persistAttachment(photo.base64, msgId, photo.fileName).then(filePath => {
|
||||||
|
setMessages(prev => prev.map(m =>
|
||||||
|
m.id === msgId ? { ...m, attachments: m.attachments?.map(a => ({ ...a, uri: filePath })) } : m
|
||||||
|
));
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
rvs.send('file', {
|
rvs.send('file', {
|
||||||
name: photo.fileName,
|
name: photo.fileName,
|
||||||
type: photo.type,
|
type: photo.type,
|
||||||
@@ -382,7 +489,30 @@ const ChatScreen: React.FC = () => {
|
|||||||
source={{ uri: att.uri }}
|
source={{ uri: att.uri }}
|
||||||
style={styles.attachmentImage}
|
style={styles.attachmentImage}
|
||||||
resizeMode="contain"
|
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}>
|
<View style={styles.attachmentFile}>
|
||||||
<Text style={styles.attachmentFileIcon}>
|
<Text style={styles.attachmentFileIcon}>
|
||||||
@@ -393,6 +523,12 @@ const ChatScreen: React.FC = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</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.size ? <Text style={styles.attachmentFileSize}>{Math.round(att.size / 1024)}KB</Text> : null}
|
||||||
|
{!att.uri && att.serverPath && (
|
||||||
|
<TouchableOpacity onPress={() => rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id })}>
|
||||||
|
<Text style={[styles.attachmentFileSize, {color: '#0096FF'}]}>(laden)</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{!att.uri && !att.serverPath && <Text style={styles.attachmentFileSize}>(nicht verfuegbar)</Text>}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -16,10 +16,16 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Platform,
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import RNFS from 'react-native-fs';
|
||||||
|
import DocumentPicker from 'react-native-document-picker';
|
||||||
import rvs, { ConnectionState, RVSMessage, ConnectionConfig, ConnectionLogEntry } from '../services/rvs';
|
import rvs, { ConnectionState, RVSMessage, ConnectionConfig, ConnectionLogEntry } from '../services/rvs';
|
||||||
import ModeSelector from '../components/ModeSelector';
|
import ModeSelector from '../components/ModeSelector';
|
||||||
import QRScanner from '../components/QRScanner';
|
import QRScanner from '../components/QRScanner';
|
||||||
|
|
||||||
|
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
|
||||||
|
const DEFAULT_STORAGE_PATH = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
||||||
|
|
||||||
// --- Typen ---
|
// --- Typen ---
|
||||||
|
|
||||||
interface LogEntry {
|
interface LogEntry {
|
||||||
@@ -62,6 +68,10 @@ const SettingsScreen: React.FC = () => {
|
|||||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
const [events, setEvents] = useState<EventEntry[]>([]);
|
const [events, setEvents] = useState<EventEntry[]>([]);
|
||||||
const [connLog, setConnLog] = useState<ConnectionLogEntry[]>(rvs.getConnectionLog());
|
const [connLog, setConnLog] = useState<ConnectionLogEntry[]>(rvs.getConnectionLog());
|
||||||
|
const [storagePath, setStoragePath] = useState(DEFAULT_STORAGE_PATH);
|
||||||
|
const [storageSize, setStorageSize] = useState('...');
|
||||||
|
const [editingPath, setEditingPath] = useState(false);
|
||||||
|
const [tempPath, setTempPath] = useState('');
|
||||||
|
|
||||||
let logIdCounter = 0;
|
let logIdCounter = 0;
|
||||||
|
|
||||||
@@ -73,8 +83,104 @@ const SettingsScreen: React.FC = () => {
|
|||||||
setManualPort(String(config.port));
|
setManualPort(String(config.port));
|
||||||
setManualToken(config.token);
|
setManualToken(config.token);
|
||||||
}
|
}
|
||||||
|
// Speicherpfad laden
|
||||||
|
AsyncStorage.getItem(STORAGE_PATH_KEY).then(saved => {
|
||||||
|
if (saved) setStoragePath(saved);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Speichergroesse berechnen
|
||||||
|
useEffect(() => {
|
||||||
|
const calcSize = async () => {
|
||||||
|
try {
|
||||||
|
const exists = await RNFS.exists(storagePath);
|
||||||
|
if (!exists) { setStorageSize('0 KB'); return; }
|
||||||
|
const items = await RNFS.readDir(storagePath);
|
||||||
|
const totalBytes = items.reduce((sum, f) => sum + (f.size || 0), 0);
|
||||||
|
if (totalBytes > 1024 * 1024) {
|
||||||
|
setStorageSize(`${(totalBytes / 1024 / 1024).toFixed(1)} MB (${items.length} Dateien)`);
|
||||||
|
} else {
|
||||||
|
setStorageSize(`${Math.round(totalBytes / 1024)} KB (${items.length} Dateien)`);
|
||||||
|
}
|
||||||
|
} catch { setStorageSize('nicht verfuegbar'); }
|
||||||
|
};
|
||||||
|
calcSize();
|
||||||
|
}, [storagePath]);
|
||||||
|
|
||||||
|
const saveStoragePath = useCallback(async (newPath: string) => {
|
||||||
|
const clean = newPath.trim();
|
||||||
|
if (!clean) return;
|
||||||
|
await AsyncStorage.setItem(STORAGE_PATH_KEY, clean);
|
||||||
|
setStoragePath(clean);
|
||||||
|
setEditingPath(false);
|
||||||
|
Alert.alert('Gespeichert', `Neuer Speicherort:\n${clean}\n\nWird ab der naechsten Nachricht verwendet.`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showPathPicker = useCallback(() => {
|
||||||
|
Alert.alert(
|
||||||
|
'Speicherort waehlen',
|
||||||
|
'Wo sollen Anhaenge gespeichert werden?',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'Ordner auswaehlen...',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
const result = await DocumentPicker.pickDirectory();
|
||||||
|
if (result?.uri) {
|
||||||
|
// SAF URI decodieren (content://com.android.externalstorage...)
|
||||||
|
const decoded = decodeURIComponent(result.uri);
|
||||||
|
// Versuche einen lesbaren Pfad zu extrahieren
|
||||||
|
const match = decoded.match(/primary[:%]3A(.+)/);
|
||||||
|
const readablePath = match
|
||||||
|
? `/storage/emulated/0/${match[1].replace(/%2F|%3A/g, '/')}`
|
||||||
|
: decoded;
|
||||||
|
saveStoragePath(readablePath);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!DocumentPicker.isCancel(e)) {
|
||||||
|
Alert.alert('Fehler', 'Ordnerauswahl fehlgeschlagen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'App-intern (Standard)',
|
||||||
|
onPress: () => saveStoragePath(DEFAULT_STORAGE_PATH),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Pfad manuell eingeben',
|
||||||
|
onPress: () => { setTempPath(storagePath); setEditingPath(true); },
|
||||||
|
},
|
||||||
|
{ text: 'Abbrechen', style: 'cancel' as const },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}, [storagePath]);
|
||||||
|
|
||||||
|
const clearStorageCache = useCallback(async () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Cache loeschen',
|
||||||
|
`Alle lokalen Anhaenge in\n${storagePath}\nloeschen?\n\nDateien koennen ueber RVS erneut heruntergeladen werden.`,
|
||||||
|
[
|
||||||
|
{ text: 'Abbrechen', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Loeschen',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
const exists = await RNFS.exists(storagePath);
|
||||||
|
if (exists) await RNFS.unlink(storagePath);
|
||||||
|
await RNFS.mkdir(storagePath);
|
||||||
|
setStorageSize('0 KB (0 Dateien)');
|
||||||
|
Alert.alert('Erledigt', 'Cache geleert. Anhaenge werden bei Bedarf neu geladen.');
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Fehler', e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}, [storagePath]);
|
||||||
|
|
||||||
// RVS-Nachrichten und Verbindungslog abonnieren
|
// RVS-Nachrichten und Verbindungslog abonnieren
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubState = rvs.onStateChange(setConnectionState);
|
const unsubState = rvs.onStateChange(setConnectionState);
|
||||||
@@ -332,6 +438,62 @@ const SettingsScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* === Speicher === */}
|
||||||
|
<Text style={styles.sectionTitle}>Anhang-Speicher</Text>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.toggleLabel}>Lokaler Speicherort</Text>
|
||||||
|
<Text style={styles.toggleHint}>
|
||||||
|
Hier werden Bilder und Dateien aus dem Chat gespeichert.
|
||||||
|
Bei geloeschtem Cache werden Anhaenge automatisch ueber RVS neu geladen.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{editingPath ? (
|
||||||
|
<View style={{marginTop: 10}}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={tempPath}
|
||||||
|
onChangeText={setTempPath}
|
||||||
|
placeholder="z.B. /storage/emulated/0/ARIA/attachments"
|
||||||
|
placeholderTextColor="#555570"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
<View style={{flexDirection: 'row', gap: 8}}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.connectButton, {flex: 1}]}
|
||||||
|
onPress={() => saveStoragePath(tempPath)}
|
||||||
|
>
|
||||||
|
<Text style={styles.connectButtonText}>Speichern</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.clearButton, {flex: 1, marginTop: 0}]}
|
||||||
|
onPress={() => setEditingPath(false)}
|
||||||
|
>
|
||||||
|
<Text style={styles.clearButtonText}>Abbrechen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={{marginTop: 10}}>
|
||||||
|
<Text style={styles.storagePathText} numberOfLines={2}>{storagePath}</Text>
|
||||||
|
<Text style={styles.storageSizeText}>{storageSize}</Text>
|
||||||
|
<View style={{flexDirection: 'row', gap: 8, marginTop: 8}}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.clearButton, {flex: 1, marginTop: 0}]}
|
||||||
|
onPress={showPathPicker}
|
||||||
|
>
|
||||||
|
<Text style={styles.clearButtonText}>Pfad aendern</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.clearButton, {flex: 1, marginTop: 0, backgroundColor: 'rgba(255,59,48,0.15)'}]}
|
||||||
|
onPress={clearStorageCache}
|
||||||
|
>
|
||||||
|
<Text style={[styles.clearButtonText, {color: '#FF3B30'}]}>Cache leeren</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* === Logs === */}
|
{/* === Logs === */}
|
||||||
<Text style={styles.sectionTitle}>Protokoll</Text>
|
<Text style={styles.sectionTitle}>Protokoll</Text>
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
@@ -559,6 +721,18 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Speicher
|
||||||
|
storagePathText: {
|
||||||
|
color: '#0096FF',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||||
|
},
|
||||||
|
storageSizeText: {
|
||||||
|
color: '#8888AA',
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
|
||||||
// Logs
|
// Logs
|
||||||
tabRow: {
|
tabRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
+51
-3
@@ -896,7 +896,7 @@ class ARIABridge:
|
|||||||
"""Sendet Heartbeats an den RVS damit die Verbindung offen bleibt."""
|
"""Sendet Heartbeats an den RVS damit die Verbindung offen bleibt."""
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(25)
|
await asyncio.sleep(25)
|
||||||
if self.ws_rvs and self.ws_rvs.open:
|
if self.ws_rvs:
|
||||||
try:
|
try:
|
||||||
await self.ws_rvs.send(json.dumps({
|
await self.ws_rvs.send(json.dumps({
|
||||||
"type": "heartbeat",
|
"type": "heartbeat",
|
||||||
@@ -977,11 +977,21 @@ class ARIABridge:
|
|||||||
f.write(base64.b64decode(file_b64))
|
f.write(base64.b64decode(file_b64))
|
||||||
size_kb = len(file_b64) // 1365
|
size_kb = len(file_b64) // 1365
|
||||||
logger.info("[rvs] Bild gespeichert: %s (%dKB)", file_path, size_kb)
|
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}"
|
text = (f"Stefan hat dir ein Bild geschickt: {file_name}"
|
||||||
f"{f' ({width}x{height}px)' if width else ''}"
|
f"{f' ({width}x{height}px)' if width else ''}"
|
||||||
f", {size_kb}KB."
|
f", {size_kb}KB."
|
||||||
f" Das Bild liegt unter: {file_path}")
|
f" Das Bild liegt unter: {file_path}")
|
||||||
await self.send_to_core(text, source="app-file")
|
await self.send_to_core(text, source="app-file")
|
||||||
|
# Dann App informieren (optional, darf nicht crashen)
|
||||||
|
try:
|
||||||
|
await self._send_to_rvs({
|
||||||
|
"type": "file_saved",
|
||||||
|
"payload": {"name": file_name, "serverPath": file_path, "mimeType": file_type},
|
||||||
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
|
||||||
elif file_b64:
|
elif file_b64:
|
||||||
# Andere Datei in Shared Volume speichern
|
# Andere Datei in Shared Volume speichern
|
||||||
safe_name = f"file_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}"
|
safe_name = f"file_{int(asyncio.get_event_loop().time())}_{file_name.replace('/', '_')}"
|
||||||
@@ -990,14 +1000,52 @@ class ARIABridge:
|
|||||||
f.write(base64.b64decode(file_b64))
|
f.write(base64.b64decode(file_b64))
|
||||||
size_kb = len(file_b64) // 1365
|
size_kb = len(file_b64) // 1365
|
||||||
logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb)
|
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}"
|
text = (f"Stefan hat dir eine Datei geschickt: {file_name}"
|
||||||
f" ({file_type}, {size_kb}KB)."
|
f" ({file_type}, {size_kb}KB)."
|
||||||
f" Die Datei liegt unter: {file_path}")
|
f" Die Datei liegt unter: {file_path}")
|
||||||
await self.send_to_core(text, source="app-file")
|
await self.send_to_core(text, source="app-file")
|
||||||
|
try:
|
||||||
|
await self._send_to_rvs({
|
||||||
|
"type": "file_saved",
|
||||||
|
"payload": {"name": file_name, "serverPath": file_path, "mimeType": file_type},
|
||||||
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
|
||||||
else:
|
else:
|
||||||
text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen."
|
text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen."
|
||||||
await self.send_to_core(text, source="app-file")
|
await self.send_to_core(text, source="app-file")
|
||||||
|
|
||||||
|
elif msg_type == "file_request":
|
||||||
|
# App fordert eine Datei an (Re-Download nach Cache-Leerung)
|
||||||
|
server_path = payload.get("serverPath", "")
|
||||||
|
req_id = payload.get("requestId", "")
|
||||||
|
if not server_path or not server_path.startswith("/shared/"):
|
||||||
|
logger.warning("[rvs] Ungueltiger file_request: %s", server_path)
|
||||||
|
return
|
||||||
|
if not os.path.isfile(server_path):
|
||||||
|
logger.warning("[rvs] Datei nicht gefunden: %s", server_path)
|
||||||
|
await self._send_to_rvs({
|
||||||
|
"type": "file_response",
|
||||||
|
"payload": {"requestId": req_id, "error": "Datei nicht gefunden"},
|
||||||
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
with open(server_path, "rb") as f:
|
||||||
|
file_b64 = base64.b64encode(f.read()).decode("ascii")
|
||||||
|
logger.info("[rvs] Re-Download: %s (%dKB)", server_path, len(file_b64) // 1365)
|
||||||
|
await self._send_to_rvs({
|
||||||
|
"type": "file_response",
|
||||||
|
"payload": {
|
||||||
|
"requestId": req_id,
|
||||||
|
"serverPath": server_path,
|
||||||
|
"base64": file_b64,
|
||||||
|
"name": os.path.basename(server_path),
|
||||||
|
},
|
||||||
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
|
})
|
||||||
|
|
||||||
elif msg_type == "audio":
|
elif msg_type == "audio":
|
||||||
# Audio von der App → decodieren → STT → an aria-core
|
# Audio von der App → decodieren → STT → an aria-core
|
||||||
audio_b64 = payload.get("base64", "")
|
audio_b64 = payload.get("base64", "")
|
||||||
@@ -1098,13 +1146,13 @@ class ARIABridge:
|
|||||||
|
|
||||||
async def _send_to_rvs(self, message: dict) -> None:
|
async def _send_to_rvs(self, message: dict) -> None:
|
||||||
"""Sendet eine Nachricht an die App (via RVS)."""
|
"""Sendet eine Nachricht an die App (via RVS)."""
|
||||||
if self.ws_rvs is None or not self.ws_rvs.open:
|
if self.ws_rvs is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.ws_rvs.send(json.dumps(message))
|
await self.ws_rvs.send(json.dumps(message))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("[rvs] Sendefehler")
|
logger.warning("[rvs] Sendefehler — RVS nicht erreichbar")
|
||||||
|
|
||||||
# ── Log-Streaming an die App ─────────────────────────────
|
# ── Log-Streaming an die App ─────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -859,7 +859,11 @@
|
|||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = `chat-msg ${type}`;
|
el.className = `chat-msg ${type}`;
|
||||||
const escaped = escapeHtml(text);
|
const escaped = escapeHtml(text);
|
||||||
const linked = linkifyText(escaped);
|
let linked = linkifyText(escaped);
|
||||||
|
// /shared/uploads/ Pfade als Inline-Bilder anzeigen
|
||||||
|
linked = linked.replace(/\/shared\/uploads\/[^\s<"]+\.(jpg|jpeg|png|gif)/gi, (match) => {
|
||||||
|
return `<a href="${match}" target="_blank">${match}</a><img src="${match}" class="chat-media" onclick="openLightbox('image','${match}')" onerror="this.style.display='none'">`;
|
||||||
|
});
|
||||||
el.innerHTML = `${linked}<div class="meta">${escapeHtml(meta)} — ${new Date().toLocaleTimeString('de-DE')}</div>`;
|
el.innerHTML = `${linked}<div class="meta">${escapeHtml(meta)} — ${new Date().toLocaleTimeString('de-DE')}</div>`;
|
||||||
chatBox.appendChild(el);
|
chatBox.appendChild(el);
|
||||||
chatBox.scrollTop = chatBox.scrollHeight;
|
chatBox.scrollTop = chatBox.scrollHeight;
|
||||||
|
|||||||
@@ -462,6 +462,11 @@ function connectRVS(forcePlain) {
|
|||||||
pipelineEnd(true, `Antwort via RVS von ${sender}: "${(msg.payload.text || "").slice(0, 120)}"`);
|
pipelineEnd(true, `Antwort via RVS von ${sender}: "${(msg.payload.text || "").slice(0, 120)}"`);
|
||||||
}
|
}
|
||||||
broadcast({ type: "rvs_chat", msg });
|
broadcast({ type: "rvs_chat", msg });
|
||||||
|
} else if (msg.type === "stt_result" && msg.payload) {
|
||||||
|
const text = msg.payload.text || "(nicht erkannt)";
|
||||||
|
log("info", "rvs", `STT: "${text.slice(0, 100)}"`);
|
||||||
|
// Im Chat als User-Nachricht anzeigen (zur Info, wurde schon an ARIA gesendet)
|
||||||
|
broadcast({ type: "rvs_chat", msg: { type: "chat", payload: { text: `\uD83C\uDFA4 ${text}`, sender: "user" } } });
|
||||||
} else if (msg.type === "heartbeat") {
|
} else if (msg.type === "heartbeat") {
|
||||||
// ignorieren
|
// ignorieren
|
||||||
} else {
|
} else {
|
||||||
@@ -945,6 +950,28 @@ const server = http.createServer((req, res) => {
|
|||||||
} else if (req.url === "/api/session") {
|
} else if (req.url === "/api/session") {
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
res.end(JSON.stringify({ sessionKey: activeSessionKey }));
|
res.end(JSON.stringify({ sessionKey: activeSessionKey }));
|
||||||
|
} else if (req.url.startsWith("/shared/")) {
|
||||||
|
// Dateien aus Shared Volume ausliefern (Bilder, Uploads)
|
||||||
|
const filePath = decodeURIComponent(req.url);
|
||||||
|
const safePath = path.resolve(filePath);
|
||||||
|
if (!safePath.startsWith("/shared/")) {
|
||||||
|
res.writeHead(403);
|
||||||
|
res.end("Forbidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(safePath)) { res.writeHead(404); res.end("Not Found"); return; }
|
||||||
|
const ext = path.extname(safePath).toLowerCase();
|
||||||
|
const mimeTypes = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif",
|
||||||
|
".pdf": "application/pdf", ".txt": "text/plain", ".json": "application/json" };
|
||||||
|
const contentType = mimeTypes[ext] || "application/octet-stream";
|
||||||
|
const data = fs.readFileSync(safePath);
|
||||||
|
res.writeHead(200, { "Content-Type": contentType, "Content-Length": data.length });
|
||||||
|
res.end(data);
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end("Error");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
res.end("Not Found");
|
res.end("Not Found");
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- ./aria-data/config/diag-state:/data # Persistenter State (aktive Session etc.)
|
- ./aria-data/config/diag-state:/data # Persistenter State (aktive Session etc.)
|
||||||
|
- aria-shared:/shared:ro # Shared Volume (Uploads lesen fuer Vorschau)
|
||||||
environment:
|
environment:
|
||||||
- ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-}
|
- ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-}
|
||||||
- PROXY_URL=http://proxy:3456
|
- PROXY_URL=http://proxy:3456
|
||||||
|
|||||||
Reference in New Issue
Block a user