feat: image attachment thumbnails, send timeout recovery, action tag parsing

- Multi-image thumbnails in chat bubbles with tap-to-fullscreen preview
- 15s send timeout in ChatViewModel and OverlayViewModel to prevent
  stuck "thinking" state when server sends no response
- Strip <action> XML tags in ActionMessage rendering (new server format)
- Add file_id/thumbnail_url to WSAttachment DTO for upload-first flow
- Replace imageDataUri with imageDataUris list for multi-image support
- Remove "[图片]" placeholder text from user messages with images

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 19:36:13 +08:00
parent 6394099e2e
commit 08d78c976a
13 changed files with 383 additions and 40 deletions
+3
View File
@@ -114,4 +114,7 @@ dependencies {
// Biometric
implementation(libs.biometric)
// Coil — image loading
implementation(libs.coil.compose)
}
@@ -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,
@@ -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<WSAttachment>?) {
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<String> = 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)
@@ -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()) }
@@ -8,4 +8,5 @@ data class Message(
val msgType: String,
val timestamp: Long,
val isStreaming: Boolean = false,
val imageDataUris: List<String> = emptyList(),
)
@@ -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<top.yeij.cyrene.data.remote.dto.WSAttachment>? = null)
fun observeMessages(): Flow<Message>
@@ -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<WSAttachment>? = 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<WSAttachment>? = null) {
val msg = buildMessage("message", sessionId, mode, content, attachments = attachments)
webSocket?.send(gson.toJson(msg))
}
@@ -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<String> = 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<String> = emptyList(),
) {
var showMenu by remember { mutableStateOf(false) }
var previewImageUri by remember { mutableStateOf<String?>(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("""</?action>""", 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,
),
@@ -97,6 +97,7 @@ private fun AnimatedChatBubble(
role = message.role,
msgType = message.msgType,
timestamp = message.timestamp,
imageDataUris = message.imageDataUris,
)
}
}
@@ -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<Uri> ->
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) },
@@ -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<Message>.deduplicate(): List<Message> {
}
class ChatViewModel(
application: Application,
private val chatRepository: ChatRepository,
private val voiceRecorder: VoiceRecorder,
private val preferencesDataStore: PreferencesDataStore,
) : ViewModel() {
) : AndroidViewModel(application) {
val isConnected: StateFlow<Boolean> = 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<List<Uri>>(emptyList())
val selectedImageUris: StateFlow<List<Uri>> = _selectedImageUris.asStateFlow()
fun addImages(uris: List<Uri>) {
_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<Application>().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
@@ -73,6 +73,7 @@ class OverlayViewModel(
val messageAnimIndex: StateFlow<Map<String, Int>> = _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()
+4
View File
@@ -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" }