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:
@@ -44,7 +44,7 @@ class CyreneApplication : Application() {
|
||||
|
||||
// Track foreground/background state
|
||||
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
|
||||
override fun onActivityStarted(activity: Activity) {
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
if (activityCount.incrementAndGet() == 1) {
|
||||
RuntimeLog.general("app", "App in foreground")
|
||||
getRepo()?.cancelNotifications()
|
||||
@@ -52,7 +52,7 @@ class CyreneApplication : Application() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityStopped(activity: Activity) {
|
||||
override fun onActivityPaused(activity: Activity) {
|
||||
if (activityCount.decrementAndGet() == 0) {
|
||||
RuntimeLog.general("app", "App in background")
|
||||
getRepo()?.onAppBackground()
|
||||
@@ -60,8 +60,8 @@ class CyreneApplication : Application() {
|
||||
}
|
||||
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
||||
override fun onActivityResumed(activity: Activity) {}
|
||||
override fun onActivityPaused(activity: Activity) {}
|
||||
override fun onActivityStarted(activity: Activity) {}
|
||||
override fun onActivityStopped(activity: Activity) {}
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||
override fun onActivityDestroyed(activity: Activity) {}
|
||||
})
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
package top.yeij.cyrene
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import org.koin.compose.koinInject
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
@@ -24,10 +29,15 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private val isDefaultAssistant = mutableStateOf(false)
|
||||
|
||||
private val notificationPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { /* granted or denied — either way we continue */ }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
requestNotificationPermission()
|
||||
isDefaultAssistant.value = checkIsDefaultAssistant()
|
||||
|
||||
setContent {
|
||||
@@ -81,4 +91,14 @@ class MainActivity : ComponentActivity() {
|
||||
private fun openAssistantSettings() {
|
||||
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")
|
||||
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")
|
||||
suspend fun deleteById(id: String)
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package top.yeij.cyrene.data.remote
|
||||
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
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.CreateSessionRequest
|
||||
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.MessagesListResponse
|
||||
import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest
|
||||
@@ -46,10 +50,17 @@ interface ApiService {
|
||||
@GET("api/v1/sessions/{id}/messages")
|
||||
suspend fun getSessionMessages(
|
||||
@Path("id") sessionId: String,
|
||||
@Query("limit") limit: Int = 50,
|
||||
@Query("limit") limit: Int = 500,
|
||||
@Query("offset") offset: Int = 0,
|
||||
): Response<MessagesListResponse>
|
||||
|
||||
// Files
|
||||
@Multipart
|
||||
@POST("api/v1/files/upload")
|
||||
suspend fun uploadFile(
|
||||
@Part file: MultipartBody.Part,
|
||||
): Response<FileUploadResponse>
|
||||
|
||||
// IoT — 注意:网关 API 文档未列出 IoT 端点,需确认网关是否代理了 /api/v1/iot/*
|
||||
@GET("api/v1/iot/devices")
|
||||
suspend fun getDevices(): Response<List<DeviceDto>>
|
||||
|
||||
@@ -36,3 +36,12 @@ data class SessionMessageDto(
|
||||
@SerializedName("content") val content: String,
|
||||
@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.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
@@ -116,34 +117,38 @@ class ChatRepositoryImpl(
|
||||
}
|
||||
|
||||
override fun onAppForeground() {
|
||||
RuntimeLog.notify("state", "onAppForeground: wasForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground")
|
||||
isAppInForeground = true
|
||||
hasEverBeenForeground = true
|
||||
notifiedMessageIds.clear()
|
||||
notificationHelper.cancelAll()
|
||||
KeepAliveReceiver.cancel(app)
|
||||
WebSocketKeepAliveService.stop(app)
|
||||
// Always reconnect and sync history when returning to foreground
|
||||
webSocketService.forceReconnect()
|
||||
RuntimeLog.notify("state", "onAppForeground: notifications cleared, keep-alive stopped")
|
||||
scope.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)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAppBackground() {
|
||||
isAppInForeground = false
|
||||
// Always start keep-alive — connection may be silently dead and need recovery
|
||||
WebSocketKeepAliveService.start(app)
|
||||
KeepAliveReceiver.schedule(app)
|
||||
// Force reconnect after foreground service is up so the socket
|
||||
// is tied to the service's lifecycle, not the foreground activity's
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(1500) // let the service start first
|
||||
webSocketService.forceReconnect()
|
||||
RuntimeLog.general("app", "Background reconnect after service start, connected=${_connectionState.value}")
|
||||
val currentlyConnected = _connectionState.value
|
||||
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 {
|
||||
kotlinx.coroutines.delay(1500) // let the service start first
|
||||
webSocketService.forceReconnect()
|
||||
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 {
|
||||
@@ -229,7 +234,7 @@ class ChatRepositoryImpl(
|
||||
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 now = System.currentTimeMillis()
|
||||
val sid = sessionId ?: currentSessionId ?: "default"
|
||||
@@ -238,8 +243,7 @@ class ChatRepositoryImpl(
|
||||
scope.launch { preferencesDataStore.saveCurrentSessionId(sid) }
|
||||
}
|
||||
|
||||
val imageUris = attachments?.filter { it.type == "image" }?.mapNotNull { it.url } ?: emptyList()
|
||||
val hasImages = imageUris.isNotEmpty()
|
||||
val hasImages = localImageUris.isNotEmpty()
|
||||
val displayContent = content.ifBlank { "" }
|
||||
val lastMsg = when {
|
||||
hasImages && content.isBlank() -> "[图片]"
|
||||
@@ -279,7 +283,7 @@ class ChatRepositoryImpl(
|
||||
msgType = "chat",
|
||||
timestamp = now,
|
||||
isStreaming = false,
|
||||
imageDataUris = imageUris,
|
||||
imageDataUris = localImageUris,
|
||||
)
|
||||
|
||||
webSocketService.sendMessage(content, sid, attachments = attachments)
|
||||
@@ -361,7 +365,7 @@ class ChatRepositoryImpl(
|
||||
ensureConversation(sessionId)
|
||||
val messages = filteredDtos.map { dto ->
|
||||
Message(
|
||||
id = "db_${dto.id}",
|
||||
id = "${dto.id}",
|
||||
conversationId = sessionId,
|
||||
role = dto.role,
|
||||
content = dto.content,
|
||||
@@ -369,19 +373,18 @@ class ChatRepositoryImpl(
|
||||
timestamp = dto.createdAt,
|
||||
)
|
||||
}
|
||||
val deduped = messages.removeWrappingDuplicates()
|
||||
deduped.forEach { msg ->
|
||||
messageDao.upsert(
|
||||
MessageEntity(
|
||||
id = msg.id,
|
||||
conversationId = msg.conversationId,
|
||||
role = msg.role,
|
||||
content = msg.content,
|
||||
msgType = msg.msgType,
|
||||
timestamp = msg.timestamp,
|
||||
)
|
||||
val deduped = messages.removeWrappingDuplicates().splitInlineActions()
|
||||
messageDao.deleteUserMessagesByConversation(sessionId)
|
||||
messageDao.upsertAll(deduped.map { msg ->
|
||||
MessageEntity(
|
||||
id = msg.id,
|
||||
conversationId = msg.conversationId,
|
||||
role = msg.role,
|
||||
content = msg.content,
|
||||
msgType = msg.msgType,
|
||||
timestamp = msg.timestamp,
|
||||
)
|
||||
}
|
||||
})
|
||||
RuntimeLog.http("loadMessages", "HTTP loaded ${deduped.size} messages (${messages.size} before dedup) for session=$sessionId")
|
||||
deduped
|
||||
} else {
|
||||
@@ -397,11 +400,16 @@ class ChatRepositoryImpl(
|
||||
}
|
||||
|
||||
private suspend fun requestHistoryViaWs(sessionId: String) {
|
||||
// Wait up to 5s for WS to connect
|
||||
if (!webSocketService.isConnected.value) {
|
||||
withTimeoutOrNull(5000) {
|
||||
val connected = withTimeoutOrNull(5000) {
|
||||
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)
|
||||
}
|
||||
@@ -491,9 +499,9 @@ class ChatRepositoryImpl(
|
||||
lastResponseContent = content
|
||||
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)
|
||||
_isAssistantStreaming.value = false
|
||||
RuntimeLog.chat("stream", "Stream end msgId=$msgId content=${content.take(80)}")
|
||||
}
|
||||
|
||||
"response" -> {
|
||||
@@ -528,14 +536,15 @@ class ChatRepositoryImpl(
|
||||
recentParsedContents.add(text)
|
||||
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)
|
||||
RuntimeLog.chat("receive", "Response msgId=$msgId role=$role msgType=$replyMsgType content=${text.take(80)}")
|
||||
}
|
||||
|
||||
"review" -> {
|
||||
recentParsedContents.clear()
|
||||
wsMsg.reviewMessages?.forEach { review ->
|
||||
val rawText = review.content ?: review.text ?: return@forEach
|
||||
wsMsg.reviewMessages?.forEachIndexed { index, review ->
|
||||
if (index > 0) delay(1000L)
|
||||
val rawText = review.content ?: review.text ?: return@forEachIndexed
|
||||
val role = review.role ?: "assistant"
|
||||
val rvMsgType = review.type ?: review.msgType ?: "action"
|
||||
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" -> {
|
||||
cancelStreamTimeout()
|
||||
streamingContent = ""
|
||||
@@ -633,18 +653,18 @@ class ChatRepositoryImpl(
|
||||
timestamp = hist.timestamp ?: System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
val deduped = messageList.removeWrappingDuplicates()
|
||||
deduped.forEach { msg ->
|
||||
messageDao.upsert(
|
||||
MessageEntity(
|
||||
id = msg.id,
|
||||
conversationId = msg.conversationId,
|
||||
role = msg.role,
|
||||
content = msg.content,
|
||||
msgType = msg.msgType,
|
||||
timestamp = msg.timestamp,
|
||||
)
|
||||
val deduped = messageList.removeWrappingDuplicates().splitInlineActions()
|
||||
messageDao.upsertAll(deduped.map { msg ->
|
||||
MessageEntity(
|
||||
id = msg.id,
|
||||
conversationId = msg.conversationId,
|
||||
role = msg.role,
|
||||
content = msg.content,
|
||||
msgType = msg.msgType,
|
||||
timestamp = msg.timestamp,
|
||||
)
|
||||
})
|
||||
deduped.forEach { msg ->
|
||||
emitMessage(
|
||||
id = msg.id,
|
||||
sessionId = msg.conversationId,
|
||||
@@ -662,7 +682,8 @@ class ChatRepositoryImpl(
|
||||
"multi_message" -> {
|
||||
recentParsedContents.clear()
|
||||
var isFirst = true
|
||||
wsMsg.multiMessages?.forEach { item ->
|
||||
wsMsg.multiMessages?.forEachIndexed { index, item ->
|
||||
if (index > 0) delay(1000L)
|
||||
val content = item.content ?: ""
|
||||
recentParsedContents.add(content)
|
||||
emitMessage(
|
||||
@@ -708,6 +729,42 @@ class ChatRepositoryImpl(
|
||||
imageDataUris: List<String> = emptyList(),
|
||||
) {
|
||||
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(
|
||||
id = id,
|
||||
conversationId = sessionId,
|
||||
@@ -720,11 +777,21 @@ class ChatRepositoryImpl(
|
||||
)
|
||||
_incomingMessages.tryEmit(message)
|
||||
|
||||
if (shouldNotify && hasEverBeenForeground && !isAppInForeground && role == "assistant" && !isStreaming) {
|
||||
if (notifiedMessageIds.add(id)) {
|
||||
notificationHelper.showMessageNotification(message)
|
||||
RuntimeLog.general("app", "Notification sent for msgId=$id")
|
||||
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)) {
|
||||
notificationHelper.showMessageNotification(message)
|
||||
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,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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(
|
||||
id = id,
|
||||
conversationId = conversationId,
|
||||
|
||||
@@ -76,7 +76,7 @@ val appModule = module {
|
||||
factory { GetConversationsUseCase(get()) }
|
||||
|
||||
// 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 { OverlayViewModel(get(), get(), get()) }
|
||||
viewModel { ProfileViewModel(get(), get(), get()) }
|
||||
|
||||
@@ -22,7 +22,7 @@ interface ChatRepository {
|
||||
|
||||
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>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import kotlinx.coroutines.launch
|
||||
import org.koin.core.context.GlobalContext
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
|
||||
class KeepAliveReceiver : BroadcastReceiver() {
|
||||
|
||||
@@ -21,6 +22,7 @@ class KeepAliveReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.d(TAG, "Keep-alive alarm fired")
|
||||
RuntimeLog.notify("keepalive", "Alarm fired in background")
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
@@ -28,27 +30,24 @@ class KeepAliveReceiver : BroadcastReceiver() {
|
||||
val token = prefs.token.firstOrNull()
|
||||
if (token.isNullOrBlank()) {
|
||||
Log.d(TAG, "No auth token, skipping wake-up")
|
||||
RuntimeLog.notify("keepalive", "Skipping: no auth token")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Always restart foreground service
|
||||
if (!WebSocketKeepAliveService.isRunning) {
|
||||
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 wasConnected = repo.connectionState.value
|
||||
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)
|
||||
|
||||
Log.d(TAG, "Keep-alive check complete, connected=${repo.connectionState.value}")
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Keep-alive check failed: ${e.message}", e)
|
||||
// Schedule next anyway
|
||||
RuntimeLog.notify("keepalive", "Failed: ${e.message}")
|
||||
schedule(context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import top.yeij.cyrene.MainActivity
|
||||
import top.yeij.cyrene.R
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
|
||||
class WebSocketKeepAliveService : Service() {
|
||||
|
||||
@@ -27,6 +28,7 @@ class WebSocketKeepAliveService : Service() {
|
||||
createChannel()
|
||||
acquireWakeLock()
|
||||
Log.i(TAG, "Service created, wakeLock held")
|
||||
RuntimeLog.notify("keepalive", "WS keep-alive service created, wakeLock acquired")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
@@ -34,6 +36,7 @@ class WebSocketKeepAliveService : Service() {
|
||||
releaseWakeLock()
|
||||
scheduleRestart()
|
||||
Log.i(TAG, "Service destroyed, restart scheduled")
|
||||
RuntimeLog.notify("keepalive", "WS keep-alive service destroyed, restart scheduled in ${RESTART_DELAY_MS}ms")
|
||||
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.WSClientMessage
|
||||
import top.yeij.cyrene.data.remote.dto.WSServerMessage
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import java.net.URLEncoder
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
@@ -136,6 +137,7 @@ class WebSocketService(
|
||||
_isConnected.value = true
|
||||
_connectionError.value = null
|
||||
startHeartbeat()
|
||||
RuntimeLog.ws("lifecycle", "WS connected #$connId")
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
@@ -143,6 +145,8 @@ class WebSocketService(
|
||||
lastMessageReceived = System.currentTimeMillis()
|
||||
try {
|
||||
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)
|
||||
} catch (e: Exception) {
|
||||
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")
|
||||
_isConnected.value = false
|
||||
cancelHeartbeat()
|
||||
RuntimeLog.ws("lifecycle", "WS closing #$connId code=$code reason='$reason'")
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
@@ -162,6 +167,7 @@ class WebSocketService(
|
||||
_isConnected.value = false
|
||||
cancelHeartbeat()
|
||||
scheduleReconnect()
|
||||
RuntimeLog.ws("lifecycle", "WS closed #$connId code=$code")
|
||||
}
|
||||
|
||||
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)
|
||||
_isConnected.value = false
|
||||
cancelHeartbeat()
|
||||
RuntimeLog.ws("lifecycle", "WS failure #$connId http=$httpCode error='${t.message}'")
|
||||
|
||||
val errorMsg = when (httpCode) {
|
||||
403 -> {
|
||||
@@ -248,6 +255,7 @@ class WebSocketService(
|
||||
}
|
||||
|
||||
fun forceReconnect() {
|
||||
RuntimeLog.ws("lifecycle", "forceReconnect called")
|
||||
shouldReconnect = true
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
@@ -264,6 +272,7 @@ class WebSocketService(
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
RuntimeLog.ws("lifecycle", "WS disconnect — user requested")
|
||||
shouldReconnect = false
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
|
||||
@@ -11,6 +11,7 @@ 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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -525,6 +527,10 @@ private fun ImagePreviewDialog(
|
||||
.clickable { onDismiss() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(imageUri)
|
||||
@@ -604,6 +610,7 @@ private fun ChatMessageBubble(
|
||||
contentDescription = "图片",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 240.dp)
|
||||
.padding(top = 6.dp, start = 6.dp, end = 6.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { previewImageUri = uri },
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
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.settings.KeepAlivePage
|
||||
import top.yeij.cyrene.ui.screens.settings.SettingsScreen
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
|
||||
object Routes {
|
||||
const val LOGIN = "login"
|
||||
@@ -52,6 +54,19 @@ fun CyreneNavGraph(
|
||||
isDefaultAssistant: Boolean,
|
||||
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(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
@@ -76,20 +91,32 @@ fun CyreneNavGraph(
|
||||
|
||||
composable(Routes.SETTINGS) {
|
||||
SettingsScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onBack = {
|
||||
if (navController.currentDestination?.route == Routes.SETTINGS) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
},
|
||||
onNavigateToKeepAlive = { navController.navigate(Routes.KEEP_ALIVE) },
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.KEEP_ALIVE) {
|
||||
KeepAlivePage(
|
||||
onBack = { navController.popBackStack() },
|
||||
onBack = {
|
||||
if (navController.currentDestination?.route == Routes.KEEP_ALIVE) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.ABOUT) {
|
||||
AboutScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onBack = {
|
||||
if (navController.currentDestination?.route == Routes.ABOUT) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -136,7 +163,10 @@ fun MainScreen(
|
||||
items.forEachIndexed { index, item ->
|
||||
NavigationRailItem(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
onClick = {
|
||||
selectedTab = index
|
||||
RuntimeLog.general("nav", "Tab switched to ${item.label} (index=$index)")
|
||||
},
|
||||
icon = item.icon,
|
||||
label = { Text(item.label) },
|
||||
)
|
||||
|
||||
@@ -3,6 +3,10 @@ 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.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.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -76,8 +80,10 @@ import top.yeij.cyrene.ui.components.CyreneStatus
|
||||
import top.yeij.cyrene.ui.components.StatusIndicator
|
||||
import top.yeij.cyrene.ui.components.TypingIndicator
|
||||
import top.yeij.cyrene.util.RecordState
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import top.yeij.cyrene.viewmodel.ChatViewModel
|
||||
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||
import kotlin.math.min
|
||||
|
||||
|
||||
@Composable
|
||||
@@ -85,13 +91,27 @@ private fun AnimatedChatBubble(
|
||||
message: Message,
|
||||
animIndex: Int,
|
||||
) {
|
||||
ChatBubble(
|
||||
content = message.content,
|
||||
role = message.role,
|
||||
msgType = message.msgType,
|
||||
timestamp = message.timestamp,
|
||||
imageDataUris = message.imageDataUris,
|
||||
)
|
||||
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(
|
||||
content = message.content,
|
||||
role = message.role,
|
||||
msgType = message.msgType,
|
||||
timestamp = message.timestamp,
|
||||
imageDataUris = message.imageDataUris,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -99,6 +119,10 @@ fun ChatScreen(
|
||||
viewModel: ChatViewModel = koinViewModel(),
|
||||
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 inputText by viewModel.inputText.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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -52,9 +53,9 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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 ->
|
||||
Tab(
|
||||
selected = 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)
|
||||
|
||||
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 = "暂无${tabs[selectedTab]}日志",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -908,16 +913,29 @@ fun SettingsScreen(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
} 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
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.weight(1f, fill = false),
|
||||
.padding(horizontal = 12.dp)
|
||||
.height(280.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
||||
RoundedCornerShape(8.dp),
|
||||
)
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(scrollState),
|
||||
) {
|
||||
filteredLogs.takeLast(500).forEach { entry ->
|
||||
displayLogs.forEach { entry ->
|
||||
Text(
|
||||
text = entry.formatted(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import top.yeij.cyrene.MainActivity
|
||||
import top.yeij.cyrene.R
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
|
||||
class NotificationHelper(private val context: Context) {
|
||||
@@ -47,7 +48,7 @@ class NotificationHelper(private val context: Context) {
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("昔涟")
|
||||
.setContentText(preview)
|
||||
.setAutoCancel(true)
|
||||
@@ -55,7 +56,9 @@ class NotificationHelper(private val context: Context) {
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.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() {
|
||||
|
||||
@@ -20,6 +20,7 @@ enum class LogCategory(val label: String) {
|
||||
GENERAL("通用"),
|
||||
HTTP("网络"),
|
||||
VOICE("语音"),
|
||||
NOTIFY("通知"),
|
||||
}
|
||||
|
||||
data class LogEntry(
|
||||
@@ -59,6 +60,7 @@ object RuntimeLog {
|
||||
fun general(tag: String, message: String) = log(LogCategory.GENERAL, 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 notify(tag: String, message: String) = log(LogCategory.NOTIFY, tag, message)
|
||||
|
||||
@Synchronized
|
||||
fun getByCategory(category: LogCategory): List<LogEntry> {
|
||||
|
||||
@@ -2,7 +2,6 @@ package top.yeij.cyrene.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -18,7 +17,11 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
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.remote.ApiService
|
||||
import top.yeij.cyrene.data.remote.dto.WSAttachment
|
||||
import top.yeij.cyrene.domain.model.Conversation
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
@@ -38,8 +41,14 @@ class ChatViewModel(
|
||||
private val chatRepository: ChatRepository,
|
||||
private val voiceRecorder: VoiceRecorder,
|
||||
private val preferencesDataStore: PreferencesDataStore,
|
||||
private val apiService: ApiService,
|
||||
) : AndroidViewModel(application) {
|
||||
|
||||
companion object {
|
||||
private var instanceCounter = 0
|
||||
}
|
||||
private val instanceId = ++instanceCounter
|
||||
|
||||
val isConnected: StateFlow<Boolean> = chatRepository.connectionState
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
@@ -78,22 +87,29 @@ class ChatViewModel(
|
||||
private var sendTimeoutJob: Job? = null
|
||||
|
||||
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 {
|
||||
try {
|
||||
RuntimeLog.general("app", "ChatViewModel #$instanceId — initializing session...")
|
||||
val sessionId = chatRepository.initializeSession()
|
||||
currentSessionId = sessionId
|
||||
chatRepository.currentSessionId = sessionId
|
||||
RuntimeLog.general("app", "Session initialized: $sessionId")
|
||||
chatRepository.ensureConnected()
|
||||
chatRepository.loadMessagesFromServer(sessionId)
|
||||
} catch (_: Exception) { }
|
||||
// Fall back to persisted sessionId if initializeSession failed
|
||||
} catch (e: Exception) {
|
||||
RuntimeLog.general("app", "initializeSession failed: ${e.message}")
|
||||
}
|
||||
// Always try to load from DB, even if initializeSession failed.
|
||||
// After process death the persisted session ID gives us the history.
|
||||
val sid = currentSessionId
|
||||
?: preferencesDataStore.currentSessionId.firstOrNull()
|
||||
if (sid != null) {
|
||||
currentSessionId = sid
|
||||
chatRepository.currentSessionId = sid
|
||||
RuntimeLog.general("app", "Loading messages from DB for session=$sid")
|
||||
loadMessagesFromDb(sid)
|
||||
} else {
|
||||
RuntimeLog.general("app", "No session ID available — cannot load messages")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +132,11 @@ class ChatViewModel(
|
||||
}
|
||||
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) {
|
||||
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) ---
|
||||
@@ -216,24 +217,45 @@ class ChatViewModel(
|
||||
_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) {
|
||||
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() }
|
||||
val mimeType = cr.getType(uri) ?: "image/jpeg"
|
||||
val filename = uri.lastPathSegment ?: "image"
|
||||
val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: return@withContext null
|
||||
if (bytes.isEmpty()) return@withContext null
|
||||
val b64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
val dataUri = "data:$mimeType;base64,$b64"
|
||||
WSAttachment(
|
||||
|
||||
// Upload to server, get file_id
|
||||
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",
|
||||
url = dataUri,
|
||||
filename = uri.lastPathSegment ?: "image",
|
||||
fileId = fileId,
|
||||
thumbnailUrl = thumbnailUrl,
|
||||
filename = filename,
|
||||
size = bytes.size.toLong(),
|
||||
)
|
||||
UploadResult(attachment, thumbnailUrl)
|
||||
} 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
|
||||
}
|
||||
}
|
||||
@@ -244,10 +266,14 @@ class ChatViewModel(
|
||||
val text = _inputText.value.trim()
|
||||
val uris = _selectedImageUris.value
|
||||
if (text.isEmpty() && uris.isEmpty()) return
|
||||
val sid = currentSessionId
|
||||
if (sid == null) {
|
||||
RuntimeLog.chat("send", "Cannot send — no current session")
|
||||
return
|
||||
}
|
||||
|
||||
_inputText.value = ""
|
||||
_isSending.value = true
|
||||
val sid = currentSessionId
|
||||
|
||||
sendTimeoutJob?.cancel()
|
||||
sendTimeoutJob = viewModelScope.launch {
|
||||
@@ -258,11 +284,19 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
val localUriStrings = uris.map { it.toString() }
|
||||
|
||||
viewModelScope.launch {
|
||||
val attachments = uris.mapNotNull { uriToAttachment(it) }
|
||||
val results = uris.mapNotNull { uploadAndBuildAttachment(it) }
|
||||
clearImages()
|
||||
val attachments = results.map { it.attachment }
|
||||
val thumbnailUrls = results.map { it.thumbnailUrl }
|
||||
try {
|
||||
chatRepository.sendMessage(text, sid, attachments.ifEmpty { null })
|
||||
chatRepository.sendMessage(
|
||||
text, sid,
|
||||
attachments = attachments.ifEmpty { null },
|
||||
localImageUris = thumbnailUrls.ifEmpty { localUriStrings },
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatViewModel", "sendMessage failed: ${e.message}", e)
|
||||
_isSending.value = false
|
||||
@@ -291,6 +325,7 @@ class ChatViewModel(
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatViewModel", "Error loading messages: ${e.message}", e)
|
||||
RuntimeLog.general("app", "loadMessagesFromDb failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,6 +352,10 @@ class ChatViewModel(
|
||||
viewModelScope.launch {
|
||||
_isRefreshing.value = true
|
||||
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) {
|
||||
chatRepository.ensureConnected()
|
||||
}
|
||||
|
||||
@@ -95,6 +95,12 @@ class OverlayViewModel(
|
||||
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.id != lastAssistantMessageId && message.content.isNotBlank()) {
|
||||
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 |
Reference in New Issue
Block a user