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
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user