/** * ChatScreen - Hauptchat-Oberflaeche * * Zeigt die Konversation mit ARIA, Texteingabe, Sprach-Button, * Datei- und Kamera-Upload. */ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { View, Text, TextInput, TouchableOpacity, FlatList, KeyboardAvoidingView, Platform, StyleSheet, Image, ScrollView, Modal, ToastAndroid, AppState, NativeModules, Alert, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import RNFS from 'react-native-fs'; import { SvgUri } from 'react-native-svg'; import { Dimensions } from 'react-native'; import ZoomableImage from '../components/ZoomableImage'; import MemoryDetailModal from '../components/MemoryDetailModal'; import MemoryBrowser from '../components/MemoryBrowser'; import ErrorBoundary from '../components/ErrorBoundary'; import rvs, { RVSMessage, ConnectionState } from '../services/rvs'; import audioService from '../services/audio'; import wakeWordService from '../services/wakeword'; import phoneCallService from '../services/phoneCall'; import { playWakeReadySound } from '../services/wakeReadySound'; import { acquireBackgroundAudio, releaseBackgroundAudio, } from '../services/backgroundAudio'; import updateService from '../services/updater'; import VoiceButton from '../components/VoiceButton'; import FileUpload, { FileData } from '../components/FileUpload'; import CameraUpload, { PhotoData } from '../components/CameraUpload'; import MessageText from '../components/MessageText'; import { RecordingResult, loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT } from '../services/audio'; import Geolocation from '@react-native-community/geolocation'; // --- Typen --- 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 deleted?: boolean; // Datei wurde nachtraeglich geloescht (Diagnostic-Manager) } interface ChatMessage { id: string; sender: 'user' | 'aria'; text: string; timestamp: number; attachments?: Attachment[]; /** Bridge-Message-ID zur Zuordnung von TTS-Audio */ messageId?: string; /** Lokaler Pfad zur gecachten TTS-Audio-Datei (file://...) */ audioPath?: string; /** Korrelations-ID fuer Sprachnachrichten — wird mit dem STT-Result zurueck- * gespiegelt damit wir die EXAKT richtige Placeholder-Bubble ersetzen, * auch wenn mehrere Aufnahmen parallel offen sind. */ audioRequestId?: string; /** Skill-Created-Bubble: ARIA hat einen neuen Skill angelegt */ skillCreated?: { name: string; description: string; execution: string; active: boolean; setupError?: string; }; /** Trigger-Created-Bubble: ARIA hat einen neuen Trigger angelegt */ triggerCreated?: { name: string; type: 'timer' | 'watcher' | string; message: string; fires_at?: string; condition?: string; }; /** Memory-Saved-Bubble: ARIA hat etwas via memory_save in die Qdrant-DB gepackt */ memorySaved?: { id?: string; title: string; type: string; category?: string; pinned: boolean; preview?: string; /** Was passiert ist: angelegt / geaendert / geloescht. Default created * fuer Rueckwaerts-Kompatibilitaet mit aelteren Events. */ action?: 'created' | 'updated' | 'deleted'; attachments?: Array<{ name: string; mime?: string; size?: number; path?: string; // Server-Pfad /shared/memory-attachments// localUri?: string; // Nach file_request gefuelltes file://-URI }>; }; /** Backup-Timestamp aus chat_backup.jsonl auf dem Bridge — Voraussetzung * zum Loeschen der Bubble via Muelltonne. Lokale Bubbles ohne backupTs * sind noch nicht persistiert (kurzer Race) — Muelltonne erscheint erst * wenn das chat_backup-Event vom Bridge zurueck kommt. */ backupTs?: number; /** Client-seitige Eindeutigs-ID fuer Delivery-Tracking (offline-Queue, * ACK von Bridge, Idempotenz bei Retry). Wird beim Senden generiert und * durch die Bridge zurueck-gespiegelt. */ clientMsgId?: string; /** Delivery-Status der User-Bubble (WhatsApp-style): queued = noch nicht * raus (offline), sending = an Bridge unterwegs, sent = Bridge hat ACK * gesendet, delivered = Brain hat geantwortet, failed = Retry-Limit. */ deliveryStatus?: 'queued' | 'sending' | 'sent' | 'delivered' | 'failed'; /** Anzahl der bisherigen Sende-Versuche (fuer Retry-Limit). */ sendAttempts?: number; } /** Ein Eintrag im Gedanken-Stream — chronologisches Log dessen was ARIA * intern macht (Brain-`agent_activity`-Events). Bleibt zwischen Denk- * Phasen stehen, wird in AsyncStorage persistiert. */ interface ThoughtEntry { ts: number; /** Roh-Activity vom Brain: thinking, tool, assistant, idle (= ✓ fertig). */ activity: string; /** Bei activity='tool' der Tool-Name, sonst leer. */ tool?: string; } // --- Konstanten --- const CHAT_STORAGE_KEY = 'aria_chat_messages'; const THOUGHT_STORAGE_KEY = 'aria_thought_stream'; const MAX_STORED_MESSAGES = 500; const MAX_MEMORY_MESSAGES = 500; const MAX_THOUGHTS = 500; // Hilfe: Messages-Array auf Max kappen (aelteste raus) — verhindert OOM // im Gespraechsmodus bei sehr vielen Nachrichten. const capMessages = (msgs: ChatMessage[]): ChatMessage[] => msgs.length > MAX_MEMORY_MESSAGES ? msgs.slice(-MAX_MEMORY_MESSAGES) : msgs; // Bridge fuegt User-Texten Praefixe in eckigen Klammern hinzu damit Brain // Kontext hat (GPS-Position, Barge-In-Hint etc.). Diese sollen nicht in der // Bubble auftauchen — nur Brain sieht sie. Filtert alle aufeinanderfolgenden // [...]-Bloecke am Textanfang weg, inkl. der Trennleerzeichen dahinter. function stripSystemHints(text: string): string { if (!text) return text; let out = text; // Mehrere Hints koennen aneinanderhaengen — "[A] [B] Hallo" → "Hallo" while (true) { const m = out.match(/^\s*\[[^\]]*\]\s*/); if (!m) break; out = out.slice(m[0].length); } return out; } const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`; const STORAGE_PATH_KEY = 'aria_attachment_storage_path'; const { FileOpener } = NativeModules as { FileOpener?: { open: (filePath: string, mimeType: string) => Promise }; }; /** Datei mit Android-Intent-Picker oeffnen (System waehlt App nach MIME). */ async function openFileWithIntent(filePath: string, mimeType: string): Promise { if (!FileOpener) { ToastAndroid.show('FileOpener Native Module fehlt', ToastAndroid.SHORT); return; } try { await FileOpener.open(filePath, mimeType || 'application/octet-stream'); } catch (err: any) { ToastAndroid.show(`Oeffnen fehlgeschlagen: ${err?.message || err}`, ToastAndroid.LONG); } } /** Image-Vorschau in der Chat-Bubble. Misst die echte Bild-Dimension via * Image.getSize + setzt aspectRatio dynamisch — dadurch passt sich die * Bubble ans Bild an (kein "Strich" mehr bei breiten oder hohen Bildern). */ const CHAT_IMAGE_STYLE = { width: 260, borderRadius: 8, marginBottom: 6, backgroundColor: '#0D0D1A', } as const; const ChatImage: React.FC<{ uri: string; onPress: () => void; onError: () => void; }> = ({ uri, onPress, onError }) => { const [aspectRatio, setAspectRatio] = useState(4 / 3); const isSvg = /\.svg(?:\?|$)/i.test(uri); useEffect(() => { if (isSvg) return; // SvgUri hat kein getSize let cancelled = false; Image.getSize(uri, (w, h) => { if (!cancelled && w > 0 && h > 0) { // Aspect-Ratio capen damit sehr lange Panorama-Bilder oder hohe // Screenshot-Streifen die Bubble nicht sprengen const r = Math.max(0.5, Math.min(2.5, w / h)); setAspectRatio(r); } }, () => {}); return () => { cancelled = true; }; }, [uri, isSvg]); if (isSvg) { return ( ); } return ( ); }; async function getAttachmentDir(): Promise { 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 { 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 { if (!uri || !uri.startsWith('file://')) return false; return RNFS.exists(uri.replace('file://', '')); } // --- Komponente --- const ChatScreen: React.FC = () => { const [messages, setMessages] = useState([]); const [inputText, setInputText] = useState(''); const [connectionState, setConnectionState] = useState('disconnected'); const [showFileUpload, setShowFileUpload] = useState(false); const [showCameraUpload, setShowCameraUpload] = useState(false); const [gpsEnabled, setGpsEnabled] = useState(false); const [wakeWordActive, setWakeWordActive] = useState(false); // Genauer State (off/armed/conversing) fuer UI-Feedback am Button const [wakeWordState, setWakeWordState] = useState<'off' | 'armed' | 'conversing'>('off'); const [fullscreenImage, setFullscreenImage] = useState(null); const [memoryDetailId, setMemoryDetailId] = useState(null); const [inboxVisible, setInboxVisible] = useState(false); const [showJumpDown, setShowJumpDown] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [searchVisible, setSearchVisible] = useState(false); const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]); const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''}); // Gedanken-Stream: chronologisches Log dessen was ARIA intern macht. // Wird aus agent_activity-Events gefuettert und in AsyncStorage persistiert. const [thoughts, setThoughts] = useState([]); const [thoughtsVisible, setThoughtsVisible] = useState(false); // Spiegel der letzten Activity in einer Ref — verhindert dass aufeinander- // folgende identische Events (z.B. zwei 'thinking' hintereinander) den // Stream zumuellen. Eigentlich seltener Fall, aber billig zu pruefen. const lastThoughtKeyRef = useRef(''); // Service-Status (Gamebox: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit const [serviceStatus, setServiceStatus] = useState>({}); const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false); // Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button) const [ttsDeviceEnabled, setTtsDeviceEnabled] = useState(true); const [ttsMuted, setTtsMuted] = useState(false); // System-Hints in Bubble: Bridge fuegt User-Text Praefixe wie // "[Stefans aktuelle GPS-Position: ...]" oder "[Hinweis: Stefan hat // dich gerade unterbrochen...]" hinzu damit Brain Kontext hat. Die // App soll sie standardmaessig NICHT anzeigen — Stefan sieht sonst // jeden Hint mit. Toggle in Settings. const [showSystemHints, setShowSystemHints] = useState(false); // Gerätelokale XTTS-Voice-Wahl (bevorzugt gegenueber dem globalen Default) const localXttsVoiceRef = useRef(''); // Geraetelokale TTS-Wiedergabegeschwindigkeit (speed-Param an F5-TTS) const ttsSpeedRef = useRef(TTS_SPEED_DEFAULT); // Spiegelung der TTS-Settings in einer Ref — damit die onMessage-Closure // (useEffect mit []-deps) IMMER die aktuellen Werte sieht. Ohne Ref // bliebe canPlay auf dem Mount-Initial-Wert haengen (mute ignoriert, // oder AsyncStorage-Load nicht beruecksichtigt). const ttsCanPlayRef = useRef(true); const flatListRef = useRef(null); const messageIdCounter = useRef(0); // Spiegel der messages-Liste in einer Ref — Closures (z.B. dispatchWithAck- // Retry) brauchen Zugriff auf den aktuellen Status einer Bubble. const messagesRef = useRef([]); // Watchdog gegen "ARIA denkt"-Hang: wird bei jedem agent_activity-Event mit // nicht-idle Status neu armiert. Feuert er, sind 180s lang KEINE Updates // vom Brain mehr gekommen → wir gehen davon aus dass die Verbindung // verloren ist oder das Brain abgestuerzt — Timeout-Bubble + Reset. const stuckWatchdog = useRef | null>(null); const clearStuckWatchdog = () => { if (stuckWatchdog.current) { clearTimeout(stuckWatchdog.current); stuckWatchdog.current = null; } }; // ServerPaths fuer die der User auf "oeffnen" geklickt hat — beim // file_response wird die Datei nach dem Speichern direkt mit dem System- // Intent geoeffnet (PDF-Viewer, Galerie, etc.). const autoOpenPaths = useRef>(new Set()); // Eindeutige Message-ID generieren const nextId = (): string => { messageIdCounter.current += 1; return `msg_${Date.now()}_${messageIdCounter.current}`; }; // Eindeutige clientMsgId fuer Delivery-Tracking (Bridge-Echo, Retry, // Idempotenz). Format: cmsg__ — eindeutig genug fuer eine // 100er-Dedup-Window auf der Bridge. const nextClientMsgId = (): string => `cmsg_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`; // Wie lange wir auf das ACK warten bevor wir retryen. Bridge sollte // unmittelbar zurueckmelden — 30s ist grosszuegig fuer schlechte Netze. // 60s — grosszuegiger als 30s, weil langsame Brain-Calls (Multi-Tool) sonst // 90s × 3 Retries lang die User-Bubble auf ⏳ stehen lassen wuerden. Der // wichtige Pfad ist sowieso: agent_activity = thinking → markiert die // Bubble sofort als 'sent' (siehe handler). Das hier ist Fallback wenn // weder ACK noch agent_activity ankommt. const ACK_TIMEOUT_MS = 60_000; // Wie oft re-tryen wir bevor wir "failed" anzeigen. const MAX_SEND_ATTEMPTS = 3; // Pending ACK-Timer pro clientMsgId — fuer cancel beim ACK. const ackTimers = useRef>>(new Map()); const clearAckTimer = (cmid: string) => { const t = ackTimers.current.get(cmid); if (t) { clearTimeout(t); ackTimers.current.delete(cmid); } }; // Pending-Payloads pro clientMsgId — wir brauchen sie fuer Retry nach // ACK-Timeout oder nach Reconnect (offline-Queue). Liegt in einer Ref // damit der Inhalt Closures ueberlebt. const pendingPayloads = useRef }>>(new Map()); // ConnectionState in Ref spiegeln — fuer Closures (onMessage, Send-Pfade) // die sonst auf einen veralteten Wert zugreifen wuerden. const connectionStateRef = useRef('disconnected'); // Status einer Bubble per clientMsgId aendern (Helper) const updateMessageStatus = useCallback( (cmid: string, patch: Partial>) => { setMessages(prev => prev.map(m => (m.clientMsgId === cmid ? { ...m, ...patch } : m))); }, [], ); // Sende eine 'chat'- oder 'audio'-Nachricht an die Bridge mit ACK-Tracking. // - Wenn offline → status='queued', wird beim Reconnect rausgeschickt. // - Wenn online → status='sending', Timer fuer ACK-Erwartung. // - Bei ACK-Timeout: retry (bis MAX_SEND_ATTEMPTS) oder 'failed'. // - Wenn die Bubble inzwischen 'delivered' ist (z.B. ARIA hat geantwortet // bevor das ACK durchkam) → komplett abbrechen, keinen Retry mehr. const dispatchWithAck = useCallback( (cmid: string, type: 'chat' | 'audio', payload: Record, attempt = 1) => { // Schutz: wenn die Bubble inzwischen delivered ist, Retry-Loop stoppen // (kann bei verspaeteten ACKs oder manuellem Retry passieren wenn ARIA // schon laengst geantwortet hat). const current = messagesRef.current.find(m => m.clientMsgId === cmid); if (current?.deliveryStatus === 'delivered') { clearAckTimer(cmid); pendingPayloads.current.delete(cmid); return; } pendingPayloads.current.set(cmid, { type, payload }); const online = connectionStateRef.current === 'connected'; if (!online) { updateMessageStatus(cmid, { deliveryStatus: 'queued', sendAttempts: attempt }); return; } // RVS.send mit clientMsgId — Bridge spiegelt das im chat_ack zurueck rvs.send(type, { ...payload, clientMsgId: cmid }); updateMessageStatus(cmid, { deliveryStatus: 'sending', sendAttempts: attempt }); clearAckTimer(cmid); ackTimers.current.set( cmid, setTimeout(() => { ackTimers.current.delete(cmid); // Vor dem Retry erneut pruefen ob die Bubble nicht inzwischen // delivered wurde — sonst spawnen wir endlose Retries. const fresh = messagesRef.current.find(m => m.clientMsgId === cmid); if (fresh?.deliveryStatus === 'delivered') { pendingPayloads.current.delete(cmid); return; } if (attempt >= MAX_SEND_ATTEMPTS) { updateMessageStatus(cmid, { deliveryStatus: 'failed', sendAttempts: attempt }); console.warn('[Chat] Send fehlgeschlagen nach %d Versuchen: %s', attempt, cmid); } else { console.warn('[Chat] kein ACK fuer %s — Retry #%d', cmid, attempt + 1); dispatchWithAck(cmid, type, payload, attempt + 1); } }, ACK_TIMEOUT_MS), ); }, [updateMessageStatus], ); // Alle 'queued'-Nachrichten beim Reconnect rausschicken const flushQueuedMessages = useCallback(() => { setMessages(prev => { for (const m of prev) { if (m.deliveryStatus !== 'queued' || !m.clientMsgId) continue; const pending = pendingPayloads.current.get(m.clientMsgId); if (!pending) continue; // Versuchszaehler beibehalten (oder mit 1 starten falls leer) dispatchWithAck(m.clientMsgId, pending.type, pending.payload, m.sendAttempts || 1); } return prev; }); }, [dispatchWithAck]); // Manueller Retry nach 'failed' (tap auf das ⚠️-Icon) const retryFailedMessage = useCallback((cmid: string) => { const pending = pendingPayloads.current.get(cmid); if (!pending) return; dispatchWithAck(cmid, pending.type, pending.payload, 1); }, [dispatchWithAck]); // TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle // sofort greift, ohne Context- oder Event-System) useEffect(() => { const loadSettings = async () => { const enabled = await AsyncStorage.getItem('aria_tts_enabled'); setTtsDeviceEnabled(enabled !== 'false'); // default true const muted = await AsyncStorage.getItem('aria_tts_muted'); const isMuted = muted === 'true'; setTtsMuted(isMuted); // default false audioService.setMuted(isMuted); // service-internen Flag synchronisieren const voice = await AsyncStorage.getItem('aria_xtts_voice'); localXttsVoiceRef.current = voice || ''; ttsSpeedRef.current = await loadTtsSpeed(); const gps = await AsyncStorage.getItem('aria_gps_enabled'); setGpsEnabled(gps === 'true'); const hints = await AsyncStorage.getItem('aria_show_hints'); setShowSystemHints(hints === 'true'); // default false }; loadSettings(); const interval = setInterval(loadSettings, 2000); return () => clearInterval(interval); }, []); // Wake Word: einmalig laden + Porcupine vorbereiten (wenn Access Key gesetzt) useEffect(() => { wakeWordService.loadFromStorage().catch(() => {}); const unsub = wakeWordService.onStateChange((s) => { setWakeWordState(s); setWakeWordActive(s !== 'off'); // Conversation-Focus an Wake-Word-State koppeln: solange wir aktiv im // Dialog sind, soll Spotify dauerhaft gepaust bleiben (auch ueber // Render-Pausen + zwischen Antworten hinweg). Sobald wir zurueck nach // 'armed' oder 'off' fallen, darf Spotify wieder. if (s === 'conversing') audioService.acquireConversationFocus(); else audioService.releaseConversationFocus(); // Foreground-Service-Slot 'wake' — solange das Ohr ueberhaupt aktiv ist // (armed oder conversing), soll der App-Prozess im Hintergrund am Leben // bleiben damit Mikro-Lauschen + Aufnahme weiterlaufen. if (s !== 'off') acquireBackgroundAudio('wake').catch(() => {}); else releaseBackgroundAudio('wake').catch(() => {}); }); return () => unsub(); }, []); // Anruf-Erkennung: TTS pausieren wenn das Telefon klingelt useEffect(() => { phoneCallService.start().catch(err => console.warn('[Chat] phoneCall.start fehlgeschlagen', err)); return () => { phoneCallService.stop().catch(() => {}); }; }, []); // App-Resume: drei Schutzmaßnahmen gegen verirrte Wake-Word-Trigger // beim Wechsel Background→Foreground: // (a) Cooldown 3s — Audio-Pegel-Spikes (AudioFocus-Switch, AudioTrack // re-route) sollen openWakeWord nicht faelschlich triggern // (b) Wenn die App laenger im Hintergrund war und in 'conversing' // zurueckkommt: vermutlich false-positive durch ein Hintergrund- // Geraeusch (TV, Husten etc.) waehrend Stefan gar nicht da war. // Wir verwerfen den Trigger und gehen zurueck zu 'armed'. // (c) Aktuelle Aufnahme abbrechen falls sie aus dem false-positive // gerade gestartet wurde. useEffect(() => { let lastState: string = AppState.currentState; let lastBackgroundAt = 0; const sub = AppState.addEventListener('change', (next) => { if (next === 'background' || next === 'inactive') { lastBackgroundAt = Date.now(); } else if (lastState !== 'active' && next === 'active') { wakeWordService.setResumeCooldown(3000); const bgDur = lastBackgroundAt > 0 ? Date.now() - lastBackgroundAt : 0; // Bei laengerer Hintergrund-Zeit (>30s): pruefen ob ein frisches // Wake-Word getriggert wurde wahrend die App weg war — wenn ja, // verwerfen + laufende Aufnahme stoppen. if (bgDur > 30_000) { wakeWordService.discardIfFreshlyTriggered(15_000).then(discarded => { if (discarded) { try { audioService.cancelRecording(); } catch {} } }).catch(() => {}); } // PhoneCall-Listener pruefen: kann passieren dass der nach laengerer // Hintergrund-Zeit verloren geht (Bridge-Context recreated). Refresh // versucht ihn neu zu attachen falls noetig — sonst kriegt die App // bei display-aus / minimized keine Anruf-Events mit. phoneCallService.refresh().catch(() => {}); } lastState = next; }); return () => sub.remove(); }, []); // Recording-State an Background-Service-Slot 'rec' koppeln — damit das Mikro // auch im Hintergrund weiter aufnehmen darf (Android killt den App-Prozess // sonst und die Aufnahme bricht ab). useEffect(() => { const unsub = audioService.onStateChange((s) => { if (s === 'recording') acquireBackgroundAudio('rec').catch(() => {}); else releaseBackgroundAudio('rec').catch(() => {}); }); return () => unsub(); }, []); // ttsCanPlayRef live aktuell halten — Closure in onMessage unten liest // darueber statt direkt ttsDeviceEnabled/ttsMuted (sonst stale). useEffect(() => { ttsCanPlayRef.current = ttsDeviceEnabled && !ttsMuted; }, [ttsDeviceEnabled, ttsMuted]); const toggleMute = useCallback(() => { setTtsMuted(prev => { const next = !prev; AsyncStorage.setItem('aria_tts_muted', String(next)); // Ref synchron updaten — sonst kommen noch Chunks im selben Tick // mit canPlay=true durch (Race vor dem useEffect-Update). ttsCanPlayRef.current = ttsDeviceEnabled && !next; // Globalen Mute-Flag im audioService setzen — uebersteuert auch // payload.silent in handlePcmChunk und stoppt laufende Wiedergabe. audioService.setMuted(next); return next; }); }, [ttsDeviceEnabled]); // 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); if (Array.isArray(parsed) && parsed.length > 0) { console.log('[Chat] ${parsed.length} Nachrichten geladen'); // MERGE statt Overwrite: zwischen Mount und Load-Done koennen // bereits Nachrichten ankommen (User schreibt sofort, WS-Events // kommen vor Load-Ende). Vorher hat setMessages(parsed) diese // ueberschrieben → "Nachricht weg ohne Spur". Jetzt mergen wir // per id; lokal-gerade-hinzugefuegte schlagen Gespeichertes // (die sind frischer). setMessages(prev => { if (prev.length === 0) return parsed; const byId = new Map(); for (const m of parsed) byId.set(m.id, m); for (const m of prev) byId.set(m.id, m); return [...byId.values()].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)); }); const maxId = parsed.reduce((max, msg) => { const num = parseInt(msg.id.split('_').pop() || '0', 10); return num > max ? num : max; }, 0); messageIdCounter.current = Math.max(messageIdCounter.current, maxId); } } } catch (err) { console.error('[Chat] Fehler beim Laden des Verlaufs:', err); } finally { isInitialLoad.current = false; } }; 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) => { // chat_ack: Bridge bestaetigt Empfang einer chat/audio-Nachricht. // Wir markieren die Bubble als 'sent' (✓) und stoppen den ACK-Timer. if (message.type === ('chat_ack' as any)) { const cmid = (message.payload as any).clientMsgId as string | undefined; if (cmid) { clearAckTimer(cmid); pendingPayloads.current.delete(cmid); setMessages(prev => prev.map(m => m.clientMsgId === cmid && m.deliveryStatus !== 'delivered' ? { ...m, deliveryStatus: 'sent' } : m )); } return; } // 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; } // skill_created: ARIA hat einen neuen Skill angelegt → eigene Bubble // chat_cleared: Diagnostic hat die History komplett geleert // → lokal auch loeschen (visuell + Persistenz) if (message.type === 'chat_cleared') { console.log('[Chat] chat_cleared — leere lokale Anzeige + Storage'); setMessages([]); AsyncStorage.removeItem(CHAT_STORAGE_KEY).catch(() => {}); AsyncStorage.removeItem('aria_chat_last_sync').catch(() => {}); return; } // chat_message_deleted: Bridge hat eine Bubble aus chat_backup + Brain // entfernt. Wir loeschen sie lokal per backupTs-Match. if (message.type === 'chat_message_deleted') { const ts = (message.payload || {}).ts; if (typeof ts !== 'number') return; console.log(`[Chat] chat_message_deleted ts=${ts}`); setMessages(prev => prev.filter(m => m.backupTs !== ts)); return; } // chat_history_response: kompletter Server-Stand. App ersetzt ihre // persistierte Chat-History damit. Lokal-only Bubbles (laufende // Voice-Aufnahmen ohne STT-Result, Skill-Created-Events ohne // text) bleiben erhalten — die sind durch fehlendes 'text' oder // skillCreated/audioRequestId klar als "lokal" erkennbar. if (message.type === 'chat_history_response') { const p = (message.payload || {}) as any; const incoming = (p.messages || []) as Array; console.log(`[Chat] Server-Sync: ${incoming.length} Nachrichten vom Server`); const fromServer: ChatMessage[] = incoming.map(m => { const role = m.role === 'user' ? 'user' : 'aria'; const files = Array.isArray(m.files) ? m.files : []; const attachments = files.map((f: any) => ({ type: (typeof f.mimeType === 'string' && f.mimeType.startsWith('image/')) ? 'image' : 'file', name: f.name || 'datei', size: f.size || 0, mimeType: f.mimeType || '', serverPath: f.serverPath || '', })) as Attachment[]; // clientMsgId weiterreichen — Bridge spiegelt sie im chat_backup, // damit wir lokale Bubbles per ID dedupen koennen statt nur per // Text/Timestamp-Heuristik. const cmid = typeof m.clientMsgId === 'string' ? m.clientMsgId : undefined; return { id: nextId(), sender: role as 'user' | 'aria', text: m.text || '', timestamp: m.ts || Date.now(), attachments: attachments.length ? attachments : undefined, backupTs: typeof m.ts === 'number' ? m.ts : undefined, ...(cmid && { clientMsgId: cmid }), // Server-Bubble = vom Brain verarbeitet → 'delivered' (✓✓) ...(role === 'user' && cmid && { deliveryStatus: 'delivered' as const }), }; }); const maxTs = incoming.reduce((mx: number, m: any) => Math.max(mx, m.ts || 0), 0); setMessages(prev => { // ClientMsgIds die der Server kennt — lokale Bubbles mit der // gleichen ID werden durch die Server-Version ersetzt. const serverCmids = new Set( fromServer.map(s => s.clientMsgId).filter((x): x is string => !!x) ); // Lokal-only Bubbles erkennen + behalten: // - Skill-Created-Notifications (skillCreated gesetzt) // - Laufende Sprachnachrichten ohne STT-Result (audioRequestId // gesetzt UND text leer/Placeholder) // - User-Bubbles deren clientMsgId der Server noch nicht kennt: // z.B. waehrend Reconnect-Race oder solange flushQueuedMessages // noch laeuft. ABER: wenn der Server eine textgleiche User- // Bubble hat (egal mit welcher cmid oder ohne — z.B. wenn die // Bubble aus einer Bridge-Version vor dem clientMsgId-Patch // stammt oder wenn die ts kaputt sind), werten wir das als // Treffer und verwerfen die lokale Kopie. Sonst Doppelpost: // einmal als Server-Bubble (delivered) und einmal als lokale // failed/queued mit Retry-Knopf. const serverUserTexts = new Set( fromServer.filter(s => s.sender === 'user').map(s => s.text || '') ); const localOnly = prev.filter(m => { if (m.skillCreated || m.triggerCreated || m.memorySaved) return true; if (m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...')) return true; if (m.sender === 'user' && m.clientMsgId && !serverCmids.has(m.clientMsgId)) { // Text-Match-Fallback: wenn der Server irgendwo eine textgleiche // User-Bubble hat, ist es dieselbe Nachricht (vor cmid-Aera, ts // kaputt etc.) — wir verwerfen die lokale Kopie. Leerer Text // (z.B. nur Anhang) faellt nicht in den Vergleich. const text = m.text || ''; if (text && serverUserTexts.has(text)) return false; return true; } return false; }); // Server-Stand + lokal-only (chronologisch sortiert) const merged = [...fromServer, ...localOnly].sort((a, b) => a.timestamp - b.timestamp); return capMessages(merged); }); if (maxTs > 0) { AsyncStorage.setItem('aria_chat_last_sync', String(maxTs)).catch(() => {}); } else { // Server leer → unsere lastSync auch zuruecksetzen AsyncStorage.removeItem('aria_chat_last_sync').catch(() => {}); } return; } if (message.type === 'skill_created') { const p = (message.payload || {}) as any; const skillMsg: ChatMessage = { id: nextId(), sender: 'aria', text: '', timestamp: Date.now(), skillCreated: { name: String(p.name || '(unbenannt)'), description: String(p.description || ''), execution: String(p.execution || 'bash'), active: p.active !== false, setupError: p.setup_error ? String(p.setup_error) : undefined, }, }; setMessages(prev => capMessages([...prev, skillMsg])); return; } // trigger_created: ARIA hat einen Trigger angelegt → eigene Bubble if (message.type === 'trigger_created') { const p = (message.payload || {}) as any; const triggerMsg: ChatMessage = { id: nextId(), sender: 'aria', text: '', timestamp: Date.now(), triggerCreated: { name: String(p.name || '(unbenannt)'), type: String(p.type || 'timer'), message: String(p.message || ''), fires_at: p.fires_at ? String(p.fires_at) : undefined, condition: p.condition ? String(p.condition) : undefined, }, }; setMessages(prev => capMessages([...prev, triggerMsg])); return; } // memory_saved: ARIA hat etwas via memory_save Tool in die Qdrant-DB // gepackt — eigene Bubble (gelb wie trigger/skill). if (message.type === 'memory_saved') { const p = (message.payload || {}) as any; const atts = Array.isArray(p.attachments) ? p.attachments.map((a: any) => ({ name: String(a?.name || 'datei'), mime: a?.mime ? String(a.mime) : undefined, size: typeof a?.size === 'number' ? a.size : undefined, path: a?.path ? String(a.path) : undefined, })) : []; const memoryMsg: ChatMessage = { id: nextId(), sender: 'aria', text: '', timestamp: Date.now(), memorySaved: { id: p.id ? String(p.id) : undefined, title: String(p.title || '(ohne Titel)'), type: String(p.type || 'fact'), category: p.category ? String(p.category) : undefined, pinned: !!p.pinned, preview: p.content_preview ? String(p.content_preview) : undefined, action: (p.action === 'updated' || p.action === 'deleted') ? p.action : 'created', attachments: atts.length ? atts : undefined, }, }; setMessages(prev => capMessages([...prev, memoryMsg])); return; } // file_deleted: Datei wurde geloescht (vom Diagnostic User) → Bubble updaten if (message.type === 'file_deleted') { const p = (message.payload?.path as string) || ''; if (!p) return; setMessages(prev => prev.map(m => ({ ...m, attachments: m.attachments?.map(a => a.serverPath === p ? { ...a, deleted: true } : a ), }))); return; } // file_list_response: wird vom Datei-Manager im SettingsScreen verarbeitet. // file_from_aria: ARIA hat eine Datei rausgegeben → als ARIA-Bubble anzeigen if (message.type === 'file_from_aria') { const p = message.payload || {}; const ariaMsg: ChatMessage = { id: nextId(), sender: 'aria', text: '', timestamp: Date.now(), attachments: [{ type: (typeof p.mimeType === 'string' && p.mimeType.startsWith('image/')) ? 'image' : 'file', name: (p.name as string) || 'datei', size: (p.size as number) || 0, mimeType: (p.mimeType as string) || '', serverPath: (p.serverPath as string) || '', }], }; setMessages(prev => capMessages([...prev, ariaMsg])); 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) || ''; const mimeType = (message.payload.mimeType as string) || ''; if (b64 && reqId) { const fileName = (message.payload.name as string) || 'download'; persistAttachment(b64, reqId, fileName).then(filePath => { setMessages(prev => prev.map(m => { // Hauptattachments updaten (Bilder/Files am User-Send / ARIA-File-Bubble) const updatedAtts = m.attachments?.map(a => a.serverPath === serverPath ? { ...a, uri: filePath } : a ); // Memory-Anhang-Match (Bubble vom memory_saved-Event) const ms = m.memorySaved; let updatedMs = ms; if (ms && Array.isArray(ms.attachments)) { const hit = ms.attachments.some(a => a.path === serverPath); if (hit) { updatedMs = { ...ms, attachments: ms.attachments.map(a => a.path === serverPath ? { ...a, localUri: filePath } : a ), }; } } return { ...m, attachments: updatedAtts, memorySaved: updatedMs }; })); // Wenn der User dieses File explizit oeffnen wollte → Intent-Picker // (Bilder werden separat via setFullscreenImage in der memorySaved- // Bubble geoeffnet, das laeuft nicht ueber autoOpenPaths) if (serverPath && autoOpenPaths.current.has(serverPath)) { autoOpenPaths.current.delete(serverPath); const isImage = (mimeType || '').startsWith('image/'); if (isImage) { setFullscreenImage(filePath); } else { openFileWithIntent(filePath.replace(/^file:\/\//, ''), mimeType); } } }).catch(() => {}); } return; } if (message.type === 'chat') { const sender = (message.payload.sender as string) || ''; const dbgText = ((message.payload.text as string) || '').slice(0, 60); console.log('[Chat] chat-event sender=%s text=%s', sender || '(none)', dbgText); // last-sync tracken — so dass beim Reconnect nicht wieder dieselbe // Nachricht aus dem Server-Backup nachgeladen wird if (sender === 'aria' || sender === 'user' || sender === 'stt') { const ts = message.timestamp || Date.now(); AsyncStorage.setItem('aria_chat_last_sync', String(ts)).catch(() => {}); } // STT-Ergebnis: Transkribierten Text in die Sprach-Bubble schreiben. // WICHTIG: Nur die ERSTE noch unaufgeloeste Aufnahme matchen — sonst // wuerde bei zwei kurz hintereinander gesendeten Audios beide Bubbles // den gleichen Text bekommen (Bug: zweite Antwort ueberschreibt erste). if (sender === 'stt') { const sttText = (message.payload.text as string) || ''; const sttAudioReqId = (message.payload.audioRequestId as string) || ''; if (!sttText) { return; } setMessages(prev => { const newText = `\uD83C\uDFA4 ${sttText}`; // Primaer: matche per audioRequestId (eindeutig pro Aufnahme). // So gibt's keine Verwechslung wenn zwei Audios kurz hintereinander // gesendet wurden und ihre STT-Results ueberlappen. if (sttAudioReqId) { const idxById = prev.findIndex(m => m.audioRequestId === sttAudioReqId); if (idxById >= 0) { const next = prev.slice(); next[idxById] = { ...next[idxById], text: newText }; return next; } } // Fallback: alte Bridge-Version ohne audioRequestId \u2014 match per Substring, // nimmt die ERSTE noch unaufgeloeste Placeholder. const idx = prev.findIndex(m => m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet') ); if (idx >= 0) { const next = prev.slice(); next[idx] = { ...next[idx], text: newText }; return next; } // Letzter Fallback: gar keine Placeholder \u2192 neue Bubble einfuegen return capMessages([...prev, { id: nextId(), sender: 'user', text: newText, timestamp: message.timestamp, attachments: [{ type: 'audio', name: 'Sprachaufnahme' }], }]); }); return; } // Eigene App-Nachrichten ignorieren (werden lokal hinzugefuegt) if (sender === 'user') return; // Diagnostic-Nachrichten als User-Nachricht anzeigen if (sender === 'diagnostic') { const diagText = (message.payload.text as string) || ''; if (diagText) { setMessages(prev => capMessages([...prev, { id: nextId(), sender: 'user', text: diagText, timestamp: message.timestamp, }])); } 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, messageId: (message.payload.messageId as string) || undefined, backupTs: (message.payload.backupTs as number) || undefined, }; // ARIA hat geantwortet → alle User-Bubbles davor als 'delivered' // markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet. return capMessages([...prev, ariaMsg]).map(m => m.sender === 'user' && (m.deliveryStatus === 'sent' || m.deliveryStatus === 'sending') ? { ...m, deliveryStatus: 'delivered' } : m ); }); // ARIA hat geantwortet → Watchdog clearen, falls noch armiert clearStuckWatchdog(); // ALLE noch laufenden ACK-Timer clearen — Bridge hat unsere Messages // ja offensichtlich verarbeitet (sonst keine ARIA-Antwort). Wenn // ein ACK aus Netzgruenden verloren ging, soll der Retry nicht // nachtraeglich loslaufen und die Bubble auf 'failed' setzen. for (const cmid of Array.from(ackTimers.current.keys())) { clearAckTimer(cmid); pendingPayloads.current.delete(cmid); } } // TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable // WICHTIG: via Ref statt direkt state lesen, sonst ist's stale (Closure-Bug). const canPlay = ttsCanPlayRef.current; if (message.type === 'audio_pcm' || (message.type === 'audio' && message.payload.base64)) { console.log('[Chat] audio-msg canPlay=%s (enabled=%s muted=%s)', canPlay, ttsDeviceEnabled, ttsMuted); } if (message.type === 'audio' && message.payload.base64) { const b64 = message.payload.base64 as string; const refId = (message.payload.messageId as string) || ''; if (canPlay) audioService.playAudio(b64); // Cache IMMER schreiben — Play-Button soll auch bei Mute spaeter funktionieren if (refId) { audioService.cacheAudio(b64, refId).then(audioPath => { if (!audioPath) return; setMessages(prev => prev.map(m => m.messageId === refId ? { ...m, audioPath } : m )); }).catch(() => {}); } } // XTTS PCM-Stream: Cache IMMER bauen, Playback nur wenn nicht gemutet if (message.type === ('audio_pcm' as any)) { const p = { ...(message.payload as any), silent: !canPlay }; const refId = (p.messageId as string) || ''; audioService.handlePcmChunk(p).then((audioPath: any) => { if (p.final && audioPath && refId) { setMessages(prev => prev.map(m => m.messageId === refId ? { ...m, audioPath } : m )); } }).catch(() => {}); } // Thinking-Indicator Status von der Bridge if (message.type === 'agent_activity') { const activity = (message.payload.activity as string) || 'idle'; const tool = (message.payload.tool as string) || ''; setAgentActivity({ activity, tool }); // Implizite ACK-Bestaetigung: Brain hat angefangen zu arbeiten → // unsere Nachricht ist offensichtlich angekommen, auch wenn das // chat_ack aus irgendeinem Grund nicht durchkam. Alle laufenden // ACK-Timer canceln + sending-Bubbles auf 'sent' setzen. // Vermeidet das Symptom "Sanduhr bleibt + Timeout" bei langsamen // Brain-Antworten (>90 s, also nach 3 ACK-Retries auf failed). if (activity !== 'idle' && ackTimers.current.size > 0) { for (const cmid of Array.from(ackTimers.current.keys())) { clearAckTimer(cmid); } // Reference-stable: wenn keine Bubble zu aendern ist, geben wir // prev unveraendert zurueck. Sonst triggert .map() ein neues // Array + Re-Render, was waehrend einer aktiven Such-Scroll- // Sequenz die FlatList-Layouts invalidiert → permanenter // onScrollToIndexFailed-Loop. setMessages(prev => { const needs = prev.some(m => m.sender === 'user' && m.deliveryStatus === 'sending'); if (!needs) return prev; return prev.map(m => m.sender === 'user' && m.deliveryStatus === 'sending' ? { ...m, deliveryStatus: 'sent' } : m, ); }); } // In den Gedanken-Stream einfuegen. Dedup gegen identische Folge- // Events (z.B. zwei mal 'thinking' direkt hintereinander). Tool- // Events NIE dedupen — wenn ARIA dreimal Bash hintereinander ruft, // sollen alle drei sichtbar sein. const key = `${activity}|${tool}`; const isTool = activity === 'tool'; if (isTool || key !== lastThoughtKeyRef.current) { lastThoughtKeyRef.current = key; setThoughts(prev => { const next = [...prev, { ts: Date.now(), activity, tool }]; return next.length > MAX_THOUGHTS ? next.slice(-MAX_THOUGHTS) : next; }); } // Spotify darf waehrend "ARIA denkt/schreibt" weiterspielen — pausiert // nur wenn TTS startet (dann acquired _firePlaybackStarted den Focus). // Watchdog: solange Brain noch Lebenszeichen sendet (jedes neue // activity-Event), Timer neu starten. 21 Min ohne Update → Hang. // Knapp ueber Brain-Timeout (20 Min) damit nur bei echten // Verbindungsabbruechen / Brain-Crashes gefeuert wird, nicht waehrend // legitimer langer Multi-Tool-Sessions die das Brain selbst kappt. clearStuckWatchdog(); if (activity !== 'idle') { stuckWatchdog.current = setTimeout(() => { stuckWatchdog.current = null; setAgentActivity({ activity: 'idle', tool: '' }); setMessages(prev => capMessages([...prev, { id: nextId(), sender: 'aria', text: '⚠️ Habe gerade keine Verbindung zurueck bekommen (Timeout nach 21 Min). Deine letzte Nachricht ist evtl. nicht durchgekommen — schick sie nochmal.', timestamp: Date.now(), }])); }, 1_260_000); } } // Voice-Config aus Diagnostic — setzt die lokale App-Stimme auf den // gerade in Diagnostic gewaehlten Wert zurueck. User-Wahl in der App // wird dadurch ueberschrieben. if (message.type === ('config' as any)) { const newVoice = ((message.payload as any).xttsVoice as string) ?? ''; localXttsVoiceRef.current = newVoice; AsyncStorage.setItem('aria_xtts_voice', newVoice); } // XTTS-Bridge meldet Stimme fertig geladen (kurzer Status-Toast) if (message.type === ('voice_ready' as any)) { const v = ((message.payload as any).voice as string) ?? ''; const err = (message.payload as any).error as string | undefined; if (err) { ToastAndroid.show(`Stimme "${v}" Fehler: ${err}`, ToastAndroid.LONG); } else { ToastAndroid.show(`Stimme "${v || 'Standard'}" bereit`, ToastAndroid.SHORT); } } // Gamebox-Bridges (f5tts/whisper) melden Lade-Status — Banner oben if (message.type === ('service_status' as any)) { const p = message.payload as any; const svc = (p?.service as string) || ''; if (!svc) return; setServiceStatus(prev => ({ ...prev, [svc]: { state: (p?.state as string) || 'unknown', model: p?.model as string | undefined, loadSeconds: p?.loadSeconds as number | undefined, error: p?.error as string | undefined, }, })); // Bei neuer Loading-Phase Banner wieder aktivieren if (p?.state === 'loading') setServiceBannerDismissed(false); } }); const unsubState = rvs.onStateChange((state) => { setConnectionState(state); connectionStateRef.current = state; // Bei (re)connect: KOMPLETTEN Server-Stand holen. Server ist die // Source-of-Truth — wenn er leer ist (z.B. nach "Konversation // zuruecksetzen"), soll die App das spiegeln, auch wenn sie offline // war als das passiert ist. since=0 + limit=200 → die letzten 200 // Nachrichten vom Server, oder leeres Array wenn Server leer. if (state === 'connected') { rvs.send('chat_history_request' as any, { since: 0, limit: 200 }); // Offline-Queue flushen — alle 'queued'-Bubbles raussschicken flushQueuedMessages(); } else if (state === 'disconnected') { // ACK-Timer cancellen, betroffene Bubbles auf 'queued' zurueck for (const [cmid, t] of ackTimers.current.entries()) { clearTimeout(t); ackTimers.current.delete(cmid); setMessages(prev => prev.map(m => m.clientMsgId === cmid && m.deliveryStatus === 'sending' ? { ...m, deliveryStatus: 'queued' } : m )); } } }); // Initalen Status setzen const initialState = rvs.getState(); setConnectionState(initialState); connectionStateRef.current = initialState; return () => { unsubMessage(); unsubState(); }; }, []); // Auto-Update: Bei App-Start pruefen useEffect(() => { const unsubUpdate = updateService.onUpdateAvailable((info) => { updateService.promptUpdate(info); }); // Nach 5s pruefen (RVS muss erst verbunden sein) const timer = setTimeout(() => updateService.checkForUpdate(), 5000); return () => { unsubUpdate(); clearTimeout(timer); }; }, []); // Gespraechsmodus: Nach TTS-Wiedergabe automatisch Aufnahme starten useEffect(() => { const unsubPlayback = audioService.onPlaybackFinished(() => { if (wakeWordService.isActive()) { wakeWordService.resume(); } }); return () => unsubPlayback(); }, []); // Wake Word / Gespraechsmodus: Auto-Aufnahme starten useEffect(() => { const unsubWake = wakeWordService.onWakeWord(async () => { console.log('[Chat] Gespraechsmodus — starte Auto-Aufnahme'); // Conversation-Window: User hat X Sekunden um anzufangen, sonst Konversation aus const windowMs = await loadConvWindowMs(); const started = await audioService.startRecording(true, windowMs); if (started) { // Erst JETZT signalisieren dass das Mikro wirklich offen ist — // vorher war's noch in der Init-Phase. So weiss der User exakt // ab wann er reden kann. "Bereit"-Sound (Ding-Dong) ist optional // ueber Settings → Wake-Word abschaltbar. ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT); playWakeReadySound().catch(() => {}); } else { // Mikrofon nicht verfuegbar, naechsten Versuch 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) { // User hat im Fenster gesprochen → Sprachnachricht senden // Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist. const wasInterrupted = interruptAriaIfBusy(); const location = await getCurrentLocation(); const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`; const userMsg: ChatMessage = { id: nextId(), sender: 'user', text: '🎙 Spracheingabe wird verarbeitet...', timestamp: Date.now(), attachments: [{ type: 'audio', name: 'Sprachaufnahme' }], audioRequestId, }; setMessages(prev => capMessages([...prev, userMsg])); rvs.send('audio', { base64: result.base64, durationMs: result.durationMs, mimeType: result.mimeType, voice: localXttsVoiceRef.current, speed: ttsSpeedRef.current, interrupted: wasInterrupted, audioRequestId, ...(location && { location }), }); scheduleStaleAudioCleanup(audioRequestId, result.durationMs); // resume() wird durch onPlaybackFinished nach ARIAs Antwort getriggert. } else { // Kein Speech im Window → Konversation beenden (Ohr geht aus oder // bleibt armed wenn Wake Word verfuegbar) wakeWordService.endConversation(); // UI-State synchron halten if (!wakeWordService.isActive()) setWakeWordActive(false); } }); // Barge-In via Wake-Word: User sagt "Computer" waehrend ARIA spricht. // Wake-Word-Service hat bei TTS-Start parallel zu lauschen begonnen // (mit AcousticEchoCanceler damit ARIAs eigene Stimme nicht triggert). const unsubBarge = wakeWordService.onBargeIn(async () => { console.log('[Chat] Barge-In via Wake-Word — TTS abbrechen + neue Aufnahme'); audioService.haltAllPlayback('barge-in via wake-word'); setAgentActivity({ activity: 'idle', tool: '' }); rvs.send('cancel_request' as any, {}); // Kurze Pause damit halt durchgreift, dann neue Aufnahme starten await new Promise(r => setTimeout(r, 150)); const windowMs = await loadConvWindowMs(); const started = await audioService.startRecording(true, windowMs); if (started) { ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT); playWakeReadySound().catch(() => {}); } }); // TTS-Lifecycle: solange ARIA spricht und Wake-Word verfuegbar ist, // parallel mitlauschen — User kann "Computer" sagen statt manuell tappen. // PLUS: Foreground-Service-Slot 'tts' belegen damit Android den App- // Prozess nicht killt wenn die App im Hintergrund ist. const unsubTtsStart = audioService.onPlaybackStarted(() => { acquireBackgroundAudio('tts').catch(() => {}); if (wakeWordService.isConversing() && wakeWordService.hasWakeWord()) { wakeWordService.startBargeListening().catch(() => {}); } }); const unsubTtsEnd = audioService.onPlaybackFinished(() => { releaseBackgroundAudio('tts').catch(() => {}); // Vor naechster Aufnahme: barge-listening aus damit der AudioRecorder // das Mikro greifen kann. wakeWordService.stopBargeListening().catch(() => {}); }); return () => { unsubWake(); unsubSilence(); unsubBarge(); unsubTtsStart(); unsubTtsEnd(); }; }, [wakeWordActive]); // Wake Word Toggle Handler const toggleWakeWord = useCallback(async () => { if (wakeWordActive) { // Vor Porcupine-Stop: eventuelle laufende Aufnahme abbrechen. Sonst // bleibt audioService.recordingState=='recording' haengen und der // normale Aufnahme-Button wirkt nicht mehr (startRecording lehnt // ab weil "Aufnahme laeuft bereits"). try { await audioService.stopRecording(); } catch {} await wakeWordService.stop(); setWakeWordActive(false); } else { const started = await wakeWordService.start(); setWakeWordActive(started); } }, [wakeWordActive]); // Chat-Verlauf in AsyncStorage speichern (debounced, nur nach initialem Laden) const saveTimer = useRef | null>(null); useEffect(() => { 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]); // Gedanken-Stream beim Mount aus AsyncStorage laden useEffect(() => { AsyncStorage.getItem(THOUGHT_STORAGE_KEY) .then(raw => { if (!raw) return; try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) setThoughts(parsed.slice(-MAX_THOUGHTS)); } catch {} }) .catch(() => {}); }, []); // Gedanken-Stream persistieren (debounced) const thoughtSaveTimer = useRef | null>(null); useEffect(() => { if (thoughts.length === 0) { AsyncStorage.removeItem(THOUGHT_STORAGE_KEY).catch(() => {}); return; } if (thoughtSaveTimer.current) clearTimeout(thoughtSaveTimer.current); thoughtSaveTimer.current = setTimeout(() => { AsyncStorage.setItem( THOUGHT_STORAGE_KEY, JSON.stringify(thoughts.slice(-MAX_THOUGHTS)), ).catch(() => {}); }, 500); return () => { if (thoughtSaveTimer.current) clearTimeout(thoughtSaveTimer.current); }; }, [thoughts]); // messagesRef immer aktuell halten — wird von dispatchWithAck/Retry gelesen // damit Retries auf den aktuellen deliveryStatus reagieren koennen. useEffect(() => { messagesRef.current = messages; }, [messages]); // Inverted FlatList: neueste Nachrichten unten, kein manuelles Scrollen noetig // Spezial-Bubbles (memorySaved/triggerCreated/skillCreated) sollen im Chat // NICHT mehr erscheinen — sie werden in der Notizen-Inbox angezeigt. // Das verhindert dass sie chronologisch unten im Chat haengen und der // eigentliche Chat-Verlauf darunter verschwindet. const chatVisibleMessages = useMemo( () => messages.filter(m => !m.memorySaved && !m.triggerCreated && !m.skillCreated), [messages], ); const invertedMessages = useMemo(() => [...chatVisibleMessages].reverse(), [chatVisibleMessages]); // Such-Treffer: alle Message-IDs die zur Query passen. NEUESTE ZUERST — // analog zu WhatsApp/Telegram: User ist visuell unten im Chat, der erste // Treffer ist meist schon im Viewport (kein weiter Pre-Scroll, kein // Cold-Start-Sprung-Fail). „Naechster" geht in die Vergangenheit. // WICHTIG: nur in chatVisibleMessages suchen — Spezial-Bubbles (Memory/ // Skill/Trigger) sind im Chat-Stream nicht sichtbar und Treffer auf die // wuerden zu „ID nicht im FlatList → findIndex=-1 → kein Scroll"-Fail // fuehren. const searchMatchIds = useMemo(() => { const q = searchQuery.trim().toLowerCase(); if (!q) return [] as string[]; return chatVisibleMessages .filter(m => (m.text || '').toLowerCase().includes(q)) .map(m => m.id) .reverse(); }, [chatVisibleMessages, searchQuery]); useEffect(() => { setSearchIndex(0); }, [searchQuery]); // Tracking damit wir nicht zur selben Bubble mehrfach scrollen (z.B. wenn // neue Nachrichten kommen waehrend Suche aktiv ist → invertedMessages // aendert sich, soll aber nicht den Scroll erneut triggern). const lastSearchScrollKey = useRef(''); // Pending Retry-Timer fuer onScrollToIndexFailed — wird gecancelt sobald // ein neuer Search-Hit kommt, damit alte Retries nicht den neuen // Scroll-Versuch durcheinanderbringen ("permanent springen"-Bug). const pendingScrollRetry = useRef | null>(null); // Zaehler fuer fehlgeschlagene Scroll-Retries. Hartes Limit gegen // Endlos-Loops wenn das Item-Layout aus irgendwelchen Gruenden nie // verfuegbar wird (z.B. weil setMessages mitten in der Sequenz die // FlatList re-rendert). const scrollRetryCount = useRef(0); // 6 Retries: bei weiten Spruengen (Suche auf Bubble #150 von Position 0) // kann FlatList mehrere Iterationen brauchen bis die Items in der Naehe // gemessen sind. Vorher 3 = vorzeitig aufgegeben. const MAX_SCROLL_RETRIES = 6; const clearPendingScrollRetry = () => { if (pendingScrollRetry.current) { clearTimeout(pendingScrollRetry.current); pendingScrollRetry.current = null; } scrollRetryCount.current = 0; }; // Bei Search-Index-Wechsel zur entsprechenden Bubble scrollen. // FlatList ist `inverted`. viewPosition 0 = Item-Top oben am Viewport → // Treffer-Bubble liegt mit dem Anfang direkt oben sichtbar. // WICHTIG: invertedMessages bewusst NICHT in den Deps — sonst feuert das // Effekt bei jeder neuen ARIA-Nachricht erneut und scrollt amok. // Den aktuellen Snapshot von invertedMessages holen wir via Ref. const invertedMessagesRef = useRef(invertedMessages); invertedMessagesRef.current = invertedMessages; // Cache fuer echte Bubble-Hoehen, gefuettert per onLayout in // renderMessage. Wird beim Pre-Scroll genutzt damit der grobe Sprung // praezise landet (statt mit dem 150-px-Pauschalwert weit daneben). const itemHeights = useRef>(new Map()); const AVG_BUBBLE_HEIGHT = 150; // Fallback fuer noch nicht gemessene Items useEffect(() => { if (!searchMatchIds.length) { lastSearchScrollKey.current = ''; clearPendingScrollRetry(); return; } const id = searchMatchIds[searchIndex]; if (!id) return; // Eindeutiger Schluessel pro Treffer-Stop — verhindert dass identische // Re-Renders erneut scrollen. const key = `${searchIndex}:${id}`; if (lastSearchScrollKey.current === key) return; lastSearchScrollKey.current = key; // Neue Suche → alte Retries verwerfen clearPendingScrollRetry(); const idx = invertedMessagesRef.current.findIndex(m => m.id === id); if (idx < 0 || !flatListRef.current) return; // Pre-Scroll: erst grob in die Naehe springen, damit FlatList die // Bubbles in der Umgebung ueberhaupt rendert (sonst basiert // averageItemLength im Failed-Handler nur auf den ersten ~10 Items // und liefert einen voellig falschen Sprung). // Offset = Summe echter Hoehen (aus itemHeights-Cache, gefuettert per // onLayout) + dynamischer Fallback aus dem Mittel der bisher // gemessenen Items. Beim Cold-Start gibt's nur 10 Messungen (die // neuesten unten in der invertierten Liste) — der Mittel daraus ist // immer noch besser als die Pauschal-150. const measured = Array.from(itemHeights.current.values()); const dynamicAvg = measured.length >= 5 ? measured.reduce((a, b) => a + b, 0) / measured.length : AVG_BUBBLE_HEIGHT; let preOffset = 0; const inv = invertedMessagesRef.current; for (let i = 0; i < idx; i++) { preOffset += itemHeights.current.get(inv[i].id) || dynamicAvg; } try { flatListRef.current?.scrollToOffset({ offset: preOffset, animated: false, }); } catch {} // Nach Render-Pause praezise nachsetzen. 350 ms — bei weiten Spruengen // (Pre-Scroll 5000+ px) braucht FlatList Zeit die Items dort zu // mounten und onLayout zu feuern. Zu kurz → averageItemLength im // Failed-Handler basiert noch auf den falschen Items. requestAnimationFrame(() => { setTimeout(() => { try { flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0 }); } catch { // onScrollToIndexFailed-Handler uebernimmt den Fallback } }, 350); }); }, [searchIndex, searchMatchIds]); // Unmount → pending Timer verwerfen, sonst feuern sie nach Navigation ins Leere useEffect(() => () => { clearPendingScrollRetry(); clearStuckWatchdog(); }, []); const activeSearchId = searchMatchIds[searchIndex] || ''; const gotoSearchPrev = () => { if (!searchMatchIds.length) return; setSearchIndex(i => (i - 1 + searchMatchIds.length) % searchMatchIds.length); }; const gotoSearchNext = () => { if (!searchMatchIds.length) return; setSearchIndex(i => (i + 1) % searchMatchIds.length); }; // GPS-Position holen (optional) const getCurrentLocation = useCallback((): Promise<{ lat: number; lon: number } | null> => { if (!gpsEnabled) { console.log('[GPS] gpsEnabled=false → kein Standort'); return Promise.resolve(null); } return new Promise((resolve) => { Geolocation.getCurrentPosition( (position) => { const loc = { lat: position.coords.latitude, lon: position.coords.longitude, }; console.log('[GPS] Position: lat=%s lon=%s', loc.lat, loc.lon); resolve(loc); }, (error) => { console.warn('[GPS] getCurrentPosition Fehler:', error?.code, error?.message); resolve(null); }, { enableHighAccuracy: false, timeout: 5000 }, ); }); }, [gpsEnabled]); // --- Nachricht senden --- // Aufraeumen von "verarbeitet"-Placeholder die nie ein STT-Result bekommen // haben (leere Aufnahme, Wake-Word-Echo, STT-Fehler etc). Timeout skaliert // mit der Aufnahmedauer — Whisper braucht auf der Gamebox grob real-time/5, // plus Bridge-Roundtrip + Network. Formel: 60s Buffer + 1x Aufnahmedauer. // Bei 5min Aufnahme = 6 min Wait, bei 5s Aufnahme = 65s. Sicher genug damit // langsame STTs nicht versehentlich aufgeraeumt werden. const scheduleStaleAudioCleanup = useCallback((audioRequestId: string, recordingMs: number) => { const timeoutMs = 60000 + recordingMs; setTimeout(() => { setMessages(prev => { const idx = prev.findIndex(m => m.audioRequestId === audioRequestId && m.text.includes('Spracheingabe wird verarbeitet') ); if (idx < 0) return prev; console.log('[Chat] Sprachnachricht ohne STT-Result nach %dms entfernt: %s', timeoutMs, audioRequestId); ToastAndroid.show('Sprachnachricht nicht erkannt — entfernt', ToastAndroid.SHORT); return prev.filter((_, i) => i !== idx); }); }, timeoutMs); }, []); const sendTextMessage = useCallback(async () => { const text = inputText.trim(); // Wenn pending Anhaenge vorhanden → Anhaenge + Text zusammen senden if (pendingAttachments.length > 0) { sendPendingAttachments(text); return; } if (!text) return; setInputText(''); // Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist. const wasInterrupted = interruptAriaIfBusy(); const location = await getCurrentLocation(); const cmid = nextClientMsgId(); const userMsg: ChatMessage = { id: nextId(), sender: 'user', text, timestamp: Date.now(), clientMsgId: cmid, deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued', sendAttempts: 1, }; setMessages(prev => capMessages([...prev, userMsg])); console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s', cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted); dispatchWithAck(cmid, 'chat', { text, voice: localXttsVoiceRef.current, speed: ttsSpeedRef.current, interrupted: wasInterrupted, ...(location && { location }), }); }, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]); // Anfrage abbrechen — sofort lokalen Indicator weg, Bridge triggert doctor --fix const cancelRequest = useCallback(() => { setAgentActivity({ activity: 'idle', tool: '' }); clearStuckWatchdog(); rvs.send('cancel_request' as any, {}); }, []); // Barge-In: wenn der User waehrend ARIA arbeitet/spricht eine neue Sprach- // Nachricht aufnimmt, alte Aktivitaet sofort abbrechen — TTS verstummen, // aria-core-Run via cancel_request abbrechen. So kann man "ach vergiss es, // mach lieber X" sagen wie in einem echten Gespraech. const interruptAriaIfBusy = useCallback(() => { const speaking = audioService.isPlayingAudio(); const thinking = agentActivity.activity !== 'idle'; if (!speaking && !thinking) return false; console.log('[Chat] Barge-In: speaking=%s thinking=%s — interrupting ARIA', speaking, thinking); if (speaking) audioService.haltAllPlayback('user spricht (barge-in)'); if (thinking) { setAgentActivity({ activity: 'idle', tool: '' }); clearStuckWatchdog(); rvs.send('cancel_request' as any, {}); } return true; }, [agentActivity]); // Sprachaufnahme abgeschlossen const handleVoiceRecording = useCallback(async (result: RecordingResult) => { // Barge-In: laufende ARIA-Aktivitaet abbrechen falls aktiv. const wasInterrupted = interruptAriaIfBusy(); const location = await getCurrentLocation(); const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`; const cmid = nextClientMsgId(); const userMsg: ChatMessage = { id: nextId(), sender: 'user', text: '🎙 Spracheingabe wird verarbeitet...', timestamp: Date.now(), audioRequestId, clientMsgId: cmid, deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued', sendAttempts: 1, }; setMessages(prev => capMessages([...prev, userMsg])); dispatchWithAck(cmid, 'audio', { base64: result.base64, durationMs: result.durationMs, mimeType: result.mimeType, voice: localXttsVoiceRef.current, speed: ttsSpeedRef.current, interrupted: wasInterrupted, audioRequestId, ...(location && { location }), }); scheduleStaleAudioCleanup(audioRequestId, result.durationMs); // Manueller Mikro-Stop waehrend Wake-Word-Konversation: User hat explizit // den Knopf gedrueckt → er moechte nicht in den automatischen Multi-Turn- // Modus, sondern nach ARIAs Antwort zurueck zu passivem Wake-Word-Lauschen. // Bei VAD-Auto-Stop (Wake-Word-Pfad) laeuft das ueber den silence-callback // und endet mit resume() — der manuelle Stop hier ist der "ich bin fertig"- // Knopf. if (wakeWordService.isConversing()) { console.log('[Chat] Manueller Stop in Konversation → endConversation, zurueck zu armed'); await wakeWordService.endConversation(); } }, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]); // Datei auswaehlen → zur Pending-Liste hinzufuegen const handleFileSelected = useCallback(async (file: FileData) => { setShowFileUpload(false); setPendingAttachments(prev => [...prev, { file, isPhoto: false }]); }, []); // Foto auswaehlen → zur Pending-Liste hinzufuegen const handlePhotoSelected = useCallback(async (photo: PhotoData) => { setShowCameraUpload(false); setPendingAttachments(prev => [...prev, { file: photo, isPhoto: true }]); }, []); // Alle Pending Anhaenge + Text senden const sendPendingAttachments = useCallback(async (messageText: string) => { if (pendingAttachments.length === 0) return; console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', pendingAttachments.length); const location = await getCurrentLocation(); const msgId = nextId(); // Alle Attachments fuer die Chat-Nachricht sammeln const attachments: Attachment[] = []; for (const { file, isPhoto } of pendingAttachments) { const isImage = isPhoto || (file.type && file.type.startsWith('image/')); const name = isPhoto ? file.fileName : file.name; const base64 = file.base64 || ''; const mimeType = file.type || ''; const imageUri = isImage && base64 ? `data:${mimeType};base64,${base64}` : file.uri; attachments.push({ type: isImage ? 'image' : 'file', name, size: file.size, uri: imageUri, mimeType, }); } // Chat-Nachricht mit allen Anhaengen. clientMsgId nur wenn Text dabei // ist — files selber haben (noch) kein ACK-Tracking auf der Bridge. const cmid = messageText ? nextClientMsgId() : undefined; const userMsg: ChatMessage = { id: msgId, sender: 'user', text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`, timestamp: Date.now(), attachments, ...(cmid && { clientMsgId: cmid, deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued', sendAttempts: 1, }), }; setMessages(prev => capMessages([...prev, userMsg])); // Alle Dateien an RVS senden + auf Disk speichern for (const { file, isPhoto } of pendingAttachments) { const name = isPhoto ? file.fileName : file.name; const base64 = file.base64 || ''; const mimeType = file.type || ''; // Auf Disk speichern if (base64) { persistAttachment(base64, msgId + '_' + name, name).then(filePath => { setMessages(prev => prev.map(m => m.id === msgId ? { ...m, attachments: m.attachments?.map(a => a.name === name && !a.uri?.startsWith('file://') ? { ...a, uri: filePath } : a )} : m )); }).catch(() => {}); } // An RVS senden console.log('[Chat] sende file: name=%s mime=%s size=%s b64Bytes=%s', name, mimeType, file.size, base64.length); rvs.send('file', { name, type: mimeType, size: file.size, base64, ...(isPhoto && file.width && { width: file.width, height: file.height }), ...(location && { location }), }); } // Text als separate Nachricht (damit ARIA weiss was zu tun ist) — mit // dem clientMsgId der Bubble, damit Bridge+ACK die richtige Bubble // adressieren. if (messageText && cmid) { dispatchWithAck(cmid, 'chat', { text: messageText, voice: localXttsVoiceRef.current, speed: ttsSpeedRef.current, ...(location && { location }), }); } setPendingAttachments([]); setInputText(''); }, [pendingAttachments, getCurrentLocation, dispatchWithAck]); // --- 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', }); const isSearchHit = activeSearchId === item.id; const searchHighlightStyle = isSearchHit ? { borderWidth: 2, borderColor: '#FFD60A' } : null; // Spezial-Bubble: ARIA hat etwas via memory_save gespeichert if (item.memorySaved) { const m = item.memorySaved; const catPart = m.category ? ` · [${m.category}]` : ''; const atts = m.attachments || []; const action = m.action || 'created'; const headline = action === 'updated' ? '🧠 ARIA hat eine Notiz geändert' : action === 'deleted' ? '🧠 ARIA hat eine Notiz gelöscht' : '🧠 ARIA hat etwas gemerkt'; const headlineColor = action === 'deleted' ? '#FF6B6B' : '#FFD60A'; const borderColor = action === 'deleted' ? '#FF6B6B' : '#FFD60A'; const openable = !!m.id && action !== 'deleted'; const Wrapper: any = openable ? TouchableOpacity : View; const wrapperProps = openable ? { onPress: () => setMemoryDetailId(m.id || null), activeOpacity: 0.7 } : {}; return ( {headline} {m.title} {` (${m.type}${m.pinned ? ' · 📌 pinned' : ''}${catPart})`} {m.preview ? ( {m.preview}{m.preview.length >= 140 ? '…' : ''} ) : null} {atts.map((a, idx) => { const isImage = (a.mime || '').startsWith('image/'); const icon = isImage ? '🖼️' : '📄'; const sizeStr = a.size ? ` · ${(a.size / 1024).toFixed(0)} KB` : ''; return ( { if (!a.path) return; if (a.localUri) { const localPath = a.localUri.replace(/^file:\/\//, ''); const exists = await RNFS.exists(localPath).catch(() => false); if (exists) { if (isImage) setFullscreenImage(a.localUri); else openFileWithIntent(localPath, a.mime || ''); return; } // Cache weg → localUri leeren + neu laden setMessages(prev => prev.map(mm => mm.id === item.id && mm.memorySaved ? { ...mm, memorySaved: { ...mm.memorySaved, attachments: mm.memorySaved.attachments?.map(x => x.path === a.path ? { ...x, localUri: undefined } : x) } } : mm)); if (Platform.OS === 'android') { ToastAndroid.show('Cache leer — lade nach...', ToastAndroid.SHORT); } } // Datei via Bridge nachladen — file_response hat den // memorySaved-Match-Path und cached + zeigt direkt autoOpenPaths.current.add(a.path); rvs.send('file_request' as any, { serverPath: a.path, requestId: `memAtt_${item.id}_${idx}` }); }} > {icon} {a.name} {a.localUri ? '(tippen zum oeffnen)' : `(tippen zum Laden${sizeStr})`} ); })} ARIA-Memory · {time}{openable ? ' · tippen für Details' : ''} ); } // Spezial-Bubble: ARIA hat einen Trigger angelegt if (item.triggerCreated) { const t = item.triggerCreated; const detailLine = t.type === 'timer' ? `feuert: ${t.fires_at || '?'}` : `wenn: ${t.condition || '?'}`; return ( {'⏰ ARIA hat einen Trigger angelegt'} {t.name} {` (${t.type})`} {detailLine} {`"${t.message}"`} ARIA-Trigger · {time} ); } // Spezial-Bubble: ARIA hat einen Skill erstellt if (item.skillCreated) { const s = item.skillCreated; return ( {'🛠 ARIA hat einen neuen Skill erstellt'} {s.name} {` (${s.execution}, ${s.active ? 'aktiv' : 'deaktiviert'})`} {s.description} {s.setupError && ( {'⚠ Setup-Fehler: '}{s.setupError.slice(0, 200)} )} ARIA-Skill · {time} ); } return ( { // Echte Hoehe in Cache speichern — Pre-Scroll der Suche nutzt // die summierten Cache-Werte fuer praezisen Sprung. Bei // unbekannten Items faellt's auf AVG_BUBBLE_HEIGHT zurueck. itemHeights.current.set(item.id, e.nativeEvent.layout.height); }} > {/* Anhang-Vorschau */} {item.attachments?.map((att, idx) => ( {att.deleted ? ( {'🗑️'} {att.name} (geloescht) ) : att.type === 'image' && att.uri ? ( setFullscreenImage(att.uri || null)} onError={() => { 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 ? ( { if (att.serverPath) { rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id }); } }} > {'\uD83D\uDDBC\uFE0F'} {att.name} {att.serverPath ? '(tippen zum Laden)' : '(nicht verfuegbar)'} ) : ( { // Lokal vorhanden? Cache koennte geleert worden sein \u2014 // Datei-Existenz pruefen bevor wir den Intent feuern. if (att.uri) { const localPath = att.uri.replace(/^file:\/\//, ''); const exists = await RNFS.exists(localPath).catch(() => false); if (exists) { openFileWithIntent(localPath, att.mimeType || ''); return; } // Cache weg \u2192 uri im State leeren damit UI "tippen zum Laden" zeigt setMessages(prev => prev.map(m => m.id === item.id ? { ...m, attachments: m.attachments?.map(a => a.serverPath === att.serverPath ? { ...a, uri: undefined } : a) } : m)); if (Platform.OS === 'android') { ToastAndroid.show('Cache leer \u2014 lade nach...', ToastAndroid.SHORT); } } // Re-Download via file_request \u2192 bei file_response wird die // Datei gespeichert UND geoeffnet (autoOpenPaths-Tracking). if (att.serverPath) { autoOpenPaths.current.add(att.serverPath); rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id }); } else if (Platform.OS === 'android') { ToastAndroid.show('Datei kann nicht nachgeladen werden (kein serverPath)', ToastAndroid.LONG); } }} > {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'} {att.name} {att.size ? {Math.round(att.size / 1024)}KB : null} {!att.uri && att.serverPath && ( (tippen zum oeffnen) )} {!att.uri && !att.serverPath && (nicht verfuegbar)} )} ))} {/* 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)) && ( )} {/* Play-Button fuer ARIA-Nachrichten — Cache bevorzugt, sonst Bridge-TTS mit aktueller Engine */} {!isUser && item.text.length > 0 && ( { // Erst lokalen Cache pruefen — audioPath kann auf eine geloeschte // Datei zeigen (TTS-Cache geleert oder Auto-Cleanup). In dem Fall // ueber RVS neu rendern lassen statt stumm zu bleiben. const cachePath = item.audioPath?.replace(/^file:\/\//, '') || ''; const cached = cachePath ? await RNFS.exists(cachePath).catch(() => false) : false; if (cached) { audioService.playFromPath(item.audioPath!); return; } // messageId mitschicken damit die Bridge das generierte Audio // wieder mit der Nachricht verknuepft (fuer den naechsten Replay aus Cache) rvs.send('tts_request' as any, { text: item.text, voice: localXttsVoiceRef.current, speed: ttsSpeedRef.current, messageId: item.messageId || '', }); }} > {'\uD83D\uDD0A'} )} {item.backupTs ? ( confirmDeleteBubble(item)} > {'🗑'} ) : null} {time} {isUser && item.deliveryStatus ? ( item.deliveryStatus === 'failed' && item.clientMsgId ? ( retryFailedMessage(item.clientMsgId!)} > {'⚠ tippen f. Retry'} ) : ( {item.deliveryStatus === 'queued' ? '⏱' : item.deliveryStatus === 'sending' ? '⏳' : item.deliveryStatus === 'sent' ? '✓' : /* delivered */ '✓✓'} ) ) : null} ); }; const confirmDeleteBubble = (item: ChatMessage) => { const ts = item.backupTs; if (!ts) return; const preview = (item.text || '').slice(0, 80) || '(leere Bubble)'; Alert.alert( 'Bubble loeschen?', `"${preview}${item.text && item.text.length > 80 ? '…' : ''}"\n\nWird aus chat_backup, Brain-Konversation und allen Clients entfernt.`, [ { text: 'Abbrechen', style: 'cancel' }, { text: 'Loeschen', style: 'destructive', onPress: () => { console.log(`[Chat] delete_message_request ts=${ts}`); rvs.send('delete_message_request' as any, { ts }); }, }, ], ); }; const connectionDotColor = connectionState === 'connected' ? '#34C759' : connectionState === 'connecting' ? '#FFD60A' : '#FF3B30'; return ( {/* Verbindungsstatus-Leiste */} {connectionState === 'connected' ? 'Verbunden' : connectionState === 'connecting' ? 'Verbinde...' : 'Getrennt'} setThoughtsVisible(true)} style={{marginLeft: 'auto', paddingHorizontal: 6, flexDirection: 'row', alignItems: 'center'}} hitSlop={{top:8,bottom:8,left:6,right:6}}> {'\uD83D\uDCAD'} {thoughts.length > 0 ? ( {thoughts.length} ) : null} setInboxVisible(true)} style={{paddingHorizontal: 6}} hitSlop={{top:8,bottom:8,left:6,right:6}}> {'\uD83D\uDDC2\uFE0F'} setSearchVisible(!searchVisible)} style={{paddingHorizontal: 6}} hitSlop={{top:8,bottom:8,left:6,right:6}}> {'\uD83D\uDD0D'} {/* Service-Status Banner (Gamebox: F5-TTS / Whisper Lade-Status) */} {(() => { const entries = Object.entries(serviceStatus); if (entries.length === 0 || serviceBannerDismissed) return null; const anyLoading = entries.some(([, v]) => v.state === 'loading'); const anyError = entries.some(([, v]) => v.state === 'error'); const allReady = !anyLoading && !anyError && entries.every(([, v]) => v.state === 'ready'); const bg = anyError ? '#3A1F1F' : anyLoading ? '#3A331F' : '#1F3A2A'; const border = anyError ? '#FF3B30' : anyLoading ? '#FFD60A' : '#34C759'; const labels: Record = { f5tts: 'F5-TTS', whisper: 'Whisper STT' }; return ( { if (allReady) setServiceBannerDismissed(true); }} style={[styles.serviceBanner, { backgroundColor: bg, borderColor: border }]} > {entries.map(([svc, info]) => { let icon = '\u23F3', text = ''; if (info.state === 'loading') { text = `${labels[svc] || svc}: laedt${info.model ? ' ' + info.model : ''}...`; } else if (info.state === 'ready') { icon = '\u2705'; const sec = info.loadSeconds ? ` (${info.loadSeconds.toFixed(1)}s)` : ''; text = `${labels[svc] || svc}: bereit${info.model ? ' ' + info.model : ''}${sec}`; } else if (info.state === 'error') { icon = '\u274C'; text = `${labels[svc] || svc}: Fehler ${info.error || ''}`; } else { text = `${labels[svc] || svc}: ${info.state}`; } return ( {icon} {text} ); })} {allReady ? 'Tippen zum Schliessen' : 'Bitte warten...'} ); })()} {/* Suchleiste mit Treffer-Navigation */} {searchVisible && ( {searchQuery ? ( {searchMatchIds.length ? `${searchIndex + 1}/${searchMatchIds.length}` : '0/0'} ) : null} {'▲'} {'▼'} { setSearchVisible(false); setSearchQuery(''); }}> X )} {/* Nachrichtenliste — Suche FILTERT NICHT mehr, sondern hebt aktiven Treffer hervor (siehe renderMessage: activeSearchId-Border). */} { // Bei inverted FlatList: contentOffset.y > 0 = weg von "unten" // (= aelter scrollen). Wir zeigen den Jump-Down-Button ab ~250px. const y = e.nativeEvent.contentOffset.y; setShowJumpDown(y > 250); }} scrollEventThrottle={120} onScrollToIndexFailed={(info) => { // FlatList kennt das Item-Layout noch nicht. Wir scrollen grob in // die Naehe (Average-Item-Hoehe-Schaetzung) und versuchen bis zu // MAX_SCROLL_RETRIES mal praezise nachzusetzen. Danach geben wir // auf — User sieht die Bubble in der ungefaehren Naehe und kann // selber finetunen. Frueher: jeder failed Retry triggerte einen // neuen Retry ohne Limit → "permanent springen"-Bug, vor allem // wenn waehrenddessen setMessages die Layouts invalidierte. const offset = info.averageItemLength * info.index; try { flatListRef.current?.scrollToOffset({ offset, animated: false }); } catch {} if (pendingScrollRetry.current) { clearTimeout(pendingScrollRetry.current); pendingScrollRetry.current = null; } if (scrollRetryCount.current >= MAX_SCROLL_RETRIES) { // Aufgeben — Item ist offenbar nicht stabil renderbar scrollRetryCount.current = 0; return; } scrollRetryCount.current += 1; pendingScrollRetry.current = setTimeout(() => { pendingScrollRetry.current = null; try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0 }); } catch {} }, 300); }} keyExtractor={item => item.id} renderItem={renderMessage} contentContainerStyle={styles.messageList} showsVerticalScrollIndicator={false} ListEmptyComponent={ {'\uD83E\uDD16'} ARIA Cockpit Starte eine Konversation mit ARIA } /> {/* Thinking-Indicator */} {agentActivity.activity !== 'idle' && ( {agentActivity.activity === 'tool' && agentActivity.tool ? `\uD83D\uDD27 ${agentActivity.tool}` : agentActivity.activity === 'assistant' ? '\u270D\uFE0F ARIA schreibt...' : '\uD83D\uDCAD ARIA denkt...'} Abbrechen )} {/* Pending Anhaenge Vorschau */} {pendingAttachments.length > 0 && ( {pendingAttachments.map((att, idx) => ( {att.file.type?.startsWith('image/') || att.isPhoto ? ( ) : ( {'\uD83D\uDCC4'} )} setPendingAttachments(prev => prev.filter((_, i) => i !== idx))} > X ))} {pendingAttachments.length} setPendingAttachments([])}> Alle X )} {/* Jump-to-Bottom-Button — erscheint wenn man weg von der neuesten Nachricht gescrollt hat. Bei inverted FlatList ist scrollToOffset 0 == neueste Nachricht visuell unten. */} {showJumpDown && ( { try { flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); } catch {} setShowJumpDown(false); }} > {'↓'} )} {/* Eingabebereich */} {/* Datei-Buttons */} setShowFileUpload(true)} > {'\uD83D\uDCCE'} setShowCameraUpload(true)} > {'\uD83D\uDCF7'} {/* Texteingabe */} 0 ? "Text zu den Anhaengen (optional)..." : "Nachricht an ARIA..."} placeholderTextColor="#555570" multiline maxLength={4000} onSubmitEditing={sendTextMessage} returnKeyType="send" /> {/* Senden oder Sprache */} {inputText.trim() || pendingAttachments.length > 0 ? ( {'\u2B06\uFE0F'} ) : ( <> {/* Mund-Button: TTS auf diesem Geraet muten/aufheben. Nur sichtbar wenn TTS in den Settings grundsaetzlich aktiv ist. */} {ttsDeviceEnabled && ( {ttsMuted ? '🤐' : '👄'} )} {wakeWordState === 'conversing' ? '🎙️' : wakeWordState === 'armed' ? '👂' : '🔇'} )} {/* Memory-Detail/Edit-Modal — wird durch Tap auf eine memorySaved-Bubble geoeffnet */} {memoryDetailId ? ( setMemoryDetailId(null)}> setMemoryDetailId(null)} onDeleted={() => setMemoryDetailId(null)} /> ) : null} {/* Gedanken-Stream — chronologisches Log von ARIAs interner Aktivitaet. Bottom-Sheet (slide-up), 60% Bildschirmhoehe. Mülltonne zum Leeren. */} setThoughtsVisible(false)} > {/* Tap-Outside-Bereich oberhalb des Sheets — separater Touchable damit das Sheet-View NICHT als Responder den FlatList-Scroll blockiert. Frueher hatten wir den ganzen Hintergrund als TouchableOpacity + inneren View mit onStartShouldSetResponder = das hat alle Touch-Events kassiert. */} setThoughtsVisible(false)} /> {/* Drag-Indicator */} {'💭'} Gedanken-Stream {thoughts.length > 0 ? `(${thoughts.length})` : ''} {thoughts.length > 0 ? ( { Alert.alert('Gedanken-Stream leeren?', `Alle ${thoughts.length} Eintraege werden geloescht.`, [ { text: 'Abbrechen', style: 'cancel' }, { text: 'Leeren', style: 'destructive', onPress: () => { setThoughts([]); lastThoughtKeyRef.current = ''; } }, ]); }} hitSlop={{top:8,bottom:8,left:8,right:8}} style={{paddingHorizontal:8}} > {'🗑'} ) : null} setThoughtsVisible(false)} hitSlop={{top:8,bottom:8,left:8,right:8}}> × {thoughts.length === 0 ? ( Noch keine Gedanken aufgezeichnet.{'\n'}Sobald ARIA was tut, taucht's hier auf. ) : ( `t_${i}`} contentContainerStyle={{paddingVertical:8}} renderItem={({ item, index }) => { const prev = index > 0 ? thoughts[index - 1] : null; // Lange Pause? → Trenn-Linie mit Minuten-Hint const gapMin = prev ? Math.floor((item.ts - prev.ts) / 60000) : 0; const showGap = gapMin >= 1; const time = new Date(item.ts).toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit', second:'2-digit'}); const icon = item.activity === 'idle' ? '✓' : item.activity === 'tool' ? '🔧' : item.activity === 'assistant' ? '✍️' : item.activity === 'thinking' ? '💭' : '•'; const label = item.activity === 'idle' ? 'fertig' : item.activity === 'tool' ? (item.tool || 'tool') : item.activity === 'assistant' ? 'schreibt' : item.activity === 'thinking' ? 'denkt' : item.activity; const isIdle = item.activity === 'idle'; return ( {showGap ? ( {gapMin < 60 ? `${gapMin} Min` : `${Math.floor(gapMin/60)}h ${gapMin%60}m`} ) : null} {time} {icon} {label} ); }} /> )} {/* Notizen-Inbox — Listet alle Memories aus dem aktuellen Chat (Special-Bubbles). Bestes-Aus-beiden-Welten: nur die Memory-IDs aus den memorySaved-Bubbles des aktuellen Chats, plus den vollen Browser darunter wenn der User mehr will. */} setInboxVisible(false)}> setInboxVisible(false)}> {'🗂️'} Notizen-Inbox setInboxVisible(false)} hitSlop={{top:8,bottom:8,left:8,right:8}}> × {/* Aus aktuellem Chat: Spezial-Bubbles (memory/trigger/skill) kompakt auflisten — neueste oben. Klick auf Memory oeffnet Detail-Modal. */} {(() => { const specials = messages .filter(m => m.memorySaved || m.triggerCreated || m.skillCreated) .slice().reverse(); if (specials.length === 0) { return ( (keine Notizen-Bubbles im aktuellen Chat) ); } return ( Aus diesem Chat {specials.map(m => { if (m.memorySaved) { const ms = m.memorySaved; const action = ms.action || 'created'; const verb = action === 'updated' ? 'geändert' : action === 'deleted' ? 'gelöscht' : 'angelegt'; const dotColor = action === 'deleted' ? '#FF6B6B' : '#FFD60A'; return ( { if (ms.id && action !== 'deleted') { setInboxVisible(false); setMemoryDetailId(ms.id); } }} disabled={!ms.id || action === 'deleted'} > {'🧠'} {ms.title} Memory · {verb} · {ms.type} {ms.id && action !== 'deleted' ? : null} ); } if (m.triggerCreated) { const t = m.triggerCreated; return ( {'⏰'} {t.name} Trigger · {t.type}{t.fires_at ? ` · ${t.fires_at.slice(0,16).replace('T',' ')}` : ''} ); } if (m.skillCreated) { const sk = m.skillCreated; return ( {'🛠'} {sk.name} Skill · {sk.execution} ); } return null; })} ); })()} Alle Memories aus der DB {/* flex:1 Wrapper damit MemoryBrowser den verbleibenden Platz bekommt (sonst rendert die FlatList intern mit 0 Hoehe und nimmt nur was der Inhalt sagt → Scroll-Gestures verschwinden). */} { setInboxVisible(false); setMemoryDetailId(id); }} /> {/* Bild-Vollbild Modal */} setFullscreenImage(null)}> {fullscreenImage && ( /\.svg(?:\?|$)/i.test(fullscreenImage) ? ( // SVG: bisher keine Pinch-Zoom — Tap zum Schliessen setFullscreenImage(null)}> ) : ( // Pixel-Bild: Pinch-Zoom + Pan ueber ZoomableImage ) )} {/* Close-Button oben rechts — die TouchableOpacity-uebergreifend funktioniert wegen ZoomableImage-PanResponder nicht zuverlaessig fuer Tap-to-Close */} setFullscreenImage(null)} > {'✕'} {/* Datei-Upload Modal */} setShowFileUpload(false)} /> {/* Kamera-Upload Modal */} setShowCameraUpload(false)} /> ); }; // --- 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, }, serviceBanner: { paddingVertical: 8, paddingHorizontal: 12, borderTopWidth: 0, borderBottomWidth: 1, borderLeftWidth: 0, borderRightWidth: 0, }, serviceBannerLine: { color: '#FFFFFF', fontSize: 12, lineHeight: 18, }, serviceBannerHint: { color: '#AAAACC', fontSize: 10, marginTop: 2, fontStyle: 'italic', }, 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', }, attachmentImage: { // Feste Breite + dynamische aspectRatio (in ChatImage gesetzt) damit die // Bubble sich ans Bild anpasst. Mit width: '100%' ohne explizite Parent- // Breite wuerde RN das Bild auf 0px schrumpfen → "Strich". width: 260, aspectRatio: 4 / 3, borderRadius: 8, marginBottom: 6, backgroundColor: '#0D0D1A', }, 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)', fontSize: 10, marginTop: 4, alignSelf: 'flex-end', }, statusRow: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-end', gap: 6, marginTop: 4, }, statusQueued: { color: '#FFD60A', // Gelb — wartet auf Verbindung fontSize: 11, }, statusSending: { color: 'rgba(255,255,255,0.5)', fontSize: 11, }, statusSent: { color: 'rgba(255,255,255,0.6)', fontSize: 12, }, statusDelivered: { color: '#34C759', // Gruen — Brain hat geantwortet fontSize: 12, fontWeight: '700', }, statusFailed: { color: '#FF3B30', fontSize: 11, fontWeight: '700', }, 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)', }, mouthBtnMuted: { backgroundColor: 'rgba(255, 59, 48, 0.25)', }, wakeWordIcon: { fontSize: 16, }, thinkingBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: '#1E1E2E', paddingHorizontal: 12, paddingVertical: 6, borderTopWidth: 1, borderTopColor: '#2A2A3E', }, thinkingText: { color: '#FFD60A', fontSize: 12, flex: 1, }, thinkingCancel: { paddingHorizontal: 10, paddingVertical: 4, borderWidth: 1, borderColor: '#FF3B30', borderRadius: 4, }, thinkingCancelText: { color: '#FF3B30', fontSize: 11, fontWeight: 'bold', }, pendingBar: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#1E1E2E', paddingHorizontal: 12, paddingVertical: 8, borderTopWidth: 1, borderTopColor: '#2A2A3E', }, pendingItem: { position: 'relative', marginRight: 8, }, pendingThumb: { width: 50, height: 50, borderRadius: 6, backgroundColor: '#0D0D1A', }, pendingRemove: { position: 'absolute', top: -4, right: -4, width: 18, height: 18, borderRadius: 9, backgroundColor: '#FF3B30', justifyContent: 'center', alignItems: 'center', }, searchBar: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#12122A', paddingHorizontal: 12, paddingVertical: 6, borderBottomWidth: 1, borderBottomColor: '#1E1E2E', }, searchInput: { flex: 1, color: '#FFFFFF', fontSize: 14, paddingVertical: 4, }, playButton: { alignSelf: 'flex-end', paddingHorizontal: 8, paddingVertical: 2, marginTop: 4, }, playButtonText: { fontSize: 16, }, inboxRow: { flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: '#1E1E2E', padding: 10, borderRadius: 6, marginBottom: 4, }, inboxRowTitle: { color: '#E0E0F0', fontSize: 13, fontWeight: '600', }, inboxRowMeta: { color: '#8888AA', fontSize: 11, marginTop: 1, }, memoryAttachmentRow: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#0D0D1A', borderRadius: 6, paddingHorizontal: 8, paddingVertical: 6, marginTop: 4, gap: 6, }, memoryAttachmentIcon: { fontSize: 16, }, memoryAttachmentName: { flex: 1, color: '#E0E0F0', fontSize: 12, }, memoryAttachmentMeta: { color: '#555570', fontSize: 10, }, jumpDownBtn: { position: 'absolute', right: 16, bottom: 80, width: 44, height: 44, borderRadius: 22, backgroundColor: '#0096FF', alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.4, shadowRadius: 4, elevation: 5, zIndex: 100, }, bubbleTrash: { position: 'absolute', top: 4, right: 6, width: 24, height: 24, borderRadius: 12, backgroundColor: 'rgba(255,59,48,0.18)', alignItems: 'center', justifyContent: 'center', }, bubbleTrashIcon: { fontSize: 12, color: '#FF6B6B', }, fullscreenOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.95)', justifyContent: 'center', alignItems: 'center', }, fullscreenImage: { width: '100%', height: '100%', }, modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', }, }); export default ChatScreen;