fix: popBackStack guard, refresh dedup, action tag splitting, notification debug logging

- Navigation: guard popBackStack with currentDestination check to prevent
  double-pop during exit animation causing white screen (Settings→Main overlap)
- Chat refresh: clear all messages before reload to avoid local-UUID / server-ID
  duplication; split inline <action> tags from bulk-loaded HTTP/WS history messages
- Chat animation: restore AnimatedVisibility (fadeIn+slideInVertically) in
  AnimatedChatBubble composable
- Notification debug: add NOTIFY log category with strategic log points across
  the entire background/notification pipeline — WS lifecycle, foreground/background
  transitions, emitMessage decision reasons, keep-alive service events
- Settings UI: switch log tabs from TabRow to ScrollableTabRow, add fixed-height
  card-styled log viewer with entry count header
- Clean up obsolete launcher drawable/mipmap resources

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 08:08:53 +08:00
parent 08d78c976a
commit bc7630c43a
23 changed files with 410 additions and 157 deletions
@@ -44,7 +44,7 @@ class CyreneApplication : Application() {
// Track foreground/background state // Track foreground/background state
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivityStarted(activity: Activity) { override fun onActivityResumed(activity: Activity) {
if (activityCount.incrementAndGet() == 1) { if (activityCount.incrementAndGet() == 1) {
RuntimeLog.general("app", "App in foreground") RuntimeLog.general("app", "App in foreground")
getRepo()?.cancelNotifications() getRepo()?.cancelNotifications()
@@ -52,7 +52,7 @@ class CyreneApplication : Application() {
} }
} }
override fun onActivityStopped(activity: Activity) { override fun onActivityPaused(activity: Activity) {
if (activityCount.decrementAndGet() == 0) { if (activityCount.decrementAndGet() == 0) {
RuntimeLog.general("app", "App in background") RuntimeLog.general("app", "App in background")
getRepo()?.onAppBackground() getRepo()?.onAppBackground()
@@ -60,8 +60,8 @@ class CyreneApplication : Application() {
} }
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
override fun onActivityResumed(activity: Activity) {} override fun onActivityStarted(activity: Activity) {}
override fun onActivityPaused(activity: Activity) {} override fun onActivityStopped(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {} override fun onActivityDestroyed(activity: Activity) {}
}) })
@@ -1,16 +1,21 @@
package top.yeij.cyrene package top.yeij.cyrene
import android.Manifest
import android.content.ComponentName import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.core.content.ContextCompat
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import org.koin.compose.koinInject import org.koin.compose.koinInject
import top.yeij.cyrene.data.local.PreferencesDataStore import top.yeij.cyrene.data.local.PreferencesDataStore
@@ -24,10 +29,15 @@ class MainActivity : ComponentActivity() {
private val isDefaultAssistant = mutableStateOf(false) private val isDefaultAssistant = mutableStateOf(false)
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { /* granted or denied — either way we continue */ }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
requestNotificationPermission()
isDefaultAssistant.value = checkIsDefaultAssistant() isDefaultAssistant.value = checkIsDefaultAssistant()
setContent { setContent {
@@ -81,4 +91,14 @@ class MainActivity : ComponentActivity() {
private fun openAssistantSettings() { private fun openAssistantSettings() {
startActivity(Intent(Settings.ACTION_VOICE_INPUT_SETTINGS)) startActivity(Intent(Settings.ACTION_VOICE_INPUT_SETTINGS))
} }
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
} }
@@ -25,6 +25,9 @@ interface MessageDao {
@Query("UPDATE messages SET conversationId = :newId WHERE conversationId = :oldId") @Query("UPDATE messages SET conversationId = :newId WHERE conversationId = :oldId")
suspend fun migrateConversationId(oldId: String, newId: String) suspend fun migrateConversationId(oldId: String, newId: String)
@Query("DELETE FROM messages WHERE conversationId = :conversationId AND role = 'user'")
suspend fun deleteUserMessagesByConversation(conversationId: String)
@Query("DELETE FROM messages WHERE id = :id") @Query("DELETE FROM messages WHERE id = :id")
suspend fun deleteById(id: String) suspend fun deleteById(id: String)
@@ -1,10 +1,13 @@
package top.yeij.cyrene.data.remote package top.yeij.cyrene.data.remote
import okhttp3.MultipartBody
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
import top.yeij.cyrene.data.remote.dto.AuthRequest import top.yeij.cyrene.data.remote.dto.AuthRequest
@@ -12,6 +15,7 @@ import top.yeij.cyrene.data.remote.dto.AuthResponse
import top.yeij.cyrene.data.remote.dto.ProfileResponse import top.yeij.cyrene.data.remote.dto.ProfileResponse
import top.yeij.cyrene.data.remote.dto.CreateSessionRequest import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
import top.yeij.cyrene.data.remote.dto.DeviceDto import top.yeij.cyrene.data.remote.dto.DeviceDto
import top.yeij.cyrene.data.remote.dto.FileUploadResponse
import top.yeij.cyrene.data.remote.dto.IoTControlRequest import top.yeij.cyrene.data.remote.dto.IoTControlRequest
import top.yeij.cyrene.data.remote.dto.MessagesListResponse import top.yeij.cyrene.data.remote.dto.MessagesListResponse
import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest
@@ -46,10 +50,17 @@ interface ApiService {
@GET("api/v1/sessions/{id}/messages") @GET("api/v1/sessions/{id}/messages")
suspend fun getSessionMessages( suspend fun getSessionMessages(
@Path("id") sessionId: String, @Path("id") sessionId: String,
@Query("limit") limit: Int = 50, @Query("limit") limit: Int = 500,
@Query("offset") offset: Int = 0, @Query("offset") offset: Int = 0,
): Response<MessagesListResponse> ): Response<MessagesListResponse>
// Files
@Multipart
@POST("api/v1/files/upload")
suspend fun uploadFile(
@Part file: MultipartBody.Part,
): Response<FileUploadResponse>
// IoT — 注意:网关 API 文档未列出 IoT 端点,需确认网关是否代理了 /api/v1/iot/* // IoT — 注意:网关 API 文档未列出 IoT 端点,需确认网关是否代理了 /api/v1/iot/*
@GET("api/v1/iot/devices") @GET("api/v1/iot/devices")
suspend fun getDevices(): Response<List<DeviceDto>> suspend fun getDevices(): Response<List<DeviceDto>>
@@ -36,3 +36,12 @@ data class SessionMessageDto(
@SerializedName("content") val content: String, @SerializedName("content") val content: String,
@SerializedName("created_at") val createdAt: Long, @SerializedName("created_at") val createdAt: Long,
) )
// POST /api/v1/files/upload — response
data class FileUploadResponse(
@SerializedName("id") val id: String,
@SerializedName("filename") val filename: String? = null,
@SerializedName("mime_type") val mimeType: String? = null,
@SerializedName("size") val size: Long? = null,
@SerializedName("url") val url: String? = null,
)
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
@@ -116,34 +117,38 @@ class ChatRepositoryImpl(
} }
override fun onAppForeground() { override fun onAppForeground() {
RuntimeLog.notify("state", "onAppForeground: wasForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground")
isAppInForeground = true isAppInForeground = true
hasEverBeenForeground = true hasEverBeenForeground = true
notifiedMessageIds.clear() notifiedMessageIds.clear()
notificationHelper.cancelAll() notificationHelper.cancelAll()
KeepAliveReceiver.cancel(app) KeepAliveReceiver.cancel(app)
WebSocketKeepAliveService.stop(app) WebSocketKeepAliveService.stop(app)
// Always reconnect and sync history when returning to foreground RuntimeLog.notify("state", "onAppForeground: notifications cleared, keep-alive stopped")
webSocketService.forceReconnect()
scope.launch { scope.launch {
val sid = currentSessionId ?: return@launch val sid = currentSessionId ?: return@launch
RuntimeLog.general("app", "Foreground — reconnecting and requesting history for session=$sid") RuntimeLog.general("app", "Foreground — requesting history for session=$sid")
requestHistoryViaWs(sid) requestHistoryViaWs(sid)
} }
} }
override fun onAppBackground() { override fun onAppBackground() {
isAppInForeground = false isAppInForeground = false
// Always start keep-alive — connection may be silently dead and need recovery
WebSocketKeepAliveService.start(app) WebSocketKeepAliveService.start(app)
KeepAliveReceiver.schedule(app) KeepAliveReceiver.schedule(app)
// Force reconnect after foreground service is up so the socket val currentlyConnected = _connectionState.value
// is tied to the service's lifecycle, not the foreground activity's RuntimeLog.notify("state", "onAppBackground: connected=$currentlyConnected hasEverBeen=$hasEverBeenForeground keepAliveStarted=true")
// Only reconnect if the WS is already dead. Tearing down a healthy
// connection creates a message loss window with no benefit.
if (!currentlyConnected) {
scope.launch { scope.launch {
kotlinx.coroutines.delay(1500) // let the service start first kotlinx.coroutines.delay(1500) // let the service start first
webSocketService.forceReconnect() webSocketService.forceReconnect()
RuntimeLog.general("app", "Background reconnect after service start, connected=${_connectionState.value}") RuntimeLog.general("app", "Background reconnect done, connected=${_connectionState.value}")
}
} else {
RuntimeLog.general("app", "WS healthy — skipping background reconnect to avoid message loss")
} }
RuntimeLog.general("app", "Started keep-alive service + periodic alarm for background (connected=${_connectionState.value})")
} }
init { init {
@@ -229,7 +234,7 @@ class ChatRepositoryImpl(
webSocketService.forceReconnect() webSocketService.forceReconnect()
} }
override suspend fun sendMessage(content: String, sessionId: String?, attachments: List<WSAttachment>?) { override suspend fun sendMessage(content: String, sessionId: String?, attachments: List<WSAttachment>?, localImageUris: List<String>) {
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"
@@ -238,8 +243,7 @@ class ChatRepositoryImpl(
scope.launch { preferencesDataStore.saveCurrentSessionId(sid) } scope.launch { preferencesDataStore.saveCurrentSessionId(sid) }
} }
val imageUris = attachments?.filter { it.type == "image" }?.mapNotNull { it.url } ?: emptyList() val hasImages = localImageUris.isNotEmpty()
val hasImages = imageUris.isNotEmpty()
val displayContent = content.ifBlank { "" } val displayContent = content.ifBlank { "" }
val lastMsg = when { val lastMsg = when {
hasImages && content.isBlank() -> "[图片]" hasImages && content.isBlank() -> "[图片]"
@@ -279,7 +283,7 @@ class ChatRepositoryImpl(
msgType = "chat", msgType = "chat",
timestamp = now, timestamp = now,
isStreaming = false, isStreaming = false,
imageDataUris = imageUris, imageDataUris = localImageUris,
) )
webSocketService.sendMessage(content, sid, attachments = attachments) webSocketService.sendMessage(content, sid, attachments = attachments)
@@ -361,7 +365,7 @@ class ChatRepositoryImpl(
ensureConversation(sessionId) ensureConversation(sessionId)
val messages = filteredDtos.map { dto -> val messages = filteredDtos.map { dto ->
Message( Message(
id = "db_${dto.id}", id = "${dto.id}",
conversationId = sessionId, conversationId = sessionId,
role = dto.role, role = dto.role,
content = dto.content, content = dto.content,
@@ -369,9 +373,9 @@ class ChatRepositoryImpl(
timestamp = dto.createdAt, timestamp = dto.createdAt,
) )
} }
val deduped = messages.removeWrappingDuplicates() val deduped = messages.removeWrappingDuplicates().splitInlineActions()
deduped.forEach { msg -> messageDao.deleteUserMessagesByConversation(sessionId)
messageDao.upsert( messageDao.upsertAll(deduped.map { msg ->
MessageEntity( MessageEntity(
id = msg.id, id = msg.id,
conversationId = msg.conversationId, conversationId = msg.conversationId,
@@ -380,8 +384,7 @@ class ChatRepositoryImpl(
msgType = msg.msgType, msgType = msg.msgType,
timestamp = msg.timestamp, timestamp = msg.timestamp,
) )
) })
}
RuntimeLog.http("loadMessages", "HTTP loaded ${deduped.size} messages (${messages.size} before dedup) for session=$sessionId") RuntimeLog.http("loadMessages", "HTTP loaded ${deduped.size} messages (${messages.size} before dedup) for session=$sessionId")
deduped deduped
} else { } else {
@@ -397,11 +400,16 @@ class ChatRepositoryImpl(
} }
private suspend fun requestHistoryViaWs(sessionId: String) { private suspend fun requestHistoryViaWs(sessionId: String) {
// Wait up to 5s for WS to connect
if (!webSocketService.isConnected.value) { if (!webSocketService.isConnected.value) {
withTimeoutOrNull(5000) { val connected = withTimeoutOrNull(5000) {
webSocketService.isConnected.first { it } webSocketService.isConnected.first { it }
} }
if (connected != true) {
// WS couldn't connect, fall back to REST API
RuntimeLog.chat("history", "WS not connected after 5s, falling back to REST")
loadMessagesFromServer(sessionId)
return
}
} }
webSocketService.requestHistory(sessionId) webSocketService.requestHistory(sessionId)
} }
@@ -491,9 +499,9 @@ class ChatRepositoryImpl(
lastResponseContent = content lastResponseContent = content
lastResponseTime = System.currentTimeMillis() lastResponseTime = System.currentTimeMillis()
RuntimeLog.notify("trigger", "stream_end: id=$msgId isForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground content='${content.take(50)}'")
emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = wsMsg.msgType ?: "chat", timestamp = ts, isStreaming = false, shouldNotify = true) emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = wsMsg.msgType ?: "chat", timestamp = ts, isStreaming = false, shouldNotify = true)
_isAssistantStreaming.value = false _isAssistantStreaming.value = false
RuntimeLog.chat("stream", "Stream end msgId=$msgId content=${content.take(80)}")
} }
"response" -> { "response" -> {
@@ -528,14 +536,15 @@ class ChatRepositoryImpl(
recentParsedContents.add(text) recentParsedContents.add(text)
lastParsedTime = System.currentTimeMillis() lastParsedTime = System.currentTimeMillis()
RuntimeLog.notify("trigger", "reply: id=$msgId role=$role isForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground content='${text.take(50)}'")
emitMessage(id = msgId, sessionId = sid, role = role, content = text, msgType = replyMsgType, timestamp = ts, isStreaming = false, shouldNotify = true) emitMessage(id = msgId, sessionId = sid, role = role, content = text, msgType = replyMsgType, timestamp = ts, isStreaming = false, shouldNotify = true)
RuntimeLog.chat("receive", "Response msgId=$msgId role=$role msgType=$replyMsgType content=${text.take(80)}")
} }
"review" -> { "review" -> {
recentParsedContents.clear() recentParsedContents.clear()
wsMsg.reviewMessages?.forEach { review -> wsMsg.reviewMessages?.forEachIndexed { index, review ->
val rawText = review.content ?: review.text ?: return@forEach if (index > 0) delay(1000L)
val rawText = review.content ?: review.text ?: return@forEachIndexed
val role = review.role ?: "assistant" val role = review.role ?: "assistant"
val rvMsgType = review.type ?: review.msgType ?: "action" val rvMsgType = review.type ?: review.msgType ?: "action"
val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}" val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}"
@@ -580,6 +589,17 @@ class ChatRepositoryImpl(
) )
} }
"queued" -> {
emitMessage(
id = "queued_${System.currentTimeMillis()}",
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
role = "system",
content = "消息已加入处理队列",
msgType = "system_info",
isStreaming = false,
)
}
"error" -> { "error" -> {
cancelStreamTimeout() cancelStreamTimeout()
streamingContent = "" streamingContent = ""
@@ -633,9 +653,8 @@ class ChatRepositoryImpl(
timestamp = hist.timestamp ?: System.currentTimeMillis(), timestamp = hist.timestamp ?: System.currentTimeMillis(),
) )
} }
val deduped = messageList.removeWrappingDuplicates() val deduped = messageList.removeWrappingDuplicates().splitInlineActions()
deduped.forEach { msg -> messageDao.upsertAll(deduped.map { msg ->
messageDao.upsert(
MessageEntity( MessageEntity(
id = msg.id, id = msg.id,
conversationId = msg.conversationId, conversationId = msg.conversationId,
@@ -644,7 +663,8 @@ class ChatRepositoryImpl(
msgType = msg.msgType, msgType = msg.msgType,
timestamp = msg.timestamp, timestamp = msg.timestamp,
) )
) })
deduped.forEach { msg ->
emitMessage( emitMessage(
id = msg.id, id = msg.id,
sessionId = msg.conversationId, sessionId = msg.conversationId,
@@ -662,7 +682,8 @@ class ChatRepositoryImpl(
"multi_message" -> { "multi_message" -> {
recentParsedContents.clear() recentParsedContents.clear()
var isFirst = true var isFirst = true
wsMsg.multiMessages?.forEach { item -> wsMsg.multiMessages?.forEachIndexed { index, item ->
if (index > 0) delay(1000L)
val content = item.content ?: "" val content = item.content ?: ""
recentParsedContents.add(content) recentParsedContents.add(content)
emitMessage( emitMessage(
@@ -708,6 +729,42 @@ class ChatRepositoryImpl(
imageDataUris: List<String> = emptyList(), imageDataUris: List<String> = emptyList(),
) { ) {
if (content.isBlank() && msgType == "chat" && imageDataUris.isEmpty()) return if (content.isBlank() && msgType == "chat" && imageDataUris.isEmpty()) return
// Fallback: detect inline <action> tags missed by server parsing
if (role == "assistant" && msgType == "chat") {
val actionRegex = Regex("""<action>(.*?)</action>\s*""")
val match = actionRegex.find(content)
if (match != null) {
val actionText = match.groupValues[1].trim()
val remaining = actionRegex.replaceFirst(content, "").trim()
RuntimeLog.chat("receive", "Split inline <action> from chat: action='${actionText.take(40)}' remaining='${remaining.take(40)}'")
if (actionText.isNotEmpty()) {
emitMessage(
id = "${id}_action",
sessionId = sessionId,
role = "assistant",
content = actionText,
msgType = "action",
timestamp = timestamp,
shouldNotify = false,
)
}
if (remaining.isNotEmpty()) {
emitMessage(
id = id,
sessionId = sessionId,
role = role,
content = remaining,
msgType = msgType,
timestamp = timestamp + 1,
isStreaming = isStreaming,
shouldNotify = shouldNotify,
)
}
return
}
}
val message = Message( val message = Message(
id = id, id = id,
conversationId = sessionId, conversationId = sessionId,
@@ -720,12 +777,22 @@ class ChatRepositoryImpl(
) )
_incomingMessages.tryEmit(message) _incomingMessages.tryEmit(message)
if (shouldNotify && hasEverBeenForeground && !isAppInForeground && role == "assistant" && !isStreaming) { if (shouldNotify && role == "assistant" && !isStreaming) {
if (!hasEverBeenForeground) {
RuntimeLog.notify("skip", "Not showing notification for $id: app has never been foregrounded")
} else if (isAppInForeground) {
RuntimeLog.notify("skip", "Not showing notification for $id: app is in foreground")
} else {
if (notifiedMessageIds.add(id)) { if (notifiedMessageIds.add(id)) {
notificationHelper.showMessageNotification(message) notificationHelper.showMessageNotification(message)
RuntimeLog.general("app", "Notification sent for msgId=$id") RuntimeLog.notify("show", "Notification sent: id=$id content='${content.take(40)}'")
} else {
RuntimeLog.notify("dup", "Notification already sent for $id, skipping")
} }
} }
} else if (shouldNotify && role == "assistant" && isStreaming) {
RuntimeLog.notify("skip", "Not showing notification for $id: still streaming")
}
} }
/** /**
@@ -760,6 +827,35 @@ class ChatRepositoryImpl(
createdAt = createdAt, createdAt = createdAt,
) )
/**
* Split inline `<action>` tags from assistant chat messages into separate messages.
* Used for bulk-loaded messages (HTTP history, WS history_response) that bypass emitMessage.
*/
private fun List<Message>.splitInlineActions(): List<Message> {
val actionRegex = Regex("""<action>(.*?)</action>\s*""")
return flatMap { msg ->
if (msg.role == "assistant" && msg.msgType == "chat") {
val match = actionRegex.find(msg.content)
if (match != null) {
val actionText = match.groupValues[1].trim()
val remaining = actionRegex.replaceFirst(msg.content, "").trim()
val result = mutableListOf<Message>()
if (actionText.isNotEmpty()) {
result.add(msg.copy(id = "${msg.id}_action", content = actionText, msgType = "action"))
}
if (remaining.isNotEmpty()) {
result.add(msg.copy(content = remaining))
}
if (result.isEmpty()) listOf(msg) else result
} else {
listOf(msg)
}
} else {
listOf(msg)
}
}
}
private fun MessageEntity.toDomain() = Message( private fun MessageEntity.toDomain() = Message(
id = id, id = id,
conversationId = conversationId, conversationId = conversationId,
@@ -76,7 +76,7 @@ val appModule = module {
factory { GetConversationsUseCase(get()) } factory { GetConversationsUseCase(get()) }
// ViewModels // ViewModels
viewModel { ChatViewModel(androidContext() as android.app.Application, get(), get(), get()) } viewModel { ChatViewModel(androidContext() as android.app.Application, get(), 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()) }
@@ -22,7 +22,7 @@ interface ChatRepository {
suspend fun connectWebSocket(sessionId: String?) suspend fun connectWebSocket(sessionId: String?)
suspend fun sendMessage(content: String, sessionId: String?, attachments: List<top.yeij.cyrene.data.remote.dto.WSAttachment>? = null) suspend fun sendMessage(content: String, sessionId: String?, attachments: List<top.yeij.cyrene.data.remote.dto.WSAttachment>? = null, localImageUris: List<String> = emptyList())
fun observeMessages(): Flow<Message> fun observeMessages(): Flow<Message>
@@ -14,6 +14,7 @@ import kotlinx.coroutines.launch
import org.koin.core.context.GlobalContext import org.koin.core.context.GlobalContext
import top.yeij.cyrene.data.local.PreferencesDataStore import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.repository.ChatRepositoryImpl import top.yeij.cyrene.data.repository.ChatRepositoryImpl
import top.yeij.cyrene.util.RuntimeLog
class KeepAliveReceiver : BroadcastReceiver() { class KeepAliveReceiver : BroadcastReceiver() {
@@ -21,6 +22,7 @@ class KeepAliveReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "Keep-alive alarm fired") Log.d(TAG, "Keep-alive alarm fired")
RuntimeLog.notify("keepalive", "Alarm fired in background")
scope.launch { scope.launch {
try { try {
@@ -28,27 +30,24 @@ class KeepAliveReceiver : BroadcastReceiver() {
val token = prefs.token.firstOrNull() val token = prefs.token.firstOrNull()
if (token.isNullOrBlank()) { if (token.isNullOrBlank()) {
Log.d(TAG, "No auth token, skipping wake-up") Log.d(TAG, "No auth token, skipping wake-up")
RuntimeLog.notify("keepalive", "Skipping: no auth token")
return@launch return@launch
} }
// Always restart foreground service
if (!WebSocketKeepAliveService.isRunning) { if (!WebSocketKeepAliveService.isRunning) {
WebSocketKeepAliveService.start(context) WebSocketKeepAliveService.start(context)
Log.i(TAG, "Keep-alive service restarted") RuntimeLog.notify("keepalive", "Foreground service restarted")
} }
// Always force reconnect — connectionState may be stuck at true on a dead socket
val repo: ChatRepositoryImpl = GlobalContext.get().get() val repo: ChatRepositoryImpl = GlobalContext.get().get()
val wasConnected = repo.connectionState.value
repo.ensureConnected() repo.ensureConnected()
Log.i(TAG, "WebSocket reconnection triggered, connected=${repo.connectionState.value}") RuntimeLog.notify("keepalive", "WS reconnect triggered: wasConnected=$wasConnected nowConnected=${repo.connectionState.value}")
// Schedule next wake-up
schedule(context) schedule(context)
Log.d(TAG, "Keep-alive check complete, connected=${repo.connectionState.value}")
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(TAG, "Keep-alive check failed: ${e.message}", e) Log.e(TAG, "Keep-alive check failed: ${e.message}", e)
// Schedule next anyway RuntimeLog.notify("keepalive", "Failed: ${e.message}")
schedule(context) schedule(context)
} }
} }
@@ -14,6 +14,7 @@ import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import top.yeij.cyrene.MainActivity import top.yeij.cyrene.MainActivity
import top.yeij.cyrene.R import top.yeij.cyrene.R
import top.yeij.cyrene.util.RuntimeLog
class WebSocketKeepAliveService : Service() { class WebSocketKeepAliveService : Service() {
@@ -27,6 +28,7 @@ class WebSocketKeepAliveService : Service() {
createChannel() createChannel()
acquireWakeLock() acquireWakeLock()
Log.i(TAG, "Service created, wakeLock held") Log.i(TAG, "Service created, wakeLock held")
RuntimeLog.notify("keepalive", "WS keep-alive service created, wakeLock acquired")
} }
override fun onDestroy() { override fun onDestroy() {
@@ -34,6 +36,7 @@ class WebSocketKeepAliveService : Service() {
releaseWakeLock() releaseWakeLock()
scheduleRestart() scheduleRestart()
Log.i(TAG, "Service destroyed, restart scheduled") Log.i(TAG, "Service destroyed, restart scheduled")
RuntimeLog.notify("keepalive", "WS keep-alive service destroyed, restart scheduled in ${RESTART_DELAY_MS}ms")
super.onDestroy() super.onDestroy()
} }
@@ -26,6 +26,7 @@ import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.dto.WSAttachment 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 top.yeij.cyrene.util.RuntimeLog
import java.net.URLEncoder import java.net.URLEncoder
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
@@ -136,6 +137,7 @@ class WebSocketService(
_isConnected.value = true _isConnected.value = true
_connectionError.value = null _connectionError.value = null
startHeartbeat() startHeartbeat()
RuntimeLog.ws("lifecycle", "WS connected #$connId")
} }
override fun onMessage(webSocket: WebSocket, text: String) { override fun onMessage(webSocket: WebSocket, text: String) {
@@ -143,6 +145,8 @@ class WebSocketService(
lastMessageReceived = System.currentTimeMillis() lastMessageReceived = System.currentTimeMillis()
try { try {
val msg = gson.fromJson(text, WSServerMessage::class.java) val msg = gson.fromJson(text, WSServerMessage::class.java)
val preview = text.take(100).replace("\n", "\\n")
RuntimeLog.ws("receive", "type=${msg.type} id=${msg.messageId ?: "-"} preview=$preview")
_incomingMessages.tryEmit(msg) _incomingMessages.tryEmit(msg)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "[#$connId] Failed to parse message: ${e.message}") Log.w(TAG, "[#$connId] Failed to parse message: ${e.message}")
@@ -154,6 +158,7 @@ class WebSocketService(
Log.i(TAG, "[#$connId] Server closing: code=$code reason=$reason") Log.i(TAG, "[#$connId] Server closing: code=$code reason=$reason")
_isConnected.value = false _isConnected.value = false
cancelHeartbeat() cancelHeartbeat()
RuntimeLog.ws("lifecycle", "WS closing #$connId code=$code reason='$reason'")
} }
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
@@ -162,6 +167,7 @@ class WebSocketService(
_isConnected.value = false _isConnected.value = false
cancelHeartbeat() cancelHeartbeat()
scheduleReconnect() scheduleReconnect()
RuntimeLog.ws("lifecycle", "WS closed #$connId code=$code")
} }
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
@@ -170,6 +176,7 @@ class WebSocketService(
Log.e(TAG, "[#$connId] Failure: ${t.message} (http=$httpCode)", t) Log.e(TAG, "[#$connId] Failure: ${t.message} (http=$httpCode)", t)
_isConnected.value = false _isConnected.value = false
cancelHeartbeat() cancelHeartbeat()
RuntimeLog.ws("lifecycle", "WS failure #$connId http=$httpCode error='${t.message}'")
val errorMsg = when (httpCode) { val errorMsg = when (httpCode) {
403 -> { 403 -> {
@@ -248,6 +255,7 @@ class WebSocketService(
} }
fun forceReconnect() { fun forceReconnect() {
RuntimeLog.ws("lifecycle", "forceReconnect called")
shouldReconnect = true shouldReconnect = true
reconnectJob?.cancel() reconnectJob?.cancel()
reconnectJob = null reconnectJob = null
@@ -264,6 +272,7 @@ class WebSocketService(
} }
fun disconnect() { fun disconnect() {
RuntimeLog.ws("lifecycle", "WS disconnect — user requested")
shouldReconnect = false shouldReconnect = false
reconnectJob?.cancel() reconnectJob?.cancel()
reconnectJob = null reconnectJob = null
@@ -11,6 +11,7 @@ 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
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
@@ -23,6 +24,7 @@ import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -525,6 +527,10 @@ private fun ImagePreviewDialog(
.clickable { onDismiss() }, .clickable { onDismiss() },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
CircularProgressIndicator(
color = Color.White.copy(alpha = 0.6f),
modifier = Modifier.size(48.dp),
)
AsyncImage( AsyncImage(
model = ImageRequest.Builder(context) model = ImageRequest.Builder(context)
.data(imageUri) .data(imageUri)
@@ -604,6 +610,7 @@ private fun ChatMessageBubble(
contentDescription = "图片", contentDescription = "图片",
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 120.dp, max = 240.dp)
.padding(top = 6.dp, start = 6.dp, end = 6.dp) .padding(top = 6.dp, start = 6.dp, end = 6.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable { previewImageUri = uri }, .clickable { previewImageUri = uri },
@@ -17,6 +17,7 @@ import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
@@ -34,6 +35,7 @@ import top.yeij.cyrene.ui.screens.about.AboutScreen
import top.yeij.cyrene.ui.screens.profile.ProfileScreen import top.yeij.cyrene.ui.screens.profile.ProfileScreen
import top.yeij.cyrene.ui.screens.settings.KeepAlivePage import top.yeij.cyrene.ui.screens.settings.KeepAlivePage
import top.yeij.cyrene.ui.screens.settings.SettingsScreen import top.yeij.cyrene.ui.screens.settings.SettingsScreen
import top.yeij.cyrene.util.RuntimeLog
object Routes { object Routes {
const val LOGIN = "login" const val LOGIN = "login"
@@ -52,6 +54,19 @@ fun CyreneNavGraph(
isDefaultAssistant: Boolean, isDefaultAssistant: Boolean,
onOpenAssistantSettings: () -> Unit, onOpenAssistantSettings: () -> Unit,
) { ) {
// After process death, the NavController may restore a stale back stack
// (e.g. showing SETTINGS instead of MAIN). Reset to the intended start.
LaunchedEffect(Unit) {
val entries = navController.currentBackStack.value
val currentRoute = navController.currentDestination?.route
RuntimeLog.general("nav", "NavGraph start — currentRoute=$currentRoute backStackSize=${entries.size}")
if (entries.size > 1 && entries.first().destination.route != startDestination) {
RuntimeLog.general("nav", "Resetting stale back stack to $startDestination")
navController.popBackStack(startDestination, inclusive = true)
navController.navigate(startDestination)
}
}
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination, startDestination = startDestination,
@@ -76,20 +91,32 @@ fun CyreneNavGraph(
composable(Routes.SETTINGS) { composable(Routes.SETTINGS) {
SettingsScreen( SettingsScreen(
onBack = { navController.popBackStack() }, onBack = {
if (navController.currentDestination?.route == Routes.SETTINGS) {
navController.popBackStack()
}
},
onNavigateToKeepAlive = { navController.navigate(Routes.KEEP_ALIVE) }, onNavigateToKeepAlive = { navController.navigate(Routes.KEEP_ALIVE) },
) )
} }
composable(Routes.KEEP_ALIVE) { composable(Routes.KEEP_ALIVE) {
KeepAlivePage( KeepAlivePage(
onBack = { navController.popBackStack() }, onBack = {
if (navController.currentDestination?.route == Routes.KEEP_ALIVE) {
navController.popBackStack()
}
},
) )
} }
composable(Routes.ABOUT) { composable(Routes.ABOUT) {
AboutScreen( AboutScreen(
onBack = { navController.popBackStack() }, onBack = {
if (navController.currentDestination?.route == Routes.ABOUT) {
navController.popBackStack()
}
},
) )
} }
} }
@@ -136,7 +163,10 @@ fun MainScreen(
items.forEachIndexed { index, item -> items.forEachIndexed { index, item ->
NavigationRailItem( NavigationRailItem(
selected = selectedTab == index, selected = selectedTab == index,
onClick = { selectedTab = index }, onClick = {
selectedTab = index
RuntimeLog.general("nav", "Tab switched to ${item.label} (index=$index)")
},
icon = item.icon, icon = item.icon,
label = { Text(item.label) }, label = { Text(item.label) },
) )
@@ -3,6 +3,10 @@ package top.yeij.cyrene.ui.screens.chat
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -76,14 +80,29 @@ import top.yeij.cyrene.ui.components.CyreneStatus
import top.yeij.cyrene.ui.components.StatusIndicator import top.yeij.cyrene.ui.components.StatusIndicator
import top.yeij.cyrene.ui.components.TypingIndicator import top.yeij.cyrene.ui.components.TypingIndicator
import top.yeij.cyrene.util.RecordState import top.yeij.cyrene.util.RecordState
import top.yeij.cyrene.util.RuntimeLog
import top.yeij.cyrene.viewmodel.ChatViewModel import top.yeij.cyrene.viewmodel.ChatViewModel
import top.yeij.cyrene.viewmodel.SettingsViewModel import top.yeij.cyrene.viewmodel.SettingsViewModel
import kotlin.math.min
@Composable @Composable
private fun AnimatedChatBubble( private fun AnimatedChatBubble(
message: Message, message: Message,
animIndex: Int, animIndex: Int,
) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(min(animIndex, 10) * 60L)
visible = true
}
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = tween(300)) +
slideInVertically(
animationSpec = tween(300),
initialOffsetY = { it / 4 },
),
) { ) {
ChatBubble( ChatBubble(
content = message.content, content = message.content,
@@ -93,12 +112,17 @@ private fun AnimatedChatBubble(
imageDataUris = message.imageDataUris, imageDataUris = message.imageDataUris,
) )
} }
}
@Composable @Composable
fun ChatScreen( fun ChatScreen(
viewModel: ChatViewModel = koinViewModel(), viewModel: ChatViewModel = koinViewModel(),
settingsViewModel: SettingsViewModel = koinInject(), settingsViewModel: SettingsViewModel = koinInject(),
) { ) {
// Track composition to diagnose navigation-related issues
LaunchedEffect(Unit) {
RuntimeLog.general("chat", "ChatScreen composed, ChatViewModel instance resolved")
}
val messages by viewModel.currentMessages.collectAsState() val messages by viewModel.currentMessages.collectAsState()
val inputText by viewModel.inputText.collectAsState() val inputText by viewModel.inputText.collectAsState()
val isStreaming by viewModel.isStreaming.collectAsState() val isStreaming by viewModel.isStreaming.collectAsState()
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -52,9 +53,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -847,23 +848,20 @@ fun SettingsScreen(
} }
} }
TabRow(selectedTabIndex = selectedTab) { ScrollableTabRow(
selectedTabIndex = selectedTab,
edgePadding = 16.dp,
divider = {},
) {
tabs.forEachIndexed { index, label -> tabs.forEachIndexed { index, label ->
Tab( Tab(
selected = selectedTab == index, selected = selectedTab == index,
onClick = { selectedTab = index }, onClick = { selectedTab = index },
text = { Text(label, maxLines = 1) }, text = { Text(label, maxLines = 1, style = MaterialTheme.typography.labelMedium) },
) )
} }
} }
val filteredLogs = if (selectedTab == 0) {
logEntries
} else {
val cat = allCategories.getOrNull(selectedTab - 1)
if (cat != null) logEntries.filter { it.category == cat } else emptyList()
}
val currentCategory = if (selectedTab == 0) null else allCategories.getOrNull(selectedTab - 1) val currentCategory = if (selectedTab == 0) null else allCategories.getOrNull(selectedTab - 1)
Row( Row(
@@ -900,7 +898,14 @@ fun SettingsScreen(
} }
} }
if (filteredLogs.isEmpty()) { val displayLogs = if (selectedTab == 0) {
logEntries.takeLast(500)
} else {
val cat = allCategories.getOrNull(selectedTab - 1)
if (cat != null) logEntries.filter { it.category == cat }.takeLast(500) else emptyList()
}
if (displayLogs.isEmpty()) {
Text( Text(
text = "暂无${tabs[selectedTab]}日志", text = "暂无${tabs[selectedTab]}日志",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -908,16 +913,29 @@ fun SettingsScreen(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) )
} else { } else {
Column( // Log count header
Text(
text = "${displayLogs.size}${if (displayLogs.size >= 500) "+" else ""}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
// Fixed-height scrollable log area
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 12.dp)
.weight(1f, fill = false), .height(280.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
RoundedCornerShape(8.dp),
)
.padding(8.dp),
) { ) {
Column( Column(
modifier = Modifier.verticalScroll(scrollState), modifier = Modifier.verticalScroll(scrollState),
) { ) {
filteredLogs.takeLast(500).forEach { entry -> displayLogs.forEach { entry ->
Text( Text(
text = entry.formatted(), text = entry.formatted(),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -8,6 +8,7 @@ import android.content.Intent
import android.os.Build import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import top.yeij.cyrene.MainActivity import top.yeij.cyrene.MainActivity
import top.yeij.cyrene.R
import top.yeij.cyrene.domain.model.Message import top.yeij.cyrene.domain.model.Message
class NotificationHelper(private val context: Context) { class NotificationHelper(private val context: Context) {
@@ -47,7 +48,7 @@ class NotificationHelper(private val context: Context) {
} }
val notification = NotificationCompat.Builder(context, CHANNEL_ID) val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info) .setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("昔涟") .setContentTitle("昔涟")
.setContentText(preview) .setContentText(preview)
.setAutoCancel(true) .setAutoCancel(true)
@@ -55,7 +56,9 @@ class NotificationHelper(private val context: Context) {
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.build() .build()
notificationManager.notify(message.id.hashCode(), notification) val notifyId = message.id.hashCode()
notificationManager.notify(notifyId, notification)
RuntimeLog.notify("posted", "Notification posted: id=$notifyId preview='$preview'")
} }
fun cancelAll() { fun cancelAll() {
@@ -20,6 +20,7 @@ enum class LogCategory(val label: String) {
GENERAL("通用"), GENERAL("通用"),
HTTP("网络"), HTTP("网络"),
VOICE("语音"), VOICE("语音"),
NOTIFY("通知"),
} }
data class LogEntry( data class LogEntry(
@@ -59,6 +60,7 @@ object RuntimeLog {
fun general(tag: String, message: String) = log(LogCategory.GENERAL, tag, message) fun general(tag: String, message: String) = log(LogCategory.GENERAL, tag, message)
fun http(tag: String, message: String) = log(LogCategory.HTTP, tag, message) fun http(tag: String, message: String) = log(LogCategory.HTTP, tag, message)
fun voice(tag: String, message: String) = log(LogCategory.VOICE, tag, message) fun voice(tag: String, message: String) = log(LogCategory.VOICE, tag, message)
fun notify(tag: String, message: String) = log(LogCategory.NOTIFY, tag, message)
@Synchronized @Synchronized
fun getByCategory(category: LogCategory): List<LogEntry> { fun getByCategory(category: LogCategory): List<LogEntry> {
@@ -2,7 +2,6 @@ package top.yeij.cyrene.viewmodel
import android.app.Application import android.app.Application
import android.net.Uri import android.net.Uri
import android.util.Base64
import android.util.Log import android.util.Log
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -18,7 +17,11 @@ 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 kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import top.yeij.cyrene.data.local.PreferencesDataStore import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.ApiService
import top.yeij.cyrene.data.remote.dto.WSAttachment 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
@@ -38,8 +41,14 @@ class ChatViewModel(
private val chatRepository: ChatRepository, private val chatRepository: ChatRepository,
private val voiceRecorder: VoiceRecorder, private val voiceRecorder: VoiceRecorder,
private val preferencesDataStore: PreferencesDataStore, private val preferencesDataStore: PreferencesDataStore,
private val apiService: ApiService,
) : AndroidViewModel(application) { ) : AndroidViewModel(application) {
companion object {
private var instanceCounter = 0
}
private val instanceId = ++instanceCounter
val isConnected: StateFlow<Boolean> = chatRepository.connectionState val isConnected: StateFlow<Boolean> = chatRepository.connectionState
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
@@ -78,22 +87,29 @@ class ChatViewModel(
private var sendTimeoutJob: 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 RuntimeLog.general("app", "ChatViewModel instance #$instanceId created")
viewModelScope.launch { viewModelScope.launch {
try { try {
RuntimeLog.general("app", "ChatViewModel #$instanceId — initializing session...")
val sessionId = chatRepository.initializeSession() val sessionId = chatRepository.initializeSession()
currentSessionId = sessionId currentSessionId = sessionId
chatRepository.currentSessionId = sessionId chatRepository.currentSessionId = sessionId
RuntimeLog.general("app", "Session initialized: $sessionId")
chatRepository.ensureConnected() chatRepository.ensureConnected()
chatRepository.loadMessagesFromServer(sessionId) } catch (e: Exception) {
} catch (_: Exception) { } RuntimeLog.general("app", "initializeSession failed: ${e.message}")
// Fall back to persisted sessionId if initializeSession failed }
// Always try to load from DB, even if initializeSession failed.
// After process death the persisted session ID gives us the history.
val sid = currentSessionId val sid = currentSessionId
?: preferencesDataStore.currentSessionId.firstOrNull() ?: preferencesDataStore.currentSessionId.firstOrNull()
if (sid != null) { if (sid != null) {
currentSessionId = sid currentSessionId = sid
chatRepository.currentSessionId = sid chatRepository.currentSessionId = sid
RuntimeLog.general("app", "Loading messages from DB for session=$sid")
loadMessagesFromDb(sid) loadMessagesFromDb(sid)
} else {
RuntimeLog.general("app", "No session ID available — cannot load messages")
} }
} }
@@ -116,6 +132,11 @@ class ChatViewModel(
} }
updated.deduplicate() updated.deduplicate()
} }
// Any non-user response means the server acknowledged our message
if (_isSending.value && message.role != "user") {
_isSending.value = false
sendTimeoutJob?.cancel()
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ChatViewModel", "Error processing message: ${e.message}", e) Log.e("ChatViewModel", "Error processing message: ${e.message}", e)
} }
@@ -149,26 +170,6 @@ class ChatViewModel(
} }
} }
// Auto-refresh when connection transitions from offline to online
viewModelScope.launch {
var wasOffline = false
chatRepository.connectionState.collect { connected ->
if (connected && wasOffline && currentSessionId != null) {
try {
val lastCleared = preferencesDataStore.lastClearedTimestamp
.firstOrNull()?.toLongOrNull() ?: 0L
val latestMessages = chatRepository.loadMessagesFromServer(currentSessionId!!)
val latestServerTs = latestMessages.maxOfOrNull { it.timestamp } ?: 0L
if (latestServerTs > lastCleared) {
// Newer messages exist on server — DB upsert already
// triggered the Room Flow; force a scroll to newest
RuntimeLog.chat("auto-refresh", "Reconnected, loaded ${latestMessages.size} messages")
}
} catch (_: Exception) { }
}
wasOffline = !connected
}
}
} }
// --- Voice recording (WeChat-style gesture) --- // --- Voice recording (WeChat-style gesture) ---
@@ -216,24 +217,45 @@ class ChatViewModel(
_selectedImageUris.value = emptyList() _selectedImageUris.value = emptyList()
} }
private suspend fun uriToAttachment(uri: Uri): WSAttachment? { private data class UploadResult(
val attachment: WSAttachment,
val thumbnailUrl: String,
)
private suspend fun uploadAndBuildAttachment(uri: Uri): UploadResult? {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val cr = getApplication<Application>().contentResolver val cr = getApplication<Application>().contentResolver
val mimeType = cr.getType(uri) ?: "image/*" val mimeType = cr.getType(uri) ?: "image/jpeg"
val inputStream = cr.openInputStream(uri) ?: return@withContext null val filename = uri.lastPathSegment ?: "image"
val bytes = inputStream.use { it.readBytes() } val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: return@withContext null
if (bytes.isEmpty()) return@withContext null if (bytes.isEmpty()) return@withContext null
val b64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
val dataUri = "data:$mimeType;base64,$b64" // Upload to server, get file_id
WSAttachment( val requestBody = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("file", filename, requestBody)
val response = apiService.uploadFile(part)
if (!response.isSuccessful) {
Log.e("ChatViewModel", "Upload failed: ${response.code()} ${response.message()}")
return@withContext null
}
val fileId = response.body()?.id ?: return@withContext null
// Construct thumbnail URL
val baseUrl = preferencesDataStore.baseUrl.firstOrNull()
?.trimEnd('/') ?: "http://10.0.2.2:8080"
val thumbnailUrl = "$baseUrl/api/v1/files/$fileId/thumbnail"
val attachment = WSAttachment(
type = "image", type = "image",
url = dataUri, fileId = fileId,
filename = uri.lastPathSegment ?: "image", thumbnailUrl = thumbnailUrl,
filename = filename,
size = bytes.size.toLong(), size = bytes.size.toLong(),
) )
UploadResult(attachment, thumbnailUrl)
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ChatViewModel", "Failed to convert URI to attachment: ${e.message}") Log.e("ChatViewModel", "Failed to upload image: ${e.message}", e)
null null
} }
} }
@@ -244,10 +266,14 @@ class ChatViewModel(
val text = _inputText.value.trim() val text = _inputText.value.trim()
val uris = _selectedImageUris.value val uris = _selectedImageUris.value
if (text.isEmpty() && uris.isEmpty()) return if (text.isEmpty() && uris.isEmpty()) return
val sid = currentSessionId
if (sid == null) {
RuntimeLog.chat("send", "Cannot send — no current session")
return
}
_inputText.value = "" _inputText.value = ""
_isSending.value = true _isSending.value = true
val sid = currentSessionId
sendTimeoutJob?.cancel() sendTimeoutJob?.cancel()
sendTimeoutJob = viewModelScope.launch { sendTimeoutJob = viewModelScope.launch {
@@ -258,11 +284,19 @@ class ChatViewModel(
} }
} }
val localUriStrings = uris.map { it.toString() }
viewModelScope.launch { viewModelScope.launch {
val attachments = uris.mapNotNull { uriToAttachment(it) } val results = uris.mapNotNull { uploadAndBuildAttachment(it) }
clearImages() clearImages()
val attachments = results.map { it.attachment }
val thumbnailUrls = results.map { it.thumbnailUrl }
try { try {
chatRepository.sendMessage(text, sid, attachments.ifEmpty { null }) chatRepository.sendMessage(
text, sid,
attachments = attachments.ifEmpty { null },
localImageUris = thumbnailUrls.ifEmpty { localUriStrings },
)
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ChatViewModel", "sendMessage failed: ${e.message}", e) Log.e("ChatViewModel", "sendMessage failed: ${e.message}", e)
_isSending.value = false _isSending.value = false
@@ -291,6 +325,7 @@ class ChatViewModel(
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ChatViewModel", "Error loading messages: ${e.message}", e) Log.e("ChatViewModel", "Error loading messages: ${e.message}", e)
RuntimeLog.general("app", "loadMessagesFromDb failed: ${e.message}")
} }
} }
} }
@@ -317,6 +352,10 @@ class ChatViewModel(
viewModelScope.launch { viewModelScope.launch {
_isRefreshing.value = true _isRefreshing.value = true
try { try {
// Clear all messages and let the DB observer rebuild from scratch.
// This avoids duplicates that occur when local-UUID messages survive
// the merge alongside server-ID versions loaded from HTTP.
_currentMessages.value = emptyList()
if (!isConnected.value) { if (!isConnected.value) {
chatRepository.ensureConnected() chatRepository.ensureConnected()
} }
@@ -95,6 +95,12 @@ class OverlayViewModel(
updated.deduplicate() updated.deduplicate()
} }
// Any non-user response means the server acknowledged our message
if (_state.value == OverlayState.PROCESSING && message.role != "user") {
cancelProcessingTimeout()
setWaiting()
}
if (message.role == "assistant" && !message.isStreaming && message.msgType == "chat") { if (message.role == "assistant" && !message.isStreaming && message.msgType == "chat") {
if (message.id != lastAssistantMessageId && message.content.isNotBlank()) { if (message.id != lastAssistantMessageId && message.content.isNotBlank()) {
lastAssistantMessageId = message.id lastAssistantMessageId = message.id
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#6D3BC0"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- 昔涟首字母 C,圆形背景 -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,30 C67.255,30 78,40.745 78,54 C78,67.255 67.255,78 54,78 C40.745,78 30,67.255 30,54 C30,40.745 40.745,30 54,30 Z M54,36 C44.059,36 36,44.059 36,54 C36,63.941 44.059,72 54,72 C58.935,72 63.437,70.1 66.878,66.878 C68.523,65.301 69.761,63.394 70.505,61.289 C70.963,59.947 71.213,58.524 71.233,57.067 C71.239,55.712 71.041,54.369 70.646,53.084 L66.757,58.243 L58.243,49.729 L53.084,53.619 L44.57,45.106 L45.398,44.278 C47.881,41.795 51.235,40.233 54,40.233 Z" />
</vector>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB