Turn persistence: onTurnEnd handler creates conversation if needed, adds user + assistant messages to UI list, caches in Room. Server already persists via WS-Server — app only needs local state sync. Model check: CallButton only triggers Live Call when a vertex:*live* model is in the catalog (liveAvailable). Falls back to PTT otherwise. Permission: RECORD_AUDIO runtime request via ActivityResultContracts before starting the call. Denied → toast, no crash. Title generation: endLiveCall triggers generateTitle + sidebar refresh, same pattern as text chat.
1537 lines
80 KiB
Kotlin
1537 lines
80 KiB
Kotlin
package dev.kaizen.app.chat
|
|
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.border
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.isSystemInDarkTheme
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.PaddingValues
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.heightIn
|
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
import androidx.compose.foundation.layout.WindowInsets
|
|
import androidx.compose.foundation.layout.ime
|
|
import androidx.compose.foundation.layout.isImeVisible
|
|
import androidx.compose.foundation.layout.navigationBars
|
|
import androidx.compose.foundation.layout.offset
|
|
import androidx.compose.ui.platform.LocalDensity
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.foundation.layout.statusBarsPadding
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.layout.widthIn
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.foundation.lazy.items
|
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
import dev.kaizen.app.ui.icon.KaizenIcons
|
|
import androidx.compose.material3.DrawerValue
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.ModalNavigationDrawer
|
|
import androidx.compose.material3.rememberDrawerState
|
|
import androidx.compose.runtime.Composable
|
|
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
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.runtime.snapshotFlow
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.clip
|
|
import androidx.compose.ui.graphics.Brush
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.platform.LocalConfiguration
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import dev.kaizen.app.ui.effect.GlassSurface
|
|
import dev.kaizen.app.ui.effect.GlassTiers
|
|
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
|
|
import androidx.activity.result.PickVisualMediaRequest
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.compose.ui.platform.LocalContext
|
|
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(
|
|
settingsViewModel: SettingsViewModel,
|
|
session: SessionViewModel,
|
|
chat: ChatViewModel,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
val haptics = rememberHaptics()
|
|
val scope = rememberCoroutineScope()
|
|
val messages = remember { mutableStateListOf<Message>() }
|
|
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<Int?>(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<String?>(null) }
|
|
var streamJob by remember { mutableStateOf<Job?>(null) }
|
|
|
|
// Tree state for branch navigation
|
|
var chatTree by remember { mutableStateOf(ChatTree.EMPTY) }
|
|
val storedMessagesMap = remember { mutableMapOf<String, dev.kaizen.app.net.StoredMessage>() }
|
|
var editParentId by remember { mutableStateOf<String?>(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<MediaRecorder?>(null) }
|
|
val audioFileRef = remember { mutableStateOf<File?>(null) }
|
|
var recordingAmplitude by remember { mutableStateOf(0f) }
|
|
|
|
// ─── Live Call state (uses context/ttsVoice/currentConversationId declared below) ───
|
|
var liveService by remember { mutableStateOf<LiveCallService?>(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<Long?>(null) }
|
|
val ttsPlayerRef = remember { mutableStateOf<MediaPlayer?>(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 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()) }
|
|
var showModelSheet by remember { mutableStateOf(false) }
|
|
|
|
// Conversation persistence: sidebar list from Room (instant), active conversation id
|
|
var conversationId by remember { mutableStateOf<String?>(null) }
|
|
var viewingLockedChat by remember { mutableStateOf(false) }
|
|
val conversations by chat.conversationRepo.observeAll().collectAsState(initial = emptyList())
|
|
|
|
var loadError by remember { mutableStateOf<String?>(null) }
|
|
|
|
// Password unlock dialog state — shown when biometrics unavailable for locked chats
|
|
var passwordUnlockTarget by remember { mutableStateOf<ConversationSummary?>(null) }
|
|
var passwordUnlockError by remember { mutableStateOf<String?>(null) }
|
|
var passwordUnlockForToggle by remember { mutableStateOf(false) }
|
|
|
|
// Pending file attachments — uploaded immediately on pick, cleared on send.
|
|
val pendingFiles = remember { mutableStateListOf<PendingFile>() }
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(liveCallActive) {
|
|
if (liveCallActive) {
|
|
liveStartedAt = System.currentTimeMillis()
|
|
while (liveCallActive) {
|
|
liveElapsedMs = System.currentTimeMillis() - liveStartedAt
|
|
kotlinx.coroutines.delay(500)
|
|
}
|
|
}
|
|
}
|
|
|
|
val liveAvailable = models.any { it.id.startsWith("vertex:") && it.id.contains("live") }
|
|
|
|
fun startLiveCallInner() {
|
|
val cfg = session.config ?: return
|
|
liveCallActive = true
|
|
|
|
val intent = LiveCallService.intent(context)
|
|
context.startForegroundService(intent)
|
|
context.bindService(intent, liveServiceConnection, 0)
|
|
|
|
scope.launch {
|
|
var attempts = 0
|
|
while (liveService == null && attempts < 20) {
|
|
kotlinx.coroutines.delay(50)
|
|
attempts++
|
|
}
|
|
|
|
val svc = liveService ?: return@launch
|
|
|
|
// 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<dev.kaizen.app.db.MessageEntity>()
|
|
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 liveCallPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
|
if (granted) startLiveCallInner()
|
|
else Toast.makeText(context, context.getString(R.string.chat_mic_permission), Toast.LENGTH_SHORT).show()
|
|
}
|
|
|
|
fun startLiveCall() {
|
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
|
|
liveCallPermission.launch(Manifest.permission.RECORD_AUDIO)
|
|
return
|
|
}
|
|
startLiveCallInner()
|
|
}
|
|
|
|
fun endLiveCall() {
|
|
liveService?.onTurnEnd = null
|
|
liveService?.onError = null
|
|
liveService?.endCall()
|
|
liveCallActive = false
|
|
try { context.unbindService(liveServiceConnection) } catch (_: Exception) {}
|
|
liveService = null
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
val galleryPicker = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris ->
|
|
val cr = context.contentResolver
|
|
for (uri in uris) {
|
|
val mimeType = cr.getType(uri) ?: "image/jpeg"
|
|
val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "image_${System.currentTimeMillis()}"
|
|
val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: continue
|
|
uploadPickedFile(fileName, mimeType, bytes)
|
|
}
|
|
}
|
|
|
|
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
|
|
val cr = context.contentResolver
|
|
for (uri in uris) {
|
|
val mimeType = cr.getType(uri) ?: "application/octet-stream"
|
|
val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "file"
|
|
val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: continue
|
|
uploadPickedFile(fileName, mimeType, bytes)
|
|
}
|
|
}
|
|
|
|
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, bytes ->
|
|
isRecording = false
|
|
val cfg = session.config ?: return@stopRecording
|
|
scope.launch {
|
|
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()
|
|
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId))
|
|
isStreaming = true
|
|
haptics.click()
|
|
|
|
val history = messages
|
|
.filter { it.id != assistantId }
|
|
.map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) }
|
|
|
|
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 ->
|
|
val idx = messages.indexOfLast { it.id == assistantId }
|
|
if (idx < 0) return@collect
|
|
if (!sawContent && state.content.isNotEmpty()) {
|
|
sawContent = true; haptics.responseStart()
|
|
}
|
|
messages[idx] = messages[idx].copy(
|
|
thinking = if (sawContent) false else messages[idx].thinking,
|
|
content = state.content,
|
|
reasoning = state.reasoning,
|
|
tools = state.tools,
|
|
sources = state.sources,
|
|
query = state.query,
|
|
streaming = true,
|
|
)
|
|
}
|
|
} catch (_: kotlinx.coroutines.CancellationException) {
|
|
} catch (_: Exception) {}
|
|
val assistantIdx = messages.indexOfLast { it.id == assistantId }
|
|
if (assistantIdx >= 0) messages[assistantIdx] = messages[assistantIdx].copy(streaming = false, thinking = false)
|
|
isStreaming = false
|
|
haptics.responseEnd()
|
|
// Persist
|
|
val cid = convIdDeferred?.await() ?: conversationId ?: return@launch
|
|
if (conversationId == null) conversationId = cid
|
|
val assistantMsg = messages.lastOrNull { it.id == assistantId } ?: 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<String>()
|
|
|
|
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<Attachment> = 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
|
|
val cfg = session.config ?: 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()
|
|
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId))
|
|
isStreaming = true
|
|
val wasNewConversation = conversationId == null
|
|
|
|
val history = messages
|
|
.filter { it.id != assistantId }
|
|
.map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) }
|
|
|
|
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 ->
|
|
val idx = messages.indexOfLast { it.id == assistantId }
|
|
if (idx < 0) return@collect
|
|
if (!sawContent && state.content.isNotEmpty()) {
|
|
sawContent = true
|
|
haptics.responseStart()
|
|
}
|
|
messages[idx] = messages[idx].copy(
|
|
thinking = if (sawContent) false else messages[idx].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 idx = messages.indexOfLast { it.id == assistantId }
|
|
val finalMsg = if (idx >= 0) messages[idx] else null
|
|
val finalContent = finalMsg?.content ?: ""
|
|
val finalReasoning = finalMsg?.reasoning?.takeIf { it.isNotBlank() }
|
|
val finalSources = finalMsg?.sources?.takeIf { it.isNotEmpty() }
|
|
if (idx >= 0) messages[idx] = messages[idx].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 idx = messages.indexOfLast { it.id == assistantId }
|
|
if (e is kotlinx.coroutines.CancellationException) {
|
|
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
|
|
} else if (e is ChatHttpException && e.code == 401) {
|
|
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
|
|
session.logout()
|
|
} else if (idx >= 0) {
|
|
val existing = messages[idx].content
|
|
val errorSuffix = "\n\n⚠ ${chatErrorText(e, context)}"
|
|
messages[idx] = messages[idx].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) }
|
|
}
|
|
LaunchedEffect(Unit) {
|
|
snapshotFlow { autoScrollKey }.collect {
|
|
if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
|
|
}
|
|
}
|
|
|
|
// Single source of truth Screen routing
|
|
when (currentScreen) {
|
|
AppScreen.Settings -> {
|
|
SettingsScreen(
|
|
viewModel = settingsViewModel,
|
|
config = session.config,
|
|
onBack = { 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,
|
|
onClose = { scope.launch { drawerState.close() } },
|
|
onNewChat = {
|
|
haptics.tick()
|
|
newChat()
|
|
scope.launch { drawerState.close() }
|
|
},
|
|
onSelectConversation = { summary ->
|
|
haptics.tick()
|
|
openConversation(summary)
|
|
scope.launch { drawerState.close() }
|
|
},
|
|
onOpenSettings = {
|
|
haptics.tick()
|
|
currentScreen = AppScreen.Settings
|
|
scope.launch { drawerState.close() }
|
|
},
|
|
onLogout = {
|
|
haptics.tick()
|
|
newChat()
|
|
scope.launch { drawerState.close() }
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
)
|
|
},
|
|
gesturesEnabled = true // Allows swiping from the screen's left edge to open the drawer
|
|
) {
|
|
MeshBackground(animateBlobs = 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()) {
|
|
EmptyHero(
|
|
userName = userName,
|
|
onPick = { haptics.tick(); send(it) },
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier)
|
|
)
|
|
} else {
|
|
LazyColumn(
|
|
state = listState,
|
|
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
|
|
),
|
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
|
) {
|
|
items(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,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!isStreaming) {
|
|
val canScrollUp by remember { derivedStateOf { listState.canScrollBackward } }
|
|
val canScrollDown by remember { derivedStateOf { listState.canScrollForward } }
|
|
val showButton = canScrollUp || canScrollDown
|
|
val nearBottom by remember { derivedStateOf {
|
|
val info = listState.layoutInfo
|
|
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: 0
|
|
val total = info.totalItemsCount
|
|
if (total <= 0) true else lastVisible >= total - 2
|
|
}}
|
|
val pointsUp = !nearBottom && canScrollUp
|
|
|
|
if (showButton && !(canScrollUp && !canScrollDown && nearBottom)) {
|
|
GlassSurface(
|
|
shape = KaizenShapes.circle,
|
|
tintAlpha = GlassTiers.pill(isSystemInDarkTheme()),
|
|
shadowStack = KaizenShadows.level1,
|
|
cornerRadius = 18.dp,
|
|
modifier = Modifier
|
|
.align(Alignment.CenterEnd)
|
|
.padding(end = 8.dp)
|
|
.size(36.dp)
|
|
.clickable {
|
|
scope.launch {
|
|
if (pointsUp) listState.animateScrollToItem(0)
|
|
else listState.animateScrollToItem(messages.size - 1)
|
|
}
|
|
},
|
|
) {
|
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
Icon(
|
|
if (pointsUp) KaizenIcons.ArrowUp else KaizenIcons.ArrowDown,
|
|
null,
|
|
tint = MaterialTheme.colorScheme.onBackground,
|
|
modifier = Modifier.size(20.dp),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Floating Top Menu Button
|
|
val scrimColor = MaterialTheme.colorScheme.background
|
|
Box(
|
|
modifier = Modifier
|
|
.align(Alignment.TopStart)
|
|
.fillMaxWidth()
|
|
.background(
|
|
Brush.verticalGradient(
|
|
colorStops = arrayOf(
|
|
0f to scrimColor,
|
|
0.7f to scrimColor.copy(alpha = 0.55f),
|
|
1f to Color.Transparent,
|
|
),
|
|
)
|
|
)
|
|
.statusBarsPadding()
|
|
) {
|
|
Row(
|
|
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
|
|
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
|
|
Box(
|
|
Modifier
|
|
.size(38.dp)
|
|
.clip(KaizenShapes.circle)
|
|
.clickable { 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),
|
|
)
|
|
}
|
|
// Divider dot
|
|
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 }
|
|
.padding(start = 10.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
|
) {
|
|
Text(
|
|
modelDisplayName(displayModel, models),
|
|
color = MaterialTheme.colorScheme.onBackground,
|
|
fontSize = 13.sp,
|
|
fontWeight = FontWeight.W300,
|
|
maxLines = 1,
|
|
modifier = Modifier.widthIn(max = 180.dp),
|
|
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
Spacer(Modifier.weight(1f))
|
|
Box(
|
|
Modifier.size(36.dp).clip(KaizenShapes.circle)
|
|
.clickable { haptics.tick(); newChat() },
|
|
contentAlignment = Alignment.Center,
|
|
) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Error banner when initial data load fails
|
|
if (loadError != null) {
|
|
Box(
|
|
modifier = Modifier
|
|
.align(Alignment.TopCenter)
|
|
.statusBarsPadding()
|
|
.padding(top = 62.dp, start = 24.dp, end = 24.dp)
|
|
.clip(KaizenShapes.sm)
|
|
.background(KaizenSemanticColors.errorDeep.copy(alpha = 0.15f))
|
|
.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,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Version skew warning banner
|
|
val skew = 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)
|
|
.clip(KaizenShapes.sm)
|
|
.background(warningColor.copy(alpha = 0.15f))
|
|
.border(1.dp, warningColor.copy(alpha = 0.3f), KaizenShapes.sm)
|
|
.clickable { 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,
|
|
)
|
|
}
|
|
}
|
|
|
|
// 3. Floating Bottom Control Dock
|
|
@OptIn(ExperimentalLayoutApi::class)
|
|
val imeVisible = WindowInsets.isImeVisible
|
|
val imeBottom = WindowInsets.ime.getBottom(LocalDensity.current)
|
|
val navBottom = WindowInsets.navigationBars.getBottom(LocalDensity.current)
|
|
val bottomOffset = if (imeVisible) maxOf(imeBottom, navBottom) else navBottom
|
|
Column(
|
|
modifier = Modifier
|
|
.align(Alignment.BottomCenter)
|
|
.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),
|
|
),
|
|
)
|
|
} else {
|
|
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)
|
|
) {
|
|
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,
|
|
onMicClick = { toggleRecording() },
|
|
onCallClick = if (liveAvailable) {{ startLiveCall() }} else {{ /* PTT TODO */ }},
|
|
pendingFiles = pendingFiles,
|
|
onAddClick = { showAttachMenu = !showAttachMenu },
|
|
onRemoveFile = { id -> pendingFiles.removeAll { it.id == 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() },
|
|
)
|
|
},
|
|
)
|
|
Spacer(Modifier.height(if (imeVisible) 4.dp else 16.dp))
|
|
}
|
|
|
|
// Dismiss scrim — tap anywhere to close the attachment menu
|
|
if (showAttachMenu) {
|
|
Box(
|
|
Modifier
|
|
.fillMaxSize()
|
|
.clickable(
|
|
interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() },
|
|
indication = null,
|
|
) { showAttachMenu = false }
|
|
)
|
|
}
|
|
|
|
// Attachment menu — floats above the + button at bottom-left
|
|
androidx.compose.animation.AnimatedVisibility(
|
|
visible = 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("*/*")) },
|
|
)
|
|
}
|
|
|
|
// Model picker (bottom sheet), opened by the top pill
|
|
if (showModelSheet) {
|
|
ModelSheet(
|
|
models = models,
|
|
selectedModelId = session.config?.model ?: "",
|
|
favorites = session.favorites,
|
|
onSelectModel = { session.setModel(it); chatModel = it; haptics.tick() },
|
|
onToggleFavorite = { session.toggleFavorite(it) },
|
|
onDismiss = { showModelSheet = false },
|
|
)
|
|
}
|
|
|
|
// Password fallback dialog for locked conversations
|
|
val pwTarget = 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,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Live Call Overlay ────────────────────────────────
|
|
androidx.compose.animation.AnimatedVisibility(
|
|
visible = liveCallActive,
|
|
enter = androidx.compose.animation.fadeIn(tween(250)),
|
|
exit = androidx.compose.animation.fadeOut(tween(200)),
|
|
) {
|
|
LiveCallOverlay(
|
|
status = liveStatus,
|
|
subtitle = liveSubtitle,
|
|
amplitude = liveAmplitude,
|
|
userSpeaking = liveUserSpeaking,
|
|
elapsedMs = liveElapsedMs,
|
|
onEndCall = { endLiveCall() },
|
|
onInterrupt = { 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<MediaRecorder?>,
|
|
audioFileRef: androidx.compose.runtime.MutableState<File?>,
|
|
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<MediaRecorder?>,
|
|
audioFileRef: androidx.compose.runtime.MutableState<File?>,
|
|
onRecorded: (String, String, ByteArray) -> 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) {
|
|
val bytes = file.readBytes()
|
|
file.delete()
|
|
onRecorded("voice_${System.currentTimeMillis()}.m4a", "audio/mp4", bytes)
|
|
}
|
|
}
|