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:
@@ -114,4 +114,7 @@ dependencies {
|
|||||||
|
|
||||||
// Biometric
|
// Biometric
|
||||||
implementation(libs.biometric)
|
implementation(libs.biometric)
|
||||||
|
|
||||||
|
// Coil — image loading
|
||||||
|
implementation(libs.coil.compose)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ data class WSClientMessage(
|
|||||||
data class WSAttachment(
|
data class WSAttachment(
|
||||||
@SerializedName("type") val type: String,
|
@SerializedName("type") val type: String,
|
||||||
@SerializedName("url") val url: String? = null,
|
@SerializedName("url") val url: String? = null,
|
||||||
|
@SerializedName("file_id") val fileId: String? = null,
|
||||||
@SerializedName("thumbnail_url") val thumbnailUrl: String? = null,
|
@SerializedName("thumbnail_url") val thumbnailUrl: String? = null,
|
||||||
@SerializedName("filename") val filename: String? = null,
|
@SerializedName("filename") val filename: String? = null,
|
||||||
@SerializedName("width") val width: Int? = 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.local.entity.MessageEntity
|
||||||
import top.yeij.cyrene.data.remote.ApiService
|
import top.yeij.cyrene.data.remote.ApiService
|
||||||
import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
|
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.data.remote.dto.WSServerMessage
|
||||||
import top.yeij.cyrene.domain.model.Conversation
|
import top.yeij.cyrene.domain.model.Conversation
|
||||||
import top.yeij.cyrene.domain.model.Message
|
import top.yeij.cyrene.domain.model.Message
|
||||||
@@ -68,6 +69,7 @@ class ChatRepositoryImpl(
|
|||||||
|
|
||||||
private var streamingContent = ""
|
private var streamingContent = ""
|
||||||
private var streamingMessageId: String? = null
|
private var streamingMessageId: String? = null
|
||||||
|
private var streamTimeoutJob: kotlinx.coroutines.Job? = null
|
||||||
override var currentSessionId: String? = null
|
override var currentSessionId: String? = null
|
||||||
|
|
||||||
private var isAppInForeground = false
|
private var isAppInForeground = false
|
||||||
@@ -87,6 +89,32 @@ class ChatRepositoryImpl(
|
|||||||
notificationHelper.cancelAll()
|
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() {
|
override fun onAppForeground() {
|
||||||
isAppInForeground = true
|
isAppInForeground = true
|
||||||
hasEverBeenForeground = true
|
hasEverBeenForeground = true
|
||||||
@@ -201,7 +229,7 @@ class ChatRepositoryImpl(
|
|||||||
webSocketService.forceReconnect()
|
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 messageId = UUID.randomUUID().toString()
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val sid = sessionId ?: currentSessionId ?: "default"
|
val sid = sessionId ?: currentSessionId ?: "default"
|
||||||
@@ -210,13 +238,22 @@ class ChatRepositoryImpl(
|
|||||||
scope.launch { preferencesDataStore.saveCurrentSessionId(sid) }
|
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(
|
conversationDao.upsert(
|
||||||
ConversationEntity(
|
ConversationEntity(
|
||||||
id = sid,
|
id = sid,
|
||||||
title = "对话",
|
title = "对话",
|
||||||
lastMessage = content,
|
lastMessage = lastMsg,
|
||||||
lastMessageType = "chat",
|
lastMessageType = "chat",
|
||||||
updatedAt = now,
|
updatedAt = now,
|
||||||
createdAt = now,
|
createdAt = now,
|
||||||
@@ -228,7 +265,7 @@ class ChatRepositoryImpl(
|
|||||||
id = messageId,
|
id = messageId,
|
||||||
conversationId = sid,
|
conversationId = sid,
|
||||||
role = "user",
|
role = "user",
|
||||||
content = content,
|
content = displayContent,
|
||||||
msgType = "chat",
|
msgType = "chat",
|
||||||
timestamp = now,
|
timestamp = now,
|
||||||
)
|
)
|
||||||
@@ -238,13 +275,14 @@ class ChatRepositoryImpl(
|
|||||||
id = messageId,
|
id = messageId,
|
||||||
sessionId = sid,
|
sessionId = sid,
|
||||||
role = "user",
|
role = "user",
|
||||||
content = content,
|
content = displayContent,
|
||||||
msgType = "chat",
|
msgType = "chat",
|
||||||
timestamp = now,
|
timestamp = now,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
|
imageDataUris = imageUris,
|
||||||
)
|
)
|
||||||
|
|
||||||
webSocketService.sendMessage(content, sid)
|
webSocketService.sendMessage(content, sid, attachments = attachments)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun loadConversationsFromServer() {
|
override suspend fun loadConversationsFromServer() {
|
||||||
@@ -392,12 +430,14 @@ class ChatRepositoryImpl(
|
|||||||
streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}"
|
streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}"
|
||||||
_isAssistantStreaming.value = true
|
_isAssistantStreaming.value = true
|
||||||
recentParsedContents.clear()
|
recentParsedContents.clear()
|
||||||
|
resetStreamTimeout()
|
||||||
RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId")
|
RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId")
|
||||||
}
|
}
|
||||||
|
|
||||||
"stream_chunk" -> {
|
"stream_chunk" -> {
|
||||||
val delta = wsMsg.content ?: wsMsg.text ?: return
|
val delta = wsMsg.content ?: wsMsg.text ?: return
|
||||||
streamingContent += delta
|
streamingContent += delta
|
||||||
|
resetStreamTimeout()
|
||||||
emitMessage(
|
emitMessage(
|
||||||
id = streamingMessageId ?: "s_${System.currentTimeMillis()}",
|
id = streamingMessageId ?: "s_${System.currentTimeMillis()}",
|
||||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||||
@@ -409,6 +449,7 @@ class ChatRepositoryImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
"stream_end" -> {
|
"stream_end" -> {
|
||||||
|
cancelStreamTimeout()
|
||||||
val msgId = wsMsg.messageId ?: streamingMessageId ?: "s_${System.currentTimeMillis()}"
|
val msgId = wsMsg.messageId ?: streamingMessageId ?: "s_${System.currentTimeMillis()}"
|
||||||
val content = streamingContent.ifEmpty { wsMsg.content ?: wsMsg.text ?: "" }
|
val content = streamingContent.ifEmpty { wsMsg.content ?: wsMsg.text ?: "" }
|
||||||
streamingContent = ""
|
streamingContent = ""
|
||||||
@@ -540,6 +581,10 @@ class ChatRepositoryImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
"error" -> {
|
"error" -> {
|
||||||
|
cancelStreamTimeout()
|
||||||
|
streamingContent = ""
|
||||||
|
streamingMessageId = null
|
||||||
|
_isAssistantStreaming.value = false
|
||||||
RuntimeLog.chat("error", "Server error: ${wsMsg.error ?: "未知错误"}")
|
RuntimeLog.chat("error", "Server error: ${wsMsg.error ?: "未知错误"}")
|
||||||
emitMessage(
|
emitMessage(
|
||||||
id = "err_${System.currentTimeMillis()}",
|
id = "err_${System.currentTimeMillis()}",
|
||||||
@@ -660,8 +705,9 @@ class ChatRepositoryImpl(
|
|||||||
isStreaming: Boolean = false,
|
isStreaming: Boolean = false,
|
||||||
timestamp: Long = System.currentTimeMillis(),
|
timestamp: Long = System.currentTimeMillis(),
|
||||||
shouldNotify: Boolean = false,
|
shouldNotify: Boolean = false,
|
||||||
|
imageDataUris: List<String> = emptyList(),
|
||||||
) {
|
) {
|
||||||
if (content.isBlank() && msgType == "chat") return
|
if (content.isBlank() && msgType == "chat" && imageDataUris.isEmpty()) return
|
||||||
val message = Message(
|
val message = Message(
|
||||||
id = id,
|
id = id,
|
||||||
conversationId = sessionId,
|
conversationId = sessionId,
|
||||||
@@ -670,6 +716,7 @@ class ChatRepositoryImpl(
|
|||||||
msgType = msgType,
|
msgType = msgType,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
isStreaming = isStreaming,
|
isStreaming = isStreaming,
|
||||||
|
imageDataUris = imageDataUris,
|
||||||
)
|
)
|
||||||
_incomingMessages.tryEmit(message)
|
_incomingMessages.tryEmit(message)
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ val appModule = module {
|
|||||||
factory { GetConversationsUseCase(get()) }
|
factory { GetConversationsUseCase(get()) }
|
||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
viewModel { ChatViewModel(get(), get(), get()) }
|
viewModel { ChatViewModel(androidContext() as android.app.Application, get(), get(), get()) }
|
||||||
viewModel { IoTViewModel(get()) }
|
viewModel { IoTViewModel(get()) }
|
||||||
viewModel { OverlayViewModel(get(), get(), get()) }
|
viewModel { OverlayViewModel(get(), get(), get()) }
|
||||||
viewModel { ProfileViewModel(get(), get(), get()) }
|
viewModel { ProfileViewModel(get(), get(), get()) }
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ data class Message(
|
|||||||
val msgType: String,
|
val msgType: String,
|
||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
val isStreaming: Boolean = false,
|
val isStreaming: Boolean = false,
|
||||||
|
val imageDataUris: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ interface ChatRepository {
|
|||||||
|
|
||||||
suspend fun connectWebSocket(sessionId: String?)
|
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>
|
fun observeMessages(): Flow<Message>
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import okhttp3.WebSocket
|
|||||||
import okhttp3.WebSocketListener
|
import okhttp3.WebSocketListener
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
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.WSClientMessage
|
||||||
import top.yeij.cyrene.data.remote.dto.WSServerMessage
|
import top.yeij.cyrene.data.remote.dto.WSServerMessage
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
@@ -194,19 +195,21 @@ class WebSocketService(
|
|||||||
sessionId: String? = null,
|
sessionId: String? = null,
|
||||||
mode: String? = null,
|
mode: String? = null,
|
||||||
content: String? = null,
|
content: String? = null,
|
||||||
|
attachments: List<WSAttachment>? = null,
|
||||||
): WSClientMessage = WSClientMessage(
|
): WSClientMessage = WSClientMessage(
|
||||||
type = type,
|
type = type,
|
||||||
sessionId = sessionId ?: currentSessionId,
|
sessionId = sessionId ?: currentSessionId,
|
||||||
mode = mode,
|
mode = mode,
|
||||||
content = content,
|
content = content,
|
||||||
|
attachments = attachments,
|
||||||
timestamp = System.currentTimeMillis(),
|
timestamp = System.currentTimeMillis(),
|
||||||
clientId = clientId.ifBlank { null },
|
clientId = clientId.ifBlank { null },
|
||||||
deviceName = deviceName.ifBlank { null },
|
deviceName = deviceName.ifBlank { null },
|
||||||
userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}",
|
userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}",
|
||||||
)
|
)
|
||||||
|
|
||||||
fun sendMessage(content: String, sessionId: String? = null, mode: String = "text") {
|
fun sendMessage(content: String, sessionId: String? = null, mode: String = "text", attachments: List<WSAttachment>? = null) {
|
||||||
val msg = buildMessage("message", sessionId, mode, content)
|
val msg = buildMessage("message", sessionId, mode, content, attachments = attachments)
|
||||||
webSocket?.send(gson.toJson(msg))
|
webSocket?.send(gson.toJson(msg))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
package top.yeij.cyrene.ui.components
|
package top.yeij.cyrene.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
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.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
@@ -31,8 +36,11 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
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.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -469,12 +481,13 @@ fun ChatBubble(
|
|||||||
msgType: String,
|
msgType: String,
|
||||||
timestamp: Long,
|
timestamp: Long,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
imageDataUris: List<String> = emptyList(),
|
||||||
) {
|
) {
|
||||||
val isUser = role == "user"
|
val isUser = role == "user"
|
||||||
val formattedTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
|
val formattedTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
|
||||||
|
|
||||||
when (msgType) {
|
when (msgType) {
|
||||||
"chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier)
|
"chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier, imageDataUris)
|
||||||
"action" -> ActionMessage(content, modifier)
|
"action" -> ActionMessage(content, modifier)
|
||||||
"markdown" -> CollapsibleBubble(content, modifier) { text, mod ->
|
"markdown" -> CollapsibleBubble(content, modifier) { text, mod ->
|
||||||
MarkdownBubble(text, mod)
|
MarkdownBubble(text, mod)
|
||||||
@@ -489,7 +502,53 @@ fun ChatBubble(
|
|||||||
ToolProgressBubble(text, mod)
|
ToolProgressBubble(text, mod)
|
||||||
}
|
}
|
||||||
"system_info" -> SystemInfoBubble(content, modifier)
|
"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,
|
isUser: Boolean,
|
||||||
time: String,
|
time: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
imageDataUris: List<String> = emptyList(),
|
||||||
) {
|
) {
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
var previewImageUri by remember { mutableStateOf<String?>(null) }
|
||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val hasImages = imageDataUris.isNotEmpty()
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -530,15 +593,40 @@ private fun ChatMessageBubble(
|
|||||||
onLongClick = { showMenu = true },
|
onLongClick = { showMenu = true },
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
|
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(
|
||||||
text = renderInlineMarkdown(content),
|
text = renderInlineMarkdown(content),
|
||||||
modifier = Modifier.padding(12.dp),
|
modifier = Modifier.padding(
|
||||||
|
start = 12.dp, end = 12.dp,
|
||||||
|
top = if (hasImages) 6.dp else 12.dp,
|
||||||
|
bottom = 12.dp,
|
||||||
|
),
|
||||||
color = if (isUser)
|
color = if (isUser)
|
||||||
MaterialTheme.colorScheme.onPrimary
|
MaterialTheme.colorScheme.onPrimary
|
||||||
else
|
else
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = showMenu,
|
expanded = showMenu,
|
||||||
onDismissRequest = { showMenu = false },
|
onDismissRequest = { showMenu = false },
|
||||||
@@ -563,12 +651,25 @@ private fun ChatMessageBubble(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Full-screen image preview
|
||||||
|
if (previewImageUri != null) {
|
||||||
|
ImagePreviewDialog(
|
||||||
|
imageUri = previewImageUri!!,
|
||||||
|
onDismiss = { previewImageUri = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Action message ---
|
// --- Action message ---
|
||||||
|
|
||||||
|
private val actionTagRegex = Regex("""</?action>""", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
|
private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
|
||||||
|
val displayText = remember(content) {
|
||||||
|
content.replace(actionTagRegex, "").trim()
|
||||||
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -576,7 +677,7 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
|
|||||||
horizontalArrangement = Arrangement.Start,
|
horizontalArrangement = Arrangement.Start,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = content,
|
text = displayText,
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
fontStyle = FontStyle.Italic,
|
fontStyle = FontStyle.Italic,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ private fun AnimatedChatBubble(
|
|||||||
role = message.role,
|
role = message.role,
|
||||||
msgType = message.msgType,
|
msgType = message.msgType,
|
||||||
timestamp = message.timestamp,
|
timestamp = message.timestamp,
|
||||||
|
imageDataUris = message.imageDataUris,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
package top.yeij.cyrene.ui.screens.chat
|
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.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.offset
|
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.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
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.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
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.KeyboardVoice
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.Mic
|
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.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import coil.request.ImageRequest
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import org.koin.androidx.compose.koinViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
@@ -76,6 +90,7 @@ private fun AnimatedChatBubble(
|
|||||||
role = message.role,
|
role = message.role,
|
||||||
msgType = message.msgType,
|
msgType = message.msgType,
|
||||||
timestamp = message.timestamp,
|
timestamp = message.timestamp,
|
||||||
|
imageDataUris = message.imageDataUris,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +134,17 @@ fun ChatScreen(
|
|||||||
val inCancelZone = isDragging && dragOffsetY < -120f
|
val inCancelZone = isDragging && dragOffsetY < -120f
|
||||||
val inLockZone = isDragging && dragOffsetX > 60f
|
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
|
// Stay at bottom for new messages unless user scrolled up
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
snapshotFlow { messages.size to isNearBottom }
|
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)
|
// Messages area (fills remaining space, shrinks with IME)
|
||||||
Box(modifier = Modifier.weight(1f)) {
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
if (messages.isEmpty() && !isStreaming) {
|
if (messages.isEmpty() && !isStreaming) {
|
||||||
@@ -316,6 +390,15 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
IconButton(
|
||||||
|
onClick = { imagePickerLauncher.launch("image/*") },
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.AddPhotoAlternate,
|
||||||
|
contentDescription = "添加图片",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = inputText,
|
value = inputText,
|
||||||
onValueChange = { viewModel.onInputChanged(it) },
|
onValueChange = { viewModel.onInputChanged(it) },
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package top.yeij.cyrene.viewmodel
|
package top.yeij.cyrene.viewmodel
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Base64
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -12,7 +17,9 @@ import kotlinx.coroutines.flow.firstOrNull
|
|||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
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.Conversation
|
||||||
import top.yeij.cyrene.domain.model.Message
|
import top.yeij.cyrene.domain.model.Message
|
||||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||||
@@ -27,10 +34,11 @@ private fun List<Message>.deduplicate(): List<Message> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ChatViewModel(
|
class ChatViewModel(
|
||||||
|
application: Application,
|
||||||
private val chatRepository: ChatRepository,
|
private val chatRepository: ChatRepository,
|
||||||
private val voiceRecorder: VoiceRecorder,
|
private val voiceRecorder: VoiceRecorder,
|
||||||
private val preferencesDataStore: PreferencesDataStore,
|
private val preferencesDataStore: PreferencesDataStore,
|
||||||
) : ViewModel() {
|
) : AndroidViewModel(application) {
|
||||||
|
|
||||||
val isConnected: StateFlow<Boolean> = chatRepository.connectionState
|
val isConnected: StateFlow<Boolean> = chatRepository.connectionState
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
@@ -67,6 +75,7 @@ class ChatViewModel(
|
|||||||
|
|
||||||
private var currentSessionId: String? = null
|
private var currentSessionId: String? = null
|
||||||
private var dbObserverJob: Job? = null
|
private var dbObserverJob: Job? = null
|
||||||
|
private var sendTimeoutJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Phase 1: find/create main session, reconnect WS, load server history into DB, then observe DB
|
// 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
|
// Reset user-side sending state when server starts responding
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
chatRepository.isAssistantStreaming.collect { streaming ->
|
chatRepository.isAssistantStreaming.collect { streaming ->
|
||||||
if (streaming) _isSending.value = false
|
if (streaming) {
|
||||||
|
_isSending.value = false
|
||||||
|
sendTimeoutJob?.cancel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +197,80 @@ class ChatViewModel(
|
|||||||
voiceRecorder.cancel()
|
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) {
|
private fun loadMessagesFromDb(sessionId: String) {
|
||||||
dbObserverJob?.cancel()
|
dbObserverJob?.cancel()
|
||||||
dbObserverJob = viewModelScope.launch {
|
dbObserverJob = viewModelScope.launch {
|
||||||
@@ -213,19 +299,6 @@ class ChatViewModel(
|
|||||||
_inputText.value = text
|
_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) {
|
fun switchSession(sessionId: String) {
|
||||||
currentSessionId = sessionId
|
currentSessionId = sessionId
|
||||||
chatRepository.currentSessionId = sessionId
|
chatRepository.currentSessionId = sessionId
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class OverlayViewModel(
|
|||||||
val messageAnimIndex: StateFlow<Map<String, Int>> = _messageAnimIndex.asStateFlow()
|
val messageAnimIndex: StateFlow<Map<String, Int>> = _messageAnimIndex.asStateFlow()
|
||||||
|
|
||||||
private var silenceTimer: Job? = null
|
private var silenceTimer: Job? = null
|
||||||
|
private var processingTimeoutJob: Job? = null
|
||||||
private var lastAssistantMessageId: String? = null
|
private var lastAssistantMessageId: String? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -104,7 +105,9 @@ class OverlayViewModel(
|
|||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
chatRepository.isAssistantStreaming.collect { streaming ->
|
chatRepository.isAssistantStreaming.collect { streaming ->
|
||||||
if (!streaming && _state.value == OverlayState.PROCESSING) {
|
if (streaming) {
|
||||||
|
cancelProcessingTimeout()
|
||||||
|
} else if (_state.value == OverlayState.PROCESSING) {
|
||||||
delay(500)
|
delay(500)
|
||||||
if (_state.value == OverlayState.PROCESSING) {
|
if (_state.value == OverlayState.PROCESSING) {
|
||||||
setWaiting()
|
setWaiting()
|
||||||
@@ -147,8 +150,14 @@ class OverlayViewModel(
|
|||||||
|
|
||||||
_state.value = OverlayState.PROCESSING
|
_state.value = OverlayState.PROCESSING
|
||||||
cancelSilenceTimer()
|
cancelSilenceTimer()
|
||||||
|
startProcessingTimeout()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
chatRepository.sendMessage(text, null)
|
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
|
if (base64.isNullOrBlank()) return
|
||||||
|
|
||||||
_state.value = OverlayState.PROCESSING
|
_state.value = OverlayState.PROCESSING
|
||||||
|
startProcessingTimeout()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
chatRepository.sendVoiceInput(base64, "voice_msg")
|
chatRepository.sendVoiceInput(base64, "voice_msg")
|
||||||
}
|
}
|
||||||
@@ -235,6 +245,22 @@ class OverlayViewModel(
|
|||||||
silenceTimer = null
|
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() {
|
override fun onCleared() {
|
||||||
voiceRecorder.cancel()
|
voiceRecorder.cancel()
|
||||||
ttsEngine.shutdown()
|
ttsEngine.shutdown()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ koin = "4.0.0"
|
|||||||
datastore = "1.1.1"
|
datastore = "1.1.1"
|
||||||
coroutines = "1.9.0"
|
coroutines = "1.9.0"
|
||||||
material3 = "1.3.1"
|
material3 = "1.3.1"
|
||||||
|
coil = "2.7.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# Compose BOM
|
# Compose BOM
|
||||||
@@ -56,6 +57,9 @@ coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-andro
|
|||||||
# Core
|
# Core
|
||||||
core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.15.0" }
|
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
|
||||||
biometric = { group = "androidx.biometric", name = "biometric", version = "1.1.0" }
|
biometric = { group = "androidx.biometric", name = "biometric", version = "1.1.0" }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user