diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt index dfa2258..565f0ad 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt @@ -553,6 +553,34 @@ fun MessageRow( } } +@Composable +private fun RecordingIndicator() { + val cs = MaterialTheme.colorScheme + val transition = rememberInfiniteTransition(label = "rec") + val dotAlpha by transition.animateFloat( + initialValue = 1f, targetValue = 0.3f, + animationSpec = infiniteRepeatable(tween(800, easing = FastOutSlowInEasing), RepeatMode.Reverse), + label = "dot", + ) + + Row( + Modifier.fillMaxWidth().heightIn(min = 48.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Canvas(Modifier.size(10.dp)) { + drawCircle(Color(0xFFEF4444).copy(alpha = dotAlpha)) + } + Spacer(Modifier.width(10.dp)) + Text( + stringResource(R.string.chat_recording), + color = cs.onSurfaceVariant.copy(alpha = 0.70f), + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + ) + } +} + @Composable private fun MessageActionButton(icon: ImageVector, tint: Color, onClick: () -> Unit) { Box( @@ -629,59 +657,71 @@ fun ChatInput( cornerRadius = 32.dp, ) { Column(Modifier.padding(horizontal = 14.dp, vertical = 14.dp)) { - if (pendingFiles.isNotEmpty()) { - Row( - Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(bottom = 10.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - pendingFiles.forEach { pf -> PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) }) } + if (isRecording) { + // Recording state — replaces the text field with a recording indicator + RecordingIndicator() + Spacer(Modifier.height(10.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.weight(1f)) + Box( + Modifier.size(42.dp).clip(KaizenShapes.circle) + .background(Color(0xFFEF4444)) + .clickable(onClick = onMicClick), + contentAlignment = Alignment.Center, + ) { + Icon(KaizenIcons.Square, stringResource(R.string.chat_stop), tint = Color.White, modifier = Modifier.size(16.dp)) + } + Spacer(Modifier.weight(1f)) } - } - - Box(Modifier.fillMaxWidth().heightIn(min = 48.dp), contentAlignment = Alignment.CenterStart) { - if (value.isEmpty()) Text( - stringResource(R.string.chat_input_placeholder), - color = cs.onSurfaceVariant.copy(alpha = 0.5f), - fontSize = 16.sp, - ) - BasicTextField( - value = value, onValueChange = onValueChange, - textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 24.sp), - cursorBrush = SolidColor(accent.primary), maxLines = 6, modifier = Modifier.fillMaxWidth(), - ) - } - - Spacer(Modifier.height(10.dp)) - - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - Modifier.size(38.dp).clip(KaizenShapes.circle).background(iconBg).clickable(onClick = onAddClick), - contentAlignment = Alignment.Center, - ) { - Icon(KaizenIcons.Plus, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(19.dp)) - } - Spacer(Modifier.width(8.dp)) - Box( - Modifier.size(38.dp).clip(KaizenShapes.circle) - .background(if (isRecording) Color(0xFFEF4444).copy(alpha = if (isDark) 0.85f else 0.80f) else iconBg) - .clickable(onClick = onMicClick), - contentAlignment = Alignment.Center, - ) { - if (isRecording) { - Icon(KaizenIcons.Square, stringResource(R.string.chat_recording), tint = Color.White, modifier = Modifier.size(14.dp)) - } else { - Icon(KaizenIcons.Mic, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(19.dp)) + } else { + if (pendingFiles.isNotEmpty()) { + Row( + Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(bottom = 10.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + pendingFiles.forEach { pf -> PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) }) } } } - Spacer(Modifier.weight(1f)) + Box(Modifier.fillMaxWidth().heightIn(min = 48.dp), contentAlignment = Alignment.CenterStart) { + if (value.isEmpty()) Text( + stringResource(R.string.chat_input_placeholder), + color = cs.onSurfaceVariant.copy(alpha = 0.5f), + fontSize = 16.sp, + ) + BasicTextField( + value = value, onValueChange = onValueChange, + textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 24.sp), + cursorBrush = SolidColor(accent.primary), maxLines = 6, modifier = Modifier.fillMaxWidth(), + ) + } - if (isStreaming) { - StopButton(onClick = onStop) - } else if (hasContent) { - SendButton(enabled = canSend, onClick = onSend) - } else { - CallButton(onClick = { /* TODO: call feature */ }) + Spacer(Modifier.height(10.dp)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(38.dp).clip(KaizenShapes.circle).background(iconBg).clickable(onClick = onAddClick), + contentAlignment = Alignment.Center, + ) { + Icon(KaizenIcons.Plus, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(19.dp)) + } + Spacer(Modifier.width(8.dp)) + Box( + Modifier.size(38.dp).clip(KaizenShapes.circle).background(iconBg).clickable(onClick = onMicClick), + contentAlignment = Alignment.Center, + ) { + Icon(KaizenIcons.Mic, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(19.dp)) + } + + Spacer(Modifier.weight(1f)) + + if (isStreaming) { + StopButton(onClick = onStop) + } else if (hasContent) { + SendButton(enabled = canSend, onClick = onSend) + } else { + CallButton(onClick = { /* TODO: call feature */ }) + } } } } 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 a557dba..b30d1cb 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -217,7 +217,77 @@ fun ChatScreen( if (isRecording) { stopRecording(recorderRef, audioFileRef) { name, mime, bytes -> isRecording = false - uploadPickedFile(name, mime, bytes) + 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 userSave = SaveMessage(id = userMsg.wireId, parentId = userParentId, role = "user", content = "", attachments = listOf(att)) + val assistantSave = SaveMessage(id = assistantMsg.wireId, parentId = userMsg.wireId, role = "assistant", content = assistantMsg.content, model = cfg.model, reasoning = assistantMsg.reasoning.takeIf { it.isNotBlank() }, sources = assistantMsg.sources.takeIf { it.isNotEmpty() }?.map { dev.kaizen.app.net.SearchSource(it.title, it.url, it.snippet) }) + KaizenApi.saveMessages(cfg.baseUrl, cfg.token, cid, listOf(userSave, assistantSave)) + 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) {