diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ee442c1..86281a2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -114,4 +114,7 @@ dependencies { // Biometric implementation(libs.biometric) + + // Coil — image loading + implementation(libs.coil.compose) } diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt index 579f386..d929413 100644 --- a/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt +++ b/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt @@ -20,6 +20,7 @@ data class WSClientMessage( data class WSAttachment( @SerializedName("type") val type: String, @SerializedName("url") val url: String? = null, + @SerializedName("file_id") val fileId: String? = null, @SerializedName("thumbnail_url") val thumbnailUrl: String? = null, @SerializedName("filename") val filename: String? = null, @SerializedName("width") val width: Int? = null, diff --git a/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt b/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt index 88a9826..4e8057b 100644 --- a/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt +++ b/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt @@ -23,6 +23,7 @@ import top.yeij.cyrene.data.local.entity.ConversationEntity import top.yeij.cyrene.data.local.entity.MessageEntity import top.yeij.cyrene.data.remote.ApiService import top.yeij.cyrene.data.remote.dto.CreateSessionRequest +import top.yeij.cyrene.data.remote.dto.WSAttachment import top.yeij.cyrene.data.remote.dto.WSServerMessage import top.yeij.cyrene.domain.model.Conversation import top.yeij.cyrene.domain.model.Message @@ -68,6 +69,7 @@ class ChatRepositoryImpl( private var streamingContent = "" private var streamingMessageId: String? = null + private var streamTimeoutJob: kotlinx.coroutines.Job? = null override var currentSessionId: String? = null private var isAppInForeground = false @@ -87,6 +89,32 @@ class ChatRepositoryImpl( notificationHelper.cancelAll() } + private fun resetStreamTimeout() { + cancelStreamTimeout() + streamTimeoutJob = scope.launch { + kotlinx.coroutines.delay(120_000L) // 2 min timeout + if (_isAssistantStreaming.value) { + RuntimeLog.chat("stream", "Stream timeout — no chunk or end for 120s, resetting") + streamingContent = "" + streamingMessageId = null + _isAssistantStreaming.value = false + emitMessage( + id = "timeout_${System.currentTimeMillis()}", + sessionId = currentSessionId ?: "default", + role = "system", + content = "AI 响应超时,请重试", + msgType = "system_info", + isStreaming = false, + ) + } + } + } + + private fun cancelStreamTimeout() { + streamTimeoutJob?.cancel() + streamTimeoutJob = null + } + override fun onAppForeground() { isAppInForeground = true hasEverBeenForeground = true @@ -201,7 +229,7 @@ class ChatRepositoryImpl( webSocketService.forceReconnect() } - override suspend fun sendMessage(content: String, sessionId: String?) { + override suspend fun sendMessage(content: String, sessionId: String?, attachments: List?) { val messageId = UUID.randomUUID().toString() val now = System.currentTimeMillis() val sid = sessionId ?: currentSessionId ?: "default" @@ -210,13 +238,22 @@ class ChatRepositoryImpl( scope.launch { preferencesDataStore.saveCurrentSessionId(sid) } } - RuntimeLog.chat("send", "session=$sid msgId=$messageId content=${content.take(80)}") + val imageUris = attachments?.filter { it.type == "image" }?.mapNotNull { it.url } ?: emptyList() + val hasImages = imageUris.isNotEmpty() + val displayContent = content.ifBlank { "" } + val lastMsg = when { + hasImages && content.isBlank() -> "[图片]" + hasImages -> content + else -> content + } + + RuntimeLog.chat("send", "session=$sid msgId=$messageId content=${content.take(80)} attachments=${attachments?.size ?: 0}") conversationDao.upsert( ConversationEntity( id = sid, title = "对话", - lastMessage = content, + lastMessage = lastMsg, lastMessageType = "chat", updatedAt = now, createdAt = now, @@ -228,7 +265,7 @@ class ChatRepositoryImpl( id = messageId, conversationId = sid, role = "user", - content = content, + content = displayContent, msgType = "chat", timestamp = now, ) @@ -238,13 +275,14 @@ class ChatRepositoryImpl( id = messageId, sessionId = sid, role = "user", - content = content, + content = displayContent, msgType = "chat", timestamp = now, isStreaming = false, + imageDataUris = imageUris, ) - webSocketService.sendMessage(content, sid) + webSocketService.sendMessage(content, sid, attachments = attachments) } override suspend fun loadConversationsFromServer() { @@ -392,12 +430,14 @@ class ChatRepositoryImpl( streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}" _isAssistantStreaming.value = true recentParsedContents.clear() + resetStreamTimeout() RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId") } "stream_chunk" -> { val delta = wsMsg.content ?: wsMsg.text ?: return streamingContent += delta + resetStreamTimeout() emitMessage( id = streamingMessageId ?: "s_${System.currentTimeMillis()}", sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", @@ -409,6 +449,7 @@ class ChatRepositoryImpl( } "stream_end" -> { + cancelStreamTimeout() val msgId = wsMsg.messageId ?: streamingMessageId ?: "s_${System.currentTimeMillis()}" val content = streamingContent.ifEmpty { wsMsg.content ?: wsMsg.text ?: "" } streamingContent = "" @@ -540,6 +581,10 @@ class ChatRepositoryImpl( } "error" -> { + cancelStreamTimeout() + streamingContent = "" + streamingMessageId = null + _isAssistantStreaming.value = false RuntimeLog.chat("error", "Server error: ${wsMsg.error ?: "未知错误"}") emitMessage( id = "err_${System.currentTimeMillis()}", @@ -660,8 +705,9 @@ class ChatRepositoryImpl( isStreaming: Boolean = false, timestamp: Long = System.currentTimeMillis(), shouldNotify: Boolean = false, + imageDataUris: List = emptyList(), ) { - if (content.isBlank() && msgType == "chat") return + if (content.isBlank() && msgType == "chat" && imageDataUris.isEmpty()) return val message = Message( id = id, conversationId = sessionId, @@ -670,6 +716,7 @@ class ChatRepositoryImpl( msgType = msgType, timestamp = timestamp, isStreaming = isStreaming, + imageDataUris = imageDataUris, ) _incomingMessages.tryEmit(message) diff --git a/app/src/main/java/top/yeij/cyrene/di/AppModule.kt b/app/src/main/java/top/yeij/cyrene/di/AppModule.kt index 76dc938..0c99302 100644 --- a/app/src/main/java/top/yeij/cyrene/di/AppModule.kt +++ b/app/src/main/java/top/yeij/cyrene/di/AppModule.kt @@ -76,7 +76,7 @@ val appModule = module { factory { GetConversationsUseCase(get()) } // ViewModels - viewModel { ChatViewModel(get(), get(), get()) } + viewModel { ChatViewModel(androidContext() as android.app.Application, get(), get(), get()) } viewModel { IoTViewModel(get()) } viewModel { OverlayViewModel(get(), get(), get()) } viewModel { ProfileViewModel(get(), get(), get()) } diff --git a/app/src/main/java/top/yeij/cyrene/domain/model/Message.kt b/app/src/main/java/top/yeij/cyrene/domain/model/Message.kt index 5e874d1..0dafb51 100644 --- a/app/src/main/java/top/yeij/cyrene/domain/model/Message.kt +++ b/app/src/main/java/top/yeij/cyrene/domain/model/Message.kt @@ -8,4 +8,5 @@ data class Message( val msgType: String, val timestamp: Long, val isStreaming: Boolean = false, + val imageDataUris: List = emptyList(), ) diff --git a/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt b/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt index bd46354..cbfe949 100644 --- a/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt +++ b/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt @@ -22,7 +22,7 @@ interface ChatRepository { suspend fun connectWebSocket(sessionId: String?) - suspend fun sendMessage(content: String, sessionId: String?) + suspend fun sendMessage(content: String, sessionId: String?, attachments: List? = null) fun observeMessages(): Flow diff --git a/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt b/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt index a67dbe9..4039080 100644 --- a/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt +++ b/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt @@ -23,6 +23,7 @@ import okhttp3.WebSocket import okhttp3.WebSocketListener import java.util.concurrent.atomic.AtomicLong import top.yeij.cyrene.data.local.PreferencesDataStore +import top.yeij.cyrene.data.remote.dto.WSAttachment import top.yeij.cyrene.data.remote.dto.WSClientMessage import top.yeij.cyrene.data.remote.dto.WSServerMessage import java.net.URLEncoder @@ -194,19 +195,21 @@ class WebSocketService( sessionId: String? = null, mode: String? = null, content: String? = null, + attachments: List? = null, ): WSClientMessage = WSClientMessage( type = type, sessionId = sessionId ?: currentSessionId, mode = mode, content = content, + attachments = attachments, timestamp = System.currentTimeMillis(), clientId = clientId.ifBlank { null }, deviceName = deviceName.ifBlank { null }, userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}", ) - fun sendMessage(content: String, sessionId: String? = null, mode: String = "text") { - val msg = buildMessage("message", sessionId, mode, content) + fun sendMessage(content: String, sessionId: String? = null, mode: String = "text", attachments: List? = null) { + val msg = buildMessage("message", sessionId, mode, content, attachments = attachments) webSocket?.send(gson.toJson(msg)) } diff --git a/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt b/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt index 8628adc..ce799d2 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt @@ -1,18 +1,23 @@ package top.yeij.cyrene.ui.components import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore @@ -31,8 +36,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -44,6 +52,10 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil.compose.AsyncImage +import coil.request.ImageRequest import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -469,12 +481,13 @@ fun ChatBubble( msgType: String, timestamp: Long, modifier: Modifier = Modifier, + imageDataUris: List = emptyList(), ) { val isUser = role == "user" val formattedTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp)) when (msgType) { - "chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier) + "chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier, imageDataUris) "action" -> ActionMessage(content, modifier) "markdown" -> CollapsibleBubble(content, modifier) { text, mod -> MarkdownBubble(text, mod) @@ -489,7 +502,53 @@ fun ChatBubble( ToolProgressBubble(text, mod) } "system_info" -> SystemInfoBubble(content, modifier) - else -> ChatMessageBubble(content, isUser, formattedTime, modifier) + else -> ChatMessageBubble(content, isUser, formattedTime, modifier, imageDataUris) + } +} + +// --- Full-screen image preview dialog --- + +@Composable +private fun ImagePreviewDialog( + imageUri: String, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.95f)) + .clickable { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(imageUri) + .crossfade(true) + .build(), + contentDescription = "图片预览", + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentScale = ContentScale.Fit, + ) + IconButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(16.dp), + ) { + Icon( + Icons.Default.Close, + contentDescription = "关闭", + tint = Color.White, + ) + } + } } } @@ -502,9 +561,13 @@ private fun ChatMessageBubble( isUser: Boolean, time: String, modifier: Modifier = Modifier, + imageDataUris: List = emptyList(), ) { var showMenu by remember { mutableStateOf(false) } + var previewImageUri by remember { mutableStateOf(null) } val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current + val hasImages = imageDataUris.isNotEmpty() Row( modifier = modifier @@ -530,14 +593,39 @@ private fun ChatMessageBubble( onLongClick = { showMenu = true }, ), ) { - Text( - text = renderInlineMarkdown(content), - modifier = Modifier.padding(12.dp), - color = if (isUser) - MaterialTheme.colorScheme.onPrimary - else - MaterialTheme.colorScheme.onSurfaceVariant, - ) + Column { + if (hasImages) { + imageDataUris.forEach { uri -> + AsyncImage( + model = ImageRequest.Builder(context) + .data(uri) + .crossfade(true) + .build(), + contentDescription = "图片", + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp, start = 6.dp, end = 6.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable { previewImageUri = uri }, + contentScale = ContentScale.FillWidth, + ) + } + } + if (content.isNotBlank()) { + Text( + text = renderInlineMarkdown(content), + modifier = Modifier.padding( + start = 12.dp, end = 12.dp, + top = if (hasImages) 6.dp else 12.dp, + bottom = 12.dp, + ), + color = if (isUser) + MaterialTheme.colorScheme.onPrimary + else + MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } DropdownMenu( expanded = showMenu, @@ -563,12 +651,25 @@ private fun ChatMessageBubble( ) } } + + // Full-screen image preview + if (previewImageUri != null) { + ImagePreviewDialog( + imageUri = previewImageUri!!, + onDismiss = { previewImageUri = null }, + ) + } } // --- Action message --- +private val actionTagRegex = Regex("""""", RegexOption.IGNORE_CASE) + @Composable private fun ActionMessage(content: String, modifier: Modifier = Modifier) { + val displayText = remember(content) { + content.replace(actionTagRegex, "").trim() + } Row( modifier = modifier .fillMaxWidth() @@ -576,7 +677,7 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) { horizontalArrangement = Arrangement.Start, ) { Text( - text = content, + text = displayText, style = MaterialTheme.typography.bodyMedium.copy( fontStyle = FontStyle.Italic, ), diff --git a/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt b/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt index 062c853..10c4fd5 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt @@ -97,6 +97,7 @@ private fun AnimatedChatBubble( role = message.role, msgType = message.msgType, timestamp = message.timestamp, + imageDataUris = message.imageDataUris, ) } } diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt index a32092e..397c500 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt @@ -1,13 +1,20 @@ package top.yeij.cyrene.ui.screens.chat +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.offset @@ -16,6 +23,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape @@ -24,6 +32,8 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.AddPhotoAlternate +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.KeyboardVoice import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Mic @@ -47,12 +57,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest import kotlinx.coroutines.delay import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject @@ -76,6 +90,7 @@ private fun AnimatedChatBubble( role = message.role, msgType = message.msgType, timestamp = message.timestamp, + imageDataUris = message.imageDataUris, ) } @@ -119,6 +134,17 @@ fun ChatScreen( val inCancelZone = isDragging && dragOffsetY < -120f val inLockZone = isDragging && dragOffsetX > 60f + // Image picker + val selectedImages by viewModel.selectedImageUris.collectAsState() + val imagePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetMultipleContents() + ) { uris: List -> + if (uris.isNotEmpty()) { + viewModel.addImages(uris) + } + } + val context = LocalContext.current + // Stay at bottom for new messages unless user scrolled up LaunchedEffect(Unit) { snapshotFlow { messages.size to isNearBottom } @@ -186,6 +212,54 @@ fun ChatScreen( } } + // Selected images preview + if (selectedImages.isNotEmpty()) { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + itemsIndexed(selectedImages, key = { i, _ -> i }) { index, uri -> + Box( + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(8.dp)) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(8.dp)), + ) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(uri) + .crossfade(true) + .build(), + contentDescription = "已选图片", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + IconButton( + onClick = { viewModel.removeImage(index) }, + modifier = Modifier + .align(Alignment.TopEnd) + .size(20.dp) + .padding(0.dp), + ) { + Icon( + Icons.Default.Close, + contentDescription = "移除", + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier + .size(14.dp) + .background( + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f), + CircleShape, + ), + ) + } + } + } + } + } + // Messages area (fills remaining space, shrinks with IME) Box(modifier = Modifier.weight(1f)) { if (messages.isEmpty() && !isStreaming) { @@ -316,6 +390,15 @@ fun ChatScreen( ) } } else { + IconButton( + onClick = { imagePickerLauncher.launch("image/*") }, + ) { + Icon( + Icons.Default.AddPhotoAlternate, + contentDescription = "添加图片", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } OutlinedTextField( value = inputText, onValueChange = { viewModel.onInputChanged(it) }, diff --git a/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt b/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt index 4fd39c8..c8b0611 100644 --- a/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt @@ -1,9 +1,14 @@ package top.yeij.cyrene.viewmodel +import android.app.Application +import android.net.Uri +import android.util.Base64 import android.util.Log -import androidx.lifecycle.ViewModel +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -12,7 +17,9 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import top.yeij.cyrene.data.local.PreferencesDataStore +import top.yeij.cyrene.data.remote.dto.WSAttachment import top.yeij.cyrene.domain.model.Conversation import top.yeij.cyrene.domain.model.Message import top.yeij.cyrene.domain.repository.ChatRepository @@ -27,10 +34,11 @@ private fun List.deduplicate(): List { } class ChatViewModel( + application: Application, private val chatRepository: ChatRepository, private val voiceRecorder: VoiceRecorder, private val preferencesDataStore: PreferencesDataStore, -) : ViewModel() { +) : AndroidViewModel(application) { val isConnected: StateFlow = chatRepository.connectionState .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) @@ -67,6 +75,7 @@ class ChatViewModel( private var currentSessionId: String? = null private var dbObserverJob: Job? = null + private var sendTimeoutJob: Job? = null init { // Phase 1: find/create main session, reconnect WS, load server history into DB, then observe DB @@ -133,7 +142,10 @@ class ChatViewModel( // Reset user-side sending state when server starts responding viewModelScope.launch { chatRepository.isAssistantStreaming.collect { streaming -> - if (streaming) _isSending.value = false + if (streaming) { + _isSending.value = false + sendTimeoutJob?.cancel() + } } } @@ -185,6 +197,80 @@ class ChatViewModel( voiceRecorder.cancel() } + // --- Image attachments --- + + private val _selectedImageUris = MutableStateFlow>(emptyList()) + val selectedImageUris: StateFlow> = _selectedImageUris.asStateFlow() + + fun addImages(uris: List) { + _selectedImageUris.update { it + uris } + } + + fun removeImage(index: Int) { + _selectedImageUris.update { list -> + list.filterIndexed { i, _ -> i != index } + } + } + + fun clearImages() { + _selectedImageUris.value = emptyList() + } + + private suspend fun uriToAttachment(uri: Uri): WSAttachment? { + return withContext(Dispatchers.IO) { + try { + val cr = getApplication().contentResolver + val mimeType = cr.getType(uri) ?: "image/*" + val inputStream = cr.openInputStream(uri) ?: return@withContext null + val bytes = inputStream.use { it.readBytes() } + if (bytes.isEmpty()) return@withContext null + val b64 = Base64.encodeToString(bytes, Base64.NO_WRAP) + val dataUri = "data:$mimeType;base64,$b64" + WSAttachment( + type = "image", + url = dataUri, + filename = uri.lastPathSegment ?: "image", + size = bytes.size.toLong(), + ) + } catch (e: Exception) { + Log.e("ChatViewModel", "Failed to convert URI to attachment: ${e.message}") + null + } + } + } + + // Override sendMessage to support image attachments + fun sendMessage() { + val text = _inputText.value.trim() + val uris = _selectedImageUris.value + if (text.isEmpty() && uris.isEmpty()) return + + _inputText.value = "" + _isSending.value = true + val sid = currentSessionId + + sendTimeoutJob?.cancel() + sendTimeoutJob = viewModelScope.launch { + delay(15_000L) + if (_isSending.value) { + Log.w("ChatViewModel", "Send timeout — no response in 15s, resetting") + _isSending.value = false + } + } + + viewModelScope.launch { + val attachments = uris.mapNotNull { uriToAttachment(it) } + clearImages() + try { + chatRepository.sendMessage(text, sid, attachments.ifEmpty { null }) + } catch (e: Exception) { + Log.e("ChatViewModel", "sendMessage failed: ${e.message}", e) + _isSending.value = false + sendTimeoutJob?.cancel() + } + } + } + private fun loadMessagesFromDb(sessionId: String) { dbObserverJob?.cancel() dbObserverJob = viewModelScope.launch { @@ -213,19 +299,6 @@ class ChatViewModel( _inputText.value = text } - fun sendMessage() { - val text = _inputText.value.trim() - if (text.isEmpty()) return - - _inputText.value = "" - _isSending.value = true - val sid = currentSessionId - - viewModelScope.launch { - chatRepository.sendMessage(text, sid) - } - } - fun switchSession(sessionId: String) { currentSessionId = sessionId chatRepository.currentSessionId = sessionId diff --git a/app/src/main/java/top/yeij/cyrene/viewmodel/OverlayViewModel.kt b/app/src/main/java/top/yeij/cyrene/viewmodel/OverlayViewModel.kt index 20c8382..2b6b3e0 100644 --- a/app/src/main/java/top/yeij/cyrene/viewmodel/OverlayViewModel.kt +++ b/app/src/main/java/top/yeij/cyrene/viewmodel/OverlayViewModel.kt @@ -73,6 +73,7 @@ class OverlayViewModel( val messageAnimIndex: StateFlow> = _messageAnimIndex.asStateFlow() private var silenceTimer: Job? = null + private var processingTimeoutJob: Job? = null private var lastAssistantMessageId: String? = null init { @@ -104,7 +105,9 @@ class OverlayViewModel( } viewModelScope.launch { chatRepository.isAssistantStreaming.collect { streaming -> - if (!streaming && _state.value == OverlayState.PROCESSING) { + if (streaming) { + cancelProcessingTimeout() + } else if (_state.value == OverlayState.PROCESSING) { delay(500) if (_state.value == OverlayState.PROCESSING) { setWaiting() @@ -147,8 +150,14 @@ class OverlayViewModel( _state.value = OverlayState.PROCESSING cancelSilenceTimer() + startProcessingTimeout() viewModelScope.launch { - chatRepository.sendMessage(text, null) + try { + chatRepository.sendMessage(text, null) + } catch (e: Exception) { + Log.e("OverlayVM", "sendText failed: ${e.message}", e) + if (_state.value == OverlayState.PROCESSING) setWaiting() + } } } @@ -171,6 +180,7 @@ class OverlayViewModel( if (base64.isNullOrBlank()) return _state.value = OverlayState.PROCESSING + startProcessingTimeout() viewModelScope.launch { chatRepository.sendVoiceInput(base64, "voice_msg") } @@ -235,6 +245,22 @@ class OverlayViewModel( silenceTimer = null } + private fun startProcessingTimeout() { + cancelProcessingTimeout() + processingTimeoutJob = viewModelScope.launch { + delay(15_000L) + if (_state.value == OverlayState.PROCESSING) { + Log.w("OverlayVM", "Processing timeout — no response in 15s, resetting to WAITING") + setWaiting() + } + } + } + + private fun cancelProcessingTimeout() { + processingTimeoutJob?.cancel() + processingTimeoutJob = null + } + override fun onCleared() { voiceRecorder.cancel() ttsEngine.shutdown() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d5db064..88965ff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ koin = "4.0.0" datastore = "1.1.1" coroutines = "1.9.0" material3 = "1.3.1" +coil = "2.7.0" [libraries] # Compose BOM @@ -56,6 +57,9 @@ coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-andro # Core core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.15.0" } +# Coil +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } + # Biometric biometric = { group = "androidx.biometric", name = "biometric", version = "1.1.0" }