Files
ARIA-AGENT/android/src/screens/ChatScreen.tsx
T
duffyduckandClaude Opus 4.8 648e3b04fd feat(zwischenruf): Korrektur mitten in den laufenden Turn schieben
Neben Senden ein 'Zwischenruf' (App: oranger Button, nur wenn ARIA im
aktiven Kontext arbeitet; Diagnostic: Buttons neben beiden Senden). Geht
NICHT in die Queue und bricht NICHT ab — die Nachricht wird in den
laufenden claude-Subprozess geschoben; er greift sie an der naechsten
Tool-Grenze auf.

Technik:
- proxy-patches/manager.js: claude laeuft jetzt im --input-format
  stream-json-Modus, initialer Prompt als stream-json User-Message,
  stdin bleibt OFFEN; sendMessage() schiebt weitere User-Messages nach;
  bei 'result' wird stdin geschlossen (Turn endet sauber). Ersetzt die
  bisherigen sed-Patches (jetzt volle Datei via cp, docker-compose.yml).
- proxy-patches/routes.js: Side-Channel POST /interject {projectId,text}
  → subprocess.sendMessage der Kontext-Subprozesse.
- rvs: 'interject' erlaubt. bridge: RVS interject → Proxy /interject.
- App/Diagnostic: Zwischenruf-Buttons + lokale '📣 Zwischenruf'-Bubble.

Empirisch verifiziert: mid-turn injizierte Message wird an der naechsten
Tool-Grenze aufgegriffen (nicht mitten in einem blockierenden Befehl).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 05:05:13 +02:00

