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 // 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,14 +593,39 @@ private fun ChatMessageBubble(
onLongClick = { showMenu = true }, onLongClick = { showMenu = true },
), ),
) { ) {
Text( Column {
text = renderInlineMarkdown(content), if (hasImages) {
modifier = Modifier.padding(12.dp), imageDataUris.forEach { uri ->
color = if (isUser) AsyncImage(
MaterialTheme.colorScheme.onPrimary model = ImageRequest.Builder(context)
else .data(uri)
MaterialTheme.colorScheme.onSurfaceVariant, .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( DropdownMenu(
expanded = showMenu, expanded = showMenu,
@@ -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 {
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 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()
+4
View File
@@ -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" }