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
|
// 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,8 +80,10 @@ 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
|
||||||
@@ -85,6 +91,19 @@ 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,
|
||||||
role = message.role,
|
role = message.role,
|
||||||
@@ -92,6 +111,7 @@ private fun AnimatedChatBubble(
|
|||||||
timestamp = message.timestamp,
|
timestamp = message.timestamp,
|
||||||
imageDataUris = message.imageDataUris,
|
imageDataUris = message.imageDataUris,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -99,6 +119,10 @@ 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 |
Reference in New Issue
Block a user