diff --git a/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt b/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt index 9521a4a..0a21d48 100644 --- a/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt +++ b/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt @@ -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) {} }) diff --git a/app/src/main/java/top/yeij/cyrene/MainActivity.kt b/app/src/main/java/top/yeij/cyrene/MainActivity.kt index d474574..ecceb73 100644 --- a/app/src/main/java/top/yeij/cyrene/MainActivity.kt +++ b/app/src/main/java/top/yeij/cyrene/MainActivity.kt @@ -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) + } + } + } } diff --git a/app/src/main/java/top/yeij/cyrene/data/local/dao/MessageDao.kt b/app/src/main/java/top/yeij/cyrene/data/local/dao/MessageDao.kt index f07490d..aee7c89 100644 --- a/app/src/main/java/top/yeij/cyrene/data/local/dao/MessageDao.kt +++ b/app/src/main/java/top/yeij/cyrene/data/local/dao/MessageDao.kt @@ -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) diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/ApiService.kt b/app/src/main/java/top/yeij/cyrene/data/remote/ApiService.kt index a0aeb35..44c6ba8 100644 --- a/app/src/main/java/top/yeij/cyrene/data/remote/ApiService.kt +++ b/app/src/main/java/top/yeij/cyrene/data/remote/ApiService.kt @@ -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 + // Files + @Multipart + @POST("api/v1/files/upload") + suspend fun uploadFile( + @Part file: MultipartBody.Part, + ): Response + // IoT — 注意:网关 API 文档未列出 IoT 端点,需确认网关是否代理了 /api/v1/iot/* @GET("api/v1/iot/devices") suspend fun getDevices(): Response> diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/SessionDtos.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/SessionDtos.kt index 14f0ae6..2380c91 100644 --- a/app/src/main/java/top/yeij/cyrene/data/remote/dto/SessionDtos.kt +++ b/app/src/main/java/top/yeij/cyrene/data/remote/dto/SessionDtos.kt @@ -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, +) diff --git a/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt b/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt index 4e8057b..76ef8ee 100644 --- a/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt +++ b/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt @@ -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?) { + override suspend fun sendMessage(content: String, sessionId: String?, attachments: List?, localImageUris: List) { 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 = emptyList(), ) { if (content.isBlank() && msgType == "chat" && imageDataUris.isEmpty()) return + + // Fallback: detect inline tags missed by server parsing + if (role == "assistant" && msgType == "chat") { + val actionRegex = Regex("""(.*?)\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 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 `` tags from assistant chat messages into separate messages. + * Used for bulk-loaded messages (HTTP history, WS history_response) that bypass emitMessage. + */ + private fun List.splitInlineActions(): List { + val actionRegex = Regex("""(.*?)\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() + 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, diff --git a/app/src/main/java/top/yeij/cyrene/di/AppModule.kt b/app/src/main/java/top/yeij/cyrene/di/AppModule.kt index 0c99302..eea6dc2 100644 --- a/app/src/main/java/top/yeij/cyrene/di/AppModule.kt +++ b/app/src/main/java/top/yeij/cyrene/di/AppModule.kt @@ -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()) } diff --git a/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt b/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt index cbfe949..dec197a 100644 --- a/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt +++ b/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt @@ -22,7 +22,7 @@ interface ChatRepository { suspend fun connectWebSocket(sessionId: String?) - suspend fun sendMessage(content: String, sessionId: String?, attachments: List? = null) + suspend fun sendMessage(content: String, sessionId: String?, attachments: List? = null, localImageUris: List = emptyList()) fun observeMessages(): Flow diff --git a/app/src/main/java/top/yeij/cyrene/service/KeepAliveReceiver.kt b/app/src/main/java/top/yeij/cyrene/service/KeepAliveReceiver.kt index 61ddc68..ead6732 100644 --- a/app/src/main/java/top/yeij/cyrene/service/KeepAliveReceiver.kt +++ b/app/src/main/java/top/yeij/cyrene/service/KeepAliveReceiver.kt @@ -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) } } diff --git a/app/src/main/java/top/yeij/cyrene/service/WebSocketKeepAliveService.kt b/app/src/main/java/top/yeij/cyrene/service/WebSocketKeepAliveService.kt index 9a8de84..962fa3a 100644 --- a/app/src/main/java/top/yeij/cyrene/service/WebSocketKeepAliveService.kt +++ b/app/src/main/java/top/yeij/cyrene/service/WebSocketKeepAliveService.kt @@ -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() } diff --git a/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt b/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt index 4039080..35948eb 100644 --- a/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt +++ b/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt @@ -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 diff --git a/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt b/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt index ce799d2..ea4c3e3 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt @@ -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 }, diff --git a/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt b/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt index 51b8182..6abc8e8 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt @@ -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) }, ) diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt index 397c500..5a32516 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt @@ -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() diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt index 26f2712..a9ce4b5 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt @@ -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, diff --git a/app/src/main/java/top/yeij/cyrene/util/NotificationHelper.kt b/app/src/main/java/top/yeij/cyrene/util/NotificationHelper.kt index 34d7fd2..57050fe 100644 --- a/app/src/main/java/top/yeij/cyrene/util/NotificationHelper.kt +++ b/app/src/main/java/top/yeij/cyrene/util/NotificationHelper.kt @@ -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() { diff --git a/app/src/main/java/top/yeij/cyrene/util/RuntimeLog.kt b/app/src/main/java/top/yeij/cyrene/util/RuntimeLog.kt index 9d5cde1..15f7f22 100644 --- a/app/src/main/java/top/yeij/cyrene/util/RuntimeLog.kt +++ b/app/src/main/java/top/yeij/cyrene/util/RuntimeLog.kt @@ -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 { diff --git a/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt b/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt index c8b0611..3ae47de 100644 --- a/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt @@ -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 = 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().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() } diff --git a/app/src/main/java/top/yeij/cyrene/viewmodel/OverlayViewModel.kt b/app/src/main/java/top/yeij/cyrene/viewmodel/OverlayViewModel.kt index 2b6b3e0..1e7baa4 100644 --- a/app/src/main/java/top/yeij/cyrene/viewmodel/OverlayViewModel.kt +++ b/app/src/main/java/top/yeij/cyrene/viewmodel/OverlayViewModel.kt @@ -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 diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index a4933e5..0000000 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index 11606d0..0000000 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 6b78462..0000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..9bb6e99 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