diff --git a/app/src/main/java/dev/kaizen/app/MainActivity.kt b/app/src/main/java/dev/kaizen/app/MainActivity.kt index 8c9cfcc..eb397ab 100644 --- a/app/src/main/java/dev/kaizen/app/MainActivity.kt +++ b/app/src/main/java/dev/kaizen/app/MainActivity.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.collectAsState import androidx.activity.enableEdgeToEdge import dev.kaizen.app.auth.LoginScreen import dev.kaizen.app.chat.ChatScreen +import dev.kaizen.app.chat.ChatScreenViewModel import dev.kaizen.app.chat.ChatViewModel import dev.kaizen.app.net.SecureStore import dev.kaizen.app.net.SessionViewModel @@ -21,10 +22,7 @@ import dev.kaizen.app.ui.theme.KaizenTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - // Enable High Dynamic Range (HDR) window color mode on Android 14+ (API 34+) - // and Wide Color Gamut (Display P3) on Android 8.0+ (API 26+) - // This leverages the hardware capabilities of high-end AMOLED/OLED displays (S25 Ultra, Pixel 10). + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { window.colorMode = ActivityInfo.COLOR_MODE_HDR } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -35,8 +33,13 @@ class MainActivity : ComponentActivity() { val settingsViewModel = SettingsViewModel(secureStore) val sessionViewModel = SessionViewModel(secureStore) val chatViewModel = ChatViewModel(applicationContext) + val chatScreenViewModel = ChatScreenViewModel( + app = application, + session = sessionViewModel, + chat = chatViewModel, + settings = settingsViewModel, + ) - // auto() picks light/dark system-bar icons based on the system theme enableEdgeToEdge( statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT), navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT), @@ -50,10 +53,8 @@ class MainActivity : ComponentActivity() { AppAppearance.System -> androidx.compose.foundation.isSystemInDarkTheme() } KaizenTheme(themeId = themeId.value, darkTheme = darkOverride) { - // Snapshot-state routing: logging in/out flips session.config and - // recomposes this branch — no NavHost needed for two top-level states. if (sessionViewModel.isLoggedIn) { - ChatScreen(settingsViewModel = settingsViewModel, session = sessionViewModel, chat = chatViewModel) + ChatScreen(vm = chatScreenViewModel, settingsViewModel = settingsViewModel) } else { LoginScreen(session = sessionViewModel) } diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index 4707153..da66542 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -40,7 +40,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -62,19 +61,6 @@ import dev.kaizen.app.ui.effect.KaizenShadows import dev.kaizen.app.ui.theme.KaizenSemanticColors import dev.kaizen.app.ui.shape.KaizenShapes import dev.kaizen.app.R -import dev.kaizen.app.db.MessageEntity -import dev.kaizen.app.db.toEntity -import dev.kaizen.app.haptics.rememberHaptics -import dev.kaizen.app.net.Attachment -import dev.kaizen.app.net.ChatHttpException -import dev.kaizen.app.net.ChatSpeed -import dev.kaizen.app.net.ConversationSummary -import dev.kaizen.app.net.FetchResult -import dev.kaizen.app.net.KaizenApi -import dev.kaizen.app.net.KaizenModel -import dev.kaizen.app.net.SaveMessage -import dev.kaizen.app.net.SessionViewModel -import dev.kaizen.app.net.WireMessage import dev.kaizen.app.settings.SettingsScreen import dev.kaizen.app.settings.SettingsViewModel import androidx.activity.compose.rememberLauncherForActivityResult @@ -85,995 +71,167 @@ import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.LifecycleResumeEffect import android.Manifest import android.content.pm.PackageManager -import android.media.MediaPlayer -import android.media.MediaRecorder import android.widget.Toast import androidx.core.content.ContextCompat import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween -import android.content.ComponentName -import android.content.ServiceConnection -import android.os.IBinder -import kotlinx.coroutines.Job -import kotlinx.coroutines.async import kotlinx.coroutines.launch import java.io.ByteArrayOutputStream -import java.io.File import dev.kaizen.app.live.CallStatus import dev.kaizen.app.live.LiveCallOverlay -import dev.kaizen.app.live.LiveCallService - -// Screen enumeration for modular state-driven navigation (Zero Technical Debt) -enum class AppScreen { Chat, Settings } @Composable fun ChatScreen( + vm: ChatScreenViewModel, settingsViewModel: SettingsViewModel, - session: SessionViewModel, - chat: ChatViewModel, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { - val haptics = rememberHaptics() val scope = rememberCoroutineScope() - val messages = remember { mutableStateListOf() } val listState = rememberLazyListState() - var input by remember { mutableStateOf("") } - var isStreaming by remember { mutableStateOf(false) } - var nextId by remember { mutableStateOf(0L) } - var activeMode by remember { mutableStateOf(null) } - var reasoningPreset by remember { mutableStateOf(ReasoningPreset.Standard) } - var samplingPrefs by remember { mutableStateOf(SamplingPrefs()) } - var webSearchProvider by remember { mutableStateOf("google") } - var usedTokens by remember { mutableStateOf(0) } - var chatModel by remember { mutableStateOf(null) } - var streamJob by remember { mutableStateOf(null) } - - // Tree state for branch navigation - var chatTree by remember { mutableStateOf(ChatTree.EMPTY) } - val storedMessagesMap = remember { mutableMapOf() } - var editParentId by remember { mutableStateOf(null) } - - fun rebuildVisibleMessages() { - val path = getVisiblePath(chatTree) - messages.clear() - for (entry in path) { - val sm = storedMessagesMap[entry.messageId] ?: continue - messages.add( - Message( - id = nextId++, - role = if (sm.role == "assistant") Role.Assistant else Role.User, - content = sm.content, - wireId = sm.id, - attachments = sm.attachments, - reasoning = sm.reasoning ?: "", - sources = sm.sources ?: emptyList(), - branchInfo = entry.branchInfo, - ), - ) - } - } - - // Audio recording state - var isRecording by remember { mutableStateOf(false) } - val recorderRef = remember { mutableStateOf(null) } - val audioFileRef = remember { mutableStateOf(null) } - var recordingAmplitude by remember { mutableStateOf(0f) } - - // ─── Live Call state (uses context/ttsVoice/currentConversationId declared below) ─── - var liveService by remember { mutableStateOf(null) } - var liveCallActive by remember { mutableStateOf(false) } - val liveStatus by liveService?.status?.collectAsState() ?: remember { mutableStateOf(CallStatus.IDLE) } - val liveSubtitle by liveService?.subtitle?.collectAsState() ?: remember { mutableStateOf("") } - val liveAmplitude by liveService?.amplitude?.collectAsState() ?: remember { mutableStateOf(0f) } - val liveUserSpeaking by liveService?.userSpeaking?.collectAsState() ?: remember { mutableStateOf(false) } - var liveStartedAt by remember { mutableStateOf(0L) } - var liveElapsedMs by remember { mutableStateOf(0L) } - - LaunchedEffect(isRecording) { - if (!isRecording) { recordingAmplitude = 0f; return@LaunchedEffect } - while (isRecording) { - val amp = try { recorderRef.value?.maxAmplitude ?: 0 } catch (_: Exception) { 0 } - recordingAmplitude = (amp / 32768f).coerceIn(0f, 1f) - kotlinx.coroutines.delay(60) - } - } - - // TTS playback state - var playingTtsForId by remember { mutableStateOf(null) } - val ttsPlayerRef = remember { mutableStateOf(null) } - var ttsVoice by remember { mutableStateOf("Kore") } - - // Navigation State - var currentScreen by remember { mutableStateOf(AppScreen.Chat) } - - // Collect the user name reactively from the ViewModel - val userName by settingsViewModel.userName.collectAsState() - - // Tablet & Landscape optimization: Detect large screens and cap the content width + val context = LocalContext.current val configuration = LocalConfiguration.current val isTablet = configuration.screenWidthDp > 600 - - // Native sidebar drawer state val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) - - // Model picker: catalog (loads instantly from local cache!) + sheet visibility - var models by remember { mutableStateOf(session.store.loadModelsCache() ?: emptyList()) } + val userName by settingsViewModel.userName.collectAsState() + val conversations by vm.conversations.collectAsState(initial = emptyList()) var showModelSheet by remember { mutableStateOf(false) } - // Conversation persistence: sidebar list from Room (instant), active conversation id - var conversationId by remember { mutableStateOf(null) } - var viewingLockedChat by remember { mutableStateOf(false) } - val conversations by chat.conversationRepo.observeAll().collectAsState(initial = emptyList()) - - var loadError by remember { mutableStateOf(null) } - - // Password unlock dialog state — shown when biometrics unavailable for locked chats - var passwordUnlockTarget by remember { mutableStateOf(null) } - var passwordUnlockError by remember { mutableStateOf(null) } - var passwordUnlockForToggle by remember { mutableStateOf(false) } - - // Pending file attachments — uploaded immediately on pick, cleared on send. - val pendingFiles = remember { mutableStateListOf() } - val context = LocalContext.current - - // ─── Live Call service binding + control ────────────────────────────── - val liveServiceConnection = remember { - object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { - liveService = (binder as? LiveCallService.LocalBinder)?.service - } - override fun onServiceDisconnected(name: ComponentName?) { - liveService = null - } + val activity = remember(context) { + var ctx = context + while (ctx is android.content.ContextWrapper) { + if (ctx is androidx.fragment.app.FragmentActivity) return@remember ctx + ctx = ctx.baseContext } + null as androidx.fragment.app.FragmentActivity? } - LaunchedEffect(liveCallActive) { - if (liveCallActive) { - liveStartedAt = System.currentTimeMillis() - while (liveCallActive) { - liveElapsedMs = System.currentTimeMillis() - liveStartedAt - kotlinx.coroutines.delay(500) - } - } + // ── Initialization + lifecycle ────────────────────────────────────── + + LaunchedEffect(vm.session.config?.baseUrl, vm.session.config?.token) { + vm.initialize() } - val liveAvailable = models.any { it.id.startsWith("vertex:") && it.id.contains("live") } + LifecycleResumeEffect(vm.session.config?.baseUrl) { + vm.prewarm() + onPauseOrDispose { vm.clearLockedOnPause() } + } - fun startLiveCallInner() { - val cfg = session.config ?: return - liveCallActive = true + // ── Activity result launchers (must stay in Composable) ───────────── - val intent = LiveCallService.intent(context) - context.startForegroundService(intent) - context.bindService(intent, liveServiceConnection, 0) + val galleryPicker = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris -> + for (uri in uris) vm.uploadPickedUri(uri, context) + } - scope.launch { - var attempts = 0 - while (liveService == null && attempts < 20) { - kotlinx.coroutines.delay(50) - attempts++ - } + val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> + for (uri in uris) vm.uploadPickedUri(uri, context) + } - val svc = liveService ?: return@launch + val cameraPicker = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap -> + bitmap ?: return@rememberLauncherForActivityResult + val stream = ByteArrayOutputStream() + bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 92, stream) + vm.uploadPickedFile("photo_${System.currentTimeMillis()}.jpg", "image/jpeg", stream.toByteArray()) + } - // Create conversation if needed (same pattern as text chat) - val cid = conversationId ?: run { - val newId = KaizenApi.createConversation(cfg.baseUrl, cfg.token) - if (newId != null) { - conversationId = newId - chat.conversationRepo.insertOptimistic(newId, "Live Call") - } - newId - } - - svc.onTurnEnd = { turn -> - scope.launch { - val convId = conversationId ?: return@launch - - // Add messages to local UI - if (turn.userText.isNotEmpty()) { - val userMsg = Message(nextId++, Role.User, turn.userText, wireId = turn.userMessageId ?: java.util.UUID.randomUUID().toString()) - messages.add(userMsg) - } - if (turn.assistantText.isNotEmpty()) { - val aMsg = Message(nextId++, Role.Assistant, turn.assistantText, wireId = turn.assistantMessageId ?: java.util.UUID.randomUUID().toString()) - messages.add(aMsg) - chatModel = cfg.model - usedTokens = turn.usage.inputTokens + turn.usage.outputTokens - } - - // Cache in Room (server already persisted via WS-Server) - val msgCount = messages.size - val entitiesToCache = mutableListOf() - if (turn.userMessageId != null && turn.userText.isNotEmpty()) { - entitiesToCache.add(dev.kaizen.app.db.MessageEntity( - id = turn.userMessageId, conversationId = convId, - role = "user", content = turn.userText, - attachments = emptyList(), sortOrder = msgCount - 2, - )) - } - if (turn.assistantMessageId != null && turn.assistantText.isNotEmpty()) { - entitiesToCache.add(dev.kaizen.app.db.MessageEntity( - id = turn.assistantMessageId, conversationId = convId, - role = "assistant", content = turn.assistantText, - attachments = emptyList(), sortOrder = msgCount - 1, - )) - } - if (entitiesToCache.isNotEmpty()) { - chat.messageRepo.cacheMessages(convId, entitiesToCache) - } - } - } - - svc.onError = { code, message -> - scope.launch { - val errorText = message ?: code - Toast.makeText(context, errorText, Toast.LENGTH_SHORT).show() - } - } - - svc.startCall( - baseUrl = cfg.baseUrl, - token = cfg.token, - model = cfg.model, - voice = ttsVoice, - conversationId = cid, - ) - } + val micPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) vm.startRecording(context) + else Toast.makeText(context, context.getString(R.string.chat_mic_permission), Toast.LENGTH_SHORT).show() } val liveCallPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted) startLiveCallInner() + if (granted) vm.startLiveCallInner(context) else Toast.makeText(context, context.getString(R.string.chat_mic_permission), Toast.LENGTH_SHORT).show() } + fun toggleRecording() { + if (vm.isRecording) { + vm.stopRecordingAndSend() + } else { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { + vm.startRecording(context) + } else { + micPermission.launch(Manifest.permission.RECORD_AUDIO) + } + } + } + fun startLiveCall() { if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { liveCallPermission.launch(Manifest.permission.RECORD_AUDIO) return } - startLiveCallInner() + vm.startLiveCallInner(context) } - fun endLiveCall() { - liveService?.onTurnEnd = null - liveService?.onError = null - liveService?.endCall() - liveCallActive = false - try { context.unbindService(liveServiceConnection) } catch (_: Exception) {} - liveService = null + // ── Auto-scroll ───────────────────────────────────────────────────── - // Generate title for new conversations - val cfg = session.config ?: return - val cid = conversationId ?: return - scope.launch { - val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, cid) - if (title != null) chat.conversationRepo.updateTitle(cid, title) - chat.conversationRepo.refresh(cfg.baseUrl, cfg.token) - } - } - - var showAttachMenu by remember { mutableStateOf(false) } - - fun uploadPickedFile(name: String, mimeType: String, bytes: ByteArray) { - val cfg = session.config ?: return - val pf = PendingFile(name = name, mimeType = mimeType, bytes = bytes) - pendingFiles.add(pf) - scope.launch { - when (val r = KaizenApi.uploadFile(cfg.baseUrl, cfg.token, name, mimeType, bytes)) { - is FetchResult.Ok -> { - val idx = pendingFiles.indexOfFirst { it.id == pf.id } - if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(uploaded = r.data, uploading = false) - } - is FetchResult.Fail -> { - val idx = pendingFiles.indexOfFirst { it.id == pf.id } - if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(error = r.reason, uploading = false) - } - } - } - } - - fun uploadPickedUri(uri: android.net.Uri) { - val cfg = session.config ?: return - val cr = context.contentResolver - val mimeType = cr.getType(uri) ?: "application/octet-stream" - val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "file_${System.currentTimeMillis()}" - val size = try { cr.openFileDescriptor(uri, "r")?.use { it.statSize } ?: -1L } catch (_: Exception) { -1L } - val pf = PendingFile(name = fileName, mimeType = mimeType) - pendingFiles.add(pf) - scope.launch { - val inputStream = cr.openInputStream(uri) - if (inputStream == null) { - val idx = pendingFiles.indexOfFirst { it.id == pf.id } - if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(error = "Cannot read file", uploading = false) - return@launch - } - val r = inputStream.use { stream -> - KaizenApi.uploadStream(cfg.baseUrl, cfg.token, fileName, mimeType, stream, size) - } - when (r) { - is FetchResult.Ok -> { - val idx = pendingFiles.indexOfFirst { it.id == pf.id } - if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(uploaded = r.data, uploading = false) - } - is FetchResult.Fail -> { - val idx = pendingFiles.indexOfFirst { it.id == pf.id } - if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(error = r.reason, uploading = false) - } - } - } - } - - val galleryPicker = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris -> - for (uri in uris) uploadPickedUri(uri) - } - - val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> - for (uri in uris) uploadPickedUri(uri) - } - - val cameraPicker = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap -> - bitmap ?: return@rememberLauncherForActivityResult - val stream = ByteArrayOutputStream() - bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 92, stream) - uploadPickedFile("photo_${System.currentTimeMillis()}.jpg", "image/jpeg", stream.toByteArray()) - } - - val micPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted) startRecording(context, recorderRef, audioFileRef) { isRecording = true } - else Toast.makeText(context, context.getString(R.string.chat_mic_permission), Toast.LENGTH_SHORT).show() - } - - fun toggleRecording() { - if (isRecording) { - stopRecording(recorderRef, audioFileRef) { name, mime, file -> - isRecording = false - val cfg = session.config ?: return@stopRecording - scope.launch { - val bytes = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - file.readBytes().also { file.delete() } - } - when (val r = KaizenApi.uploadFile(cfg.baseUrl, cfg.token, name, mime, bytes)) { - is FetchResult.Ok -> { - val att = r.data - val userParentId = messages.lastOrNull()?.wireId - val userMsg = Message(nextId++, Role.User, "", attachments = listOf(att)) - messages.add(userMsg) - val assistantId = nextId++ - val assistantWireId = java.util.UUID.randomUUID().toString() - val history = messages.map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) } - messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId)) - val voiceAssistantIdx = messages.lastIndex - isStreaming = true - haptics.click() - - val convIdDeferred = if (conversationId == null) { - scope.async { KaizenApi.createConversation(cfg.baseUrl, cfg.token) } - } else null - - streamJob = launch { - haptics.thinkingStart() - var sawContent = false - try { - KaizenApi.chat( - cfg.baseUrl, cfg.token, cfg.model, history, - attachments = listOf(att), - ).collect { state -> - if (voiceAssistantIdx >= messages.size) return@collect - if (!sawContent && state.content.isNotEmpty()) { - sawContent = true; haptics.responseStart() - } - messages[voiceAssistantIdx] = messages[voiceAssistantIdx].copy( - thinking = if (sawContent) false else messages[voiceAssistantIdx].thinking, - content = state.content, - reasoning = state.reasoning, - tools = state.tools, - sources = state.sources, - query = state.query, - streaming = true, - ) - } - } catch (_: kotlinx.coroutines.CancellationException) { - } catch (_: Exception) {} - if (voiceAssistantIdx < messages.size) messages[voiceAssistantIdx] = messages[voiceAssistantIdx].copy(streaming = false, thinking = false) - isStreaming = false - haptics.responseEnd() - val cid = convIdDeferred?.await() ?: conversationId ?: return@launch - if (conversationId == null) conversationId = cid - val assistantMsg = if (voiceAssistantIdx < messages.size) messages[voiceAssistantIdx] else return@launch - val finalContent = assistantMsg.content - val finalReasoning = assistantMsg.reasoning.takeIf { it.isNotBlank() } - val finalSources = assistantMsg.sources.takeIf { it.isNotEmpty() } - val userSave = SaveMessage(id = userMsg.wireId, parentId = userParentId, role = "user", content = "", attachments = listOf(att)) - val assistantSave = SaveMessage(id = assistantWireId, parentId = userMsg.wireId, role = "assistant", content = finalContent, model = cfg.model, reasoning = finalReasoning, sources = finalSources?.map { dev.kaizen.app.net.SearchSource(it.title, it.url, it.snippet) }) - val saved = KaizenApi.saveMessages(cfg.baseUrl, cfg.token, cid, listOf(userSave, assistantSave)) - if (saved) { - val msgCount = messages.size - chat.messageRepo.cacheMessages(cid, listOf( - dev.kaizen.app.db.MessageEntity( - id = userMsg.wireId, conversationId = cid, - role = "user", content = "", - attachments = listOf(att), sortOrder = msgCount - 2, - ), - dev.kaizen.app.db.MessageEntity( - id = assistantWireId, conversationId = cid, - role = "assistant", content = finalContent, - attachments = emptyList(), sortOrder = msgCount - 1, - reasoning = finalReasoning, - sources = finalSources?.map { dev.kaizen.app.net.SearchSource(it.title, it.url, it.snippet) } ?: emptyList(), - ), - )) - storedMessagesMap[userMsg.wireId] = dev.kaizen.app.net.StoredMessage( - id = userMsg.wireId, role = "user", content = "", - parentId = userParentId, attachments = listOf(att), - ) - storedMessagesMap[assistantWireId] = dev.kaizen.app.net.StoredMessage( - id = assistantWireId, role = "assistant", content = finalContent, - parentId = userMsg.wireId, model = cfg.model, - reasoning = finalReasoning, - sources = finalSources?.map { dev.kaizen.app.net.SearchSource(it.title, it.url, it.snippet) }, - ) - chatTree = buildChatTree(storedMessagesMap.values.toList(), chatTree.activeRootId, chatTree.activeChild) - } - if (convIdDeferred != null) { - chat.conversationRepo.insertOptimistic(cid, "(Voice)") - val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, cid) - if (title != null) chat.conversationRepo.updateTitle(cid, title) - chat.conversationRepo.refresh(cfg.baseUrl, cfg.token) - } - } - } - is FetchResult.Fail -> { - Toast.makeText(context, r.reason, Toast.LENGTH_SHORT).show() - } - } - } - } - } else { - if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { - startRecording(context, recorderRef, audioFileRef) { isRecording = true } - } else { - micPermission.launch(Manifest.permission.RECORD_AUDIO) - } - } - } - - fun playTtsUrl(url: String, messageId: Long) { - scope.launch { - try { - val tmpFile = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - val req = okhttp3.Request.Builder().url(url).build() - KaizenApi.imageClient.newCall(req).execute().use { resp -> - if (!resp.isSuccessful) return@withContext null - val bytes = resp.body!!.bytes() - java.io.File.createTempFile("tts_", ".wav", context.cacheDir).also { it.writeBytes(bytes) } - } - } - if (tmpFile == null) { - playingTtsForId = null - return@launch - } - val player = MediaPlayer().apply { - setOnPreparedListener { start() } - setOnCompletionListener { - release() - ttsPlayerRef.value = null - playingTtsForId = null - tmpFile.delete() - } - setOnErrorListener { _, _, _ -> - release() - ttsPlayerRef.value = null - playingTtsForId = null - tmpFile.delete() - true - } - setDataSource(tmpFile.absolutePath) - prepareAsync() - } - ttsPlayerRef.value = player - } catch (_: Exception) { - playingTtsForId = null - } - } - } - - fun readAloud(content: String, messageId: Long, wireId: String) { - val cfg = session.config ?: return - if (playingTtsForId == messageId) { - ttsPlayerRef.value?.release() - ttsPlayerRef.value = null - playingTtsForId = null - return - } - ttsPlayerRef.value?.release() - playingTtsForId = messageId - val voice = ttsVoice - scope.launch { - val cached = chat.messageDao.getTtsUrl(wireId, voice) - if (cached != null) { - playTtsUrl(cached, messageId) - return@launch - } - val url = KaizenApi.generateSpeech(cfg.baseUrl, cfg.token, content, voice) - if (url == null) { - playingTtsForId = null - Toast.makeText(context, context.getString(R.string.chat_tts_error), Toast.LENGTH_SHORT).show() - return@launch - } - chat.messageDao.updateTts(wireId, url, voice) - playTtsUrl(url, messageId) - } - } - - LaunchedEffect(session.config?.baseUrl, session.config?.token) { - val cfg = session.config ?: return@LaunchedEffect - loadError = null - val errors = mutableListOf() - - when (val r = KaizenApi.fetchModels(cfg.baseUrl, cfg.token)) { - is FetchResult.Ok -> { - models = r.data - if (r.data.isNotEmpty()) session.store.saveModelsCache(r.data) - } - is FetchResult.Fail -> { - if ("HTTP 401" in r.reason) { session.logout(); return@LaunchedEffect } - errors.add(r.reason) - } - } - KaizenApi.fetchMe(cfg.baseUrl, cfg.token)?.let { me -> - me.name?.let { settingsViewModel.updateUserName(it) } - me.email?.let { settingsViewModel.updateUserEmail(it) } - me.ttsVoice?.let { ttsVoice = it } - } - chat.conversationRepo.refresh(cfg.baseUrl, cfg.token) - if (errors.isNotEmpty()) { - loadError = errors.joinToString(" · ") - } - scope.launch { session.checkServerVersion(cfg.baseUrl) } - scope.launch { - val ids = chat.conversationRepo.allUnlockedIds() - chat.messageRepo.prefetchMissing(cfg.baseUrl, cfg.token, ids) - } - } - - // Warm the HTTP/2 connection pool on every app resume (covers background → foreground - // after OkHttp's 5-minute keep-alive has expired). On first composition, the - // LaunchedEffect above already warms the pool via fetchModels/fetchConversations. - LifecycleResumeEffect(session.config?.baseUrl) { - session.config?.let { cfg -> - scope.launch { KaizenApi.prewarm(cfg.baseUrl) } - } - onPauseOrDispose { - if (viewingLockedChat) { - messages.clear() - viewingLockedChat = false - } - } - } - - fun refreshConversations() { - val cfg = session.config ?: return - scope.launch { chat.conversationRepo.refresh(cfg.baseUrl, cfg.token) } - } - - /** Start a fresh chat — drops the active conversation, the next send creates a new one. */ - fun newChat() { - messages.clear() - conversationId = null - viewingLockedChat = false - usedTokens = 0 - chatModel = null - chatTree = ChatTree.EMPTY - storedMessagesMap.clear() - } - - fun unlockAndOpen(summary: ConversationSummary, password: String? = null) { - val cfg = session.config ?: return - scope.launch { - loadError = null - val unlockToken = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token, password) - if (unlockToken == null) { - if (password != null) { - passwordUnlockError = context.getString(R.string.unlock_password_wrong) - } else { - loadError = context.getString(R.string.biometric_failed) - scope.launch { kotlinx.coroutines.delay(4000); if (loadError == context.getString(R.string.biometric_failed)) loadError = null } - } - return@launch - } - passwordUnlockTarget = null - passwordUnlockError = null - passwordUnlockForToggle = false - val data = KaizenApi.fetchLockedConversationData(cfg.baseUrl, cfg.token, summary.id, unlockToken) - if (data.messages.isNotEmpty()) { - storedMessagesMap.clear() - data.messages.forEach { storedMessagesMap[it.id] = it } - chatTree = buildChatTree(data.messages, data.activeRootId, data.activeChild) - rebuildVisibleMessages() - conversationId = summary.id - viewingLockedChat = true - } - } - } - - fun showPasswordFallback(summary: ConversationSummary, forToggle: Boolean = false) { - passwordUnlockTarget = summary - passwordUnlockError = null - passwordUnlockForToggle = forToggle - } - - fun requestBiometricOrPassword( - summary: ConversationSummary, - forToggle: Boolean = false, - onBiometricSuccess: () -> Unit, - ) { - val biometricEnabled = settingsViewModel.biometricEnabled.value - var activity: androidx.fragment.app.FragmentActivity? = null - var ctx = context - while (ctx is android.content.ContextWrapper) { - if (ctx is androidx.fragment.app.FragmentActivity) { activity = ctx; break } - ctx = ctx.baseContext - } - if (!biometricEnabled || activity == null || !dev.kaizen.app.auth.BiometricUnlock.isAvailable(activity)) { - showPasswordFallback(summary, forToggle) - return - } - dev.kaizen.app.auth.BiometricUnlock.authenticate( - activity = activity, - title = context.getString(R.string.biometric_title), - subtitle = context.getString(R.string.biometric_subtitle), - negativeButtonText = context.getString(R.string.biometric_cancel), - ) { result -> - when (result) { - is dev.kaizen.app.auth.BiometricUnlock.Result.Success -> onBiometricSuccess() - is dev.kaizen.app.auth.BiometricUnlock.Result.NotAvailable -> showPasswordFallback(summary, forToggle) - is dev.kaizen.app.auth.BiometricUnlock.Result.Failed -> { /* user cancelled */ } - } - } - } - - fun openConversation(summary: ConversationSummary) { - val cfg = session.config ?: return - if (summary.locked) { - requestBiometricOrPassword(summary) { unlockAndOpen(summary) } - return - } - viewingLockedChat = false - usedTokens = 0 - loadError = null - scope.launch { - messages.clear() - val cachedList = chat.messageRepo.getCached(summary.id) - cachedList.forEach { m -> - messages.add( - Message( - id = nextId++, - role = if (m.role == "assistant") Role.Assistant else Role.User, - content = m.content, - wireId = m.id, - attachments = m.attachments, - reasoning = m.reasoning ?: "", - sources = m.sources ?: emptyList(), - ), - ) - } - conversationId = summary.id - val data = KaizenApi.fetchConversationData(cfg.baseUrl, cfg.token, summary.id) - if (data.messages.isNotEmpty()) { - val entities = data.messages.mapIndexed { i, m -> m.toEntity(summary.id, i) } - chat.messageRepo.cacheMessages(summary.id, entities) - val lastAssistant = data.messages.lastOrNull { it.role == "assistant" } - lastAssistant?.model?.let { chatModel = it } - usedTokens = data.messages.sumOf { (it.inputTokens ?: 0) + (it.outputTokens ?: 0) } - - storedMessagesMap.clear() - data.messages.forEach { storedMessagesMap[it.id] = it } - chatTree = buildChatTree(data.messages, data.activeRootId, data.activeChild) - rebuildVisibleMessages() - } - } - } - - fun send(text: String, branchParentId: String? = null, branchAttachments: List = emptyList()) { - val trimmed = text.trim() - val uploadedAtts = if (branchAttachments.isNotEmpty()) branchAttachments else pendingFiles.mapNotNull { it.uploaded } - if (trimmed.isEmpty() && uploadedAtts.isEmpty()) return - if (isStreaming) return - isStreaming = true - val cfg = session.config ?: run { isStreaming = false; return } - haptics.click() - - val userParentId = editParentId ?: branchParentId ?: messages.lastOrNull()?.wireId - editParentId = null - val userMsg = Message(nextId++, Role.User, trimmed, attachments = uploadedAtts) - messages.add(userMsg) - input = "" - if (branchAttachments.isEmpty()) pendingFiles.clear() - val assistantId = nextId++ - val assistantWireId = java.util.UUID.randomUUID().toString() - val history = messages.map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) } - messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId)) - val assistantIdx = messages.lastIndex - val wasNewConversation = conversationId == null - - streamJob = scope.launch { - haptics.thinkingStart() - var sawContent = false - try { - val convIdDeferred = if (conversationId == null) { - async { KaizenApi.createConversation(cfg.baseUrl, cfg.token) } - } else null - - KaizenApi.chat( - cfg.baseUrl, cfg.token, cfg.model, history, - reasoningEffort = reasoningPreset.effort, - preferThroughput = reasoningPreset.throughput, - attachments = uploadedAtts.takeIf { it.isNotEmpty() }, - webSearch = activeMode == R.string.mode_search, - webSearchProvider = if (activeMode == R.string.mode_search && cfg.model.startsWith("vertex:")) webSearchProvider else null, - temperature = samplingPrefs.temperature.takeIf { it.enabled }?.value, - topP = samplingPrefs.topP.takeIf { it.enabled }?.value, - topK = samplingPrefs.topK.takeIf { it.enabled }?.value?.toInt(), - ).collect { state -> - if (assistantIdx >= messages.size) return@collect - if (!sawContent && state.content.isNotEmpty()) { - sawContent = true - haptics.responseStart() - } - messages[assistantIdx] = messages[assistantIdx].copy( - thinking = if (sawContent) false else messages[assistantIdx].thinking, - content = state.content, - reasoning = state.reasoning, - tools = state.tools, - query = state.query, - sources = state.sources, - ) - if (state.inputTokens > 0 || state.outputTokens > 0) { - usedTokens = state.inputTokens + state.outputTokens - } - } - val finalMsg = if (assistantIdx < messages.size) messages[assistantIdx] else null - val finalContent = finalMsg?.content ?: "" - val finalReasoning = finalMsg?.reasoning?.takeIf { it.isNotBlank() } - val finalSources = finalMsg?.sources?.takeIf { it.isNotEmpty() } - if (assistantIdx < messages.size) messages[assistantIdx] = messages[assistantIdx].copy(streaming = false, thinking = false) - - val convId = convIdDeferred?.await()?.also { cid -> - conversationId = cid - scope.launch { - val preview = trimmed.take(60).lines().first() - chat.conversationRepo.insertOptimistic(cid, preview) - } - } ?: conversationId - - if (convId != null && finalContent.isNotEmpty()) { - val saved = KaizenApi.saveMessages( - cfg.baseUrl, cfg.token, convId, - listOf( - SaveMessage( - id = userMsg.wireId, parentId = userParentId, - role = "user", content = trimmed, - attachments = uploadedAtts.takeIf { it.isNotEmpty() }, - ), - SaveMessage( - id = assistantWireId, parentId = userMsg.wireId, - role = "assistant", content = finalContent, model = cfg.model, - reasoning = finalReasoning, - sources = finalSources, - ), - ), - ) - if (saved) { - val msgCount = messages.size - chat.messageRepo.cacheMessages(convId, listOf( - MessageEntity( - id = userMsg.wireId, conversationId = convId, - role = "user", content = trimmed, - attachments = uploadedAtts, sortOrder = msgCount - 2, - ), - MessageEntity( - id = assistantWireId, conversationId = convId, - role = "assistant", content = finalContent, - attachments = emptyList(), sortOrder = msgCount - 1, - reasoning = finalReasoning, - sources = finalSources ?: emptyList(), - ), - )) - - // Update tree with the new nodes - storedMessagesMap[userMsg.wireId] = dev.kaizen.app.net.StoredMessage( - id = userMsg.wireId, role = "user", content = trimmed, - parentId = userParentId, attachments = uploadedAtts, - ) - storedMessagesMap[assistantWireId] = dev.kaizen.app.net.StoredMessage( - id = assistantWireId, role = "assistant", content = finalContent, - parentId = userMsg.wireId, model = cfg.model, - reasoning = finalReasoning, - sources = finalSources?.map { dev.kaizen.app.net.SearchSource(it.title, it.url, it.snippet) }, - ) - chatTree = buildChatTree( - storedMessagesMap.values.toList(), - chatTree.activeRootId, - chatTree.activeChild, - ) - - if (wasNewConversation) { - val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId) - if (title != null) { - chat.conversationRepo.updateTitle(convId, title) - } - } - refreshConversations() - } - } - } catch (e: Exception) { - val valid = assistantIdx < messages.size - if (e is kotlinx.coroutines.CancellationException) { - if (valid) messages[assistantIdx] = messages[assistantIdx].copy(streaming = false, thinking = false) - } else if (e is ChatHttpException && e.code == 401) { - if (valid) messages[assistantIdx] = messages[assistantIdx].copy(streaming = false, thinking = false) - session.logout() - } else if (valid) { - val existing = messages[assistantIdx].content - val errorSuffix = "\n\n⚠ ${chatErrorText(e, context)}" - messages[assistantIdx] = messages[assistantIdx].copy( - streaming = false, - thinking = false, - content = if (existing.isNotBlank()) existing + errorSuffix else chatErrorText(e, context), - ) - } - } finally { - isStreaming = false - streamJob = null - haptics.responseEnd() - } - } - } - - // Auto-scroll: isolated from the main composition via derivedStateOf + snapshotFlow. - // Only the snapshot read triggers work — the root composable does NOT recompose on - // every streamed character, eliminating the recomposition storm that previously - // re-drew the entire screen (top bar, input, background) at 60+ fps. val autoScrollKey by remember { - derivedStateOf { messages.size to (messages.lastOrNull()?.content?.length ?: 0) } + derivedStateOf { vm.messages.size to (vm.messages.lastOrNull()?.content?.length ?: 0) } } LaunchedEffect(Unit) { snapshotFlow { autoScrollKey }.collect { - if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex) + if (vm.messages.isNotEmpty()) listState.scrollToItem(vm.messages.lastIndex) } } - // Single source of truth Screen routing - when (currentScreen) { + // ── Screen routing ────────────────────────────────────────────────── + + when (vm.currentScreen) { AppScreen.Settings -> { SettingsScreen( viewModel = settingsViewModel, - config = session.config, - onBack = { currentScreen = AppScreen.Chat }, - modifier = modifier + config = vm.session.config, + onBack = { vm.currentScreen = AppScreen.Chat }, + modifier = modifier, ) } AppScreen.Chat -> { - // ModalNavigationDrawer provides a native drawer that slides from left-to-right ModalNavigationDrawer( drawerState = drawerState, drawerContent = { KaizenSidebar( userName = userName, conversations = conversations, - currentId = conversationId, + currentId = vm.conversationId, onClose = { scope.launch { drawerState.close() } }, onNewChat = { - haptics.tick() - newChat() + vm.haptics.tick() + vm.newChat() scope.launch { drawerState.close() } }, onSelectConversation = { summary -> - haptics.tick() - openConversation(summary) + vm.haptics.tick() + vm.openConversation(summary, activity) scope.launch { drawerState.close() } }, onOpenSettings = { - haptics.tick() - currentScreen = AppScreen.Settings + vm.haptics.tick() + vm.currentScreen = AppScreen.Settings scope.launch { drawerState.close() } }, onLogout = { - haptics.tick() - newChat() + vm.haptics.tick() + vm.newChat() scope.launch { drawerState.close() } - session.logout() + vm.session.logout() }, onAction = { action -> - val cfg = session.config ?: return@KaizenSidebar - haptics.tick() - scope.launch { - when (action) { - is SidebarAction.Rename -> { - KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("title" to action.newTitle)) - refreshConversations() - } - is SidebarAction.Delete -> { - KaizenApi.deleteConversation(cfg.baseUrl, cfg.token, action.id) - if (conversationId == action.id) newChat() - refreshConversations() - } - is SidebarAction.TogglePin -> { - KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("pinned" to action.pinned)) - refreshConversations() - } - is SidebarAction.ToggleLock -> { - if (action.locked) { - KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to true)) - refreshConversations() - } else { - val summary = conversations.find { it.id == action.id } - if (summary != null) { - requestBiometricOrPassword(summary, forToggle = true) { - scope.launch { - KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to false)) - refreshConversations() - } - } - } - } - } - is SidebarAction.GenerateTitle -> { - val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, action.id, manual = true) - if (title != null) { - chat.conversationRepo.updateTitle(action.id, title) - refreshConversations() - } - } - is SidebarAction.Duplicate -> { - val srcMessages = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, action.id) - if (srcMessages.isNotEmpty()) { - val srcTitle = conversations.find { it.id == action.id }?.title ?: "" - val newId = KaizenApi.createConversation(cfg.baseUrl, cfg.token, "$srcTitle (2)") - if (newId != null) { - val saveMessages = srcMessages.mapIndexed { i, m -> - dev.kaizen.app.net.SaveMessage( - id = java.util.UUID.randomUUID().toString(), - parentId = if (i == 0) null else null, - role = m.role, - content = m.content, - model = m.model, - attachments = m.attachments.takeIf { it.isNotEmpty() }, - reasoning = m.reasoning, - sources = m.sources, - ) - } - // Build parentId chain - val chained = saveMessages.mapIndexed { i, m -> - m.copy(parentId = if (i == 0) null else saveMessages[i - 1].id) - } - KaizenApi.saveMessages(cfg.baseUrl, cfg.token, newId, chained) - refreshConversations() - } - } - } - } - } + vm.handleSidebarAction(action, activity, conversations) }, ) }, - gesturesEnabled = true // Allows swiping from the screen's left edge to open the drawer + gesturesEnabled = true, ) { - MeshBackground(animateBlobs = messages.isEmpty()) { + MeshBackground(animateBlobs = vm.messages.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - // 1. Scrollable Chat Content - // Stretches to the full screen, allowing bubbles to scroll cleanly behind the floating bottom dock. - // Centered layout with responsive width capping on tablets. - if (messages.isEmpty()) { + if (vm.messages.isEmpty()) { EmptyHero( userName = userName, - onPick = { haptics.tick(); send(it) }, + onPick = { vm.haptics.tick(); vm.send(it) }, modifier = Modifier .fillMaxSize() - .then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier) + .then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier), ) } else { LazyColumn( @@ -1081,79 +239,40 @@ fun ChatScreen( modifier = Modifier .fillMaxSize() .then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier), - contentPadding = PaddingValues( - top = 100.dp, - bottom = 140.dp, - start = 16.dp, - end = 16.dp - ), + contentPadding = PaddingValues(top = 100.dp, bottom = 140.dp, start = 16.dp, end = 16.dp), verticalArrangement = Arrangement.spacedBy(24.dp), ) { - items(messages, key = { it.id }) { message -> + items(vm.messages, key = { it.id }) { message -> Box(Modifier.animateItem( fadeInSpec = tween(350), placementSpec = spring(stiffness = Spring.StiffnessMediumLow), fadeOutSpec = tween(200), )) { - MessageRow( - message = message, - onDelete = if (conversationId != null && !isStreaming) { wireId -> - val cfg = session.config ?: return@MessageRow - messages.removeAll { it.wireId == wireId } - scope.launch { - KaizenApi.deleteMessage(cfg.baseUrl, cfg.token, conversationId!!, wireId) - } - } else null, - onRegenerate = if (message.role == Role.Assistant && !isStreaming) { _ -> - val lastUserIdx = messages.indexOfLast { it.role == Role.User && messages.indexOf(it) < messages.indexOf(message) } - if (lastUserIdx >= 0) { - val userMsg = messages[lastUserIdx] - val parentId = chatTree.nodes[userMsg.wireId]?.parentId - val msgIdx = messages.indexOf(message) - if (msgIdx >= 0) { - messages.subList(msgIdx, messages.size).clear() - } - send(userMsg.content, branchParentId = parentId, branchAttachments = userMsg.attachments) - } - } else null, - onEdit = if (message.role == Role.User && !isStreaming) { wireId, content -> - val node = chatTree.nodes[wireId] - val parentId = node?.parentId - val idx = messages.indexOfFirst { it.wireId == wireId } - if (idx >= 0) { - messages.subList(idx, messages.size).clear() - editParentId = parentId - input = content - } - } else null, - onReadAloud = if (message.role == Role.Assistant && !message.streaming && message.content.isNotBlank()) { content -> - readAloud(content, message.id, message.wireId) - } else null, - isPlayingTts = playingTtsForId == message.id, - branchInfo = message.branchInfo, - onNavigateBranch = if (!isStreaming) { wireId, direction -> - val newTree = treeNavigate(chatTree, wireId, direction) - chatTree = newTree - rebuildVisibleMessages() - if (conversationId != null) { - val cfg = session.config ?: return@MessageRow - scope.launch { - KaizenApi.patchConversation( - cfg.baseUrl, cfg.token, conversationId!!, - mapOf( - "activeRootId" to newTree.activeRootId, - "activeChild" to newTree.activeChild, - ), - ) - } - } - } else null, - ) + MessageRow( + message = message, + onDelete = if (vm.conversationId != null && !vm.isStreaming) { wireId -> + vm.deleteMessage(wireId) + } else null, + onRegenerate = if (message.role == Role.Assistant && !vm.isStreaming) { _ -> + vm.regenerate(message) + } else null, + onEdit = if (message.role == Role.User && !vm.isStreaming) { wireId, content -> + vm.editMessage(wireId, content) + } else null, + onReadAloud = if (message.role == Role.Assistant && !message.streaming && message.content.isNotBlank()) { content -> + vm.readAloud(content, message.id, message.wireId) + } else null, + isPlayingTts = vm.playingTtsForId == message.id, + branchInfo = message.branchInfo, + onNavigateBranch = if (!vm.isStreaming) { wireId, direction -> + vm.navigateBranch(wireId, direction) + } else null, + ) } } } - if (!isStreaming) { + if (!vm.isStreaming) { val canScrollUp by remember { derivedStateOf { listState.canScrollBackward } } val canScrollDown by remember { derivedStateOf { listState.canScrollForward } } val showButton = canScrollUp || canScrollDown @@ -1178,7 +297,7 @@ fun ChatScreen( .clickable { scope.launch { if (pointsUp) listState.animateScrollToItem(0) - else listState.animateScrollToItem(messages.size - 1) + else listState.animateScrollToItem(vm.messages.size - 1) } }, ) { @@ -1195,7 +314,7 @@ fun ChatScreen( } } - // 2. Floating Top Menu Button + // Top bar val scrimColor = MaterialTheme.colorScheme.background Box( modifier = Modifier @@ -1216,48 +335,29 @@ fun ChatScreen( modifier = Modifier.padding(start = 14.dp, top = 10.dp, end = 14.dp, bottom = 20.dp), verticalAlignment = Alignment.CenterVertically, ) { - val displayModel = chatModel ?: session.config?.model ?: "" - // Unified top bar: sidebar trigger + model name in one glass pill + val displayModel = vm.chatModel ?: vm.session.config?.model ?: "" GlassSurface( shape = KaizenShapes.pill, tintAlpha = GlassTiers.pill(isSystemInDarkTheme()), shadowStack = KaizenShadows.level1, cornerRadius = 20.dp, ) { - Row( - Modifier.heightIn(min = 38.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // Sidebar trigger area + Row(Modifier.heightIn(min = 38.dp), verticalAlignment = Alignment.CenterVertically) { Box( - Modifier - .size(38.dp) - .clip(KaizenShapes.circle) - .clickable { haptics.tick(); scope.launch { drawerState.open() } }, + Modifier.size(38.dp).clip(KaizenShapes.circle) + .clickable { vm.haptics.tick(); scope.launch { drawerState.open() } }, contentAlignment = Alignment.Center, ) { - Icon( - imageVector = KaizenIcons.Menu, - contentDescription = context.getString(R.string.chat_open_menu), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(17.dp), - ) + Icon(KaizenIcons.Menu, contentDescription = context.getString(R.string.chat_open_menu), tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(17.dp)) } - // Divider dot + Box(Modifier.size(3.dp).background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.20f), KaizenShapes.circle)) Box( - Modifier - .size(3.dp) - .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.20f), KaizenShapes.circle) - ) - // Model name — tap opens picker - Box( - Modifier - .clip(KaizenShapes.pill) - .clickable { haptics.tick(); showModelSheet = true } + Modifier.clip(KaizenShapes.pill) + .clickable { vm.haptics.tick(); showModelSheet = true } .padding(start = 10.dp, end = 12.dp, top = 8.dp, bottom = 8.dp), ) { Text( - modelDisplayName(displayModel, models), + modelDisplayName(displayModel, vm.models), color = MaterialTheme.colorScheme.onBackground, fontSize = 13.sp, fontWeight = FontWeight.W300, @@ -1271,23 +371,19 @@ fun ChatScreen( Spacer(Modifier.weight(1f)) Box( Modifier.size(36.dp).clip(KaizenShapes.circle) - .clickable { haptics.tick(); newChat() }, + .clickable { vm.haptics.tick(); vm.newChat() }, contentAlignment = Alignment.Center, ) { - Icon( - KaizenIcons.PenLine, null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(17.dp), - ) + Icon(KaizenIcons.PenLine, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(17.dp)) } Spacer(Modifier.width(4.dp)) - val ctxLen = models.find { it.id == displayModel }?.context_length ?: 1_000_000 - TokenBadge(used = usedTokens, contextLength = ctxLen) + val ctxLen = vm.models.find { it.id == displayModel }?.context_length ?: 1_000_000 + TokenBadge(used = vm.usedTokens, contextLength = ctxLen) } } - // Error banner when initial data load fails - if (loadError != null) { + // Error banner + if (vm.loadError != null) { Box( modifier = Modifier .align(Alignment.TopCenter) @@ -1298,40 +394,30 @@ fun ChatScreen( .border(1.dp, KaizenSemanticColors.errorDeep.copy(alpha = 0.3f), KaizenShapes.sm) .padding(horizontal = 14.dp, vertical = 8.dp), ) { - Text( - loadError!!, - color = KaizenSemanticColors.errorDeep, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - ) + Text(vm.loadError!!, color = KaizenSemanticColors.errorDeep, fontSize = 12.sp, fontWeight = FontWeight.Medium) } } - // Version skew warning banner - val skew = session.versionSkew + // Version skew banner + val skew = vm.session.versionSkew if (skew != null) { val warningColor = Color(0xFFD97706) Box( modifier = Modifier .align(Alignment.TopCenter) .statusBarsPadding() - .padding(top = if (loadError != null) 100.dp else 62.dp, start = 24.dp, end = 24.dp) + .padding(top = if (vm.loadError != null) 100.dp else 62.dp, start = 24.dp, end = 24.dp) .clip(KaizenShapes.sm) .background(warningColor.copy(alpha = 0.15f)) .border(1.dp, warningColor.copy(alpha = 0.3f), KaizenShapes.sm) - .clickable { session.dismissVersionWarning() } + .clickable { vm.session.dismissVersionWarning() } .padding(horizontal = 14.dp, vertical = 8.dp), ) { - Text( - stringResource(R.string.error_version_skew, skew.serverVersion, skew.expectedVersion), - color = warningColor, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - ) + Text(stringResource(R.string.error_version_skew, skew.serverVersion, skew.expectedVersion), color = warningColor, fontSize = 12.sp, fontWeight = FontWeight.Medium) } } - // 3. Floating Bottom Control Dock + // Bottom dock @OptIn(ExperimentalLayoutApi::class) val imeVisible = WindowInsets.isImeVisible val imeBottom = WindowInsets.ime.getBottom(LocalDensity.current) @@ -1343,206 +429,114 @@ fun ChatScreen( .offset(y = with(LocalDensity.current) { -bottomOffset.toDp() }) .background( if (imeVisible) { - Brush.verticalGradient( - colorStops = arrayOf( - 0f to scrimColor.copy(alpha = 0.5f), - 1f to scrimColor.copy(alpha = 0.85f), - ), - ) + Brush.verticalGradient(colorStops = arrayOf(0f to scrimColor.copy(alpha = 0.5f), 1f to scrimColor.copy(alpha = 0.85f))) } else { - Brush.verticalGradient( - colorStops = arrayOf( - 0f to Color.Transparent, - 0.5f to scrimColor.copy(alpha = 0.25f), - 1f to scrimColor.copy(alpha = 0.55f), - ), - ) + Brush.verticalGradient(colorStops = arrayOf(0f to Color.Transparent, 0.5f to scrimColor.copy(alpha = 0.25f), 1f to scrimColor.copy(alpha = 0.55f))) } ) - .then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier) + .then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier), ) { Spacer(Modifier.height(if (imeVisible) 4.dp else 12.dp)) ChatInput( - value = input, - onValueChange = { input = it }, - onSend = { send(input) }, - enabled = !isStreaming, - isStreaming = isStreaming, - onStop = { streamJob?.cancel() }, - isRecording = isRecording, - recordingAmplitude = recordingAmplitude, + value = vm.input, + onValueChange = { vm.input = it }, + onSend = { vm.send(vm.input) }, + enabled = !vm.isStreaming, + isStreaming = vm.isStreaming, + onStop = { vm.cancelStream() }, + isRecording = vm.isRecording, + recordingAmplitude = vm.recordingAmplitude, onMicClick = { toggleRecording() }, - onCallClick = if (liveAvailable) {{ startLiveCall() }} else {{ Toast.makeText(context, context.getString(R.string.chat_call_unavailable), Toast.LENGTH_SHORT).show() }}, - pendingFiles = pendingFiles, - onAddClick = { showAttachMenu = !showAttachMenu }, - onRemoveFile = { id -> pendingFiles.removeAll { it.id == id } }, + onCallClick = if (vm.liveAvailable) {{ startLiveCall() }} else {{ Toast.makeText(context, context.getString(R.string.chat_call_unavailable), Toast.LENGTH_SHORT).show() }}, + pendingFiles = vm.pendingFiles, + onAddClick = { vm.showAttachMenu = !vm.showAttachMenu }, + onRemoveFile = { id -> vm.removeFile(id) }, pillsContent = { ModePillsRow( - activeMode = activeMode, - onToggle = { mode -> - activeMode = if (activeMode == mode) null else mode - haptics.tick() - }, - reasoningPreset = reasoningPreset, - onReasoningChange = { reasoningPreset = it; haptics.tick() }, - samplingPrefs = samplingPrefs, - onSamplingChange = { samplingPrefs = it }, - currentModelId = session.config?.model ?: "", - webSearchProvider = webSearchProvider, - onWebSearchProviderChange = { webSearchProvider = it; haptics.tick() }, + activeMode = vm.activeMode, + onToggle = { mode -> vm.activeMode = if (vm.activeMode == mode) null else mode; vm.haptics.tick() }, + reasoningPreset = vm.reasoningPreset, + onReasoningChange = { vm.reasoningPreset = it; vm.haptics.tick() }, + samplingPrefs = vm.samplingPrefs, + onSamplingChange = { vm.samplingPrefs = it }, + currentModelId = vm.session.config?.model ?: "", + webSearchProvider = vm.webSearchProvider, + onWebSearchProviderChange = { vm.webSearchProvider = it; vm.haptics.tick() }, ) }, ) Spacer(Modifier.height(if (imeVisible) 4.dp else 16.dp)) } - // Dismiss scrim — tap anywhere to close the attachment menu - if (showAttachMenu) { + // Attachment menu dismiss scrim + if (vm.showAttachMenu) { Box( - Modifier - .fillMaxSize() - .clickable( - interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, - indication = null, - ) { showAttachMenu = false } + Modifier.fillMaxSize().clickable( + interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, + indication = null, + ) { vm.showAttachMenu = false } ) } - // Attachment menu — floats above the + button at bottom-left + // Attachment menu androidx.compose.animation.AnimatedVisibility( - visible = showAttachMenu, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(start = 6.dp, bottom = 140.dp), + visible = vm.showAttachMenu, + modifier = Modifier.align(Alignment.BottomStart).padding(start = 6.dp, bottom = 140.dp), enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.slideInVertically { it / 2 }, exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.slideOutVertically { it / 2 }, ) { AttachmentMenu( - onCamera = { showAttachMenu = false; cameraPicker.launch(null) }, - onGallery = { showAttachMenu = false; galleryPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)) }, - onFile = { showAttachMenu = false; filePicker.launch(arrayOf("*/*")) }, + onCamera = { vm.showAttachMenu = false; cameraPicker.launch(null) }, + onGallery = { vm.showAttachMenu = false; galleryPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)) }, + onFile = { vm.showAttachMenu = false; filePicker.launch(arrayOf("*/*")) }, ) } - // Model picker (bottom sheet), opened by the top pill + // Model picker if (showModelSheet) { ModelSheet( - models = models, - selectedModelId = session.config?.model ?: "", - favorites = session.favorites, - onSelectModel = { session.setModel(it); chatModel = it; haptics.tick() }, - onToggleFavorite = { session.toggleFavorite(it) }, + models = vm.models, + selectedModelId = vm.session.config?.model ?: "", + favorites = vm.session.favorites, + onSelectModel = { vm.session.setModel(it); vm.chatModel = it; vm.haptics.tick() }, + onToggleFavorite = { vm.session.toggleFavorite(it) }, onDismiss = { showModelSheet = false }, ) } - // Password fallback dialog for locked conversations - val pwTarget = passwordUnlockTarget + // Password unlock dialog + val pwTarget = vm.passwordUnlockTarget if (pwTarget != null) { dev.kaizen.app.auth.PasswordUnlockDialog( - onSubmit = { password -> - val cfg = session.config ?: return@PasswordUnlockDialog - if (passwordUnlockForToggle) { - scope.launch { - val token = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token, password) - if (token == null) { - passwordUnlockError = context.getString(R.string.unlock_password_wrong) - } else { - KaizenApi.patchConversation(cfg.baseUrl, cfg.token, pwTarget.id, mapOf("locked" to false)) - refreshConversations() - passwordUnlockTarget = null - passwordUnlockError = null - passwordUnlockForToggle = false - } - } - } else { - unlockAndOpen(pwTarget, password) - } - }, - onDismiss = { - passwordUnlockTarget = null - passwordUnlockError = null - passwordUnlockForToggle = false - }, - error = passwordUnlockError, + onSubmit = { password -> vm.submitPasswordUnlock(password) }, + onDismiss = { vm.dismissPasswordDialog() }, + error = vm.passwordUnlockError, ) } } } } - // ─── Live Call Overlay ──────────────────────────────── + // Live Call Overlay androidx.compose.animation.AnimatedVisibility( - visible = liveCallActive, + visible = vm.liveCallActive, enter = androidx.compose.animation.fadeIn(tween(250)), exit = androidx.compose.animation.fadeOut(tween(200)), ) { + val liveStatus by vm.liveService?.status?.collectAsState() ?: remember { mutableStateOf(CallStatus.IDLE) } + val liveSubtitle by vm.liveService?.subtitle?.collectAsState() ?: remember { mutableStateOf("") } + val liveAmplitude by vm.liveService?.amplitude?.collectAsState() ?: remember { mutableStateOf(0f) } + val liveUserSpeaking by vm.liveService?.userSpeaking?.collectAsState() ?: remember { mutableStateOf(false) } LiveCallOverlay( status = liveStatus, subtitle = liveSubtitle, amplitude = liveAmplitude, userSpeaking = liveUserSpeaking, - elapsedMs = liveElapsedMs, - onEndCall = { endLiveCall() }, - onInterrupt = { liveService?.interrupt() }, + elapsedMs = vm.liveElapsedMs, + onEndCall = { vm.endLiveCall(context) }, + onInterrupt = { vm.liveService?.interrupt() }, ) } } } } - -private fun chatErrorText(e: Throwable, ctx: android.content.Context): String = when { - e is ChatHttpException && e.code == 401 -> ctx.getString(R.string.error_session_expired) - e is ChatHttpException && e.code == 402 -> ctx.getString(R.string.error_credits_exhausted) - e is ChatHttpException && e.code == 429 -> ctx.getString(R.string.error_rate_limited) - e is ChatHttpException -> ctx.getString(R.string.error_server, e.code) - else -> ctx.getString(R.string.error_no_connection) -} - -@Suppress("DEPRECATION") -private fun startRecording( - context: android.content.Context, - recorderRef: androidx.compose.runtime.MutableState, - audioFileRef: androidx.compose.runtime.MutableState, - onStarted: () -> Unit, -) { - try { - val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.m4a") - val recorder = MediaRecorder(context).apply { - setAudioSource(MediaRecorder.AudioSource.MIC) - setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) - setAudioEncoder(MediaRecorder.AudioEncoder.AAC) - setAudioSamplingRate(44100) - setAudioEncodingBitRate(128000) - setOutputFile(file.absolutePath) - prepare() - start() - } - recorderRef.value = recorder - audioFileRef.value = file - onStarted() - } catch (_: Exception) { - recorderRef.value = null - audioFileRef.value = null - } -} - -private fun stopRecording( - recorderRef: androidx.compose.runtime.MutableState, - audioFileRef: androidx.compose.runtime.MutableState, - onRecorded: (String, String, File) -> Unit, -) { - val recorder = recorderRef.value ?: return - val file = audioFileRef.value ?: return - try { - recorder.stop() - recorder.release() - } catch (_: Exception) { - recorder.release() - } - recorderRef.value = null - audioFileRef.value = null - if (file.exists() && file.length() > 0) { - onRecorded("voice_${System.currentTimeMillis()}.m4a", "audio/mp4", file) - } -} diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreenViewModel.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreenViewModel.kt new file mode 100644 index 0000000..c7ce05f --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreenViewModel.kt @@ -0,0 +1,1046 @@ +package dev.kaizen.app.chat + +import android.app.Application +import android.content.ComponentName +import android.content.Context +import android.content.ServiceConnection +import android.media.MediaPlayer +import android.media.MediaRecorder +import android.os.IBinder +import android.Manifest +import android.content.pm.PackageManager +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.kaizen.app.R +import dev.kaizen.app.db.MessageEntity +import dev.kaizen.app.db.toEntity +import dev.kaizen.app.haptics.Haptics +import dev.kaizen.app.haptics.createHaptics +import dev.kaizen.app.live.CallStatus +import dev.kaizen.app.live.LiveCallService +import dev.kaizen.app.net.Attachment +import dev.kaizen.app.net.ChatHttpException +import dev.kaizen.app.net.ConversationSummary +import dev.kaizen.app.net.FetchResult +import dev.kaizen.app.net.KaizenApi +import dev.kaizen.app.net.KaizenModel +import dev.kaizen.app.net.SaveMessage +import dev.kaizen.app.net.SearchSource +import dev.kaizen.app.net.SessionViewModel +import dev.kaizen.app.net.WireMessage +import dev.kaizen.app.settings.SettingsViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +enum class AppScreen { Chat, Settings } + +class ChatScreenViewModel( + private val app: Application? = null, + val session: SessionViewModel, + val chat: ChatViewModel? = null, + val settings: SettingsViewModel, + val haptics: Haptics = if (app != null) createHaptics(app) else Haptics(null), +) : ViewModel() { + + // ── Messages + streaming ──────────────────────────────────────────────── + + val messages = mutableStateListOf() + var isStreaming by mutableStateOf(false) + private set + var input by mutableStateOf("") + private var nextId = 0L + private var streamJob: Job? = null + + // ── Conversation ──────────────────────────────────────────────────────── + + var conversationId by mutableStateOf(null) + private set + var viewingLockedChat by mutableStateOf(false) + private set + var chatTree by mutableStateOf(ChatTree.EMPTY) + private set + val storedMessagesMap = mutableMapOf() + var editParentId by mutableStateOf(null) + + // ── Model + modes ─────────────────────────────────────────────────────── + + var models by mutableStateOf(session.store?.loadModelsCache() ?: emptyList()) + private set + var chatModel by mutableStateOf(null) + var usedTokens by mutableStateOf(0) + var activeMode by mutableStateOf(null) + var reasoningPreset by mutableStateOf(ReasoningPreset.Standard) + var samplingPrefs by mutableStateOf(SamplingPrefs()) + var webSearchProvider by mutableStateOf("google") + + // ── Error + unlock ────────────────────────────────────────────────────── + + var loadError by mutableStateOf(null) + private set + var passwordUnlockTarget by mutableStateOf(null) + var passwordUnlockError by mutableStateOf(null) + var passwordUnlockForToggle by mutableStateOf(false) + + // ── Recording ─────────────────────────────────────────────────────────── + + var isRecording by mutableStateOf(false) + private set + var recordingAmplitude by mutableStateOf(0f) + private set + private var recorder: MediaRecorder? = null + private var audioFile: File? = null + private var amplitudeJob: Job? = null + + // ── TTS ───────────────────────────────────────────────────────────────── + + var playingTtsForId by mutableStateOf(null) + private set + private var ttsPlayer: MediaPlayer? = null + var ttsVoice by mutableStateOf("Kore") + + // ── Files ─────────────────────────────────────────────────────────────── + + val pendingFiles = mutableStateListOf() + var showAttachMenu by mutableStateOf(false) + + // ── Navigation ────────────────────────────────────────────────────────── + + var currentScreen by mutableStateOf(AppScreen.Chat) + + // ── Live Call ──────────────────────────────────────────────────────────── + + var liveService by mutableStateOf(null) + private set + var liveCallActive by mutableStateOf(false) + private set + var liveStartedAt by mutableStateOf(0L) + private set + var liveElapsedMs by mutableStateOf(0L) + private set + private var liveTimerJob: Job? = null + + val liveServiceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + liveService = (binder as? LiveCallService.LocalBinder)?.service + } + override fun onServiceDisconnected(name: ComponentName?) { + liveService = null + } + } + + val liveAvailable: Boolean get() = models.any { it.id.startsWith("vertex:") && it.id.contains("live") } + + // ── Conversations from Room ───────────────────────────────────────────── + + val conversations = chat?.conversationRepo?.observeAll() ?: kotlinx.coroutines.flow.flowOf(emptyList()) + + // ═══════════════════════════════════════════════════════════════════════ + // INITIALIZATION + // ═══════════════════════════════════════════════════════════════════════ + + fun initialize() { + val cfg = session.config ?: return + viewModelScope.launch { + loadError = null + val errors = mutableListOf() + + when (val r = KaizenApi.fetchModels(cfg.baseUrl, cfg.token)) { + is FetchResult.Ok -> { + models = r.data + if (r.data.isNotEmpty()) session.store?.saveModelsCache(r.data) + } + is FetchResult.Fail -> { + if ("HTTP 401" in r.reason) { session.logout(); return@launch } + errors.add(r.reason) + } + } + KaizenApi.fetchMe(cfg.baseUrl, cfg.token)?.let { me -> + me.name?.let { settings.updateUserName(it) } + me.email?.let { settings.updateUserEmail(it) } + me.ttsVoice?.let { ttsVoice = it } + } + chat?.conversationRepo?.refresh(cfg.baseUrl, cfg.token) + if (errors.isNotEmpty()) { + loadError = errors.joinToString(" · ") + } + launch { session.checkServerVersion(cfg.baseUrl) } + launch { + val ids = chat?.conversationRepo?.allUnlockedIds() ?: return@launch + chat?.messageRepo?.prefetchMissing(cfg.baseUrl, cfg.token, ids) + } + } + } + + fun prewarm() { + val cfg = session.config ?: return + viewModelScope.launch { KaizenApi.prewarm(cfg.baseUrl) } + } + + fun clearLockedOnPause() { + if (viewingLockedChat) { + messages.clear() + viewingLockedChat = false + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // CHAT + // ═══════════════════════════════════════════════════════════════════════ + + fun newChat() { + messages.clear() + conversationId = null + viewingLockedChat = false + usedTokens = 0 + chatModel = null + chatTree = ChatTree.EMPTY + storedMessagesMap.clear() + } + + fun refreshConversations() { + val cfg = session.config ?: return + viewModelScope.launch { chat?.conversationRepo?.refresh(cfg.baseUrl, cfg.token) } + } + + fun send(text: String, branchParentId: String? = null, branchAttachments: List = emptyList()) { + val trimmed = text.trim() + val uploadedAtts = if (branchAttachments.isNotEmpty()) branchAttachments else pendingFiles.mapNotNull { it.uploaded } + if (trimmed.isEmpty() && uploadedAtts.isEmpty()) return + if (isStreaming) return + isStreaming = true + val cfg = session.config ?: run { isStreaming = false; return } + haptics.click() + + val userParentId = editParentId ?: branchParentId ?: messages.lastOrNull()?.wireId + editParentId = null + val userMsg = Message(nextId++, Role.User, trimmed, attachments = uploadedAtts) + messages.add(userMsg) + input = "" + if (branchAttachments.isEmpty()) pendingFiles.clear() + val assistantId = nextId++ + val assistantWireId = java.util.UUID.randomUUID().toString() + val history = messages.map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) } + messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId)) + val assistantIdx = messages.lastIndex + val wasNewConversation = conversationId == null + + streamJob = viewModelScope.launch { + haptics.thinkingStart() + var sawContent = false + try { + val convIdDeferred = if (conversationId == null) { + async { KaizenApi.createConversation(cfg.baseUrl, cfg.token) } + } else null + + KaizenApi.chat( + cfg.baseUrl, cfg.token, cfg.model, history, + reasoningEffort = reasoningPreset.effort, + preferThroughput = reasoningPreset.throughput, + attachments = uploadedAtts.takeIf { it.isNotEmpty() }, + webSearch = activeMode == R.string.mode_search, + webSearchProvider = if (activeMode == R.string.mode_search && cfg.model.startsWith("vertex:")) webSearchProvider else null, + temperature = samplingPrefs.temperature.takeIf { it.enabled }?.value, + topP = samplingPrefs.topP.takeIf { it.enabled }?.value, + topK = samplingPrefs.topK.takeIf { it.enabled }?.value?.toInt(), + ).collect { state -> + if (assistantIdx >= messages.size) return@collect + if (!sawContent && state.content.isNotEmpty()) { + sawContent = true + haptics.responseStart() + } + messages[assistantIdx] = messages[assistantIdx].copy( + thinking = if (sawContent) false else messages[assistantIdx].thinking, + content = state.content, + reasoning = state.reasoning, + tools = state.tools, + query = state.query, + sources = state.sources, + ) + if (state.inputTokens > 0 || state.outputTokens > 0) { + usedTokens = state.inputTokens + state.outputTokens + } + } + val finalMsg = if (assistantIdx < messages.size) messages[assistantIdx] else null + val finalContent = finalMsg?.content ?: "" + val finalReasoning = finalMsg?.reasoning?.takeIf { it.isNotBlank() } + val finalSources = finalMsg?.sources?.takeIf { it.isNotEmpty() } + if (assistantIdx < messages.size) messages[assistantIdx] = messages[assistantIdx].copy(streaming = false, thinking = false) + + val convId = convIdDeferred?.await()?.also { cid -> + conversationId = cid + viewModelScope.launch { + val preview = trimmed.take(60).lines().first() + chat?.conversationRepo?.insertOptimistic(cid, preview) + } + } ?: conversationId + + if (convId != null && finalContent.isNotEmpty()) { + val saved = KaizenApi.saveMessages( + cfg.baseUrl, cfg.token, convId, + listOf( + SaveMessage( + id = userMsg.wireId, parentId = userParentId, + role = "user", content = trimmed, + attachments = uploadedAtts.takeIf { it.isNotEmpty() }, + ), + SaveMessage( + id = assistantWireId, parentId = userMsg.wireId, + role = "assistant", content = finalContent, model = cfg.model, + reasoning = finalReasoning, + sources = finalSources, + ), + ), + ) + if (saved) { + val msgCount = messages.size + chat?.messageRepo?.cacheMessages(convId, listOf( + MessageEntity( + id = userMsg.wireId, conversationId = convId, + role = "user", content = trimmed, + attachments = uploadedAtts, sortOrder = msgCount - 2, + ), + MessageEntity( + id = assistantWireId, conversationId = convId, + role = "assistant", content = finalContent, + attachments = emptyList(), sortOrder = msgCount - 1, + reasoning = finalReasoning, + sources = finalSources ?: emptyList(), + ), + )) + + storedMessagesMap[userMsg.wireId] = dev.kaizen.app.net.StoredMessage( + id = userMsg.wireId, role = "user", content = trimmed, + parentId = userParentId, attachments = uploadedAtts, + ) + storedMessagesMap[assistantWireId] = dev.kaizen.app.net.StoredMessage( + id = assistantWireId, role = "assistant", content = finalContent, + parentId = userMsg.wireId, model = cfg.model, + reasoning = finalReasoning, + sources = finalSources?.map { SearchSource(it.title, it.url, it.snippet) }, + ) + chatTree = buildChatTree( + storedMessagesMap.values.toList(), + chatTree.activeRootId, + chatTree.activeChild, + ) + + if (wasNewConversation) { + val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId) + if (title != null) { + chat?.conversationRepo?.updateTitle(convId, title) + } + } + refreshConversations() + } + } + } catch (e: Exception) { + val valid = assistantIdx < messages.size + if (e is kotlinx.coroutines.CancellationException) { + if (valid) messages[assistantIdx] = messages[assistantIdx].copy(streaming = false, thinking = false) + } else if (e is ChatHttpException && e.code == 401) { + if (valid) messages[assistantIdx] = messages[assistantIdx].copy(streaming = false, thinking = false) + session.logout() + } else if (valid) { + val existing = messages[assistantIdx].content + val errorSuffix = "\n\n⚠ ${chatErrorText(e)}" + messages[assistantIdx] = messages[assistantIdx].copy( + streaming = false, + thinking = false, + content = if (existing.isNotBlank()) existing + errorSuffix else chatErrorText(e), + ) + } + } finally { + isStreaming = false + streamJob = null + haptics.responseEnd() + } + } + } + + fun cancelStream() { + streamJob?.cancel() + } + + private fun str(resId: Int, vararg args: Any): String = + app?.getString(resId, *args) ?: "" + + private fun chatErrorText(e: Throwable): String = when { + e is ChatHttpException && e.code == 401 -> str(R.string.error_session_expired) + e is ChatHttpException && e.code == 402 -> str(R.string.error_credits_exhausted) + e is ChatHttpException && e.code == 429 -> str(R.string.error_rate_limited) + e is ChatHttpException -> str(R.string.error_server, e.code) + else -> str(R.string.error_no_connection) + } + + // ═══════════════════════════════════════════════════════════════════════ + // OPEN CONVERSATION + UNLOCK + // ═══════════════════════════════════════════════════════════════════════ + + fun openConversation(summary: ConversationSummary, activity: androidx.fragment.app.FragmentActivity?) { + val cfg = session.config ?: return + if (summary.locked) { + requestBiometricOrPassword(activity, summary) { unlockAndOpen(summary) } + return + } + viewingLockedChat = false + usedTokens = 0 + loadError = null + viewModelScope.launch { + messages.clear() + val cachedList = chat?.messageRepo?.getCached(summary.id) ?: emptyList() + cachedList.forEach { m -> + messages.add( + Message( + id = nextId++, + role = if (m.role == "assistant") Role.Assistant else Role.User, + content = m.content, + wireId = m.id, + attachments = m.attachments, + reasoning = m.reasoning ?: "", + sources = m.sources ?: emptyList(), + ), + ) + } + conversationId = summary.id + val data = KaizenApi.fetchConversationData(cfg.baseUrl, cfg.token, summary.id) + if (data.messages.isNotEmpty()) { + val entities = data.messages.mapIndexed { i, m -> m.toEntity(summary.id, i) } + chat?.messageRepo?.cacheMessages(summary.id, entities) + val lastAssistant = data.messages.lastOrNull { it.role == "assistant" } + lastAssistant?.model?.let { chatModel = it } + usedTokens = data.messages.sumOf { (it.inputTokens ?: 0) + (it.outputTokens ?: 0) } + + storedMessagesMap.clear() + data.messages.forEach { storedMessagesMap[it.id] = it } + chatTree = buildChatTree(data.messages, data.activeRootId, data.activeChild) + rebuildVisibleMessages() + } + } + } + + fun unlockAndOpen(summary: ConversationSummary, password: String? = null) { + val cfg = session.config ?: return + viewModelScope.launch { + loadError = null + val unlockToken = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token, password) + if (unlockToken == null) { + if (password != null) { + passwordUnlockError = str(R.string.unlock_password_wrong) + } else { + loadError = str(R.string.biometric_failed) + launch { delay(4000); if (loadError == str(R.string.biometric_failed)) loadError = null } + } + return@launch + } + passwordUnlockTarget = null + passwordUnlockError = null + passwordUnlockForToggle = false + val data = KaizenApi.fetchLockedConversationData(cfg.baseUrl, cfg.token, summary.id, unlockToken) + if (data.messages.isNotEmpty()) { + storedMessagesMap.clear() + data.messages.forEach { storedMessagesMap[it.id] = it } + chatTree = buildChatTree(data.messages, data.activeRootId, data.activeChild) + rebuildVisibleMessages() + conversationId = summary.id + viewingLockedChat = true + } + } + } + + fun showPasswordFallback(summary: ConversationSummary, forToggle: Boolean = false) { + passwordUnlockTarget = summary + passwordUnlockError = null + passwordUnlockForToggle = forToggle + } + + fun dismissPasswordDialog() { + passwordUnlockTarget = null + passwordUnlockError = null + passwordUnlockForToggle = false + } + + fun requestBiometricOrPassword( + activity: androidx.fragment.app.FragmentActivity?, + summary: ConversationSummary, + forToggle: Boolean = false, + onBiometricSuccess: () -> Unit, + ) { + val biometricEnabled = settings.biometricEnabled.value + if (!biometricEnabled || activity == null || !dev.kaizen.app.auth.BiometricUnlock.isAvailable(activity)) { + showPasswordFallback(summary, forToggle) + return + } + dev.kaizen.app.auth.BiometricUnlock.authenticate( + activity = activity, + title = str(R.string.biometric_title), + subtitle = str(R.string.biometric_subtitle), + negativeButtonText = str(R.string.biometric_cancel), + ) { result -> + when (result) { + is dev.kaizen.app.auth.BiometricUnlock.Result.Success -> onBiometricSuccess() + is dev.kaizen.app.auth.BiometricUnlock.Result.NotAvailable -> showPasswordFallback(summary, forToggle) + is dev.kaizen.app.auth.BiometricUnlock.Result.Failed -> { /* user cancelled */ } + } + } + } + + fun submitPasswordUnlock(password: String) { + val target = passwordUnlockTarget ?: return + val cfg = session.config ?: return + if (passwordUnlockForToggle) { + viewModelScope.launch { + val token = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token, password) + if (token == null) { + passwordUnlockError = str(R.string.unlock_password_wrong) + } else { + KaizenApi.patchConversation(cfg.baseUrl, cfg.token, target.id, mapOf("locked" to false)) + refreshConversations() + dismissPasswordDialog() + } + } + } else { + unlockAndOpen(target, password) + } + } + + fun rebuildVisibleMessages() { + val path = getVisiblePath(chatTree) + messages.clear() + for (entry in path) { + val sm = storedMessagesMap[entry.messageId] ?: continue + messages.add( + Message( + id = nextId++, + role = if (sm.role == "assistant") Role.Assistant else Role.User, + content = sm.content, + wireId = sm.id, + attachments = sm.attachments, + reasoning = sm.reasoning ?: "", + sources = sm.sources ?: emptyList(), + branchInfo = entry.branchInfo, + ), + ) + } + } + + fun navigateBranch(wireId: String, direction: Int) { + val newTree = treeNavigate(chatTree, wireId, direction) + chatTree = newTree + rebuildVisibleMessages() + val cid = conversationId ?: return + val cfg = session.config ?: return + viewModelScope.launch { + KaizenApi.patchConversation( + cfg.baseUrl, cfg.token, cid, + mapOf("activeRootId" to newTree.activeRootId, "activeChild" to newTree.activeChild), + ) + } + } + + fun deleteMessage(wireId: String) { + messages.removeAll { it.wireId == wireId } + val cfg = session.config ?: return + val cid = conversationId ?: return + viewModelScope.launch { + KaizenApi.deleteMessage(cfg.baseUrl, cfg.token, cid, wireId) + } + } + + fun regenerate(message: Message) { + val lastUserIdx = messages.indexOfLast { it.role == Role.User && messages.indexOf(it) < messages.indexOf(message) } + if (lastUserIdx >= 0) { + val userMsg = messages[lastUserIdx] + val parentId = chatTree.nodes[userMsg.wireId]?.parentId + val msgIdx = messages.indexOf(message) + if (msgIdx >= 0) { + messages.subList(msgIdx, messages.size).clear() + } + send(userMsg.content, branchParentId = parentId, branchAttachments = userMsg.attachments) + } + } + + fun editMessage(wireId: String, content: String) { + val node = chatTree.nodes[wireId] + val parentId = node?.parentId + val idx = messages.indexOfFirst { it.wireId == wireId } + if (idx >= 0) { + messages.subList(idx, messages.size).clear() + editParentId = parentId + input = content + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // SIDEBAR ACTIONS + // ═══════════════════════════════════════════════════════════════════════ + + fun handleSidebarAction(action: SidebarAction, activity: androidx.fragment.app.FragmentActivity?, currentConversations: List) { + val cfg = session.config ?: return + haptics.tick() + viewModelScope.launch { + when (action) { + is SidebarAction.Rename -> { + KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("title" to action.newTitle)) + refreshConversations() + } + is SidebarAction.Delete -> { + KaizenApi.deleteConversation(cfg.baseUrl, cfg.token, action.id) + if (conversationId == action.id) newChat() + refreshConversations() + } + is SidebarAction.TogglePin -> { + KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("pinned" to action.pinned)) + refreshConversations() + } + is SidebarAction.ToggleLock -> { + if (action.locked) { + KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to true)) + refreshConversations() + } else { + val summary = currentConversations.find { it.id == action.id } + if (summary != null) { + requestBiometricOrPassword(activity, summary, forToggle = true) { + viewModelScope.launch { + KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to false)) + refreshConversations() + } + } + } + } + } + is SidebarAction.GenerateTitle -> { + val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, action.id, manual = true) + if (title != null) { + chat?.conversationRepo?.updateTitle(action.id, title) + refreshConversations() + } + } + is SidebarAction.Duplicate -> { + val srcMessages = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, action.id) + if (srcMessages.isNotEmpty()) { + val srcTitle = currentConversations.find { it.id == action.id }?.title ?: "" + val newId = KaizenApi.createConversation(cfg.baseUrl, cfg.token, "$srcTitle (2)") + if (newId != null) { + val saveMessages = srcMessages.mapIndexed { i, m -> + SaveMessage( + id = java.util.UUID.randomUUID().toString(), + parentId = null, + role = m.role, + content = m.content, + model = m.model, + attachments = m.attachments.takeIf { it.isNotEmpty() }, + reasoning = m.reasoning, + sources = m.sources, + ) + } + val chained = saveMessages.mapIndexed { i, m -> + m.copy(parentId = if (i == 0) null else saveMessages[i - 1].id) + } + KaizenApi.saveMessages(cfg.baseUrl, cfg.token, newId, chained) + refreshConversations() + } + } + } + } + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // FILE UPLOAD + // ═══════════════════════════════════════════════════════════════════════ + + fun uploadPickedFile(name: String, mimeType: String, bytes: ByteArray) { + val cfg = session.config ?: return + val pf = PendingFile(name = name, mimeType = mimeType, bytes = bytes) + pendingFiles.add(pf) + viewModelScope.launch { + when (val r = KaizenApi.uploadFile(cfg.baseUrl, cfg.token, name, mimeType, bytes)) { + is FetchResult.Ok -> { + val idx = pendingFiles.indexOfFirst { it.id == pf.id } + if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(uploaded = r.data, uploading = false) + } + is FetchResult.Fail -> { + val idx = pendingFiles.indexOfFirst { it.id == pf.id } + if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(error = r.reason, uploading = false) + } + } + } + } + + fun uploadPickedUri(uri: android.net.Uri, context: Context) { + val cfg = session.config ?: return + val cr = context.contentResolver + val mimeType = cr.getType(uri) ?: "application/octet-stream" + val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "file_${System.currentTimeMillis()}" + val size = try { cr.openFileDescriptor(uri, "r")?.use { it.statSize } ?: -1L } catch (_: Exception) { -1L } + val pf = PendingFile(name = fileName, mimeType = mimeType) + pendingFiles.add(pf) + viewModelScope.launch { + val inputStream = cr.openInputStream(uri) + if (inputStream == null) { + val idx = pendingFiles.indexOfFirst { it.id == pf.id } + if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(error = "Cannot read file", uploading = false) + return@launch + } + val r = inputStream.use { stream -> + KaizenApi.uploadStream(cfg.baseUrl, cfg.token, fileName, mimeType, stream, size) + } + when (r) { + is FetchResult.Ok -> { + val idx = pendingFiles.indexOfFirst { it.id == pf.id } + if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(uploaded = r.data, uploading = false) + } + is FetchResult.Fail -> { + val idx = pendingFiles.indexOfFirst { it.id == pf.id } + if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(error = r.reason, uploading = false) + } + } + } + } + + fun removeFile(id: String) { + pendingFiles.removeAll { it.id == id } + } + + // ═══════════════════════════════════════════════════════════════════════ + // VOICE RECORDING + // ═══════════════════════════════════════════════════════════════════════ + + fun startRecording(context: Context) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) return + try { + val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.m4a") + @Suppress("DEPRECATION") + val rec = MediaRecorder(context).apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setAudioSamplingRate(44100) + setAudioEncodingBitRate(128000) + setOutputFile(file.absolutePath) + prepare() + start() + } + recorder = rec + audioFile = file + isRecording = true + amplitudeJob = viewModelScope.launch { + while (isRecording) { + val amp = try { recorder?.maxAmplitude ?: 0 } catch (_: Exception) { 0 } + recordingAmplitude = (amp / 32768f).coerceIn(0f, 1f) + delay(60) + } + recordingAmplitude = 0f + } + } catch (_: Exception) { + recorder = null + audioFile = null + } + } + + fun stopRecordingAndSend() { + val rec = recorder ?: return + val file = audioFile ?: return + try { rec.stop(); rec.release() } catch (_: Exception) { rec.release() } + recorder = null + audioFile = null + isRecording = false + amplitudeJob?.cancel() + + if (!file.exists() || file.length() <= 0) return + + val cfg = session.config ?: return + viewModelScope.launch { + val bytes = withContext(Dispatchers.IO) { file.readBytes().also { file.delete() } } + val name = "voice_${System.currentTimeMillis()}.m4a" + when (val r = KaizenApi.uploadFile(cfg.baseUrl, cfg.token, name, "audio/mp4", bytes)) { + is FetchResult.Ok -> sendVoiceMessage(r.data, cfg) + is FetchResult.Fail -> { /* silently fail */ } + } + } + } + + private fun sendVoiceMessage(att: Attachment, cfg: dev.kaizen.app.net.ServerConfig) { + val userParentId = messages.lastOrNull()?.wireId + val userMsg = Message(nextId++, Role.User, "", attachments = listOf(att)) + messages.add(userMsg) + val assistantId = nextId++ + val assistantWireId = java.util.UUID.randomUUID().toString() + val history = messages.map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) } + messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId)) + val voiceAssistantIdx = messages.lastIndex + isStreaming = true + haptics.click() + + val convIdDeferred = if (conversationId == null) { + viewModelScope.async { KaizenApi.createConversation(cfg.baseUrl, cfg.token) } + } else null + + streamJob = viewModelScope.launch { + haptics.thinkingStart() + var sawContent = false + try { + KaizenApi.chat( + cfg.baseUrl, cfg.token, cfg.model, history, + attachments = listOf(att), + ).collect { state -> + if (voiceAssistantIdx >= messages.size) return@collect + if (!sawContent && state.content.isNotEmpty()) { + sawContent = true; haptics.responseStart() + } + messages[voiceAssistantIdx] = messages[voiceAssistantIdx].copy( + thinking = if (sawContent) false else messages[voiceAssistantIdx].thinking, + content = state.content, + reasoning = state.reasoning, + tools = state.tools, + sources = state.sources, + query = state.query, + streaming = true, + ) + } + } catch (_: kotlinx.coroutines.CancellationException) { + } catch (_: Exception) {} + if (voiceAssistantIdx < messages.size) messages[voiceAssistantIdx] = messages[voiceAssistantIdx].copy(streaming = false, thinking = false) + isStreaming = false + haptics.responseEnd() + val cid = convIdDeferred?.await() ?: conversationId ?: return@launch + if (conversationId == null) conversationId = cid + val assistantMsg = if (voiceAssistantIdx < messages.size) messages[voiceAssistantIdx] else return@launch + val finalContent = assistantMsg.content + val finalReasoning = assistantMsg.reasoning.takeIf { it.isNotBlank() } + val finalSources = assistantMsg.sources.takeIf { it.isNotEmpty() } + val userSave = SaveMessage(id = userMsg.wireId, parentId = userParentId, role = "user", content = "", attachments = listOf(att)) + val assistantSave = SaveMessage(id = assistantWireId, parentId = userMsg.wireId, role = "assistant", content = finalContent, model = cfg.model, reasoning = finalReasoning, sources = finalSources?.map { SearchSource(it.title, it.url, it.snippet) }) + val saved = KaizenApi.saveMessages(cfg.baseUrl, cfg.token, cid, listOf(userSave, assistantSave)) + if (saved) { + val msgCount = messages.size + chat?.messageRepo?.cacheMessages(cid, listOf( + MessageEntity( + id = userMsg.wireId, conversationId = cid, + role = "user", content = "", + attachments = listOf(att), sortOrder = msgCount - 2, + ), + MessageEntity( + id = assistantWireId, conversationId = cid, + role = "assistant", content = finalContent, + attachments = emptyList(), sortOrder = msgCount - 1, + reasoning = finalReasoning, + sources = finalSources?.map { SearchSource(it.title, it.url, it.snippet) } ?: emptyList(), + ), + )) + storedMessagesMap[userMsg.wireId] = dev.kaizen.app.net.StoredMessage( + id = userMsg.wireId, role = "user", content = "", + parentId = userParentId, attachments = listOf(att), + ) + storedMessagesMap[assistantWireId] = dev.kaizen.app.net.StoredMessage( + id = assistantWireId, role = "assistant", content = finalContent, + parentId = userMsg.wireId, model = cfg.model, + reasoning = finalReasoning, + sources = finalSources?.map { SearchSource(it.title, it.url, it.snippet) }, + ) + chatTree = buildChatTree(storedMessagesMap.values.toList(), chatTree.activeRootId, chatTree.activeChild) + } + if (convIdDeferred != null) { + chat?.conversationRepo?.insertOptimistic(cid, "(Voice)") + val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, cid) + if (title != null) chat?.conversationRepo?.updateTitle(cid, title) + chat?.conversationRepo?.refresh(cfg.baseUrl, cfg.token) + } + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // TTS + // ═══════════════════════════════════════════════════════════════════════ + + fun readAloud(content: String, messageId: Long, wireId: String) { + val cfg = session.config ?: return + if (playingTtsForId == messageId) { + ttsPlayer?.release() + ttsPlayer = null + playingTtsForId = null + return + } + ttsPlayer?.release() + playingTtsForId = messageId + val voice = ttsVoice + viewModelScope.launch { + val cached = chat?.messageDao?.getTtsUrl(wireId, voice) + if (cached != null) { + playTtsUrl(cached, messageId) + return@launch + } + val url = KaizenApi.generateSpeech(cfg.baseUrl, cfg.token, content, voice) + if (url == null) { + playingTtsForId = null + return@launch + } + chat?.messageDao?.updateTts(wireId, url, voice) + playTtsUrl(url, messageId) + } + } + + private fun playTtsUrl(url: String, messageId: Long) { + viewModelScope.launch { + try { + val tmpFile = withContext(Dispatchers.IO) { + val req = okhttp3.Request.Builder().url(url).build() + KaizenApi.imageClient.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) return@withContext null + val bytes = resp.body!!.bytes() + File.createTempFile("tts_", ".wav", app?.cacheDir).also { it.writeBytes(bytes) } + } + } + if (tmpFile == null) { + playingTtsForId = null + return@launch + } + val player = MediaPlayer().apply { + setOnPreparedListener { start() } + setOnCompletionListener { + release() + ttsPlayer = null + playingTtsForId = null + tmpFile.delete() + } + setOnErrorListener { _, _, _ -> + release() + ttsPlayer = null + playingTtsForId = null + tmpFile.delete() + true + } + setDataSource(tmpFile.absolutePath) + prepareAsync() + } + ttsPlayer = player + } catch (_: Exception) { + playingTtsForId = null + } + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // LIVE CALL + // ═══════════════════════════════════════════════════════════════════════ + + fun startLiveCallInner(context: Context) { + val cfg = session.config ?: return + liveCallActive = true + + val intent = LiveCallService.intent(context) + context.startForegroundService(intent) + context.bindService(intent, liveServiceConnection, 0) + + liveTimerJob = viewModelScope.launch { + liveStartedAt = System.currentTimeMillis() + while (liveCallActive) { + liveElapsedMs = System.currentTimeMillis() - liveStartedAt + delay(500) + } + } + + viewModelScope.launch { + var attempts = 0 + while (liveService == null && attempts < 20) { + delay(50) + attempts++ + } + + val svc = liveService ?: return@launch + + val cid = conversationId ?: run { + val newId = KaizenApi.createConversation(cfg.baseUrl, cfg.token) + if (newId != null) { + conversationId = newId + chat?.conversationRepo?.insertOptimistic(newId, "Live Call") + } + newId + } + + svc.onTurnEnd = { turn -> + viewModelScope.launch { + val convId = conversationId ?: return@launch + + if (turn.userText.isNotEmpty()) { + messages.add(Message(nextId++, Role.User, turn.userText, wireId = turn.userMessageId ?: java.util.UUID.randomUUID().toString())) + } + if (turn.assistantText.isNotEmpty()) { + messages.add(Message(nextId++, Role.Assistant, turn.assistantText, wireId = turn.assistantMessageId ?: java.util.UUID.randomUUID().toString())) + chatModel = cfg.model + usedTokens = turn.usage.inputTokens + turn.usage.outputTokens + } + + val msgCount = messages.size + val entitiesToCache = mutableListOf() + if (turn.userMessageId != null && turn.userText.isNotEmpty()) { + entitiesToCache.add(MessageEntity( + id = turn.userMessageId, conversationId = convId, + role = "user", content = turn.userText, + attachments = emptyList(), sortOrder = msgCount - 2, + )) + } + if (turn.assistantMessageId != null && turn.assistantText.isNotEmpty()) { + entitiesToCache.add(MessageEntity( + id = turn.assistantMessageId, conversationId = convId, + role = "assistant", content = turn.assistantText, + attachments = emptyList(), sortOrder = msgCount - 1, + )) + } + if (entitiesToCache.isNotEmpty()) { + chat?.messageRepo?.cacheMessages(convId, entitiesToCache) + } + } + } + + svc.onError = { code, message -> + viewModelScope.launch { + // error handled by LiveCallOverlay + } + } + + svc.startCall( + baseUrl = cfg.baseUrl, + token = cfg.token, + model = cfg.model, + voice = ttsVoice, + conversationId = cid, + ) + } + } + + fun endLiveCall(context: Context) { + liveService?.onTurnEnd = null + liveService?.onError = null + liveService?.endCall() + liveCallActive = false + liveTimerJob?.cancel() + try { context.unbindService(liveServiceConnection) } catch (_: Exception) {} + liveService = null + + val cfg = session.config ?: return + val cid = conversationId ?: return + viewModelScope.launch { + val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, cid) + if (title != null) chat?.conversationRepo?.updateTitle(cid, title) + chat?.conversationRepo?.refresh(cfg.baseUrl, cfg.token) + } + } + + override fun onCleared() { + ttsPlayer?.release() + recorder?.release() + amplitudeJob?.cancel() + super.onCleared() + } +} diff --git a/app/src/main/java/dev/kaizen/app/haptics/Haptics.kt b/app/src/main/java/dev/kaizen/app/haptics/Haptics.kt index 58ea9c1..6487e45 100644 --- a/app/src/main/java/dev/kaizen/app/haptics/Haptics.kt +++ b/app/src/main/java/dev/kaizen/app/haptics/Haptics.kt @@ -116,17 +116,18 @@ class Haptics(private val vibrator: Vibrator?) { } } -/** Builds the right Vibrator for the API level and keeps it across recompositions. */ +fun createHaptics(context: Context): Haptics { + val vib: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + (context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + } + return Haptics(vib) +} + @Composable fun rememberHaptics(): Haptics { val context = LocalContext.current - return remember { - val vib: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - (context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator - } else { - @Suppress("DEPRECATION") - context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator - } - Haptics(vib) - } + return remember { createHaptics(context) } } diff --git a/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt b/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt index 670419b..69a0aa2 100644 --- a/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt +++ b/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt @@ -14,13 +14,12 @@ import androidx.compose.runtime.setValue */ data class VersionSkew(val serverVersion: Int, val expectedVersion: Int) -class SessionViewModel(val store: SecureStore) { +class SessionViewModel(val store: SecureStore? = null) { - var config by mutableStateOf(store.load()) + var config by mutableStateOf(store?.load()) private set - /** Favorite model ids (pinned to the top of the picker), as Compose snapshot state. */ - var favorites by mutableStateOf(store.loadFavorites()) + var favorites by mutableStateOf(store?.loadFavorites() ?: emptySet()) private set var versionSkew by mutableStateOf(null) @@ -42,7 +41,7 @@ class SessionViewModel(val store: SecureStore) { val me = KaizenApi.fetchMe(baseUrl, result.token) val model = me?.defaultModel ?: DEFAULT_MODEL val cfg = ServerConfig(baseUrl = baseUrl, token = result.token, model = model, email = email.trim()) - store.save(cfg) + store?.save(cfg) config = cfg checkServerVersion(baseUrl) result @@ -65,7 +64,7 @@ class SessionViewModel(val store: SecureStore) { val cfg = config ?: return if (cfg.model == modelId) return val updated = cfg.copy(model = modelId) - store.save(updated) + store?.save(updated) config = updated } @@ -74,19 +73,19 @@ class SessionViewModel(val store: SecureStore) { val cfg = config ?: return if (cfg.speed == speed) return val updated = cfg.copy(speed = speed) - store.save(updated) + store?.save(updated) config = updated } /** Toggle a model id in/out of favorites (persisted). */ fun toggleFavorite(modelId: String) { val next = favorites.toMutableSet().apply { if (!add(modelId)) remove(modelId) } - store.saveFavorites(next) + store?.saveFavorites(next) favorites = next } fun logout() { - store.clear() + store?.clear() config = null favorites = emptySet() } diff --git a/app/src/test/java/dev/kaizen/app/ChatScreenViewModelTest.kt b/app/src/test/java/dev/kaizen/app/ChatScreenViewModelTest.kt new file mode 100644 index 0000000..a0c2cfa --- /dev/null +++ b/app/src/test/java/dev/kaizen/app/ChatScreenViewModelTest.kt @@ -0,0 +1,217 @@ +package dev.kaizen.app + +import dev.kaizen.app.chat.AppScreen +import dev.kaizen.app.chat.ChatScreenViewModel +import dev.kaizen.app.chat.Message +import dev.kaizen.app.chat.PendingFile +import dev.kaizen.app.chat.ReasoningPreset +import dev.kaizen.app.chat.Role +import dev.kaizen.app.chat.SamplingParam +import dev.kaizen.app.chat.SamplingPrefs +import dev.kaizen.app.net.ConversationSummary +import dev.kaizen.app.net.SessionViewModel +import dev.kaizen.app.settings.SettingsViewModel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatScreenViewModelTest { + + private fun createVm(): ChatScreenViewModel = ChatScreenViewModel( + session = SessionViewModel(), + settings = SettingsViewModel(), + ) + + @Test + fun initialState() { + val vm = createVm() + assertTrue(vm.messages.isEmpty()) + assertFalse(vm.isStreaming) + assertEquals("", vm.input) + assertNull(vm.conversationId) + assertFalse(vm.viewingLockedChat) + assertNull(vm.chatModel) + assertEquals(0, vm.usedTokens) + assertNull(vm.activeMode) + assertEquals(ReasoningPreset.Standard, vm.reasoningPreset) + assertEquals("google", vm.webSearchProvider) + assertFalse(vm.isRecording) + assertEquals(0f, vm.recordingAmplitude) + assertNull(vm.playingTtsForId) + assertEquals("Kore", vm.ttsVoice) + assertTrue(vm.pendingFiles.isEmpty()) + assertFalse(vm.showAttachMenu) + assertEquals(AppScreen.Chat, vm.currentScreen) + assertFalse(vm.liveCallActive) + assertNull(vm.loadError) + assertNull(vm.passwordUnlockTarget) + } + + @Test + fun newChatResetsState() { + val vm = createVm() + vm.messages.add(Message(1, Role.User, "hello")) + vm.messages.add(Message(2, Role.Assistant, "hi")) + vm.usedTokens = 500 + vm.chatModel = "vertex:gemini-2.5-flash" + + vm.newChat() + + assertTrue(vm.messages.isEmpty()) + assertNull(vm.conversationId) + assertFalse(vm.viewingLockedChat) + assertEquals(0, vm.usedTokens) + assertNull(vm.chatModel) + } + + @Test + fun sendWithoutConfigIsNoOp() { + val vm = createVm() + val sizeBefore = vm.messages.size + vm.send("hello") + assertEquals(sizeBefore, vm.messages.size) + } + + @Test + fun sendEmptyIsNoOp() { + val vm = createVm() + vm.send("") + assertTrue(vm.messages.isEmpty()) + } + + @Test + fun inputBindsTwoWay() { + val vm = createVm() + assertEquals("", vm.input) + vm.input = "test prompt" + assertEquals("test prompt", vm.input) + } + + @Test + fun modeToggle() { + val vm = createVm() + assertNull(vm.activeMode) + vm.activeMode = R.string.mode_search + assertEquals(R.string.mode_search, vm.activeMode) + vm.activeMode = null + assertNull(vm.activeMode) + } + + @Test + fun reasoningPresetCycle() { + val vm = createVm() + assertEquals(ReasoningPreset.Standard, vm.reasoningPreset) + vm.reasoningPreset = ReasoningPreset.High + assertEquals(ReasoningPreset.High, vm.reasoningPreset) + assertEquals("high", vm.reasoningPreset.effort) + assertFalse(vm.reasoningPreset.throughput) + } + + @Test + fun samplingPrefsUpdate() { + val vm = createVm() + assertFalse(vm.samplingPrefs.temperature.enabled) + vm.samplingPrefs = SamplingPrefs( + temperature = SamplingParam(true, 0.7f), + topP = SamplingParam(false, 1f), + topK = SamplingParam(true, 20f), + ) + assertTrue(vm.samplingPrefs.temperature.enabled) + assertEquals(0.7f, vm.samplingPrefs.temperature.value) + assertTrue(vm.samplingPrefs.topK.enabled) + } + + @Test + fun screenNavigation() { + val vm = createVm() + assertEquals(AppScreen.Chat, vm.currentScreen) + vm.currentScreen = AppScreen.Settings + assertEquals(AppScreen.Settings, vm.currentScreen) + vm.currentScreen = AppScreen.Chat + assertEquals(AppScreen.Chat, vm.currentScreen) + } + + @Test + fun webSearchProviderChange() { + val vm = createVm() + assertEquals("google", vm.webSearchProvider) + vm.webSearchProvider = "enterprise" + assertEquals("enterprise", vm.webSearchProvider) + } + + @Test + fun passwordDialogLifecycle() { + val vm = createVm() + val fakeSummary = ConversationSummary(id = "test-123", title = "Test") + vm.showPasswordFallback(fakeSummary, forToggle = true) + assertEquals(fakeSummary, vm.passwordUnlockTarget) + assertTrue(vm.passwordUnlockForToggle) + assertNull(vm.passwordUnlockError) + + vm.dismissPasswordDialog() + assertNull(vm.passwordUnlockTarget) + assertNull(vm.passwordUnlockError) + assertFalse(vm.passwordUnlockForToggle) + } + + @Test + fun editMessageSetsInputAndClearsMessages() { + val vm = createVm() + vm.messages.add(Message(1, Role.User, "original text", wireId = "wire-1")) + vm.messages.add(Message(2, Role.Assistant, "response", wireId = "wire-2")) + + vm.editMessage("wire-1", "original text") + assertEquals("original text", vm.input) + assertTrue(vm.messages.isEmpty()) + } + + @Test + fun clearLockedOnPauseWhenNotViewing() { + val vm = createVm() + vm.messages.add(Message(1, Role.User, "secret")) + vm.clearLockedOnPause() + assertEquals(1, vm.messages.size) + } + + @Test + fun removeFile() { + val vm = createVm() + val pf = PendingFile(name = "test.jpg", mimeType = "image/jpeg") + vm.pendingFiles.add(pf) + assertEquals(1, vm.pendingFiles.size) + vm.removeFile(pf.id) + assertTrue(vm.pendingFiles.isEmpty()) + } + + @Test + fun attachMenuToggle() { + val vm = createVm() + assertFalse(vm.showAttachMenu) + vm.showAttachMenu = true + assertTrue(vm.showAttachMenu) + vm.showAttachMenu = false + assertFalse(vm.showAttachMenu) + } + + @Test + fun ttsVoiceDefault() { + val vm = createVm() + assertEquals("Kore", vm.ttsVoice) + vm.ttsVoice = "Aoede" + assertEquals("Aoede", vm.ttsVoice) + } + + @Test + fun deleteMessageRemovesFromList() { + val vm = createVm() + vm.messages.add(Message(1, Role.User, "hello", wireId = "w1")) + vm.messages.add(Message(2, Role.Assistant, "hi", wireId = "w2")) + assertEquals(2, vm.messages.size) + + vm.deleteMessage("w1") + assertEquals(1, vm.messages.size) + assertEquals("w2", vm.messages.first().wireId) + } +}