625 lines
17 KiB
TypeScript
625 lines
17 KiB
TypeScript
/**
|
|
* ChatScreen - Hauptchat-Oberflaeche
|
|
*
|
|
* Zeigt die Konversation mit ARIA, Texteingabe, Sprach-Button,
|
|
* Datei- und Kamera-Upload.
|
|
*/
|
|
|
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
TextInput,
|
|
TouchableOpacity,
|
|
FlatList,
|
|
KeyboardAvoidingView,
|
|
Platform,
|
|
StyleSheet,
|
|
Modal,
|
|
} from 'react-native';
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
|
import audioService from '../services/audio';
|
|
import wakeWordService from '../services/wakeword';
|
|
import VoiceButton from '../components/VoiceButton';
|
|
import FileUpload, { FileData } from '../components/FileUpload';
|
|
import CameraUpload, { PhotoData } from '../components/CameraUpload';
|
|
import { RecordingResult } from '../services/audio';
|
|
import Geolocation from '@react-native-community/geolocation';
|
|
|
|
// --- Typen ---
|
|
|
|
interface Attachment {
|
|
type: 'image' | 'file' | 'audio';
|
|
name: string;
|
|
size?: number;
|
|
}
|
|
|
|
interface ChatMessage {
|
|
id: string;
|
|
sender: 'user' | 'aria';
|
|
text: string;
|
|
timestamp: number;
|
|
attachments?: Attachment[];
|
|
}
|
|
|
|
// --- Konstanten ---
|
|
|
|
const CHAT_STORAGE_KEY = 'aria_chat_messages';
|
|
const MAX_STORED_MESSAGES = 500;
|
|
|
|
// --- Komponente ---
|
|
|
|
const ChatScreen: React.FC = () => {
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
const [inputText, setInputText] = useState('');
|
|
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
|
const [showFileUpload, setShowFileUpload] = useState(false);
|
|
const [showCameraUpload, setShowCameraUpload] = useState(false);
|
|
const [gpsEnabled, setGpsEnabled] = useState(false);
|
|
const [wakeWordActive, setWakeWordActive] = useState(false);
|
|
|
|
const flatListRef = useRef<FlatList>(null);
|
|
const messageIdCounter = useRef(0);
|
|
|
|
// Eindeutige Message-ID generieren
|
|
const nextId = (): string => {
|
|
messageIdCounter.current += 1;
|
|
return `msg_${Date.now()}_${messageIdCounter.current}`;
|
|
};
|
|
|
|
// Chat-Verlauf aus AsyncStorage laden
|
|
useEffect(() => {
|
|
const loadMessages = async () => {
|
|
try {
|
|
const stored = await AsyncStorage.getItem(CHAT_STORAGE_KEY);
|
|
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;
|
|
}
|
|
} catch (err) {
|
|
console.error('[Chat] Fehler beim Laden des Verlaufs:', err);
|
|
}
|
|
};
|
|
loadMessages().then(() => {
|
|
// Auto-Scroll nach Laden des Verlaufs
|
|
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: false }), 200);
|
|
});
|
|
}, []);
|
|
|
|
// RVS-Nachrichten abonnieren
|
|
useEffect(() => {
|
|
const unsubMessage = rvs.onMessage((message: RVSMessage) => {
|
|
if (message.type === 'chat') {
|
|
// Nur Nachrichten von ARIA anzeigen — eigene Nachrichten werden lokal hinzugefuegt
|
|
const sender = (message.payload.sender as string) || '';
|
|
if (sender === 'user' || sender === 'diagnostic') return;
|
|
|
|
const text = (message.payload.text as string) || '';
|
|
const ts = message.timestamp;
|
|
// Duplikat-Schutz: gleicher Text innerhalb 5s ignorieren
|
|
setMessages(prev => {
|
|
const isDuplicate = prev.some(m =>
|
|
m.sender === 'aria' && m.text === text && Math.abs(m.timestamp - ts) < 5000
|
|
);
|
|
if (isDuplicate) return prev;
|
|
const ariaMsg: ChatMessage = {
|
|
id: nextId(),
|
|
sender: 'aria',
|
|
text,
|
|
timestamp: ts,
|
|
attachments: message.payload.attachments as Attachment[] | undefined,
|
|
};
|
|
return [...prev, ariaMsg];
|
|
});
|
|
}
|
|
|
|
// TTS-Audio abspielen wenn vorhanden
|
|
if (message.type === 'audio' && message.payload.base64) {
|
|
audioService.playAudio(message.payload.base64 as string);
|
|
}
|
|
});
|
|
|
|
const unsubState = rvs.onStateChange((state) => {
|
|
setConnectionState(state);
|
|
});
|
|
|
|
// Initalen Status setzen
|
|
setConnectionState(rvs.getState());
|
|
|
|
return () => {
|
|
unsubMessage();
|
|
unsubState();
|
|
};
|
|
}, []);
|
|
|
|
// Wake Word: "ARIA" Erkennung → Auto-Aufnahme starten
|
|
useEffect(() => {
|
|
const unsubWake = wakeWordService.onWakeWord(async () => {
|
|
console.log('[Chat] Wake Word erkannt — starte Auto-Aufnahme');
|
|
// TTS stoppen damit ARIA sich nicht selbst hoert
|
|
audioService.stopPlayback();
|
|
// Aufnahme mit Auto-Stop (VAD) starten
|
|
const started = await audioService.startRecording(true);
|
|
if (!started) {
|
|
// Mikrofon nicht verfuegbar, Wake Word wieder aktivieren
|
|
wakeWordService.resume();
|
|
}
|
|
});
|
|
|
|
// Auto-Stop Callback: wenn Stille erkannt → Aufnahme senden + Wake Word wieder starten
|
|
const unsubSilence = audioService.onSilenceDetected(async () => {
|
|
const result = await audioService.stopRecording();
|
|
if (result && result.durationMs > 500) {
|
|
// Sprachnachricht senden (gleiche Logik wie handleVoiceRecording)
|
|
const location = await getCurrentLocation();
|
|
const userMsg: ChatMessage = {
|
|
id: nextId(),
|
|
sender: 'user',
|
|
text: '🎙 Spracheingabe wird verarbeitet...',
|
|
timestamp: Date.now(),
|
|
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
|
|
};
|
|
setMessages(prev => [...prev, userMsg]);
|
|
rvs.send('audio', {
|
|
base64: result.base64,
|
|
durationMs: result.durationMs,
|
|
mimeType: result.mimeType,
|
|
...(location && { location }),
|
|
});
|
|
}
|
|
// Wake Word wieder aktivieren
|
|
if (wakeWordActive) wakeWordService.resume();
|
|
});
|
|
|
|
return () => {
|
|
unsubWake();
|
|
unsubSilence();
|
|
};
|
|
}, [wakeWordActive]);
|
|
|
|
// Wake Word Toggle Handler
|
|
const toggleWakeWord = useCallback(async () => {
|
|
if (wakeWordActive) {
|
|
wakeWordService.stop();
|
|
setWakeWordActive(false);
|
|
} else {
|
|
const started = await wakeWordService.start();
|
|
setWakeWordActive(started);
|
|
}
|
|
}, [wakeWordActive]);
|
|
|
|
// Chat-Verlauf in AsyncStorage speichern (letzte N Nachrichten)
|
|
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),
|
|
);
|
|
}, [messages]);
|
|
|
|
// Auto-Scroll bei neuen Nachrichten
|
|
useEffect(() => {
|
|
if (messages.length > 0) {
|
|
setTimeout(() => {
|
|
flatListRef.current?.scrollToEnd({ animated: true });
|
|
}, 100);
|
|
}
|
|
}, [messages]);
|
|
|
|
// GPS-Position holen (optional)
|
|
const getCurrentLocation = useCallback((): Promise<{ lat: number; lon: number } | null> => {
|
|
if (!gpsEnabled) return Promise.resolve(null);
|
|
|
|
return new Promise((resolve) => {
|
|
Geolocation.getCurrentPosition(
|
|
(position) => {
|
|
resolve({
|
|
lat: position.coords.latitude,
|
|
lon: position.coords.longitude,
|
|
});
|
|
},
|
|
(_error) => {
|
|
resolve(null);
|
|
},
|
|
{ enableHighAccuracy: false, timeout: 5000 },
|
|
);
|
|
});
|
|
}, [gpsEnabled]);
|
|
|
|
// --- Nachricht senden ---
|
|
|
|
const sendTextMessage = useCallback(async () => {
|
|
const text = inputText.trim();
|
|
if (!text) return;
|
|
|
|
setInputText('');
|
|
|
|
const location = await getCurrentLocation();
|
|
|
|
const userMsg: ChatMessage = {
|
|
id: nextId(),
|
|
sender: 'user',
|
|
text,
|
|
timestamp: Date.now(),
|
|
};
|
|
setMessages(prev => [...prev, userMsg]);
|
|
|
|
// An RVS senden
|
|
rvs.send('chat', {
|
|
text,
|
|
...(location && { location }),
|
|
});
|
|
}, [inputText, getCurrentLocation]);
|
|
|
|
// Sprachaufnahme abgeschlossen
|
|
const handleVoiceRecording = useCallback(async (result: RecordingResult) => {
|
|
const location = await getCurrentLocation();
|
|
|
|
const userMsg: ChatMessage = {
|
|
id: nextId(),
|
|
sender: 'user',
|
|
text: '🎙 Spracheingabe wird verarbeitet...',
|
|
timestamp: Date.now(),
|
|
};
|
|
setMessages(prev => [...prev, userMsg]);
|
|
|
|
rvs.send('audio', {
|
|
base64: result.base64,
|
|
durationMs: result.durationMs,
|
|
mimeType: result.mimeType,
|
|
...(location && { location }),
|
|
});
|
|
}, [getCurrentLocation]);
|
|
|
|
// Datei senden
|
|
const handleFileSelected = useCallback(async (file: FileData) => {
|
|
setShowFileUpload(false);
|
|
const location = await getCurrentLocation();
|
|
|
|
const userMsg: ChatMessage = {
|
|
id: nextId(),
|
|
sender: 'user',
|
|
text: `[Datei: ${file.name}]`,
|
|
timestamp: Date.now(),
|
|
attachments: [{ type: 'file', name: file.name, size: file.size }],
|
|
};
|
|
setMessages(prev => [...prev, userMsg]);
|
|
|
|
rvs.send('file', {
|
|
name: file.name,
|
|
type: file.type,
|
|
size: file.size,
|
|
base64: file.base64,
|
|
...(location && { location }),
|
|
});
|
|
}, [getCurrentLocation]);
|
|
|
|
// Foto senden
|
|
const handlePhotoSelected = useCallback(async (photo: PhotoData) => {
|
|
setShowCameraUpload(false);
|
|
const location = await getCurrentLocation();
|
|
|
|
const userMsg: ChatMessage = {
|
|
id: nextId(),
|
|
sender: 'user',
|
|
text: `[Foto: ${photo.fileName}]`,
|
|
timestamp: Date.now(),
|
|
attachments: [{ type: 'image', name: photo.fileName }],
|
|
};
|
|
setMessages(prev => [...prev, userMsg]);
|
|
|
|
rvs.send('file', {
|
|
name: photo.fileName,
|
|
type: photo.type,
|
|
base64: photo.base64,
|
|
width: photo.width,
|
|
height: photo.height,
|
|
...(location && { location }),
|
|
});
|
|
}, [getCurrentLocation]);
|
|
|
|
// --- Rendering ---
|
|
|
|
const renderMessage = ({ item }: { item: ChatMessage }) => {
|
|
const isUser = item.sender === 'user';
|
|
const time = new Date(item.timestamp).toLocaleTimeString('de-DE', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
|
|
return (
|
|
<View style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble]}>
|
|
<Text style={[styles.messageText, isUser ? styles.userText : styles.ariaText]}>
|
|
{item.text}
|
|
</Text>
|
|
{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>
|
|
))}
|
|
<Text style={styles.timestamp}>{time}</Text>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const connectionDotColor =
|
|
connectionState === 'connected' ? '#34C759' :
|
|
connectionState === 'connecting' ? '#FFD60A' : '#FF3B30';
|
|
|
|
return (
|
|
<KeyboardAvoidingView
|
|
style={styles.container}
|
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
keyboardVerticalOffset={90}
|
|
>
|
|
{/* Verbindungsstatus-Leiste */}
|
|
<View style={styles.statusBar}>
|
|
<View style={[styles.statusDot, { backgroundColor: connectionDotColor }]} />
|
|
<Text style={styles.statusText}>
|
|
{connectionState === 'connected' ? 'Verbunden' :
|
|
connectionState === 'connecting' ? 'Verbinde...' : 'Getrennt'}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Nachrichtenliste */}
|
|
<FlatList
|
|
ref={flatListRef}
|
|
data={messages}
|
|
keyExtractor={item => item.id}
|
|
renderItem={renderMessage}
|
|
contentContainerStyle={styles.messageList}
|
|
showsVerticalScrollIndicator={false}
|
|
ListEmptyComponent={
|
|
<View style={styles.emptyContainer}>
|
|
<Text style={styles.emptyIcon}>{'\uD83E\uDD16'}</Text>
|
|
<Text style={styles.emptyText}>ARIA Cockpit</Text>
|
|
<Text style={styles.emptyHint}>Starte eine Konversation mit ARIA</Text>
|
|
</View>
|
|
}
|
|
/>
|
|
|
|
{/* Eingabebereich */}
|
|
<View style={styles.inputContainer}>
|
|
{/* Datei-Buttons */}
|
|
<TouchableOpacity
|
|
style={styles.actionButton}
|
|
onPress={() => setShowFileUpload(true)}
|
|
>
|
|
<Text style={styles.actionIcon}>{'\uD83D\uDCCE'}</Text>
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity
|
|
style={styles.actionButton}
|
|
onPress={() => setShowCameraUpload(true)}
|
|
>
|
|
<Text style={styles.actionIcon}>{'\uD83D\uDCF7'}</Text>
|
|
</TouchableOpacity>
|
|
|
|
{/* Texteingabe */}
|
|
<TextInput
|
|
style={styles.textInput}
|
|
value={inputText}
|
|
onChangeText={setInputText}
|
|
placeholder="Nachricht an ARIA..."
|
|
placeholderTextColor="#555570"
|
|
multiline
|
|
maxLength={4000}
|
|
onSubmitEditing={sendTextMessage}
|
|
returnKeyType="send"
|
|
/>
|
|
|
|
{/* Senden oder Sprache */}
|
|
{inputText.trim() ? (
|
|
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
|
|
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
|
|
</TouchableOpacity>
|
|
) : (
|
|
<>
|
|
<VoiceButton
|
|
onRecordingComplete={handleVoiceRecording}
|
|
disabled={connectionState !== 'connected'}
|
|
wakeWordActive={wakeWordActive}
|
|
/>
|
|
<TouchableOpacity
|
|
style={[styles.wakeWordBtn, wakeWordActive && styles.wakeWordBtnActive]}
|
|
onPress={toggleWakeWord}
|
|
>
|
|
<Text style={styles.wakeWordIcon}>{wakeWordActive ? '👂' : '🔇'}</Text>
|
|
</TouchableOpacity>
|
|
</>
|
|
)}
|
|
</View>
|
|
|
|
{/* Datei-Upload Modal */}
|
|
<Modal visible={showFileUpload} transparent animationType="slide">
|
|
<View style={styles.modalOverlay}>
|
|
<FileUpload
|
|
onFileSelected={handleFileSelected}
|
|
onCancel={() => setShowFileUpload(false)}
|
|
/>
|
|
</View>
|
|
</Modal>
|
|
|
|
{/* Kamera-Upload Modal */}
|
|
<Modal visible={showCameraUpload} transparent animationType="slide">
|
|
<View style={styles.modalOverlay}>
|
|
<CameraUpload
|
|
onPhotoSelected={handlePhotoSelected}
|
|
onCancel={() => setShowCameraUpload(false)}
|
|
/>
|
|
</View>
|
|
</Modal>
|
|
</KeyboardAvoidingView>
|
|
);
|
|
};
|
|
|
|
// --- Styles ---
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: '#0D0D1A',
|
|
},
|
|
statusBar: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 8,
|
|
backgroundColor: '#12122A',
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: '#1E1E2E',
|
|
},
|
|
statusDot: {
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: 4,
|
|
marginRight: 8,
|
|
},
|
|
statusText: {
|
|
color: '#8888AA',
|
|
fontSize: 12,
|
|
},
|
|
messageList: {
|
|
padding: 12,
|
|
paddingBottom: 8,
|
|
flexGrow: 1,
|
|
},
|
|
messageBubble: {
|
|
maxWidth: '80%',
|
|
padding: 12,
|
|
borderRadius: 16,
|
|
marginBottom: 8,
|
|
},
|
|
userBubble: {
|
|
alignSelf: 'flex-end',
|
|
backgroundColor: '#0096FF',
|
|
borderBottomRightRadius: 4,
|
|
},
|
|
ariaBubble: {
|
|
alignSelf: 'flex-start',
|
|
backgroundColor: '#1E1E2E',
|
|
borderBottomLeftRadius: 4,
|
|
},
|
|
messageText: {
|
|
fontSize: 15,
|
|
lineHeight: 21,
|
|
},
|
|
userText: {
|
|
color: '#FFFFFF',
|
|
},
|
|
ariaText: {
|
|
color: '#E0E0F0',
|
|
},
|
|
attachmentBadge: {
|
|
backgroundColor: 'rgba(255,255,255,0.1)',
|
|
borderRadius: 6,
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 4,
|
|
marginTop: 6,
|
|
alignSelf: 'flex-start',
|
|
},
|
|
attachmentText: {
|
|
color: '#CCCCDD',
|
|
fontSize: 12,
|
|
},
|
|
timestamp: {
|
|
color: 'rgba(255,255,255,0.4)',
|
|
fontSize: 10,
|
|
marginTop: 4,
|
|
alignSelf: 'flex-end',
|
|
},
|
|
emptyContainer: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingTop: 120,
|
|
},
|
|
emptyIcon: {
|
|
fontSize: 48,
|
|
marginBottom: 12,
|
|
},
|
|
emptyText: {
|
|
color: '#FFFFFF',
|
|
fontSize: 22,
|
|
fontWeight: '700',
|
|
},
|
|
emptyHint: {
|
|
color: '#555570',
|
|
fontSize: 14,
|
|
marginTop: 4,
|
|
},
|
|
inputContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'flex-end',
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 8,
|
|
backgroundColor: '#12122A',
|
|
borderTopWidth: 1,
|
|
borderTopColor: '#1E1E2E',
|
|
},
|
|
actionButton: {
|
|
width: 38,
|
|
height: 38,
|
|
borderRadius: 19,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginRight: 4,
|
|
},
|
|
actionIcon: {
|
|
fontSize: 20,
|
|
},
|
|
textInput: {
|
|
flex: 1,
|
|
backgroundColor: '#1E1E2E',
|
|
borderRadius: 20,
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 10,
|
|
color: '#FFFFFF',
|
|
fontSize: 15,
|
|
maxHeight: 100,
|
|
marginHorizontal: 6,
|
|
},
|
|
sendButton: {
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 20,
|
|
backgroundColor: '#0096FF',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
sendIcon: {
|
|
fontSize: 18,
|
|
},
|
|
wakeWordBtn: {
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: 16,
|
|
backgroundColor: 'rgba(255,255,255,0.1)',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginLeft: 4,
|
|
},
|
|
wakeWordBtnActive: {
|
|
backgroundColor: 'rgba(52, 199, 89, 0.3)',
|
|
},
|
|
wakeWordIcon: {
|
|
fontSize: 16,
|
|
},
|
|
modalOverlay: {
|
|
flex: 1,
|
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
|
justifyContent: 'center',
|
|
},
|
|
});
|
|
|
|
export default ChatScreen;
|