3977 lines
172 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
Pressable,
Share,
} 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, { loadPassiveListenMs } from '../services/wakeword';
import ProjectsBrowser from '../components/ProjectsBrowser';
import brainApi, { Project as BrainProject } from '../services/brainApi';
import projectFocus from '../services/projectFocus';
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 { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs } 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)
}
// Pro-Projekt-Queue-Zustand: idle = nichts laeuft; running = ARIA arbeitet am
// aktuellen Task; awaiting_reply = ARIA hat eine blockierende Rueckfrage gestellt,
// die naechste Eingabe beantwortet sie (statt einen neuen Auftrag anzustellen).
type CtxState = 'idle' | 'running' | 'awaiting_reply';
// Wartender Queue-Eintrag (id = die Bubble-ID der pending_queue-Nachricht).
interface QueuedItem { id: string; text: string; }
interface ChatMessage {
id: string;
sender: 'user' | 'aria';
text: string;
timestamp: number;
attachments?: Attachment[];
/** Projekt-Zuordnung — leer = Hauptchat. Wird genutzt um Bubbles zu
* Projekt-Bloecken zu gruppieren (auf/einklappbar). */
projectId?: string;
/** Welcher Backend die Antwort erzeugt hat: 'local' | 'claude' | 'fast-path'.
* Fuer den optionalen Quell-Badge (Einstellung aria_show_source, default aus). */
answeredBy?: string;
/** 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/<id>/<name>
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' | 'pending_queue' | '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<boolean> };
};
/** Datei mit Android-Intent-Picker oeffnen (System waehlt App nach MIME). */
async function openFileWithIntent(filePath: string, mimeType: string): Promise<void> {
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<number>(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 (
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
<View style={[CHAT_IMAGE_STYLE, { height: 260, alignItems: 'center', justifyContent: 'center' }]}>
<SvgUri uri={uri} width="100%" height="100%" onError={onError} />
</View>
</TouchableOpacity>
);
}
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
<Image
source={{ uri }}
style={[CHAT_IMAGE_STYLE, { aspectRatio }]}
resizeMode="cover"
onError={onError}
/>
</TouchableOpacity>
);
};
async function getAttachmentDir(): Promise<string> {
try {
const saved = await AsyncStorage.getItem(STORAGE_PATH_KEY);
return saved || DEFAULT_ATTACHMENT_DIR;
} catch { return DEFAULT_ATTACHMENT_DIR; }
}
/** Speichert Base64-Daten als Datei, gibt file:// Pfad zurueck */
async function persistAttachment(base64Data: string, msgId: string, fileName: string): Promise<string> {
const cacheDir = await getAttachmentDir();
await RNFS.mkdir(cacheDir);
// Dateiendung aus originalem Dateinamen oder Fallback
const ext = fileName.includes('.') ? fileName.split('.').pop() : 'bin';
const safeName = `${msgId}_${fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
const filePath = `${cacheDir}/${safeName}`;
await RNFS.writeFile(filePath, base64Data, 'base64');
return `file://${filePath}`;
}
/** Prueft ob eine lokale Datei noch existiert */
async function checkFileExists(uri: string): Promise<boolean> {
if (!uri || !uri.startsWith('file://')) return false;
return RNFS.exists(uri.replace('file://', ''));
}
// --- Komponente ---
const ChatScreen: React.FC = () => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputText, setInputText] = useState('');
const inputTextRef = useRef('');
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);
// Genauer State (off/armed/conversing) fuer UI-Feedback am Button
const [wakeWordState, setWakeWordState] = useState<'off' | 'armed' | 'conversing' | 'listening'>('off');
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
const [memoryDetailId, setMemoryDetailId] = useState<string | null>(null);
const [inboxVisible, setInboxVisible] = useState(false);
const [showJumpDown, setShowJumpDown] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchVisible, setSearchVisible] = useState(false);
const [projectsVisible, setProjectsVisible] = useState(false);
// Focus-One-View: welchen Chat sieht Stefan gerade?
// Leer = Hauptchat, sonst die project_id. Multi-Threading:
// Wechsel des Focus stoppt NICHT ARIAs Arbeit in anderen Projekten —
// die laufen im Brain weiter, wir sehen sie hier nur nicht.
const [focusedProjectId, setFocusedProjectId] = useState<string>('');
// Lookup-Map id → Projekt (fuer Drawer + Referenzen)
const [projectNameById, setProjectNameById] = useState<Record<string, string>>({});
// Queue-Status pro Kontext — polled alle 2s, fuer Status-Dots im Drawer
const [queueStatus, setQueueStatus] = useState<Record<string, { busy: boolean; queue_size: number }>>({});
// Ref-Spiegel fuer Callbacks (interruptAriaIfBusy liest den aktuellen
// Busy-Status des fokussierten Kontexts ohne stale Closure).
const queueStatusRef = useRef<Record<string, { busy: boolean; queue_size: number }>>({});
// ── Pro-Projekt-Nachrichten-Queue + Zustandsautomat (app-lokal) ──
// Key = projectId ('' = Hauptchat). state pro Kontext + Queue der wartenden
// User-Bubbles. Sendet man waehrend 'running', wird angestellt statt
// abgebrochen; 'awaiting_reply' leitet die naechste Eingabe als Antwort weiter.
const [projectStates, setProjectStates] = useState<Record<string, CtxState>>({});
const [projectQueues, setProjectQueues] = useState<Record<string, QueuedItem[]>>({});
const projectStatesRef = useRef<Record<string, CtxState>>({});
const projectQueuesRef = useRef<Record<string, QueuedItem[]>>({});
// Wann ist ein Kontext in 'running' gegangen? Fuer den Watchdog, der eine
// haengende Queue (z.B. Antwort waehrend Verbindungsabbruch verloren) loest.
const ctxRunningSinceRef = useRef<Record<string, number>>({});
// Pro-Projekt-Textfeld-Entwuerfe (noch nicht gesendeter Feldinhalt). Key = pid.
const projectDraftsRef = useRef<Record<string, string>>({});
const prevFocusedPidRef = useRef<string>('');
// Ref-Bruecke, damit der frueh deklarierte RVS-Message-Handler den erst spaeter
// deklarierten Queue-Automaten aufrufen kann (ohne TDZ / stale Closure).
const queueApiRef = useRef<{
getCtxState: (pid: string) => CtxState;
setCtxState: (pid: string, s: CtxState) => void;
advanceQueue: (pid: string) => void;
} | null>(null);
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: ''});
// Multi-Threading: Activity pro Kontext (key = projectId, '' = Hauptchat).
// Der Indikator zeigt nur den fokussierten Kontext — nicht global.
const [agentActivityByCtx, setAgentActivityByCtx] = useState<Record<string, {activity: string; tool: string}>>({});
// Gedanken-Stream: chronologisches Log dessen was ARIA intern macht.
// Wird aus agent_activity-Events gefuettert und in AsyncStorage persistiert.
const [thoughts, setThoughts] = useState<ThoughtEntry[]>([]);
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<string>('');
// Service-Status (Gamebox: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string, downloading?: boolean, freshlyDownloaded?: boolean}>>({});
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);
// Quell-Badge (local/claude/fast-path) an ARIA-Bubbles — pro Geraet, default AUS.
const [showSource, setShowSource] = useState(false);
// Gerätelokale XTTS-Voice-Wahl (bevorzugt gegenueber dem globalen Default)
const localXttsVoiceRef = useRef<string>('');
// Geraetelokale TTS-Wiedergabegeschwindigkeit (speed-Param an F5-TTS)
const ttsSpeedRef = useRef<number>(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<boolean>(true);
// Letzte Antwort: nach dem Vorlesen 30s weiterlauschen (Gespraech) oder direkt
// stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest
// es. Default true (Konversation). false = Einzelaktion/Skill-Antwort.
const converseRef = useRef<boolean>(true);
const flatListRef = useRef<FlatList>(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<ChatMessage[]>([]);
// 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<ReturnType<typeof setTimeout> | 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<Set<string>>(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_<ms>_<rand> — eindeutig genug fuer eine
// 100er-Dedup-Window auf der Bridge.
const nextClientMsgId = (): string =>
`cmsg_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`;
// IDs (clientMsgId + audioRequestId), die DIESES Geraet ausgeloest hat.
// Fuer den geraete-lokalen Voice-Projektwechsel: ein project_changed vom Brain
// traegt die ausloesende ID; nur wenn sie zu einer unserer IDs gehoert, folgt
// dieses Geraet dem Wechsel. So wechselt „ARIA, geh in Projekt X" nur das
// Geraet, das den Befehl gab — nicht andere App-/Diagnostic-Instanzen.
const myRequestIdsRef = useRef<Set<string>>(new Set());
const rememberMyRequest = (id: string): void => {
if (!id) return;
const s = myRequestIdsRef.current;
s.add(id);
if (s.size > 80) {
const oldest = s.values().next().value;
if (oldest !== undefined) s.delete(oldest);
}
};
// 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<Map<string, ReturnType<typeof setTimeout>>>(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<Map<string, { type: 'chat' | 'audio'; payload: Record<string, unknown> }>>(new Map());
// ConnectionState in Ref spiegeln — fuer Closures (onMessage, Send-Pfade)
// die sonst auf einen veralteten Wert zugreifen wuerden.
const connectionStateRef = useRef<ConnectionState>('disconnected');
// Status einer Bubble per clientMsgId aendern (Helper)
const updateMessageStatus = useCallback(
(cmid: string, patch: Partial<Pick<ChatMessage, 'deliveryStatus' | 'sendAttempts'>>) => {
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<string, unknown>, 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 });
rememberMyRequest(cmid);
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
// Projekt-Namen laden (Lookup-Map) + focusedProjectId aus AsyncStorage
// wiederherstellen (Default = Hauptchat wenn nichts gespeichert). Der
// Brain hat mit Multi-Threading keinen global-aktiven Projekt-State mehr;
// Focus ist reine App-lokale UI-Info.
useEffect(() => {
const loadNames = () => {
brainApi.listProjects(true)
.then(list => {
const map: Record<string, string> = {};
const kinds: Record<string, 'code' | 'chat'> = {};
for (const p of list) {
map[p.id] = p.name;
kinds[p.id] = p.kind === 'code' ? 'code' : 'chat';
}
setProjectNameById(prev => ({ ...prev, ...map }));
projectFocus.setKinds(kinds);
})
.catch(() => {});
};
loadNames();
// Letzten Focus aus Storage restoren
AsyncStorage.getItem('aria_focused_project_id').then(v => {
if (v && typeof v === 'string') setFocusedProjectId(v);
}).catch(() => {});
const unsub = rvs.onStateChange(state => { if (state === 'connected') loadNames(); });
return () => unsub();
}, []);
// Focus in Storage spiegeln + Pro-Projekt-Textfeld-Entwuerfe wechseln.
useEffect(() => {
AsyncStorage.setItem('aria_focused_project_id', focusedProjectId).catch(() => {});
focusedProjectIdRef.current = focusedProjectId;
// Draft-Wechsel: der aktuelle Feldinhalt gehoert dem VERLASSENEN Projekt,
// der Entwurf des neuen Projekts wird geladen (leer = leeres Feld).
const prevPid = prevFocusedPidRef.current;
if (prevPid !== focusedProjectId) {
projectDraftsRef.current = { ...projectDraftsRef.current, [prevPid]: inputTextRef.current };
setInputText(projectDraftsRef.current[focusedProjectId] || '');
prevFocusedPidRef.current = focusedProjectId;
AsyncStorage.setItem('aria_project_drafts', JSON.stringify(projectDraftsRef.current)).catch(() => {});
}
}, [focusedProjectId]);
// Focus + Projekt-Namen EINWEG in den projectFocus-Spiegel publizieren,
// damit der Workspace-Canvas den aktiven Kontext + Code-Projekt-Status
// kennt. Rein additiv — aendert ChatScreens eigenes Verhalten nicht.
useEffect(() => { projectFocus.setFocus(focusedProjectId); }, [focusedProjectId]);
useEffect(() => { projectFocus.setNames(projectNameById); }, [projectNameById]);
// inputText-Spiegel (damit der Draft-Wechsel oben inputText nicht als Dep braucht).
useEffect(() => { inputTextRef.current = inputText; }, [inputText]);
// Drafts beim Start aus Storage laden.
useEffect(() => {
AsyncStorage.getItem('aria_project_drafts').then(v => {
if (!v) return;
try {
const map = JSON.parse(v);
if (map && typeof map === 'object') {
projectDraftsRef.current = map;
const cur = map[focusedProjectIdRef.current] || '';
if (cur) setInputText(cur);
}
} catch {}
}).catch(() => {});
}, []);
// Ref-Spiegel damit useCallback-Handler die aktuelle Focus-ID lesen
// ohne dass wir die Deps in jedes Callback muessen (sonst re-createn
// die sich bei jedem Wechsel).
const focusedProjectIdRef = useRef<string>('');
// Queue-Status alle 2s pollen — fuers Status-Dot im Focus-Header und
// fuer die Drawer-Anzeige. Nur wenn RVS verbunden ist (sonst 30s Timeout).
useEffect(() => {
let cancelled = false;
const poll = async () => {
if (rvs.getState() !== 'connected') return;
try {
const s = await brainApi.getProjectQueueStatus();
if (cancelled) return;
const ctxs = s.contexts || {};
setQueueStatus(ctxs);
queueStatusRef.current = ctxs;
// Watchdog: haengt ein Kontext seit >15s auf 'running', obwohl der Brain
// ihn NICHT als busy meldet, ist die Antwort verloren gegangen (z.B.
// Verbindungsabbruch) — sonst friert die Queue ein. Dann weiterschalten.
const api = queueApiRef.current;
if (api) {
const now = Date.now();
for (const [pid, since] of Object.entries(ctxRunningSinceRef.current)) {
if (api.getCtxState(pid) !== 'running') continue;
const busy = !!ctxs[pid || '__main__']?.busy;
if (!busy && now - since > 15000) {
console.log('[Chat] Queue-Watchdog: Kontext %s haengt (running, brain idle) → weiterschalten', pid || '(main)');
api.advanceQueue(pid);
}
}
}
} catch {}
};
poll();
const iv = setInterval(poll, 2000);
return () => { cancelled = true; clearInterval(iv); };
}, []);
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
const src = await AsyncStorage.getItem('aria_show_source');
setShowSource(src === 'true'); // default false (Mama sieht keine Badges)
};
loadSettings();
const interval = setInterval(loadSettings, 2000);
return () => clearInterval(interval);
}, []);
// Wake Word: einmalig laden + Porcupine vorbereiten (wenn Access Key gesetzt)
useEffect(() => {
wakeWordService.loadFromStorage().catch(() => {});
// Mikro-Release-Hook: vor jedem Re-Arm eine laufende (passive) Streaming-
// Aufnahme canceln, sonst haelt sie das Mikro und OpenWakeWord.start()
// schlaegt fehl → Ohr bleibt ausgegraut (state=off).
wakeWordService.setMicReleaseHook(async () => {
if (audioService.isStreamingRecording()) {
await audioService.cancelStreamingRecording('rearm-mic-free');
}
});
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. 'listening' soll
// Spotify ebenfalls leise halten (User darf jederzeit weitersprechen).
if (s === 'conversing' || s === 'listening') audioService.acquireConversationFocus();
else audioService.releaseConversationFocus();
// Beim Verlassen von 'listening' (Timer abgelaufen) eine ggf. noch
// laufende passive Streaming-Aufnahme killen, sonst hat OpenWakeWord
// keinen Zugriff aufs Mic beim Re-Arm.
if ((s === 'armed' || s === 'off') && audioService.isStreamingRecording()) {
audioService.cancelStreamingRecording('wakeword-state-' + s);
}
// 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();
wakeWordService.setBackground();
} else if (lastState !== 'active' && next === 'active') {
wakeWordService.setForeground();
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) {
// Sowohl legacy als auch Streaming-Pfad abdecken
try {
if (audioService.isStreamingRecording()) {
audioService.cancelStreamingRecording('wake-discarded');
} else {
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<string, ChatMessage>();
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<any>;
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,
projectId: typeof m.project_id === 'string' ? m.project_id : '',
answeredBy: typeof m.answeredBy === 'string' ? m.answeredBy : '',
...(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;
// Wartende Queue-Bubbles (noch nicht gesendet → kein clientMsgId, nicht
// auf dem Server) MUESSEN erhalten bleiben — sonst verschwindet die
// Bubble beim Reconnect-Sync, waehrend projectQueues den Eintrag behaelt
// → "N in Warteschlange" friert ein ohne sichtbare Nachricht.
if (m.sender === 'user' && m.deliveryStatus === 'pending_queue') 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;
}
// project_changed: ARIA hat via Tool ein Projekt erstellt/betreten/exited/beendet.
// App entscheidet ob sie den Focus wechselt basierend auf action + payload.
if (message.type === 'project_changed') {
const p: any = message.payload || {};
const action = p.action || '';
// Neuer Projekt-Name in Lookup-Map merken
if (p.id && p.name) {
setProjectNameById(prev => ({ ...prev, [p.id]: p.name }));
}
// Projekt-Typ sofort spiegeln → Editor/Desktop-Kacheln erscheinen live
// (z.B. nach set_project_kind), nicht erst beim naechsten Reconnect.
if (p.id && (p.kind === 'code' || p.kind === 'chat')) {
projectFocus.setKind(p.id, p.kind);
}
// Projekt-Fokus ist GERAETE-LOKAL. Ein Broadcast wechselt dieses Geraet
// NUR, wenn er durch EINEN EIGENEN Befehl ausgeloest wurde — erkennbar an
// der mitgelieferten clientMsgId (unsere gesendete Text-cmid oder Voice-
// audioRequestId). So folgt „ARIA, geh in Projekt X" nur dem Geraet, das
// den Befehl gab; Diagnostic / andere App-Instanzen bleiben unberuehrt.
const trigCmid = typeof p.clientMsgId === 'string' ? p.clientMsgId : '';
if (trigCmid && myRequestIdsRef.current.has(trigCmid)) {
if (action === 'entered' || action === 'created') {
if (p.id) setFocusedProjectId(p.id);
} else if (action === 'exited') {
setFocusedProjectId('');
}
}
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 sp = (p.serverPath as string) || '';
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: sp,
}],
};
setMessages(prev => {
// Falls die Chat-Bubble mit dieser Datei schon da ist (Event-Reihen-
// folge kann variieren, und die chat-Payload traegt die files jetzt
// selbst), keine doppelte Solo-Bubble anlegen.
if (sp && prev.some(m =>
m.sender === 'aria' && m.attachments?.some(a => a.serverPath === sp)
)) {
return prev;
}
return 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) || '';
// Fehler-Response (z.B. Datei zu gross, nicht gefunden) → Toast,
// kein erneuter Versuch. Hauptverdacht: 40+ MB Videos die ueber
// den 70 MB Bridge-Limit gehen.
const fileErr = (message.payload as any).error as string | undefined;
if (fileErr) {
const fname = (message.payload.name as string) || serverPath.split('/').pop() || 'Datei';
console.warn('[Chat] file_response Fehler fuer %s: %s', fname, fileErr);
ToastAndroid.show(`${fname}: ${fileErr}`, ToastAndroid.LONG);
return;
}
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) || '';
// Autoritative Projekt-Zuordnung vom Server (Voice-Router). Die App
// hatte die lokale Bubble optimistisch mit dem App-Focus getaggt;
// wenn der Router anders entschieden hat (Sticky, \u201Efuer X:"-Prefix,
// oder Fallback), uebernehmen wir hier den Server-Wert \u2014 sonst
// divergieren App- und Diagnostic-Ansicht (Frage im einen Kontext,
// Antwort im anderen). Nur uebernehmen wenn das Feld mitgeliefert
// wurde (leer/undefined = altes Bridge-Format \u2192 App-Focus behalten).
const hasServerPid = typeof (message.payload as any).projectId === 'string';
const sttProjectId = ((message.payload as any).projectId as string) || '';
const applyPid = (m: ChatMessage): ChatMessage =>
hasServerPid ? { ...m, projectId: sttProjectId } : m;
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] = applyPid({ ...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] = applyPid({ ...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' }],
projectId: hasServerPid ? sttProjectId : focusedProjectIdRef.current,
}]);
});
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;
// ARIA-Dateien, die DIREKT an dieser Chat-Bubble haengen (Bridge schickt
// sie jetzt in der chat-Payload mit — vorher kamen sie nur als separates
// file_from_aria-Event, wodurch der Anhang live nicht an der Nachricht
// erschien, sondern erst nach einem Seitenwechsel/Reload).
const incomingFiles = Array.isArray((message.payload as any).files)
? (message.payload as any).files as Array<any> : [];
const fileAttachments: Attachment[] = incomingFiles.map(f => ({
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 || '',
}));
const incomingPaths = new Set(
fileAttachments.map(a => a.serverPath).filter(Boolean) as string[]
);
// 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 payloadAtts = (message.payload.attachments as Attachment[] | undefined) || [];
const mergedAtts = [...payloadAtts, ...fileAttachments];
const ariaMsg: ChatMessage = {
id: nextId(),
sender: 'aria',
text,
timestamp: ts,
attachments: mergedAtts.length ? mergedAtts : undefined,
messageId: (message.payload.messageId as string) || undefined,
backupTs: (message.payload.backupTs as number) || undefined,
projectId: ((message.payload as any).projectId as string) || '',
answeredBy: ((message.payload as any).answeredBy as string) || '',
};
// Die separate file_from_aria-Bubble (leerer Text, nur Datei), die kurz
// zuvor fuer DIESE Datei ankam, entfernen — sonst doppelt (einmal solo,
// einmal an der Text-Bubble). Nur solche mit passendem serverPath.
const deduped = incomingPaths.size
? prev.filter(m => !(
m.sender === 'aria' && !m.text && (m.attachments?.length) &&
m.attachments.every(a => a.serverPath && incomingPaths.has(a.serverPath))
))
: prev;
// ARIA hat geantwortet → alle User-Bubbles davor als 'delivered'
// markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet.
return capMessages([...deduped, 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();
// Thinking-Indikator fuer DIESEN Kontext raeumen. Die Antwort ist der
// definitive Beweis, dass der Turn fertig ist — unabhaengig davon, ob
// das agent_activity 'idle' der Bridge ankam (es kann mit abweichender
// projectId gesendet oder wegdedupt worden sein → "ARIA denkt" blieb
// sonst haengen). Zusaetzlich global raeumen, falls der sichtbare
// Kontext ueber den Legacy-Indikator lief.
{
const ansPid = ((message.payload as any).projectId as string) || '';
setAgentActivityByCtx(prev =>
prev[ansPid] && prev[ansPid].activity !== 'idle'
? { ...prev, [ansPid]: { activity: 'idle', tool: '' } }
: prev);
setAgentActivity(cur =>
cur.activity === 'idle' ? cur : { activity: 'idle', tool: '' });
// ── Pro-Projekt-Queue-Automat: auf ARIAs Antwort reagieren ──
const awaiting = !!(message.payload as any).awaiting_reply;
const qapi = queueApiRef.current;
if (qapi) {
const st = qapi.getCtxState(ansPid);
// Nur reagieren, wenn wir fuer diesen Kontext wirklich auf eine
// Antwort warten (nicht bei unaufgeforderten Trigger-Nachrichten).
if (st === 'running' || st === 'awaiting_reply') {
if (awaiting) qapi.setCtxState(ansPid, 'awaiting_reply');
else qapi.advanceQueue(ansPid);
}
}
}
// 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);
}
// Stille Antwort (speak=false, z.B. Fast-Path „nächster Titel") → es
// kommt KEIN TTS, also feuert onPlaybackFinished nie. Genau daran haengt
// aber das Wake-Word-Re-Arm — sonst bleibt das Ohr nach dem Steuerbefehl
// grau haengen (Reproduktion Stefan im Auto). Hier dieselbe Logik wie
// bei TTS-Ende anstossen: aktiv → Konversations-Fenster (resume),
// sonst → Konversation beenden (re-arm).
// Stumm = die Bridge sagt speak=false (Steuerbefehl-Skill via Fast-Path
// ODER local). Ein Antwort-Skill (speak=true) faellt NICHT hierunter —
// der wird vorgelesen + haelt das Gespraech offen. Kein answeredBy-
// Fallback mehr: die Bridge schickt speak zuverlaessig mit.
// Merken ob nach dem Vorlesen 30s weiterlauschen (Gespraech) oder direkt
// stoppen — onPlaybackFinished liest converseRef. Default true.
converseRef.current = (message.payload as any).converse !== false;
const _isSilent = (message.payload as any).speak === false;
if (_isSilent && wakeWordService.isConversing()) {
// Klarer Steuerbefehl (Liedersteuerung etc.) = KEINE Konversation →
// STOP: direkt zurueck aufs Wake-Word. Kein Gong, keine Aufnahme,
// kein 30s-Fenster (skipPassive=true).
wakeWordService.endConversation(true).catch(() => {});
}
}
// 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) || '';
const actPid = ((message.payload as any).projectId as string) || '';
// Global (fuer die bestehende ACK-/Watchdog-Logik) UND per-Kontext
// (fuer den fokussierten Indikator) fuehren.
setAgentActivity({ activity, tool });
setAgentActivityByCtx(prev => ({ ...prev, [actPid]: { 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/flux) melden Lade-Status — Banner oben.
// Toast bei Download-Ende: erstmaliger HF-Download (mehrere GB) → User
// soll wissen dass er Bilder/Stimmen jetzt nutzen kann ohne in den
// Banner gucken zu muessen.
if (message.type === ('service_status' as any)) {
const p = message.payload as any;
const svc = (p?.service as string) || '';
if (!svc) return;
const newState = (p?.state as string) || 'unknown';
const freshlyDownloaded = p?.freshlyDownloaded === true;
setServiceStatus(prev => ({
...prev,
[svc]: {
state: newState,
model: p?.model as string | undefined,
loadSeconds: p?.loadSeconds as number | undefined,
error: p?.error as string | undefined,
downloading: p?.downloading === true,
freshlyDownloaded,
},
}));
// Bei neuer Loading-Phase Banner wieder aktivieren
if (newState === 'loading') setServiceBannerDismissed(false);
// Download-Fertig-Toast: Bridge setzt freshlyDownloaded=true bei dem
// 'ready'-Broadcast direkt nach einem Cache-Miss-Load. Ein einziger
// Toast pro Modell-Download, kein State-Tracking auf App-Seite noetig.
if (newState === 'ready' && freshlyDownloaded) {
const niceName = svc === 'flux' ? 'FLUX' : svc === 'f5tts' ? 'F5-TTS' : svc === 'whisper' ? 'Whisper' : svc;
const model = p?.model ? ` (${p.model})` : '';
try {
ToastAndroid.show(`${niceName}-Modell heruntergeladen${model} — jetzt einsatzbereit`, ToastAndroid.LONG);
} catch {}
}
}
});
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 weiter im Multi-Turn (Conversation-
// Window) oder zurueck zu armed (Wake-Word lauscht wieder)?
//
// Foreground → resume() oeffnet das Mikro fuer N Sekunden Follow-Up
// (natuerlicher Dialog moeglich ohne erneutes "Computer")
// Background → endConversation() — Wake-Word direkt wieder armed.
//
// Grund: der setTimeout(800ms) in resume() wird im Doze stark verzoegert
// (siehe Wake-Detect-Bug von 0.1.7.0). Das hat zwei nervige Folgen:
// 1) Wake-Word ist solange "tot" — User kann ARIA nicht mehr triggern
// bis er die App vorholt
// 2) Wenn er die App dann vorholt, oeffnet der verspaetete Timer das
// Mikro — sieht aus wie ein Phantom-Wake-Word-Trigger
// Background = User nutzt das Handy anderweitig, das Multi-Turn-Konzept
// ist da eh nicht nuetzlich. Direkt re-armen ist robust und erwartungs-
// konform.
useEffect(() => {
const unsubPlayback = audioService.onPlaybackFinished(() => {
if (!wakeWordService.isActive()) return;
// Gespraech (gesprochene Antwort): KEIN neuer Gong / kein Aufnahme-Fenster,
// sondern direkt passives Lauschen (30s weiterreden wie mit einem Menschen).
// Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive).
// converse=false (Skill-/Einzelantwort, z.B. 'was laeuft gerade') → auch
// ohne 30s: vorlesen + direkt zurueck aufs Wake-Word.
const bg = AppState.currentState !== 'active';
wakeWordService.endConversation(bg || !converseRef.current).catch(() => {});
});
return () => unsubPlayback();
}, []);
// Wake Word / Gespraechsmodus: Auto-Aufnahme starten (Streaming-Modus)
useEffect(() => {
const unsubWake = wakeWordService.onWakeWord(async () => {
console.log('[Chat] Gespraechsmodus — starte Streaming-Aufnahme');
import('../services/logger').then(m => m.reportAppDebug('wake.cb', 'callback fired, calling startStreamingRecording')).catch(()=>{});
// Bubble SOFORT bauen — bevor Whisper-Bridge antwortet — damit der User
// sieht "Es passiert was". stt_endpoint kommt typisch <1s spaeter mit
// dem finalen Text, dann wird die Bubble ueber audioRequestId-Match
// aktualisiert (siehe chat-Handler oben).
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
rememberMyRequest(audioRequestId);
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation();
const windowMs = await loadConvWindowMs();
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]));
const { ok } = await audioService.startStreamingRecording({
audioRequestId,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
location: location || null,
noSpeechTimeoutMs: windowMs,
endpointMs: await loadSttEndpointMs(),
hardCapMs: 60000,
projectId: focusedProjectIdRef.current,
});
import('../services/logger').then(m => m.reportAppDebug('wake.cb', `startStreamingRecording returned ok=${ok}`)).catch(()=>{});
if (ok) {
ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT);
playWakeReadySound().catch(() => {});
scheduleStaleAudioCleanup(audioRequestId, 60000);
import('../services/logger').then(m => m.reportAppDebug('wake.cb', 'gong played + streaming started')).catch(()=>{});
} else {
// Mikrofon nicht verfuegbar → Bubble wieder weg, naechster Versuch
setMessages(prev => prev.filter(m => m.audioRequestId !== audioRequestId));
wakeWordService.resume();
}
});
// STT-Endpoint-Callback ersetzt den alten onSilenceDetected.
// Feuert in 2 Faellen:
// - text != '' → Whisper-Bridge hat ML-Endpoint erkannt, Text liegt vor.
// aria-bridge bekommt das gleiche Event und triggert Brain
// direkt. App muss nix mehr senden.
// - text == '' → cancelStreamingRecording (no-speech / hardcap / error /
// speaker_mismatch). Konversation beenden, oder bei
// passive-listening: nochmal lauschen.
const unsubEndpoint = audioService.onSttEndpoint((ev) => {
if (ev.text && ev.text.trim()) {
console.log('[Chat] STT-Endpoint: %r (reason=%s, %dms, %.1fs Audio)',
ev.text.slice(0, 80), ev.reason, ev.sttMs, ev.durationS);
// Wenn passive lauschend: User hat tatsaechlich was gesagt → uebergang
// zu 'conversing' damit der normale Flow greift (TTS, resume, etc.)
if (wakeWordService.getState() === 'listening') {
wakeWordService.exitPassiveListening('speech').catch(() => {});
}
// Brain laeuft via aria-bridge — wir warten auf chat(sender=stt) +
// chat(sender=aria) wie im Legacy-Pfad.
} else {
// Kein Speech im Window → Konversation beenden
console.log('[Chat] STT-Endpoint ohne Text (reason=%s) — endConversation', ev.reason);
// NUR den noch UNAUFGELOESTEN Platzhalter entfernen. Nach manuellem Stop
// klappt oft ein zweites, LEERES stream_end-Endpoint nach (das Audio war
// schon vom echten Endpoint transkribiert) — das darf die bereits mit
// dem STT-Text gefuellte Bubble NICHT loeschen, sonst verschwindet die
// Sprachnachricht obwohl ARIA schon an der Antwort arbeitet.
if (ev.audioRequestId) {
setMessages(prev => prev.filter(m =>
!(m.audioRequestId === ev.audioRequestId
&& m.text.includes('Spracheingabe wird verarbeitet'))));
}
// Bei Passive-Listen + speaker_mismatch oder no-speech: erneut passiv
// lauschen (Timer im wakeword-service laeuft weiter, regelt das Ende).
// Sonst endConversation wie bisher.
if (wakeWordService.getState() === 'listening') {
console.log('[Chat] Passive-Listen: leeres Endpoint — naechste passive Aufnahme');
startPassiveStreamingRecording();
} else {
wakeWordService.endConversation();
if (!wakeWordService.isActive()) setWakeWordActive(false);
}
}
});
// Passive-Listen-Callback: Wake-Word-Service hat in den passiven Modus
// geschaltet (nach endConversation). Wir starten eine streaming-Aufnahme
// OHNE User-Bubble + ohne wake-ready-Sound. Speaker-ID-Gating in der
// Whisper-Bridge filtert fremde Stimmen weg.
const unsubPassive = wakeWordService.onPassiveListen(() => {
console.log('[Chat] Passive-Listen aktiviert — starte stille Streaming-Aufnahme');
startPassiveStreamingRecording();
});
// 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 Streaming-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 audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
rememberMyRequest(audioRequestId);
const location = await getCurrentLocation();
const windowMs = await loadConvWindowMs();
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]));
const { ok } = await audioService.startStreamingRecording({
audioRequestId,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: true, // Barge-In → Brain weiss "User hat unterbrochen"
location: location || null,
noSpeechTimeoutMs: windowMs,
endpointMs: await loadSttEndpointMs(),
hardCapMs: 60000,
projectId: focusedProjectIdRef.current,
});
if (ok) {
ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT);
playWakeReadySound().catch(() => {});
scheduleStaleAudioCleanup(audioRequestId, 60000);
} else {
setMessages(prev => prev.filter(m => m.audioRequestId !== audioRequestId));
}
});
// 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(() => {});
});
// Aus der TTS-Queue nachgespielte (zweite) Antwort: ihren WAV-Cache-Pfad an
// die Bubble haengen, damit der Mund-Button/Play sie auch abspielen kann.
const unsubPcmCached = audioService.onPcmCached((messageId, audioPath) => {
if (!messageId || !audioPath) return;
setMessages(prev => prev.map(m =>
m.messageId === messageId ? { ...m, audioPath } : m));
});
return () => {
unsubWake();
unsubEndpoint();
unsubBarge();
unsubPassive();
unsubTtsStart();
unsubTtsEnd();
unsubPcmCached();
};
}, [wakeWordActive]);
// Passive-Listen-Aufnahme: ohne User-Bubble, ohne Wake-Sound, Speaker-ID-
// Gating in der Whisper-Bridge entscheidet ob Stefan spricht oder z.B.
// die Frau / TV. Bei text != '' → wakeWordService.exitPassiveListening('speech')
// schaltet auf conversing, Brain antwortet, TTS spielt, resume → endConv →
// ... und passive listening startet von vorne (mit frischem Timer).
// useCallback damit der useEffect oben die Funktion stabil capturen kann.
const startPassiveStreamingRecording = useCallback(async () => {
const audioRequestId = `audio_passive_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
rememberMyRequest(audioRequestId);
const location = await getCurrentLocation();
const passiveMs = await loadPassiveListenMs();
const { ok } = await audioService.startStreamingRecording({
audioRequestId,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: false,
location: location || null,
noSpeechTimeoutMs: Math.min(passiveMs, 30000),
endpointMs: await loadSttEndpointMs(),
hardCapMs: Math.max(passiveMs + 5000, 35000),
projectId: focusedProjectIdRef.current,
});
if (!ok) {
console.warn('[Chat] passive streaming start failed — exit passive listening');
wakeWordService.exitPassiveListening('manual').catch(() => {});
}
}, []);
// Wake Word Toggle Handler
const toggleWakeWord = useCallback(async () => {
if (wakeWordActive) {
// Vor Wake-Word-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"). Beide Pfade abdecken — legacy
// file-Aufnahme + neue Streaming-Aufnahme.
try {
if (audioService.isStreamingRecording()) {
await audioService.cancelStreamingRecording('wake-toggle-off');
} else {
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<ReturnType<typeof setTimeout> | 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<ReturnType<typeof setTimeout> | 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],
);
// Focus-One-View (Multi-Threading, 06/2026): Chat zeigt NUR die Nachrichten
// des gerade fokussierten Kontexts. Hauptchat (focusedProjectId leer) →
// alle ungeтагtgeд Nachrichten. Projekt X aktiv → nur Nachrichten mit
// projectId === X. ARIA arbeitet weiterhin in allen Kontexten parallel;
// wir sehen nur den einen.
const messagesForRender = useMemo(() => {
return chatVisibleMessages.filter(m => (m.projectId || '') === focusedProjectId);
}, [chatVisibleMessages, focusedProjectId]);
const invertedMessages = useMemo(() => [...messagesForRender].reverse(), [messagesForRender]);
// 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<string>('');
// 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<ReturnType<typeof setTimeout> | 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<number>(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<Map<string, number>>(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 ---
// ── Pro-Projekt-Queue: Zustands-Helfer ──────────────────────────
const getCtxState = useCallback((pid: string): CtxState =>
projectStatesRef.current[pid] || 'idle', []);
const setCtxState = useCallback((pid: string, s: CtxState) => {
projectStatesRef.current = { ...projectStatesRef.current, [pid]: s };
// Watchdog-Zeitstempel: nur 'running' bekommt einen Start, sonst raus.
if (s === 'running') ctxRunningSinceRef.current[pid] = Date.now();
else delete ctxRunningSinceRef.current[pid];
setProjectStates(prev => ({ ...prev, [pid]: s }));
}, []);
const setCtxQueue = useCallback((pid: string, items: QueuedItem[]) => {
projectQueuesRef.current = { ...projectQueuesRef.current, [pid]: items };
setProjectQueues(prev => ({ ...prev, [pid]: items }));
}, []);
// Sendet eine Nachricht WIRKLICH (Location holen + dispatchWithAck) und setzt
// den Kontext auf 'running'. existingId gesetzt = eine bereits als pending_queue
// angezeigte Bubble wird auf 'sending' gehoben (Dequeue), sonst neue Bubble.
const actuallySend = useCallback(async (pid: string, text: string, existingId?: string) => {
const cmid = nextClientMsgId();
const location = await getCurrentLocation();
const status: ChatMessage['deliveryStatus'] =
connectionStateRef.current === 'connected' ? 'sending' : 'queued';
if (existingId) {
setMessages(prev => prev.map(m =>
m.id === existingId ? { ...m, clientMsgId: cmid, deliveryStatus: status, sendAttempts: 1 } : m));
} else {
setMessages(prev => capMessages([...prev, {
id: nextId(), sender: 'user', text, timestamp: Date.now(),
clientMsgId: cmid, deliveryStatus: status, sendAttempts: 1, projectId: pid,
}]));
}
setCtxState(pid, 'running');
dispatchWithAck(cmid, 'chat', {
text, voice: localXttsVoiceRef.current, speed: ttsSpeedRef.current,
projectId: pid, ...(location && { location }),
});
}, [getCurrentLocation, dispatchWithAck, setCtxState]);
// Naechsten Queue-Eintrag von pid abarbeiten (falls vorhanden), sonst idle.
const advanceQueue = useCallback((pid: string) => {
const q = projectQueuesRef.current[pid] || [];
if (q.length === 0) { setCtxState(pid, 'idle'); return; }
const [next, ...rest] = q;
setCtxQueue(pid, rest);
actuallySend(pid, next.text, next.id);
}, [actuallySend, setCtxQueue, setCtxState]);
// Einen wartenden Eintrag aus der Queue entfernen (User tippt auf ✕).
const removeQueued = useCallback((pid: string, id: string) => {
setCtxQueue(pid, (projectQueuesRef.current[pid] || []).filter(it => it.id !== id));
setMessages(prev => prev.filter(m => m.id !== id));
}, [setCtxQueue]);
// Ref-Bruecke fuellen, damit der frueh deklarierte Message-Handler den Automaten
// erreicht (ohne TDZ / stale Closure).
useEffect(() => {
queueApiRef.current = { getCtxState, setCtxState, advanceQueue };
}, [getCtxState, setCtxState, advanceQueue]);
// 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);
}, []);
// Anfrage abbrechen — nur den fokussierten Kontext (kontext-scoped Cancel).
const cancelRequest = useCallback(() => {
const pid = focusedProjectIdRef.current || '';
setAgentActivity({ activity: 'idle', tool: '' });
setAgentActivityByCtx(prev => ({ ...prev, [pid]: { activity: 'idle', tool: '' } }));
clearStuckWatchdog();
rvs.send('cancel_request' as any, { projectId: pid });
// Aktuellen Task abgebrochen → naechsten Queue-Eintrag dieses Projekts
// starten (oder idle, wenn leer). Wartende Eintraege einzeln loeschbar.
advanceQueue(pid);
}, [advanceQueue]);
// Queue-Modus („immer anstellen"): eine neue Sprachnachricht bricht ARIAs
// laufende Arbeit NICHT mehr ab. Sie wird — wie Text — angestellt und laeuft
// serialisiert (der Brain-Lock pro Projekt reiht /chat-/audio-Turns auf).
// Nur das TTS wird akustisch gestoppt, damit das Mikro ARIAs eigene Stimme
// nicht mithoert. Explizites Abbrechen laeuft ueber den Stop-Button
// (cancelRequest). Rueckgabe = false, weil kein Barge-In/Interrupt mehr.
const interruptAriaIfBusy = useCallback(() => {
if (audioService.isPlayingAudio()) {
audioService.haltAllPlayback('user startet Aufnahme (Queue-Modus, kein Abbruch)');
}
return false;
}, []);
// Manueller Aufnahme-Knopf (VoiceButton) — Start.
// Streaming-Variante: PcmStreamRecorder + Whisper-ML-Endpointer ersetzen
// die alte dB-VAD-Schleife. Knopf-1.-Tap startet, Knopf-2.-Tap stoppt.
// Bubble bauen wir SOFORT damit der User sofort Feedback hat — Text wird
// ueber audioRequestId-Match nachgereicht wenn whisper das Endpoint feuert.
const handleVoiceButtonStart = useCallback(async (): Promise<boolean> => {
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
rememberMyRequest(audioRequestId);
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation();
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]));
const { ok } = await audioService.startStreamingRecording({
audioRequestId,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
location: location || null,
// Manueller Knopf: kein no-speech-Watchdog (User kontrolliert via Tap-zum-
// Stoppen). Hard-Cap 5 Minuten als Notbremse — danach killt Whisper
// die Session auch app-seitig haben wir +2s Toleranz.
noSpeechTimeoutMs: 0,
endpointMs: await loadSttEndpointMs(),
hardCapMs: 300000,
projectId: focusedProjectIdRef.current,
});
if (!ok) {
// Mikro nicht verfuegbar (Anruf? OpenWakeWord blockiert?) — Bubble weg.
setMessages(prev => prev.filter(m => m.audioRequestId !== audioRequestId));
return false;
}
scheduleStaleAudioCleanup(audioRequestId, 60000);
return true;
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
// Manueller Aufnahme-Knopf — Stop. Sendet stt_stream_end an Whisper, die
// dann ihrerseits den finalen Text als stt_endpoint emittiert. aria-bridge
// forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich
// endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume.
const handleVoiceButtonStop = useCallback(async (): Promise<void> => {
// Stop WAEHREND des passiven 30s-Lauschens ('listening'): sauber beenden
// (zurueck aufs Wake-Word), NICHT den passiven Stream neu starten.
// exitPassiveListening cancelt den Stream selbst (via _freeMic) → es feuert
// kein leeres Endpoint, das startPassiveStreamingRecording erneut ausloest
// (genau das liess die 30s vorher von vorne loslaufen).
if (wakeWordService.getState() === 'listening') {
console.log('[Chat] Manueller Stop im Passiv-Lauschen → exitPassiveListening (Ende)');
await wakeWordService.exitPassiveListening('manual');
return;
}
await audioService.stopStreamingRecording('user');
if (wakeWordService.isConversing()) {
console.log('[Chat] Manueller Stop in Konversation → endConversation, zurueck zu armed');
await wakeWordService.endConversation(true); // explizit gestoppt → STOP (kein 30s-Passiv)
}
}, []);
// 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 activePid = focusedProjectIdRef.current;
const userMsg: ChatMessage = {
id: msgId,
sender: 'user',
text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
timestamp: Date.now(),
attachments,
projectId: activePid,
...(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,
projectId: activePid,
// Korrelation: dieselbe clientMsgId wie der Text, damit die Bridge
// die Datei genau DIESER Nachricht zuordnet — auch wenn Files (fire-
// and-forget) und Text (ACK-getrackt, bei Queue verzoegert) desyncen.
...(cmid && { clientMsgId: cmid }),
...(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,
projectId: activePid,
...(location && { location }),
});
}
setPendingAttachments([]);
setInputText('');
}, [pendingAttachments, getCurrentLocation, dispatchWithAck]);
// Reine Text-Nachricht senden. Steht bewusst NACH interruptAriaIfBusy und
// sendPendingAttachments — beide stehen in seinem deps-Array, und wenn es
// davor deklariert waere, laendeten sie dort in der Temporal Dead Zone
// (tsc TS2448/2454; lief nur zufaellig, weil Babel const→var hebt).
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;
const activePid = focusedProjectIdRef.current;
setInputText('');
// Draft dieses Projekts leeren (wurde ja gerade abgeschickt/angestellt).
projectDraftsRef.current = { ...projectDraftsRef.current, [activePid]: '' };
const focusKey = activePid || '__main__';
const brainBusy = !!queueStatusRef.current?.[focusKey]?.busy;
let state = getCtxState(activePid);
// Fallback: ein per SPRACHE gestarteter Turn streamt live und setzt den
// App-State nicht — aber der Brain meldet busy. Dann Kontext als 'running'
// behandeln (anstellen; die spaetere Antwort schaltet die Queue weiter).
if (state === 'idle' && brainBusy) { setCtxState(activePid, 'running'); state = 'running'; }
if (state === 'running') {
// ARIA arbeitet noch am aktuellen Task → ANSTELLEN statt abbrechen.
// Sichtbare pending_queue-Bubble; laeuft der Reihe nach, wenn der
// aktuelle Task (und ggf. seine Rueckfragen) fertig ist.
const id = nextId();
setMessages(prev => capMessages([...prev, {
id, sender: 'user', text, timestamp: Date.now(),
projectId: activePid, deliveryStatus: 'pending_queue',
}]));
setCtxQueue(activePid, [...(projectQueuesRef.current[activePid] || []), { id, text }]);
console.log('[Chat] angestellt (queue) project=%s len=%d', activePid || '(main)',
(projectQueuesRef.current[activePid] || []).length);
} else {
// idle ODER awaiting_reply → sofort senden. Bei awaiting_reply ist DAS die
// Antwort auf ARIAs Rueckfrage (normaler Turn in diesem Projekt).
console.log('[Chat] sende sofort (state=%s) project=%s', state, activePid || '(main)');
actuallySend(activePid, text);
}
}, [inputText, pendingAttachments, sendPendingAttachments, getCtxState, setCtxState, setCtxQueue, actuallySend]);
// Zwischenruf: waehrend ARIA arbeitet eine Korrektur MITTEN in den laufenden
// Turn schieben — NICHT in die Queue, KEIN Abbruch. Geht als 'interject' ueber
// RVS an die Bridge → Proxy → laufender Subprozess (greift es an der naechsten
// Tool-Grenze auf). Sichtbar nur, wenn der aktive Kontext gerade arbeitet.
const sendInterject = useCallback(() => {
const text = inputText.trim();
if (!text) return;
const activePid = focusedProjectIdRef.current;
rvs.send('interject' as any, { projectId: activePid, text });
// Lokale Bubble zur Rueckmeldung (laeuft NICHT durch Send/Queue).
setMessages(prev => capMessages([...prev, {
id: nextId(), sender: 'user', text: `📣 Zwischenruf: ${text}`,
timestamp: Date.now(), projectId: activePid,
}]));
projectDraftsRef.current = { ...projectDraftsRef.current, [activePid]: '' };
setInputText('');
}, [inputText]);
// --- 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 (
<Wrapper {...wrapperProps} style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: borderColor}, searchHighlightStyle]}>
<Text style={{color: headlineColor, fontWeight: 'bold', fontSize: 14}}>
{headline}
</Text>
<Text style={{color: '#E0E0F0', marginTop: 4, fontSize: 14}}>
<Text style={{fontWeight: 'bold'}}>{m.title}</Text>
<Text style={{color: '#8888AA', fontSize: 12}}>{` (${m.type}${m.pinned ? ' · 📌 pinned' : ''}${catPart})`}</Text>
</Text>
{m.preview ? (
<Text style={{color: '#888', fontSize: 12, marginTop: 4}}>{m.preview}{m.preview.length >= 140 ? '…' : ''}</Text>
) : 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 (
<TouchableOpacity
key={`${item.id}-att-${idx}`}
style={styles.memoryAttachmentRow}
onPress={async () => {
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}` });
}}
>
<Text style={styles.memoryAttachmentIcon}>{icon}</Text>
<Text style={styles.memoryAttachmentName} numberOfLines={1}>{a.name}</Text>
<Text style={styles.memoryAttachmentMeta}>
{a.localUri ? '(tippen zum oeffnen)' : `(tippen zum Laden${sizeStr})`}
</Text>
</TouchableOpacity>
);
})}
<Text style={{color: '#555570', fontSize: 10, marginTop: 6}}>
ARIA-Memory · {time}{openable ? ' · tippen für Details' : ''}
</Text>
</Wrapper>
);
}
// 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 (
<View style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: '#FFD60A'}, searchHighlightStyle]}>
<Text style={{color: '#FFD60A', fontWeight: 'bold', fontSize: 14}}>
{'⏰ ARIA hat einen Trigger angelegt'}
</Text>
<Text style={{color: '#E0E0F0', marginTop: 4, fontSize: 14}}>
<Text style={{fontWeight: 'bold'}}>{t.name}</Text>
<Text style={{color: '#8888AA', fontSize: 12}}>{` (${t.type})`}</Text>
</Text>
<Text style={{color: '#8888AA', fontSize: 12, marginTop: 2, fontFamily: 'monospace'}}>{detailLine}</Text>
<Text style={{color: '#888', fontSize: 12, marginTop: 2}}>{`"${t.message}"`}</Text>
<Text style={{color: '#555570', fontSize: 10, marginTop: 6}}>ARIA-Trigger · {time}</Text>
</View>
);
}
// Spezial-Bubble: ARIA hat einen Skill erstellt
if (item.skillCreated) {
const s = item.skillCreated;
return (
<View style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: '#FFD60A'}, searchHighlightStyle]}>
<Text style={{color: '#FFD60A', fontWeight: 'bold', fontSize: 14}}>
{'🛠 ARIA hat einen neuen Skill erstellt'}
</Text>
<Text style={{color: '#E0E0F0', marginTop: 4, fontSize: 14}}>
<Text style={{fontWeight: 'bold'}}>{s.name}</Text>
<Text style={{color: '#8888AA', fontSize: 12}}>{` (${s.execution}, ${s.active ? 'aktiv' : 'deaktiviert'})`}</Text>
</Text>
<Text style={{color: '#8888AA', fontSize: 12, marginTop: 2}}>{s.description}</Text>
{s.setupError && (
<Text style={{color: '#FF6B6B', fontSize: 11, marginTop: 4}}>
{'⚠ Setup-Fehler: '}{s.setupError.slice(0, 200)}
</Text>
)}
<Text style={{color: '#555570', fontSize: 10, marginTop: 6}}>ARIA-Skill · {time}</Text>
</View>
);
}
return (
<Pressable
style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble, searchHighlightStyle]}
onLayout={e => {
// 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);
}}
onLongPress={() => openBubbleActions(item)}
delayLongPress={500}
android_ripple={null}
>
{/* Anhang-Vorschau */}
{item.attachments?.map((att, idx) => (
<View key={idx}>
{att.deleted ? (
<View style={[styles.attachmentFile, {opacity: 0.6}]}>
<Text style={styles.attachmentFileIcon}>{'🗑️'}</Text>
<Text style={[styles.attachmentFileName, {textDecorationLine: 'line-through'}]} numberOfLines={1}>{att.name}</Text>
<Text style={[styles.attachmentFileSize, {color: '#FF9500'}]}>(geloescht)</Text>
</View>
) : att.type === 'image' && att.uri ? (
<ChatImage
uri={att.uri}
onPress={() => 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 ? (
<TouchableOpacity
style={styles.attachmentFile}
onPress={() => {
if (att.serverPath) {
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
}
}}
>
<Text style={styles.attachmentFileIcon}>{'\uD83D\uDDBC\uFE0F'}</Text>
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
<Text style={styles.attachmentFileSize}>
{att.serverPath ? '(tippen zum Laden)' : '(nicht verfuegbar)'}
</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
style={styles.attachmentFile}
onPress={async () => {
// 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);
}
}}
>
<Text style={styles.attachmentFileIcon}>
{att.mimeType?.includes('pdf') ? '\uD83D\uDCC4' :
att.mimeType?.includes('word') || att.mimeType?.includes('document') ? '\uD83D\uDCC3' :
att.mimeType?.includes('sheet') || att.mimeType?.includes('excel') ? '\uD83D\uDCC8' :
'\uD83D\uDCC1'}
</Text>
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
{att.size ? <Text style={styles.attachmentFileSize}>{Math.round(att.size / 1024)}KB</Text> : null}
{!att.uri && att.serverPath && (
<Text style={[styles.attachmentFileSize, {color: '#0096FF'}]}>(tippen zum oeffnen)</Text>
)}
{!att.uri && !att.serverPath && <Text style={styles.attachmentFileSize}>(nicht verfuegbar)</Text>}
</TouchableOpacity>
)}
</View>
))}
{/* Text (nicht anzeigen wenn nur "Anhang empfangen" und ein Bild da ist) */}
{!(item.text === 'Anhang empfangen' && item.attachments?.some(a => a.type === 'image' && a.uri)) && (
<MessageText
text={showSystemHints ? item.text : stripSystemHints(item.text)}
style={[styles.messageText, isUser ? styles.userText : styles.ariaText]}
/>
)}
{/* Play-Button fuer ARIA-Nachrichten — Cache bevorzugt, sonst Bridge-TTS mit aktueller Engine */}
{!isUser && item.text.length > 0 && (
<TouchableOpacity
style={styles.playButton}
onPress={async () => {
// 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 || '',
});
}}
>
<Text style={styles.playButtonText}>{'\uD83D\uDD0A'}</Text>
</TouchableOpacity>
)}
{item.backupTs ? (
<TouchableOpacity
style={styles.bubbleTrash}
hitSlop={{top:6,bottom:6,left:6,right:6}}
onPress={() => confirmDeleteBubble(item)}
>
<Text style={styles.bubbleTrashIcon}>{'🗑'}</Text>
</TouchableOpacity>
) : null}
<View style={styles.statusRow}>
<Text style={styles.timestamp}>{time}</Text>
{!isUser && showSource && item.answeredBy ? (() => {
const ab = item.answeredBy as string;
const map: { [k: string]: { t: string; c: string } } = {
local: { t: '⚡ lokal', c: '#34C759' },
claude: { t: 'Claude', c: '#0096FF' },
'fast-path': { t: '⚡ fast', c: '#AF7BFF' },
};
const b = map[ab] || { t: ab, c: '#8A8AA0' };
return <Text style={{ fontSize: 9, fontWeight: 'bold', color: b.c, marginLeft: 6 }}>{b.t}</Text>;
})() : null}
{item.text.length > 0 ? (
<TouchableOpacity
hitSlop={{top:6,bottom:6,left:6,right:6}}
onPress={() => openBubbleActions(item)}
accessibilityLabel="Aktionen"
>
<Text style={styles.bubbleCopyIcon}>{'⎘'}</Text>
</TouchableOpacity>
) : null}
{isUser && item.deliveryStatus ? (
item.deliveryStatus === 'pending_queue' ? (
// Wartet in der Projekt-Queue → tippen entfernt den Eintrag.
<TouchableOpacity
hitSlop={{top:6,bottom:6,left:6,right:6}}
onPress={() => removeQueued(item.projectId || '', item.id)}
accessibilityLabel="Aus Warteschlange entfernen"
>
<Text style={styles.statusQueued}>{'⏸ wartet · ✕'}</Text>
</TouchableOpacity>
) : item.deliveryStatus === 'failed' && item.clientMsgId ? (
<TouchableOpacity
hitSlop={{top:6,bottom:6,left:6,right:6}}
onPress={() => retryFailedMessage(item.clientMsgId!)}
>
<Text style={styles.statusFailed}>{'⚠ tippen f. Retry'}</Text>
</TouchableOpacity>
) : (
<Text style={
item.deliveryStatus === 'queued' ? styles.statusQueued :
item.deliveryStatus === 'sending' ? styles.statusSending :
item.deliveryStatus === 'sent' ? styles.statusSent :
/* delivered */ styles.statusDelivered
}>
{item.deliveryStatus === 'queued' ? '⏱' :
item.deliveryStatus === 'sending' ? '⏳' :
item.deliveryStatus === 'sent' ? '✓' :
/* delivered */ '✓✓'}
</Text>
)
) : null}
</View>
</Pressable>
);
};
// Extrahiert kopierbare Items aus dem Bubble-Text (URLs, Mails, Telefon).
// Wird vom Long-Press/Copy-Menu genutzt damit Stefan den einzelnen Wert
// teilen kann ohne den umliegenden Text mitzunehmen.
const extractCopyables = (text: string): { label: string; value: string }[] => {
const items: { label: string; value: string }[] = [];
const urlRe = /https?:\/\/[^\s<>"']+/gi;
const mailRe = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
const telRe = /(?:\+?\d[\d ()/-]{6,}\d)/g;
const seen = new Set<string>();
const push = (label: string, value: string) => {
const trimmed = value.trim().replace(/[,;.)\]}>]+$/g, '');
if (!trimmed || seen.has(trimmed)) return;
seen.add(trimmed);
items.push({ label, value: trimmed });
};
(text.match(urlRe) || []).forEach(u => push('URL', u));
(text.match(mailRe) || []).forEach(m => push('E-Mail', m));
(text.match(telRe) || []).forEach(t => push('Telefon', t));
return items.slice(0, 5); // max 5 items, mehr wird unleserlich
};
// Long-Press oder ⎘-Icon auf einer Bubble. Zeigt einen Alert mit
// "Text teilen" (= System-Share-Sheet, dort gibt's auch Zwischenablage)
// sowie pro extrahierte URL/E-Mail/Telefonnummer eine Option um
// gezielt nur dieses Item zu teilen.
const openBubbleActions = (item: ChatMessage) => {
const text = showSystemHints ? item.text : stripSystemHints(item.text);
if (!text) return;
const copyables = extractCopyables(text);
const buttons: any[] = [
{
text: '📋 Ganzen Text teilen',
onPress: () => Share.share({ message: text }).catch(() => {}),
},
];
for (const c of copyables) {
buttons.push({
text: `📎 ${c.label}: ${c.value.slice(0, 32)}${c.value.length > 32 ? '…' : ''}`,
onPress: () => Share.share({ message: c.value }).catch(() => {}),
});
}
buttons.push({ text: 'Abbrechen', style: 'cancel' });
Alert.alert(
'Bubble-Aktionen',
copyables.length > 0
? 'Was moechtest du teilen / kopieren?'
: 'Text in System-Share-Sheet oeffnen (dort "In Zwischenablage" verfuegbar).',
buttons,
);
};
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 (
<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>
<TouchableOpacity onPress={() => setThoughtsVisible(true)} style={{marginLeft: 'auto', paddingHorizontal: 6, flexDirection: 'row', alignItems: 'center'}} hitSlop={{top:8,bottom:8,left:6,right:6}}>
<Text style={{fontSize: 16}}>{'\uD83D\uDCAD'}</Text>
{thoughts.length > 0 ? (
<Text style={{color: '#8888AA', fontSize: 11, marginLeft: 3}}>{thoughts.length}</Text>
) : null}
</TouchableOpacity>
<TouchableOpacity onPress={() => setInboxVisible(true)} style={{paddingHorizontal: 6}} hitSlop={{top:8,bottom:8,left:6,right:6}}>
<Text style={{fontSize: 18}}>{'\uD83D\uDDC2\uFE0F'}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => setSearchVisible(!searchVisible)} style={{paddingHorizontal: 6}} hitSlop={{top:8,bottom:8,left:6,right:6}}>
<Text style={{fontSize: 16}}>{'\uD83D\uDD0D'}</Text>
</TouchableOpacity>
</View>
{/* 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<string, string> = { f5tts: 'F5-TTS', whisper: 'Whisper STT', flux: 'FLUX Image-Gen' };
return (
<TouchableOpacity
activeOpacity={allReady ? 0.6 : 1.0}
onPress={() => { if (allReady) setServiceBannerDismissed(true); }}
style={[styles.serviceBanner, { backgroundColor: bg, borderColor: border }]}
>
{entries.map(([svc, info]) => {
let icon = '\u23F3', text = '';
if (info.state === 'loading') {
icon = info.downloading ? '\u2B07' : '\u23F3'; // \u2B07 vs \u23F3
const action = info.downloading
? 'laedt erstmalig runter (mehrere GB, kann dauern)'
: 'laedt';
text = `${labels[svc] || svc}: ${action}${info.model ? ' ' + info.model : ''}...`;
} else if (info.state === 'ready') {
icon = info.freshlyDownloaded ? '\uD83C\uDF89' : '\u2705'; // \uD83C\uDF89 vs \u2705
const sec = info.loadSeconds ? ` (${info.loadSeconds.toFixed(1)}s)` : '';
const dl = info.freshlyDownloaded ? ' \u2014 Download fertig!' : '';
text = `${labels[svc] || svc}: bereit${info.model ? ' ' + info.model : ''}${sec}${dl}`;
} else if (info.state === 'error') {
icon = '\u274C';
text = `${labels[svc] || svc}: Fehler ${info.error || ''}`;
} else {
text = `${labels[svc] || svc}: ${info.state}`;
}
return (
<Text key={svc} style={styles.serviceBannerLine}>
{icon} {text}
</Text>
);
})}
<Text style={styles.serviceBannerHint}>
{allReady ? 'Tippen zum Schliessen' : 'Bitte warten...'}
</Text>
</TouchableOpacity>
);
})()}
{/* Projekt-Indicator: zeigt Hauptchat oder aktives Projekt */}
{/* Focus-Indicator + Drawer-Toggle. Multi-Threading: das ist reine
Anzeige „was sehe ich gerade" — ARIA arbeitet gleichzeitig in
allen Kontexten weiter, wir zeigen hier nur einen. */}
{(() => {
const isMain = !focusedProjectId;
const focusedName = isMain ? '' : (projectNameById[focusedProjectId] || focusedProjectId);
const focusedQueue = queueStatus[isMain ? '__main__' : focusedProjectId];
const dot = focusedQueue?.busy
? { color: '#FF6E6E', label: 'arbeitet' }
: focusedQueue?.queue_size
? { color: '#FFD60A', label: `Queue: ${focusedQueue.queue_size}` }
: { color: '#34C759', label: 'idle' };
// Anzahl anderer Kontexte die gerade aktiv sind (fuer Drawer-Badge)
const otherActive = Object.entries(queueStatus).filter(([k, v]) => {
const kFocus = isMain ? '__main__' : focusedProjectId;
if (k === kFocus) return false;
return v.busy || v.queue_size > 0;
}).length;
return (
<View
style={{
flexDirection: 'row', alignItems: 'center',
paddingHorizontal: 12, paddingVertical: 8,
backgroundColor: isMain ? '#1A1A26' : 'rgba(52,199,89,0.10)',
borderBottomWidth: 2,
borderColor: isMain ? '#1E1E2E' : '#34C759',
}}
>
<TouchableOpacity
onPress={() => setProjectsVisible(true)}
style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}
hitSlop={{top:6,bottom:6,left:6,right:6}}
>
<Text style={{ fontSize: 22, color: '#E0E0F0', fontWeight: '700' }}></Text>
{otherActive > 0 && (
<View style={{ backgroundColor: '#FF6E6E', borderRadius: 8, minWidth: 16, height: 16, paddingHorizontal: 4, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: '#fff', fontSize: 10, fontWeight: '700' }}>{otherActive}</Text>
</View>
)}
</TouchableOpacity>
<View style={{ flex: 1, marginLeft: 10, flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Text style={{ fontSize: 14, color: isMain ? '#E0E0F0' : '#34C759', fontWeight: '700', flex: 1 }} numberOfLines={1}>
{isMain ? '💬 Hauptchat' : `📁 ${focusedName}`}
</Text>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
<Text style={{ fontSize: 10, color: '#8888AA' }}>{dot.label}</Text>
{/* Direkter Zurueck-zum-Hauptchat-Button — nur wenn man in einem
Projekt ist. Ein Tap statt Drawer→Hauptchat. */}
{!isMain && (
<TouchableOpacity
onPress={() => setFocusedProjectId('')}
hitSlop={{top:8,bottom:8,left:8,right:8}}
style={{
marginLeft: 4, paddingHorizontal: 10, paddingVertical: 4,
borderRadius: 12, backgroundColor: 'rgba(255,255,255,0.12)',
flexDirection: 'row', alignItems: 'center', gap: 4,
}}
>
<Text style={{ color: '#E0E0F0', fontSize: 12, fontWeight: '700' }}> Hauptchat</Text>
</TouchableOpacity>
)}
</View>
</View>
);
})()}
{/* Projekt-Drawer als Modal */}
<ProjectsBrowser
visible={projectsVisible}
onClose={() => setProjectsVisible(false)}
onActiveChanged={(p) => setFocusedProjectId(p?.id || '')}
currentFocusId={focusedProjectId}
queueStatus={queueStatus}
/>
{/* Suchleiste mit Treffer-Navigation */}
{searchVisible && (
<View style={styles.searchBar}>
<TextInput
style={styles.searchInput}
value={searchQuery}
onChangeText={setSearchQuery}
placeholder="Chat durchsuchen..."
placeholderTextColor="#555570"
autoFocus
/>
{searchQuery ? (
<Text style={{color: searchMatchIds.length ? '#0096FF' : '#555570', fontSize: 12, paddingHorizontal: 6}}>
{searchMatchIds.length ? `${searchIndex + 1}/${searchMatchIds.length}` : '0/0'}
</Text>
) : null}
<TouchableOpacity
onPress={gotoSearchPrev}
disabled={!searchMatchIds.length}
style={{paddingHorizontal: 6, opacity: searchMatchIds.length ? 1 : 0.3}}
>
<Text style={{color: '#0096FF', fontSize: 18}}>{'▲'}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={gotoSearchNext}
disabled={!searchMatchIds.length}
style={{paddingHorizontal: 6, opacity: searchMatchIds.length ? 1 : 0.3}}
>
<Text style={{color: '#0096FF', fontSize: 18}}>{'▼'}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => { setSearchVisible(false); setSearchQuery(''); }}>
<Text style={{color: '#FF3B30', fontSize: 14, paddingHorizontal: 8}}>X</Text>
</TouchableOpacity>
</View>
)}
{/* Nachrichtenliste — Suche FILTERT NICHT mehr, sondern hebt aktiven
Treffer hervor (siehe renderMessage: activeSearchId-Border). */}
<FlatList
ref={flatListRef}
inverted
data={invertedMessages}
// Mehr Items beim Mount messen → bessere averageItemLength fuer
// Such-Sprung gleich nach App-Start. Default sind 10 Items, das
// ist bei 300+ Bubbles im Backup viel zu wenig.
initialNumToRender={30}
// Mehr Items im Speicher halten (Default 21 = 10 oben + 10 unten).
// Macht scroll-to-far-away weniger anfaellig fuer Layout-Holes.
windowSize={41}
onScroll={(e) => {
// 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={
<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>
}
/>
{/* Thinking-Indicator \u2014 NUR fuer den fokussierten Kontext (Multi-Threading).
ARIA kann in anderen Kontexten parallel arbeiten, ohne dass hier ein
Indikator flackert der nicht zum sichtbaren Chat gehoert. */}
{(() => {
const focusAct = agentActivityByCtx[focusedProjectId] || { activity: 'idle', tool: '' };
if (focusAct.activity === 'idle') return null;
return (
<View style={styles.thinkingBar}>
<Text style={styles.thinkingText}>
{focusAct.activity === 'tool' && focusAct.tool
? `\uD83D\uDD27 ${focusAct.tool}`
: focusAct.activity === 'assistant'
? '\u270D\uFE0F ARIA schreibt...'
: '\uD83D\uDCAD ARIA denkt...'}
</Text>
<View style={{flexDirection: 'row', gap: 6}}>
<TouchableOpacity style={styles.thinkingCancel} onPress={cancelRequest}>
<Text style={styles.thinkingCancelText}>Abbrechen</Text>
</TouchableOpacity>
</View>
</View>
);
})()}
{/* Pending Anhaenge Vorschau */}
{pendingAttachments.length > 0 && (
<View style={styles.pendingBar}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{flex: 1}}>
{pendingAttachments.map((att, idx) => (
<View key={idx} style={styles.pendingItem}>
{att.file.type?.startsWith('image/') || att.isPhoto ? (
<Image
source={{ uri: att.file.base64
? `data:${att.file.type};base64,${att.file.base64}`
: att.file.uri }}
style={styles.pendingThumb}
/>
) : (
<View style={[styles.pendingThumb, {justifyContent: 'center', alignItems: 'center'}]}>
<Text style={{fontSize: 20}}>{'\uD83D\uDCC4'}</Text>
</View>
)}
<TouchableOpacity
style={styles.pendingRemove}
onPress={() => setPendingAttachments(prev => prev.filter((_, i) => i !== idx))}
>
<Text style={{color: '#fff', fontSize: 10, fontWeight: 'bold'}}>X</Text>
</TouchableOpacity>
</View>
))}
</ScrollView>
<Text style={{color: '#8888AA', fontSize: 11, marginLeft: 8}}>{pendingAttachments.length}</Text>
<TouchableOpacity onPress={() => setPendingAttachments([])}>
<Text style={{color: '#FF3B30', fontSize: 14, paddingHorizontal: 8}}>Alle X</Text>
</TouchableOpacity>
</View>
)}
{/* 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 && (
<TouchableOpacity
style={styles.jumpDownBtn}
activeOpacity={0.85}
onPress={() => {
try {
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
} catch {}
setShowJumpDown(false);
}}
>
<Text style={{color:'#fff', fontSize:18, fontWeight:'700'}}>{'↓'}</Text>
</TouchableOpacity>
)}
{/* Rueckfrage-Banner / Queue-Zaehler fuer den fokussierten Kontext */}
{(projectStates[focusedProjectId] === 'awaiting_reply' ||
(projectQueues[focusedProjectId]?.length || 0) > 0) && (
<View style={styles.queueBanner}>
<Text style={styles.queueBannerText}>
{projectStates[focusedProjectId] === 'awaiting_reply'
? '❓ ARIA fragt nach — deine Eingabe beantwortet das'
: `⏸ ${projectQueues[focusedProjectId]?.length || 0} in der Warteschlange`}
</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={pendingAttachments.length > 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 ? (
<>
{/* Zwischenruf: nur wenn ARIA im aktiven Kontext gerade arbeitet und
Text da ist. Schiebt die Korrektur in den laufenden Turn statt
sie anzustellen. */}
{inputText.trim() && (agentActivityByCtx[focusedProjectId]?.activity || 'idle') !== 'idle' ? (
<TouchableOpacity style={styles.interjectButton} onPress={sendInterject} accessibilityLabel="Zwischenruf">
<Text style={styles.interjectIcon}>{'\uD83D\uDCE3'}</Text>
</TouchableOpacity>
) : null}
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
</TouchableOpacity>
</>
) : (
<>
<VoiceButton
onTapStart={handleVoiceButtonStart}
onTapStop={handleVoiceButtonStop}
disabled={connectionState !== 'connected'}
wakeWordActive={wakeWordActive}
/>
{/* Mund-Button: TTS auf diesem Geraet muten/aufheben.
Nur sichtbar wenn TTS in den Settings grundsaetzlich aktiv ist. */}
{ttsDeviceEnabled && (
<TouchableOpacity
style={[styles.wakeWordBtn, ttsMuted && styles.mouthBtnMuted]}
onPress={toggleMute}
accessibilityLabel={ttsMuted ? 'Sprachausgabe einschalten' : 'Sprachausgabe stumm schalten'}
>
<Text style={styles.wakeWordIcon}>{ttsMuted ? '🤐' : '👄'}</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.wakeWordBtn, wakeWordActive && styles.wakeWordBtnActive]}
onPress={toggleWakeWord}
>
<Text style={styles.wakeWordIcon}>
{wakeWordState === 'conversing' ? '🎙️' :
wakeWordState === 'armed' ? '👂' : '🔇'}
</Text>
</TouchableOpacity>
</>
)}
</View>
{/* Memory-Detail/Edit-Modal — wird durch Tap auf eine memorySaved-Bubble geoeffnet */}
{memoryDetailId ? (
<ErrorBoundary scope="ChatScreen.MemoryDetailModal" onReset={() => setMemoryDetailId(null)}>
<MemoryDetailModal
memoryId={memoryDetailId}
visible={!!memoryDetailId}
onClose={() => setMemoryDetailId(null)}
onDeleted={() => setMemoryDetailId(null)}
/>
</ErrorBoundary>
) : null}
{/* Gedanken-Stream — chronologisches Log von ARIAs interner Aktivitaet.
Bottom-Sheet (slide-up), 60% Bildschirmhoehe. Mülltonne zum Leeren. */}
<Modal
visible={thoughtsVisible}
animationType="slide"
transparent
onRequestClose={() => setThoughtsVisible(false)}
>
<View style={{flex:1, backgroundColor:'rgba(0,0,0,0.5)', justifyContent:'flex-end'}}>
{/* 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. */}
<TouchableOpacity
style={{flex:1}}
activeOpacity={1}
onPress={() => setThoughtsVisible(false)}
/>
<View
style={{height:'60%', backgroundColor:'#0D0D1A', borderTopLeftRadius:16, borderTopRightRadius:16}}
>
{/* Drag-Indicator */}
<View style={{alignItems:'center', paddingTop:8, paddingBottom:4}}>
<View style={{width:40, height:4, borderRadius:2, backgroundColor:'#2A2A3E'}} />
</View>
<View style={{flexDirection:'row', alignItems:'center', padding:14, borderBottomWidth:1, borderBottomColor:'#1E1E2E'}}>
<Text style={{color:'#FFD60A', fontWeight:'bold', fontSize:16, flex:1}}>
{'💭'} Gedanken-Stream {thoughts.length > 0 ? `(${thoughts.length})` : ''}
</Text>
{thoughts.length > 0 ? (
<TouchableOpacity
onPress={() => {
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}}
>
<Text style={{fontSize:18}}>{'🗑'}</Text>
</TouchableOpacity>
) : null}
<TouchableOpacity onPress={() => setThoughtsVisible(false)} hitSlop={{top:8,bottom:8,left:8,right:8}}>
<Text style={{color:'#8888AA', fontSize:24}}>×</Text>
</TouchableOpacity>
</View>
{thoughts.length === 0 ? (
<View style={{flex:1, alignItems:'center', justifyContent:'center', padding:24}}>
<Text style={{color:'#555570', fontSize:13, fontStyle:'italic', textAlign:'center'}}>
Noch keine Gedanken aufgezeichnet.{'\n'}Sobald ARIA was tut, taucht's hier auf.
</Text>
</View>
) : (
<FlatList
data={thoughts}
keyExtractor={(_, i) => `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 (
<View>
{showGap ? (
<View style={{flexDirection:'row', alignItems:'center', paddingHorizontal:16, paddingVertical:6}}>
<View style={{flex:1, height:1, backgroundColor:'#1E1E2E'}} />
<Text style={{color:'#555570', fontSize:10, paddingHorizontal:8}}>
{gapMin < 60 ? `${gapMin} Min` : `${Math.floor(gapMin/60)}h ${gapMin%60}m`}
</Text>
<View style={{flex:1, height:1, backgroundColor:'#1E1E2E'}} />
</View>
) : null}
<View style={{flexDirection:'row', paddingHorizontal:16, paddingVertical:5}}>
<Text style={{color:'#555570', fontSize:11, width:78}}>{time}</Text>
<Text style={{fontSize:13, width:24}}>{icon}</Text>
<Text style={{color: isIdle ? '#34C759' : '#E0E0F0', fontSize:13, flex:1}}>{label}</Text>
</View>
</View>
);
}}
/>
)}
</View>
</View>
</Modal>
{/* 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. */}
<Modal visible={inboxVisible} animationType="slide" onRequestClose={() => setInboxVisible(false)}>
<ErrorBoundary scope="ChatScreen.InboxModal" onReset={() => setInboxVisible(false)}>
<View style={{flex:1, backgroundColor:'#0D0D1A'}}>
<View style={{flexDirection:'row', alignItems:'center', padding:14, borderBottomWidth:1, borderBottomColor:'#1E1E2E'}}>
<Text style={{color:'#FFD60A', fontWeight:'bold', fontSize:16, flex:1}}>{'🗂️'} Notizen-Inbox</Text>
<TouchableOpacity onPress={() => setInboxVisible(false)} hitSlop={{top:8,bottom:8,left:8,right:8}}>
<Text style={{color:'#8888AA', fontSize:24}}>×</Text>
</TouchableOpacity>
</View>
{/* 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 (
<View style={{padding:14, borderBottomWidth:1, borderBottomColor:'#1E1E2E'}}>
<Text style={{color:'#555570', fontSize:11, fontStyle:'italic'}}>
(keine Notizen-Bubbles im aktuellen Chat)
</Text>
</View>
);
}
return (
<View style={{maxHeight:260, borderBottomWidth:1, borderBottomColor:'#1E1E2E'}}>
<Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:8, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
Aus diesem Chat
</Text>
<ScrollView style={{paddingHorizontal:8}} nestedScrollEnabled={true}>
{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 (
<TouchableOpacity
key={m.id}
style={styles.inboxRow}
onPress={() => { if (ms.id && action !== 'deleted') { setInboxVisible(false); setMemoryDetailId(ms.id); } }}
disabled={!ms.id || action === 'deleted'}
>
<Text style={{fontSize:16}}>{'🧠'}</Text>
<View style={{flex:1}}>
<Text style={styles.inboxRowTitle} numberOfLines={1}>{ms.title}</Text>
<Text style={[styles.inboxRowMeta, {color: dotColor}]}>Memory · {verb} · {ms.type}</Text>
</View>
{ms.id && action !== 'deleted' ? <Text style={{color:'#0096FF', fontSize:14}}></Text> : null}
</TouchableOpacity>
);
}
if (m.triggerCreated) {
const t = m.triggerCreated;
return (
<View key={m.id} style={styles.inboxRow}>
<Text style={{fontSize:16}}>{''}</Text>
<View style={{flex:1}}>
<Text style={styles.inboxRowTitle} numberOfLines={1}>{t.name}</Text>
<Text style={styles.inboxRowMeta}>Trigger · {t.type}{t.fires_at ? ` · ${t.fires_at.slice(0,16).replace('T',' ')}` : ''}</Text>
</View>
</View>
);
}
if (m.skillCreated) {
const sk = m.skillCreated;
return (
<View key={m.id} style={styles.inboxRow}>
<Text style={{fontSize:16}}>{'🛠'}</Text>
<View style={{flex:1}}>
<Text style={styles.inboxRowTitle} numberOfLines={1}>{sk.name}</Text>
<Text style={styles.inboxRowMeta}>Skill · {sk.execution}</Text>
</View>
</View>
);
}
return null;
})}
</ScrollView>
</View>
);
})()}
<Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:10, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
Alle Memories aus der DB
</Text>
{/* 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). */}
<View style={{flex:1}}>
<MemoryBrowser onOpenMemory={(id) => { setInboxVisible(false); setMemoryDetailId(id); }} />
</View>
</View>
</ErrorBoundary>
</Modal>
{/* Bild-Vollbild Modal */}
<Modal visible={!!fullscreenImage} transparent animationType="fade" onRequestClose={() => setFullscreenImage(null)}>
<View style={styles.fullscreenOverlay}>
{fullscreenImage && (
/\.svg(?:\?|$)/i.test(fullscreenImage) ? (
// SVG: bisher keine Pinch-Zoom — Tap zum Schliessen
<TouchableOpacity style={styles.fullscreenImage} activeOpacity={1} onPress={() => setFullscreenImage(null)}>
<SvgUri uri={fullscreenImage} width="100%" height="100%" preserveAspectRatio="xMidYMid meet" />
</TouchableOpacity>
) : (
// Pixel-Bild: Pinch-Zoom + Pan ueber ZoomableImage
<ZoomableImage
uri={fullscreenImage}
containerWidth={Dimensions.get('window').width}
containerHeight={Dimensions.get('window').height}
style={styles.fullscreenImage}
/>
)
)}
{/* Close-Button oben rechts — die TouchableOpacity-uebergreifend funktioniert
wegen ZoomableImage-PanResponder nicht zuverlaessig fuer Tap-to-Close */}
<TouchableOpacity
style={{ position: 'absolute', top: 32, right: 16, padding: 12, backgroundColor: 'rgba(0,0,0,0.5)', borderRadius: 24 }}
onPress={() => setFullscreenImage(null)}
>
<Text style={{ color: '#FFF', fontSize: 22 }}>{''}</Text>
</TouchableOpacity>
</View>
</Modal>
{/* 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,
},
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,
},
queueBanner: {
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: '#2A2410',
borderTopWidth: 1,
borderTopColor: '#4A3F14',
},
queueBannerText: {
color: '#FFD60A',
fontSize: 13,
fontWeight: '600',
textAlign: 'center',
},
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,
},
interjectButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#FF9500', // orange — Zwischenruf, klar vom blauen Senden getrennt
alignItems: 'center',
justifyContent: 'center',
marginRight: 6,
},
interjectIcon: {
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',
},
bubbleCopyIcon: {
fontSize: 13,
color: '#8888AA',
marginLeft: 6,
opacity: 0.7,
},
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;