From 367ef7f2d611e45e72bcf4cb0368109ee1915c9b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 24 May 2026 17:58:34 +0800 Subject: [PATCH] feat: reconnection, overlay UI, profile caching, history loading, log viewer, and about page - Reconnection: unlimited retries with capped backoff, forceReconnect on foreground and manual refresh when offline - Overlay: fix status bar coverage, remove scrim, fix IME layout (messages fixed at top, only full-screen IME pushes input), handle process-kill by eager ViewModel resolution with try-catch - Profile: cache-first rendering, cloud refresh on each visit, silent fallback to cache on failure - Messages: fix message swallowing by tracking DB observer job and using atomic StateFlow.update(), add dedicated isAssistantStreaming state for reliable typing indicator - History: history_response handler now emits to live message stream, HTTP fallback waits for WS connection before requesting history - Foreground: always request history on foreground to catch cross-device messages - Log viewer: enhanced with All tab, auto-scroll, new categories (HTTP, voice, general), added log points for app lifecycle and overlay events - Settings: added About page with project link (https://git.yeij.top/AskaEth/Cyrene-For-Android) Co-Authored-By: Claude Opus 4.7 --- app/src/main/AndroidManifest.xml | 30 + .../java/top/yeij/cyrene/CyreneApplication.kt | 60 +- .../main/java/top/yeij/cyrene/MainActivity.kt | 24 +- .../cyrene/data/local/PreferencesDataStore.kt | 59 ++ .../cyrene/data/local/dao/ConversationDao.kt | 3 + .../yeij/cyrene/data/local/dao/MessageDao.kt | 9 + .../top/yeij/cyrene/data/remote/ApiService.kt | 46 +- .../yeij/cyrene/data/remote/RetrofitClient.kt | 2 + .../cyrene/data/remote/TokenAuthenticator.kt | 84 +++ .../yeij/cyrene/data/remote/dto/AuthDtos.kt | 13 + .../cyrene/data/remote/dto/ConversationDto.kt | 12 - .../cyrene/data/remote/dto/ReminderDto.kt | 10 - .../cyrene/data/remote/dto/SessionDtos.kt | 38 ++ .../top/yeij/cyrene/data/remote/dto/WSDtos.kt | 13 + .../data/repository/AuthRepositoryImpl.kt | 5 +- .../data/repository/ChatRepositoryImpl.kt | 399 +++++++++-- .../main/java/top/yeij/cyrene/di/AppModule.kt | 26 +- .../domain/repository/ChatRepository.kt | 21 +- .../service/CyreneAccessibilityService.kt | 67 ++ .../cyrene/service/CyreneSessionService.kt | 27 + .../service/CyreneVoiceInteractionService.kt | 25 +- .../service/CyreneVoiceInteractionSession.kt | 102 ++- .../yeij/cyrene/service/WebSocketService.kt | 110 ++- .../yeij/cyrene/ui/components/ChatBubble.kt | 5 +- .../cyrene/ui/components/TypingIndicator.kt | 77 +++ .../top/yeij/cyrene/ui/navigation/NavGraph.kt | 56 +- .../yeij/cyrene/ui/overlay/OverlayContent.kt | 627 ++++++++++++++---- .../cyrene/ui/screens/about/AboutScreen.kt | 191 ++++++ .../yeij/cyrene/ui/screens/chat/ChatScreen.kt | 395 +++++++---- .../yeij/cyrene/ui/screens/iot/IoTScreen.kt | 4 +- .../ui/screens/profile/ProfileScreen.kt | 390 +++++++++-- .../ui/screens/settings/SettingsScreen.kt | 303 ++++++++- .../yeij/cyrene/util/NotificationHelper.kt | 64 ++ .../java/top/yeij/cyrene/util/RuntimeLog.kt | 126 ++++ .../top/yeij/cyrene/util/VoiceRecorder.kt | 158 +++++ .../yeij/cyrene/viewmodel/ChatViewModel.kt | 220 +++++- .../yeij/cyrene/viewmodel/OverlayViewModel.kt | 184 ++++- .../yeij/cyrene/viewmodel/ProfileViewModel.kt | 120 ++++ .../cyrene/viewmodel/SettingsViewModel.kt | 84 ++- .../cyrene/voice/stt/BackendSttProvider.kt | 160 +++++ .../cyrene/voice/stt/DashScopeSttService.kt | 355 ++++++++++ .../yeij/cyrene/voice/stt/SpeechRecognizer.kt | 110 ++- .../top/yeij/cyrene/voice/stt/SttManager.kt | 128 ++++ .../top/yeij/cyrene/voice/stt/SttProvider.kt | 23 + app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/accessibility_config.xml | 7 + app/src/main/res/xml/file_paths.xml | 4 + .../main/res/xml/voice_interaction_config.xml | 2 +- 48 files changed, 4439 insertions(+), 540 deletions(-) create mode 100644 app/src/main/java/top/yeij/cyrene/data/remote/TokenAuthenticator.kt delete mode 100644 app/src/main/java/top/yeij/cyrene/data/remote/dto/ConversationDto.kt delete mode 100644 app/src/main/java/top/yeij/cyrene/data/remote/dto/ReminderDto.kt create mode 100644 app/src/main/java/top/yeij/cyrene/data/remote/dto/SessionDtos.kt create mode 100644 app/src/main/java/top/yeij/cyrene/service/CyreneAccessibilityService.kt create mode 100644 app/src/main/java/top/yeij/cyrene/service/CyreneSessionService.kt create mode 100644 app/src/main/java/top/yeij/cyrene/ui/components/TypingIndicator.kt create mode 100644 app/src/main/java/top/yeij/cyrene/ui/screens/about/AboutScreen.kt create mode 100644 app/src/main/java/top/yeij/cyrene/util/NotificationHelper.kt create mode 100644 app/src/main/java/top/yeij/cyrene/util/RuntimeLog.kt create mode 100644 app/src/main/java/top/yeij/cyrene/util/VoiceRecorder.kt create mode 100644 app/src/main/java/top/yeij/cyrene/viewmodel/ProfileViewModel.kt create mode 100644 app/src/main/java/top/yeij/cyrene/voice/stt/BackendSttProvider.kt create mode 100644 app/src/main/java/top/yeij/cyrene/voice/stt/DashScopeSttService.kt create mode 100644 app/src/main/java/top/yeij/cyrene/voice/stt/SttManager.kt create mode 100644 app/src/main/java/top/yeij/cyrene/voice/stt/SttProvider.kt create mode 100644 app/src/main/res/xml/accessibility_config.xml create mode 100644 app/src/main/res/xml/file_paths.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 62d63f6..1d7dd45 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -64,6 +64,36 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt b/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt index 423470f..d26e34a 100644 --- a/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt +++ b/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt @@ -1,32 +1,74 @@ package top.yeij.cyrene +import android.app.Activity import android.app.Application +import android.os.Bundle +import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.GlobalContext +import org.koin.core.context.startKoin import top.yeij.cyrene.data.local.PreferencesDataStore import top.yeij.cyrene.data.remote.AuthInterceptor import top.yeij.cyrene.data.remote.DynamicUrlInterceptor +import top.yeij.cyrene.data.repository.ChatRepositoryImpl import top.yeij.cyrene.di.appModule -import org.koin.android.ext.koin.androidContext -import org.koin.core.context.startKoin +import top.yeij.cyrene.util.NotificationHelper +import top.yeij.cyrene.util.RuntimeLog +import java.util.concurrent.atomic.AtomicInteger class CyreneApplication : Application() { private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val activityCount = AtomicInteger(0) override fun onCreate() { super.onCreate() + RuntimeLog.general("app", "Application onCreate") startKoin { androidContext(this@CyreneApplication) modules(appModule) } + // Track foreground/background state + registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { + override fun onActivityStarted(activity: Activity) { + if (activityCount.incrementAndGet() == 1) { + RuntimeLog.general("app", "App in foreground") + getRepo()?.onAppForeground() + } + } + + override fun onActivityStopped(activity: Activity) { + if (activityCount.decrementAndGet() == 0) { + RuntimeLog.general("app", "App in background") + getRepo()?.onAppBackground() + } + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} + override fun onActivityResumed(activity: Activity) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + override fun onActivityDestroyed(activity: Activity) {} + }) + + // Set up background notification callback once Koin is ready initScope.launch { - val koin = org.koin.core.context.GlobalContext.get() + val notificationHelper = NotificationHelper(this@CyreneApplication) + val repo = getRepo() + repo?.setNotificationCallback { message -> + notificationHelper.showMessageNotification(message) + } + } + + initScope.launch { + val koin = GlobalContext.get() val prefs: PreferencesDataStore = koin.get() val urlInterceptor: DynamicUrlInterceptor = koin.get() val authInterceptor: AuthInterceptor = koin.get() @@ -39,4 +81,16 @@ class CyreneApplication : Application() { } } } + + private fun getRepo(): ChatRepositoryImpl? { + return try { + GlobalContext.get().get() + } catch (_: Exception) { + null + } + } + + companion object { + private const val TAG = "CyreneApp" + } } diff --git a/app/src/main/java/top/yeij/cyrene/MainActivity.kt b/app/src/main/java/top/yeij/cyrene/MainActivity.kt index 4be0779..e73feda 100644 --- a/app/src/main/java/top/yeij/cyrene/MainActivity.kt +++ b/app/src/main/java/top/yeij/cyrene/MainActivity.kt @@ -7,6 +7,7 @@ import android.provider.Settings import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.mutableStateOf import androidx.navigation.compose.rememberNavController import top.yeij.cyrene.service.CyreneVoiceInteractionService import top.yeij.cyrene.ui.navigation.CyreneNavGraph @@ -16,11 +17,13 @@ import top.yeij.cyrene.util.Constants class MainActivity : ComponentActivity() { + private val isDefaultAssistant = mutableStateOf(false) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - val isDefaultAssistant = checkIsDefaultAssistant() + isDefaultAssistant.value = checkIsDefaultAssistant() setContent { CyreneTheme { @@ -29,24 +32,31 @@ class MainActivity : ComponentActivity() { CyreneNavGraph( navController = navController, startDestination = Routes.MAIN, - isDefaultAssistant = isDefaultAssistant, + isDefaultAssistant = isDefaultAssistant.value, onOpenAssistantSettings = { openAssistantSettings() }, ) } } } + override fun onResume() { + super.onResume() + isDefaultAssistant.value = checkIsDefaultAssistant() + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) } private fun checkIsDefaultAssistant(): Boolean { - val componentName = ComponentName(this, CyreneVoiceInteractionService::class.java) - val intent = Intent("android.service.voice.VoiceInteractionService") - val services = packageManager.queryIntentServices(intent, 0) - return services.any { it.serviceInfo.packageName == packageName } - && CyreneVoiceInteractionService.isActive + // Standard Android check + val flat = ComponentName(this, CyreneVoiceInteractionService::class.java).flattenToString() + val current = Settings.Secure.getString(contentResolver, "voice_interaction_service") + if (current == flat) return true + // Fallback for COS and other custom OS: check persisted flag from service + if (CyreneVoiceInteractionService.wasEverActive(this)) return true + return false } private fun openAssistantSettings() { diff --git a/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt b/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt index 44fbe21..314955c 100644 --- a/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt +++ b/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt @@ -22,6 +22,15 @@ class PreferencesDataStore(private val context: Context) { private val KEY_USERNAME = stringPreferencesKey("username") private val KEY_CLIENT_ID = stringPreferencesKey("client_id") private val KEY_DEVICE_NAME = stringPreferencesKey("device_name") + private val KEY_CURRENT_SESSION_ID = stringPreferencesKey("current_session_id") + private val KEY_DASHSCOPE_API_KEY = stringPreferencesKey("dashscope_api_key") + private val KEY_DASHSCOPE_ENDPOINT = stringPreferencesKey("dashscope_endpoint") + private val KEY_DASHSCOPE_MODEL = stringPreferencesKey("dashscope_model") + private val KEY_LAST_CLEARED_TIMESTAMP = stringPreferencesKey("last_cleared_timestamp") + private val KEY_PROFILE_USER_ID = stringPreferencesKey("profile_user_id") + private val KEY_PROFILE_NICKNAME = stringPreferencesKey("profile_nickname") + private val KEY_PROFILE_IS_ADMIN = stringPreferencesKey("profile_is_admin") + private val KEY_PROFILE_CREATED_AT = stringPreferencesKey("profile_created_at") } val token: Flow = context.dataStore.data.map { it[KEY_TOKEN] } @@ -32,6 +41,10 @@ class PreferencesDataStore(private val context: Context) { val username: Flow = context.dataStore.data.map { it[KEY_USERNAME] } val clientId: Flow = context.dataStore.data.map { it[KEY_CLIENT_ID] } val deviceName: Flow = context.dataStore.data.map { it[KEY_DEVICE_NAME] } + val currentSessionId: Flow = context.dataStore.data.map { it[KEY_CURRENT_SESSION_ID] } + val dashScopeApiKey: Flow = context.dataStore.data.map { it[KEY_DASHSCOPE_API_KEY] } + val dashScopeEndpoint: Flow = context.dataStore.data.map { it[KEY_DASHSCOPE_ENDPOINT] } + val dashScopeModel: Flow = context.dataStore.data.map { it[KEY_DASHSCOPE_MODEL] } suspend fun saveToken(token: String) { context.dataStore.edit { it[KEY_TOKEN] = token } @@ -57,6 +70,18 @@ class PreferencesDataStore(private val context: Context) { context.dataStore.edit { it[KEY_USERNAME] = username } } + suspend fun saveDashScopeApiKey(key: String) { + context.dataStore.edit { it[KEY_DASHSCOPE_API_KEY] = key } + } + + suspend fun saveDashScopeEndpoint(endpoint: String) { + context.dataStore.edit { it[KEY_DASHSCOPE_ENDPOINT] = endpoint } + } + + suspend fun saveDashScopeModel(model: String) { + context.dataStore.edit { it[KEY_DASHSCOPE_MODEL] = model } + } + suspend fun saveClientId(id: String) { context.dataStore.edit { it[KEY_CLIENT_ID] = id } } @@ -65,6 +90,40 @@ class PreferencesDataStore(private val context: Context) { context.dataStore.edit { it[KEY_DEVICE_NAME] = name } } + suspend fun saveCurrentSessionId(id: String) { + context.dataStore.edit { it[KEY_CURRENT_SESSION_ID] = id } + } + + val lastClearedTimestamp: Flow = context.dataStore.data.map { it[KEY_LAST_CLEARED_TIMESTAMP] } + + suspend fun saveLastClearedTimestamp(timestamp: Long) { + context.dataStore.edit { it[KEY_LAST_CLEARED_TIMESTAMP] = timestamp.toString() } + } + + // Cached profile for offline / local-first rendering + val profileUserId: Flow = context.dataStore.data.map { it[KEY_PROFILE_USER_ID] } + val profileNickname: Flow = context.dataStore.data.map { it[KEY_PROFILE_NICKNAME] } + val profileIsAdmin: Flow = context.dataStore.data.map { it[KEY_PROFILE_IS_ADMIN] } + val profileCreatedAt: Flow = context.dataStore.data.map { it[KEY_PROFILE_CREATED_AT] } + + suspend fun saveProfileCache(userId: String, nickname: String, isAdmin: Boolean, createdAt: String) { + context.dataStore.edit { + it[KEY_PROFILE_USER_ID] = userId + it[KEY_PROFILE_NICKNAME] = nickname + it[KEY_PROFILE_IS_ADMIN] = isAdmin.toString() + it[KEY_PROFILE_CREATED_AT] = createdAt + } + } + + suspend fun clearProfileCache() { + context.dataStore.edit { + it.remove(KEY_PROFILE_USER_ID) + it.remove(KEY_PROFILE_NICKNAME) + it.remove(KEY_PROFILE_IS_ADMIN) + it.remove(KEY_PROFILE_CREATED_AT) + } + } + suspend fun clearAll() { context.dataStore.edit { it.clear() } } diff --git a/app/src/main/java/top/yeij/cyrene/data/local/dao/ConversationDao.kt b/app/src/main/java/top/yeij/cyrene/data/local/dao/ConversationDao.kt index 69ee23d..81156bc 100644 --- a/app/src/main/java/top/yeij/cyrene/data/local/dao/ConversationDao.kt +++ b/app/src/main/java/top/yeij/cyrene/data/local/dao/ConversationDao.kt @@ -13,6 +13,9 @@ interface ConversationDao { @Query("SELECT * FROM conversations ORDER BY updatedAt DESC") fun getAll(): Flow> + @Query("SELECT * FROM conversations ORDER BY updatedAt DESC") + suspend fun getAllSnapshot(): List + @Query("SELECT * FROM conversations WHERE id = :id") suspend fun getById(id: String): ConversationEntity? 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 02c4173..f07490d 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 @@ -21,4 +21,13 @@ interface MessageDao { @Query("DELETE FROM messages WHERE conversationId = :conversationId") suspend fun deleteByConversation(conversationId: String) + + @Query("UPDATE messages SET conversationId = :newId WHERE conversationId = :oldId") + suspend fun migrateConversationId(oldId: String, newId: String) + + @Query("DELETE FROM messages WHERE id = :id") + suspend fun deleteById(id: String) + + @Query("DELETE FROM messages") + suspend fun deleteAll() } 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 6de0fcc..a0aeb35 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 @@ -6,12 +6,17 @@ import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Path +import retrofit2.http.Query import top.yeij.cyrene.data.remote.dto.AuthRequest import top.yeij.cyrene.data.remote.dto.AuthResponse -import top.yeij.cyrene.data.remote.dto.ConversationDto +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.IoTControlRequest -import top.yeij.cyrene.data.remote.dto.ReminderDto +import top.yeij.cyrene.data.remote.dto.MessagesListResponse +import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest +import top.yeij.cyrene.data.remote.dto.SessionDto +import top.yeij.cyrene.data.remote.dto.SessionsListResponse interface ApiService { @@ -20,16 +25,32 @@ interface ApiService { suspend fun login(@Body request: AuthRequest): Response @POST("api/v1/auth/refresh") - suspend fun refreshToken(@Body refreshToken: String): Response + suspend fun refreshToken(@Body request: RefreshTokenRequest): Response - // Conversations - @GET("api/v1/conversations") - suspend fun getConversations(): Response> + @GET("api/v1/profile") + suspend fun getProfile(): Response - @DELETE("api/v1/conversations/{id}") - suspend fun deleteConversation(@Path("id") id: String): Response + // Sessions + @GET("api/v1/sessions") + suspend fun getSessions(): Response - // IoT + @POST("api/v1/sessions") + suspend fun createSession(@Body request: CreateSessionRequest): Response + + @DELETE("api/v1/sessions/{id}") + suspend fun deleteSession(@Path("id") id: String): Response + + @DELETE("api/v1/sessions/{id}/messages") + suspend fun clearSessionMessages(@Path("id") sessionId: String): Response + + @GET("api/v1/sessions/{id}/messages") + suspend fun getSessionMessages( + @Path("id") sessionId: String, + @Query("limit") limit: Int = 50, + @Query("offset") offset: Int = 0, + ): Response + + // IoT — 注意:网关 API 文档未列出 IoT 端点,需确认网关是否代理了 /api/v1/iot/* @GET("api/v1/iot/devices") suspend fun getDevices(): Response> @@ -38,11 +59,4 @@ interface ApiService { @Path("id") deviceId: String, @Body request: IoTControlRequest, ): Response - - // Reminders - @GET("api/v1/reminders") - suspend fun getReminders(): Response> - - @DELETE("api/v1/reminders/{id}") - suspend fun deleteReminder(@Path("id") id: String): Response } diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/RetrofitClient.kt b/app/src/main/java/top/yeij/cyrene/data/remote/RetrofitClient.kt index a68174a..8b327ca 100644 --- a/app/src/main/java/top/yeij/cyrene/data/remote/RetrofitClient.kt +++ b/app/src/main/java/top/yeij/cyrene/data/remote/RetrofitClient.kt @@ -11,6 +11,7 @@ object RetrofitClient { fun provideOkHttpClient( authInterceptor: AuthInterceptor, dynamicUrlInterceptor: DynamicUrlInterceptor, + tokenAuthenticator: TokenAuthenticator, ): OkHttpClient { val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY @@ -19,6 +20,7 @@ object RetrofitClient { return OkHttpClient.Builder() .addInterceptor(dynamicUrlInterceptor) .addInterceptor(authInterceptor) + .authenticator(tokenAuthenticator) .addInterceptor(logging) .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/TokenAuthenticator.kt b/app/src/main/java/top/yeij/cyrene/data/remote/TokenAuthenticator.kt new file mode 100644 index 0000000..9cac278 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/data/remote/TokenAuthenticator.kt @@ -0,0 +1,84 @@ +package top.yeij.cyrene.data.remote + +import com.google.gson.Gson +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.runBlocking +import okhttp3.Authenticator +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Route +import top.yeij.cyrene.data.local.PreferencesDataStore +import top.yeij.cyrene.data.remote.dto.AuthResponse +import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest +import android.util.Log + +class TokenAuthenticator( + private val authInterceptor: AuthInterceptor, + private val preferencesDataStore: PreferencesDataStore, + private val dynamicUrlInterceptor: DynamicUrlInterceptor, +) : Authenticator { + + private val gson = Gson() + private val client = OkHttpClient() + + override fun authenticate(route: Route?, response: okhttp3.Response): Request? { + if (response.code != 401) return null + + synchronized(this) { + // Double-check: if token changed while we were waiting for lock, use it + val currentToken = authInterceptor.token + val requestAuthHeader = response.request.header("Authorization") + if (requestAuthHeader != null && currentToken != null && + requestAuthHeader != "Bearer $currentToken" + ) { + return response.request.newBuilder() + .header("Authorization", "Bearer $currentToken") + .build() + } + + // Try refresh + return try { + val refreshToken = runBlocking { preferencesDataStore.refreshToken.firstOrNull() } + ?: return null + + val baseUrl = dynamicUrlInterceptor.baseUrl.trimEnd('/') + val refreshUrl = "$baseUrl/api/v1/auth/refresh" + + val jsonBody = gson.toJson(RefreshTokenRequest(refreshToken)) + val body = jsonBody.toRequestBody("application/json".toMediaType()) + + val refreshRequest = Request.Builder() + .url(refreshUrl) + .post(body) + .build() + + val refreshResponse = client.newCall(refreshRequest).execute() + if (refreshResponse.isSuccessful) { + val authResponse = gson.fromJson( + refreshResponse.body?.string(), + AuthResponse::class.java + ) + authInterceptor.token = authResponse.token + runBlocking { + preferencesDataStore.saveToken(authResponse.token) + authResponse.refreshToken?.let { + preferencesDataStore.saveRefreshToken(it) + } + } + Log.i("TokenAuthenticator", "Token refreshed successfully") + response.request.newBuilder() + .header("Authorization", "Bearer ${authResponse.token}") + .build() + } else { + Log.w("TokenAuthenticator", "Token refresh failed: ${refreshResponse.code}") + null + } + } catch (e: Exception) { + Log.e("TokenAuthenticator", "Token refresh error: ${e.message}", e) + null + } + } + } +} diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/AuthDtos.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/AuthDtos.kt index 037f509..6828016 100644 --- a/app/src/main/java/top/yeij/cyrene/data/remote/dto/AuthDtos.kt +++ b/app/src/main/java/top/yeij/cyrene/data/remote/dto/AuthDtos.kt @@ -11,6 +11,19 @@ data class AuthResponse( @SerializedName("token") val token: String, @SerializedName("refresh_token") val refreshToken: String?, @SerializedName("username") val username: String?, + @SerializedName("nickname") val nickname: String?, @SerializedName("user_id") val userId: String?, @SerializedName("expires") val expires: Long? = null, ) + +data class RefreshTokenRequest( + @SerializedName("refresh_token") val refreshToken: String, +) + +data class ProfileResponse( + @SerializedName("user_id") val userId: String, + @SerializedName("username") val username: String, + @SerializedName("nickname") val nickname: String?, + @SerializedName("is_admin") val isAdmin: Boolean? = false, + @SerializedName("created_at") val createdAt: String? = null, +) diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/ConversationDto.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/ConversationDto.kt deleted file mode 100644 index fffd7d5..0000000 --- a/app/src/main/java/top/yeij/cyrene/data/remote/dto/ConversationDto.kt +++ /dev/null @@ -1,12 +0,0 @@ -package top.yeij.cyrene.data.remote.dto - -import com.google.gson.annotations.SerializedName - -data class ConversationDto( - @SerializedName("id") val id: String, - @SerializedName("title") val title: String, - @SerializedName("last_message") val lastMessage: String?, - @SerializedName("last_message_type") val lastMessageType: String?, - @SerializedName("updated_at") val updatedAt: String, - @SerializedName("created_at") val createdAt: String, -) diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/ReminderDto.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/ReminderDto.kt deleted file mode 100644 index 4e23e9b..0000000 --- a/app/src/main/java/top/yeij/cyrene/data/remote/dto/ReminderDto.kt +++ /dev/null @@ -1,10 +0,0 @@ -package top.yeij.cyrene.data.remote.dto - -import com.google.gson.annotations.SerializedName - -data class ReminderDto( - @SerializedName("id") val id: String, - @SerializedName("content") val content: String, - @SerializedName("trigger_at") val triggerAt: String, - @SerializedName("completed") val completed: Boolean, -) 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 new file mode 100644 index 0000000..14f0ae6 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/data/remote/dto/SessionDtos.kt @@ -0,0 +1,38 @@ +package top.yeij.cyrene.data.remote.dto + +import com.google.gson.annotations.SerializedName + +// POST /api/v1/sessions — request body +data class CreateSessionRequest( + @SerializedName("session_id") val sessionId: String? = null, + @SerializedName("title") val title: String = "新的对话", + @SerializedName("is_main") val isMain: Boolean = false, +) + +// GET /api/v1/sessions — response wrapper +data class SessionsListResponse( + @SerializedName("sessions") val sessions: List, +) + +data class SessionDto( + @SerializedName("id") val id: String, + @SerializedName("user_id") val userId: String?, + @SerializedName("title") val title: String, + @SerializedName("is_main") val isMain: Boolean?, + @SerializedName("created_at") val createdAt: Long, + @SerializedName("updated_at") val updatedAt: Long, +) + +// GET /api/v1/sessions/{id}/messages — response wrapper +data class MessagesListResponse( + @SerializedName("messages") val messages: List, +) + +data class SessionMessageDto( + @SerializedName("id") val id: String, + @SerializedName("session_id") val sessionId: String, + @SerializedName("role") val role: String, + @SerializedName("msg_type") val msgType: String?, + @SerializedName("content") val content: String, + @SerializedName("created_at") val createdAt: Long, +) diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt index 67846a8..9dd58ba 100644 --- a/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt +++ b/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt @@ -9,12 +9,25 @@ data class WSClientMessage( @SerializedName("session_id") val sessionId: String? = null, @SerializedName("mode") val mode: String? = null, @SerializedName("content") val content: String? = null, + @SerializedName("audio_data") val audioData: String? = null, + @SerializedName("attachments") val attachments: List? = null, @SerializedName("timestamp") val timestamp: Long? = null, @SerializedName("client_id") val clientId: String? = null, @SerializedName("device_name") val deviceName: String? = null, @SerializedName("user_agent") val userAgent: String? = null, ) +data class WSAttachment( + @SerializedName("type") val type: String, + @SerializedName("url") val url: String? = null, + @SerializedName("thumbnail_url") val thumbnailUrl: String? = null, + @SerializedName("filename") val filename: String? = null, + @SerializedName("width") val width: Int? = null, + @SerializedName("height") val height: Int? = null, + @SerializedName("size") val size: Long? = null, + @SerializedName("description") val description: String? = null, +) + // --- Server → Client --- data class WSClientInfo( diff --git a/app/src/main/java/top/yeij/cyrene/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/top/yeij/cyrene/data/repository/AuthRepositoryImpl.kt index 744ea8a..75f8260 100644 --- a/app/src/main/java/top/yeij/cyrene/data/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/top/yeij/cyrene/data/repository/AuthRepositoryImpl.kt @@ -22,12 +22,13 @@ class AuthRepositoryImpl( authInterceptor.token = body.token preferencesDataStore.saveToken(body.token) body.refreshToken?.let { preferencesDataStore.saveRefreshToken(it) } - preferencesDataStore.saveUsername(body.username ?: body.userId ?: "开拓者") + val displayName = body.nickname ?: body.username ?: body.userId ?: "开拓者" + preferencesDataStore.saveUsername(displayName) Result.success( AuthResult( token = body.token, refreshToken = body.refreshToken, - username = body.username ?: body.userId ?: "开拓者", + username = displayName, ) ) } else { 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 9d57603..11d8b53 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,16 +12,22 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withTimeoutOrNull +import top.yeij.cyrene.data.local.PreferencesDataStore import top.yeij.cyrene.data.local.dao.ConversationDao import top.yeij.cyrene.data.local.dao.MessageDao import top.yeij.cyrene.data.local.entity.ConversationEntity import top.yeij.cyrene.data.local.entity.MessageEntity import top.yeij.cyrene.data.remote.ApiService +import top.yeij.cyrene.data.remote.dto.CreateSessionRequest import top.yeij.cyrene.data.remote.dto.WSServerMessage import top.yeij.cyrene.domain.model.Conversation import top.yeij.cyrene.domain.model.Message import top.yeij.cyrene.domain.repository.ChatRepository import top.yeij.cyrene.service.WebSocketService +import top.yeij.cyrene.util.RuntimeLog import java.util.UUID class ChatRepositoryImpl( @@ -29,6 +35,7 @@ class ChatRepositoryImpl( private val messageDao: MessageDao, private val webSocketService: WebSocketService, private val apiService: ApiService, + private val preferencesDataStore: PreferencesDataStore, ) : ChatRepository { private val exceptionHandler = CoroutineExceptionHandler { _, e -> @@ -39,25 +46,79 @@ class ChatRepositoryImpl( private val _connectionState = MutableStateFlow(false) override val connectionState: StateFlow = _connectionState.asStateFlow() + override val connectionError: StateFlow = webSocketService.connectionError + private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 64) override fun observeMessages(): Flow = _incomingMessages + private val _messageClearEvents = MutableSharedFlow(extraBufferCapacity = 4) + override val messageClearEvents: Flow = _messageClearEvents + + private val _isAssistantStreaming = MutableStateFlow(false) + override val isAssistantStreaming: StateFlow = _isAssistantStreaming.asStateFlow() + private var streamingContent = "" private var streamingMessageId: String? = null - private var currentSessionId: String? = null + override var currentSessionId: String? = null + + private var isAppInForeground = false + private var onBackgroundNotification: ((Message) -> Unit)? = null + private var historyRequested = false + + // Duplicate suppression: track items from review/multi_message to skip wrapping response + private val recentParsedContents = mutableListOf() + private var lastParsedTime = 0L + // Track last response to clean up if review/multi_message arrives after + private var lastResponseId: String? = null + private var lastResponseContent: String? = null + private var lastResponseTime = 0L + + fun setNotificationCallback(callback: ((Message) -> Unit)?) { + onBackgroundNotification = callback + } + + override fun onAppForeground() { + isAppInForeground = true + if (!_connectionState.value) { + webSocketService.forceReconnect() + } + // Always request history on foreground to catch cross-device messages + scope.launch { + val sid = currentSessionId ?: return@launch + RuntimeLog.general("app", "Foreground — requesting history for session=$sid") + requestHistoryViaWs(sid) + } + } + + override fun onAppBackground() { + isAppInForeground = false + } init { + // Restore persisted session ID, then connect and load history + scope.launch { + val persistedSid = preferencesDataStore.currentSessionId.firstOrNull() + if (!persistedSid.isNullOrBlank()) { + currentSessionId = persistedSid + } + RuntimeLog.ws("init", "Connecting WebSocket session=$currentSessionId") + webSocketService.connect(currentSessionId) + loadConversationsFromServer() + } scope.launch { webSocketService.isConnected.collect { connected -> _connectionState.value = connected + RuntimeLog.ws("connection", "Connected=$connected") } } scope.launch { webSocketService.incomingMessages.collect { wsMsg -> try { + RuntimeLog.ws("receive", "type=${wsMsg.type} msgId=${wsMsg.messageId ?: "-"}") handleServerMessage(wsMsg) } catch (e: Exception) { Log.e("ChatRepository", "Error handling ${wsMsg.type}: ${e.message}", e) + RuntimeLog.ws("error", "Handle error type=${wsMsg.type}: ${e.message}") } } } @@ -77,7 +138,39 @@ class ChatRepositoryImpl( override suspend fun deleteConversation(id: String) { conversationDao.deleteById(id) - try { apiService.deleteConversation(id) } catch (_: Exception) { } + try { apiService.deleteSession(id) } catch (_: Exception) { } + } + + override suspend fun clearLocalMessages() { + val now = System.currentTimeMillis() + messageDao.deleteAll() + preferencesDataStore.saveLastClearedTimestamp(now) + + // Also clear server-side messages for all known sessions + try { + val sessions = conversationDao.getAllSnapshot() + sessions.forEach { session -> + try { + apiService.clearSessionMessages(session.id) + RuntimeLog.chat("clear", "Server messages cleared for session=${session.id}") + } catch (_: Exception) { } + } + } catch (_: Exception) { } + + _messageClearEvents.tryEmit(Unit) + + RuntimeLog.chat("clear", "Local messages cleared, timestamp=$now") + Log.i("ChatRepository", "Local messages cleared, timestamp: $now") + } + + private suspend fun changeSessionId(newId: String) { + val oldId = currentSessionId + if (oldId != null && oldId != newId) { + messageDao.migrateConversationId(oldId, newId) + conversationDao.deleteById(oldId) + } + currentSessionId = newId + preferencesDataStore.saveCurrentSessionId(newId) } override suspend fun connectWebSocket(sessionId: String?) { @@ -85,15 +178,26 @@ class ChatRepositoryImpl( webSocketService.connect(sessionId) } - override suspend fun disconnectWebSocket() { + override suspend fun reconnectWebSocket() { webSocketService.disconnect() + webSocketService.connect(currentSessionId) + } + + override suspend fun ensureConnected() { + if (_connectionState.value) return + webSocketService.forceReconnect() } override suspend fun sendMessage(content: String, sessionId: String?) { val messageId = UUID.randomUUID().toString() val now = System.currentTimeMillis() val sid = sessionId ?: currentSessionId ?: "default" - currentSessionId = sid + if (currentSessionId == null) { + currentSessionId = sid + scope.launch { preferencesDataStore.saveCurrentSessionId(sid) } + } + + RuntimeLog.chat("send", "session=$sid msgId=$messageId content=${content.take(80)}") conversationDao.upsert( ConversationEntity( @@ -117,7 +221,6 @@ class ChatRepositoryImpl( ) ) - // Emit user message to UI emitMessage( id = messageId, sessionId = sid, @@ -133,18 +236,17 @@ class ChatRepositoryImpl( override suspend fun loadConversationsFromServer() { try { - val response = apiService.getConversations() + val response = apiService.getSessions() if (response.isSuccessful) { - response.body()?.forEach { dto -> - val timestamp = try { dto.updatedAt.toLong() } catch (_: Exception) { System.currentTimeMillis() } + response.body()?.sessions?.forEach { dto -> conversationDao.upsert( ConversationEntity( id = dto.id, title = dto.title, - lastMessage = dto.lastMessage, - lastMessageType = dto.lastMessageType, - updatedAt = timestamp, - createdAt = timestamp, + lastMessage = "", + lastMessageType = "chat", + updatedAt = dto.updatedAt, + createdAt = dto.createdAt, ) ) } @@ -152,11 +254,103 @@ class ChatRepositoryImpl( } catch (_: Exception) { } } + override suspend fun sendScreenContext(content: String) { + webSocketService.sendScreenContext(content, currentSessionId) + } + + override suspend fun sendVoiceInput(audioBase64: String, mode: String) { + webSocketService.sendVoiceInput(audioBase64, currentSessionId, mode) + } + + override suspend fun initializeSession(): String { + // Try to find an existing main session on the server + try { + val response = apiService.getSessions() + if (response.isSuccessful) { + val sessions = response.body()?.sessions ?: emptyList() + val mainSession = sessions.find { it.isMain == true } + if (mainSession != null) { + currentSessionId = mainSession.id + preferencesDataStore.saveCurrentSessionId(mainSession.id) + Log.i("ChatRepository", "Found main session: ${mainSession.id}") + return mainSession.id + } + } + } catch (_: Exception) { } + + // No main session found on server, create one with deterministic ID + val sessionId = "session_admin_main" + try { + apiService.createSession( + CreateSessionRequest( + sessionId = sessionId, + title = "主对话", + isMain = true, + ) + ) + Log.i("ChatRepository", "Created main session: $sessionId") + } catch (e: Exception) { + Log.w("ChatRepository", "Failed to create main session: ${e.message}") + } + + currentSessionId = sessionId + preferencesDataStore.saveCurrentSessionId(sessionId) + return sessionId + } + override suspend fun loadMessagesFromServer(sessionId: String): List { currentSessionId = sessionId - // Send history request via WebSocket + return try { + val response = apiService.getSessionMessages(sessionId) + if (response.isSuccessful) { + val messageDtos = response.body()?.messages ?: emptyList() + val lastCleared = preferencesDataStore.lastClearedTimestamp.firstOrNull() + ?.toLongOrNull() ?: 0L + val filteredDtos = messageDtos.filter { it.createdAt > lastCleared } + ensureConversation(sessionId) + filteredDtos.forEach { dto -> + messageDao.upsert( + MessageEntity( + id = "db_${dto.id}", + conversationId = sessionId, + role = dto.role, + content = dto.content, + msgType = dto.msgType ?: "chat", + timestamp = dto.createdAt, + ) + ) + } + RuntimeLog.http("loadMessages", "HTTP loaded ${filteredDtos.size} messages for session=$sessionId") + filteredDtos.map { dto -> + Message( + id = "db_${dto.id}", + conversationId = sessionId, + role = dto.role, + content = dto.content, + msgType = dto.msgType ?: "chat", + timestamp = dto.createdAt, + ) + }.removeWrappingDuplicates() + } else { + RuntimeLog.http("loadMessages", "HTTP failed: ${response.code()} ${response.message()}, trying WS") + requestHistoryViaWs(sessionId) + emptyList() + } + } catch (e: Exception) { + RuntimeLog.http("loadMessages", "HTTP error: ${e.message}, trying WS") + requestHistoryViaWs(sessionId) + emptyList() + } + } + + private suspend fun requestHistoryViaWs(sessionId: String) { + // Wait up to 5s for WS to connect + if (!webSocketService.isConnected.value) { + withTimeoutOrNull(5000) { + webSocketService.isConnected.first { it } + } + } webSocketService.requestHistory(sessionId) - return emptyList() } private suspend fun ensureConversation(sessionId: String, lastMessage: String = "") { @@ -181,6 +375,8 @@ class ChatRepositoryImpl( "stream_start" -> { streamingContent = "" streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}" + _isAssistantStreaming.value = true + RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId") } "stream_chunk" -> { @@ -202,22 +398,28 @@ class ChatRepositoryImpl( streamingContent = "" streamingMessageId = null val sid = wsMsg.sessionId ?: currentSessionId ?: "default" - currentSessionId = sid + if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) { + changeSessionId(sid) + } val ts = wsMsg.timestamp ?: System.currentTimeMillis() - ensureConversation(sid, content) - messageDao.upsert( - MessageEntity( - id = msgId, - conversationId = sid, - role = "assistant", - content = content, - msgType = "chat", - timestamp = ts, + if (content.isNotBlank()) { + ensureConversation(sid, content) + messageDao.upsert( + MessageEntity( + id = msgId, + conversationId = sid, + role = "assistant", + content = content, + msgType = "chat", + timestamp = ts, + ) ) - ) + } - emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = "chat", timestamp = ts, isStreaming = false) + emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = "chat", timestamp = ts, isStreaming = false, shouldNotify = true) + _isAssistantStreaming.value = false + RuntimeLog.chat("stream", "Stream end msgId=$msgId content=${content.take(80)}") } "response" -> { @@ -226,7 +428,22 @@ class ChatRepositoryImpl( val replyMsgType = wsMsg.msgType ?: "chat" val msgId = wsMsg.messageId ?: "r_${System.currentTimeMillis()}" val sid = wsMsg.sessionId ?: currentSessionId ?: "default" - currentSessionId = sid + + // Suppress response if it wraps recently emitted review/multi_message items + val timeSinceParsed = System.currentTimeMillis() - lastParsedTime + if (timeSinceParsed < 3000 && recentParsedContents.isNotEmpty()) { + val allContained = recentParsedContents.all { text.contains(it) } + if (allContained) { + RuntimeLog.chat("dedup", "Suppressed wrapping response, ${recentParsedContents.size} items already shown") + recentParsedContents.clear() + return + } + } + recentParsedContents.clear() + + if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) { + changeSessionId(sid) + } val ts = wsMsg.timestamp ?: System.currentTimeMillis() ensureConversation(sid, text) @@ -241,18 +458,27 @@ class ChatRepositoryImpl( ) ) - emitMessage(id = msgId, sessionId = sid, role = role, content = text, msgType = replyMsgType, timestamp = ts, isStreaming = false) + lastResponseId = msgId + lastResponseContent = text + lastResponseTime = System.currentTimeMillis() + + 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 text = review.content ?: review.text ?: return@forEach val role = review.role ?: "action" val rvMsgType = review.msgType ?: review.role ?: "action" val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}" - + recentParsedContents.add(text) emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = text, msgType = rvMsgType, isStreaming = false) } + if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis() + // Clean up wrapping response that arrived before this review + cleanupWrappingResponse() } "thinking" -> { @@ -285,6 +511,7 @@ class ChatRepositoryImpl( } "error" -> { + RuntimeLog.chat("error", "Server error: ${wsMsg.error ?: "未知错误"}") emitMessage( id = "err_${System.currentTimeMillis()}", sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", @@ -295,47 +522,102 @@ class ChatRepositoryImpl( ) } + "voice_transcript" -> { + val text = wsMsg.text ?: wsMsg.content ?: return + val sid = wsMsg.sessionId ?: currentSessionId ?: "default" + val ts = wsMsg.timestamp ?: System.currentTimeMillis() + val msgId = wsMsg.messageId ?: "vt_${System.currentTimeMillis()}" + ensureConversation(sid) + messageDao.upsert( + MessageEntity( + id = msgId, + conversationId = sid, + role = "user", + content = text, + msgType = "chat", + timestamp = ts, + ) + ) + emitMessage(id = msgId, sessionId = sid, role = "user", content = text, msgType = "chat", timestamp = ts, isStreaming = false) + } + "history_response" -> { val sid = wsMsg.sessionId ?: currentSessionId ?: "default" + if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) { + changeSessionId(sid) + } ensureConversation(sid) - wsMsg.messages?.forEach { hist -> + val messages = wsMsg.messages ?: return + val messageList = messages.map { hist -> val msgId = hist.id ?: "hist_${System.currentTimeMillis()}_${hist.hashCode()}" - val role = hist.role ?: "system" - val content = hist.content ?: "" - val msgType = hist.msgType ?: "chat" - val ts = hist.timestamp ?: System.currentTimeMillis() - + Message( + id = msgId, + conversationId = sid, + role = hist.role ?: "system", + content = hist.content ?: "", + msgType = hist.msgType ?: "chat", + timestamp = hist.timestamp ?: System.currentTimeMillis(), + ) + } + val deduped = messageList.removeWrappingDuplicates() + deduped.forEach { msg -> messageDao.upsert( MessageEntity( - id = msgId, - conversationId = sid, - role = role, - content = content, - msgType = msgType, - timestamp = ts, + id = msg.id, + conversationId = msg.conversationId, + role = msg.role, + content = msg.content, + msgType = msg.msgType, + timestamp = msg.timestamp, ) ) - - emitMessage(id = msgId, sessionId = sid, role = role, content = content, msgType = msgType, timestamp = ts, isStreaming = false) + emitMessage( + id = msg.id, + sessionId = msg.conversationId, + role = msg.role, + content = msg.content, + msgType = msg.msgType, + timestamp = msg.timestamp, + isStreaming = false, + shouldNotify = false, + ) } + RuntimeLog.chat("history", "Loaded ${deduped.size} messages from server history") } "multi_message" -> { + recentParsedContents.clear() wsMsg.multiMessages?.forEach { item -> + val content = item.content ?: "" + recentParsedContents.add(content) emitMessage( id = "mm_${System.currentTimeMillis()}_${item.hashCode()}", sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = item.role ?: "assistant", - content = item.content ?: "", + content = content, msgType = item.msgType ?: "chat", timestamp = wsMsg.timestamp ?: System.currentTimeMillis(), isStreaming = false, ) } + if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis() + cleanupWrappingResponse() } } } + private suspend fun cleanupWrappingResponse() { + val respId = lastResponseId ?: return + val respContent = lastResponseContent ?: return + val timeSinceResponse = System.currentTimeMillis() - lastResponseTime + if (timeSinceResponse > 5000 || recentParsedContents.size < 2) return + val allContained = recentParsedContents.all { respContent.contains(it) } + if (allContained) { + messageDao.deleteById(respId) + RuntimeLog.chat("dedup", "Cleaned up wrapping response from DB id=$respId") + } + } + private fun emitMessage( id: String, sessionId: String, @@ -344,8 +626,8 @@ class ChatRepositoryImpl( msgType: String, isStreaming: Boolean = false, timestamp: Long = System.currentTimeMillis(), + shouldNotify: Boolean = false, ) { - // Skip messages with empty content to prevent empty bubbles if (content.isBlank() && msgType == "chat") return val message = Message( id = id, @@ -357,6 +639,33 @@ class ChatRepositoryImpl( isStreaming = isStreaming, ) _incomingMessages.tryEmit(message) + + if (shouldNotify && !isAppInForeground && role == "assistant" && !isStreaming) { + onBackgroundNotification?.invoke(message) + } + } + + /** + * Remove wrapper messages whose content contains the content of 2+ other messages. + * This handles the case where the server sends both a combined "response" and + * parsed "review"/"multi_message" items for the same turn. + */ + private fun List.removeWrappingDuplicates(): List { + if (size < 3) return this + val toRemove = mutableSetOf() + for (msg in this) { + val containedCount = count { other -> + other.id != msg.id && + other.content.isNotBlank() && + other.content.length < msg.content.length && + msg.content.contains(other.content) && + kotlin.math.abs(other.timestamp - msg.timestamp) < 2000 + } + if (containedCount >= 2) { + toRemove.add(msg.id) + } + } + return if (toRemove.isEmpty()) this else filter { it.id !in toRemove } } private fun ConversationEntity.toDomain() = Conversation( 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 e8b03ef..02b7aef 100644 --- a/app/src/main/java/top/yeij/cyrene/di/AppModule.kt +++ b/app/src/main/java/top/yeij/cyrene/di/AppModule.kt @@ -9,6 +9,7 @@ import top.yeij.cyrene.data.remote.ApiService import top.yeij.cyrene.data.remote.AuthInterceptor import top.yeij.cyrene.data.remote.DynamicUrlInterceptor import top.yeij.cyrene.data.remote.RetrofitClient +import top.yeij.cyrene.data.remote.TokenAuthenticator import top.yeij.cyrene.data.repository.AuthRepositoryImpl import top.yeij.cyrene.data.repository.ChatRepositoryImpl import top.yeij.cyrene.data.repository.IoTRepositoryImpl @@ -22,8 +23,13 @@ import top.yeij.cyrene.service.WebSocketService import top.yeij.cyrene.viewmodel.ChatViewModel import top.yeij.cyrene.viewmodel.IoTViewModel import top.yeij.cyrene.viewmodel.OverlayViewModel +import top.yeij.cyrene.viewmodel.ProfileViewModel import top.yeij.cyrene.viewmodel.SettingsViewModel +import top.yeij.cyrene.util.VoiceRecorder +import top.yeij.cyrene.voice.stt.BackendSttProvider +import top.yeij.cyrene.voice.stt.DashScopeSttService import top.yeij.cyrene.voice.stt.SpeechRecognizer +import top.yeij.cyrene.voice.stt.SttManager import top.yeij.cyrene.voice.tts.TextToSpeechEngine val appModule = module { @@ -36,10 +42,11 @@ val appModule = module { single { get().conversationDao() } single { get().messageDao() } - // Network interceptors (no runBlocking — using @Volatile caches) + // Network interceptors single { AuthInterceptor() } single { DynamicUrlInterceptor() } - single { RetrofitClient.provideOkHttpClient(get(), get()) } + single { TokenAuthenticator(get(), get(), get()) } + single { RetrofitClient.provideOkHttpClient(get(), get(), get()) } single { RetrofitClient.provideRetrofit(get()) } single { get().create(ApiService::class.java) } @@ -47,12 +54,16 @@ val appModule = module { single { WebSocketService(get()) } // Voice - single { SpeechRecognizer() } + single { VoiceRecorder(androidContext()) } + single { SpeechRecognizer(androidContext()) } single { TextToSpeechEngine(androidContext()) } + single { DashScopeSttService(androidContext()) } + single { BackendSttProvider(androidContext(), get()) } + single { SttManager(get(), get(), get()) } // Repositories single { AuthRepositoryImpl(get(), get(), get()) } - single { ChatRepositoryImpl(get(), get(), get(), get()) } + single { ChatRepositoryImpl(get(), get(), get(), get(), get()) } single { IoTRepositoryImpl(get(), get()) } // UseCases @@ -61,8 +72,9 @@ val appModule = module { factory { GetConversationsUseCase(get()) } // ViewModels - viewModel { ChatViewModel(get()) } + viewModel { ChatViewModel(get(), get()) } viewModel { IoTViewModel(get()) } - viewModel { OverlayViewModel(get()) } - single { SettingsViewModel(get(), get(), get()) } + viewModel { OverlayViewModel(get(), get(), get()) } + viewModel { ProfileViewModel(get(), get(), get()) } + single { SettingsViewModel(get(), get(), 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 de77254..87db464 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 @@ -8,6 +8,10 @@ import top.yeij.cyrene.domain.model.Message interface ChatRepository { val connectionState: StateFlow + val connectionError: StateFlow + val isAssistantStreaming: StateFlow + val messageClearEvents: Flow + var currentSessionId: String? fun getConversations(): Flow> @@ -17,8 +21,6 @@ interface ChatRepository { suspend fun connectWebSocket(sessionId: String?) - suspend fun disconnectWebSocket() - suspend fun sendMessage(content: String, sessionId: String?) fun observeMessages(): Flow @@ -26,4 +28,19 @@ interface ChatRepository { suspend fun loadConversationsFromServer() suspend fun loadMessagesFromServer(sessionId: String): List + + suspend fun initializeSession(): String + + suspend fun reconnectWebSocket() + + suspend fun ensureConnected() + + suspend fun sendScreenContext(content: String) + + suspend fun sendVoiceInput(audioBase64: String, mode: String = "voice_msg") + + suspend fun clearLocalMessages() + + fun onAppForeground() + fun onAppBackground() } diff --git a/app/src/main/java/top/yeij/cyrene/service/CyreneAccessibilityService.kt b/app/src/main/java/top/yeij/cyrene/service/CyreneAccessibilityService.kt new file mode 100644 index 0000000..c09803c --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/service/CyreneAccessibilityService.kt @@ -0,0 +1,67 @@ +package top.yeij.cyrene.service + +import android.accessibilityservice.AccessibilityService +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo + +class CyreneAccessibilityService : AccessibilityService() { + + companion object { + @Volatile + private var instance: CyreneAccessibilityService? = null + + fun getScreenContent(): String { + return instance?.captureScreenContent() ?: "" + } + + fun isRunning(): Boolean = instance != null + } + + override fun onServiceConnected() { + super.onServiceConnected() + instance = this + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) {} + + override fun onInterrupt() {} + + override fun onDestroy() { + instance = null + super.onDestroy() + } + + private fun captureScreenContent(): String { + val root = rootInActiveWindow ?: return "" + return try { + val sb = StringBuilder() + collectNodeText(root, sb, depth = 0) + sb.toString().trim() + } finally { + root.recycle() + } + } + + private fun collectNodeText(node: AccessibilityNodeInfo, sb: StringBuilder, depth: Int) { + // Skip the root decor view itself content, and limit depth + if (depth > 16) return + + val text = node.text?.toString()?.trim() + val contentDesc = node.contentDescription?.toString()?.trim() + + if (!text.isNullOrEmpty() && text != contentDesc) { + if (sb.isNotEmpty()) sb.append("\n") + sb.append(text) + } else if (!contentDesc.isNullOrEmpty() && depth > 0) { + // Only include content descriptions for non-root nodes + if (sb.isNotEmpty()) sb.append("\n") + sb.append(contentDesc) + } + + for (i in 0 until node.childCount) { + val child = node.getChild(i) ?: continue + collectNodeText(child, sb, depth + 1) + child.recycle() + } + } +} diff --git a/app/src/main/java/top/yeij/cyrene/service/CyreneSessionService.kt b/app/src/main/java/top/yeij/cyrene/service/CyreneSessionService.kt new file mode 100644 index 0000000..d9a48dc --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/service/CyreneSessionService.kt @@ -0,0 +1,27 @@ +package top.yeij.cyrene.service + +import android.content.Intent +import android.os.Bundle +import android.service.voice.VoiceInteractionSessionService +import android.util.Log + +class CyreneSessionService : VoiceInteractionSessionService() { + + override fun onCreate() { + super.onCreate() + Log.i(TAG, "Session service created") + } + + override fun onNewSession(args: Bundle?): CyreneVoiceInteractionSession { + return CyreneVoiceInteractionSession(this) + } + + override fun onDestroy() { + super.onDestroy() + Log.i(TAG, "Session service destroyed") + } + + companion object { + private const val TAG = "CyreneSession" + } +} diff --git a/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionService.kt b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionService.kt index d783021..b9f629c 100644 --- a/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionService.kt +++ b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionService.kt @@ -3,27 +3,35 @@ package top.yeij.cyrene.service import android.content.Intent import android.os.Bundle import android.service.voice.VoiceInteractionService +import android.util.Log import top.yeij.cyrene.MainActivity import top.yeij.cyrene.util.Constants class CyreneVoiceInteractionService : VoiceInteractionService() { + override fun onCreate() { + super.onCreate() + Log.i(TAG, "Service created") + } + override fun onReady() { super.onReady() isActive = true + getSharedPreferences(PREF_NAME, MODE_PRIVATE) + .edit().putBoolean(KEY_WAS_ACTIVE, true).apply() + Log.i(TAG, "Service ready") } override fun onPrepareToShowSession(args: Bundle, showFlags: Int) { - // Called before the session is shown — populate args for the session. - // Starting from API 36, session creation is handled by the system - // based on android:sessionService in voice_interaction_config.xml. + Log.i(TAG, "onPrepareToShowSession") } override fun onShowSessionFailed(args: Bundle) { - // Session failed to show — could be due to permissions or system state. + Log.e(TAG, "onShowSessionFailed") } override fun onLaunchVoiceAssistFromKeyguard() { + Log.i(TAG, "onLaunchVoiceAssistFromKeyguard") val intent = Intent(this, MainActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) putExtra(Constants.EXTRA_VOICE_ASSIST, true) @@ -34,10 +42,19 @@ class CyreneVoiceInteractionService : VoiceInteractionService() { override fun onShutdown() { isActive = false super.onShutdown() + Log.i(TAG, "Service shutdown") } companion object { var isActive: Boolean = false private set + private const val TAG = "CyreneVIS" + private const val PREF_NAME = "cyrene_assistant" + private const val KEY_WAS_ACTIVE = "was_assistant_active" + + fun wasEverActive(context: android.content.Context): Boolean { + return context.getSharedPreferences(PREF_NAME, android.content.Context.MODE_PRIVATE) + .getBoolean(KEY_WAS_ACTIVE, false) + } } } diff --git a/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt index c174523..aec24ce 100644 --- a/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt +++ b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt @@ -1,34 +1,97 @@ package top.yeij.cyrene.service import android.content.Context +import android.content.Intent import android.os.Bundle import android.service.voice.VoiceInteractionSession +import android.util.Log import android.view.View +import android.view.WindowManager import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner import org.koin.core.context.GlobalContext +import top.yeij.cyrene.MainActivity import top.yeij.cyrene.ui.overlay.OverlayContent import top.yeij.cyrene.ui.theme.CyreneTheme import top.yeij.cyrene.util.Constants -import top.yeij.cyrene.voice.stt.SpeechRecognizer -import top.yeij.cyrene.voice.tts.TextToSpeechEngine +import top.yeij.cyrene.util.RuntimeLog +import top.yeij.cyrene.viewmodel.OverlayViewModel class CyreneVoiceInteractionSession(context: Context) : - VoiceInteractionSession(context) { + VoiceInteractionSession(context), LifecycleOwner, SavedStateRegistryOwner { - private val speechRecognizer: SpeechRecognizer by lazy { - GlobalContext.get().get() + private val lifecycleRegistry = LifecycleRegistry(this) + override val lifecycle: Lifecycle get() = lifecycleRegistry + + private val savedStateRegistryController = SavedStateRegistryController.create(this) + override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry + + // Resolved eagerly with fallback — lazy would silently crash composition on failure + private var overlayViewModel: OverlayViewModel? = null + private set + + init { + savedStateRegistryController.performAttach() + savedStateRegistryController.performRestore(null) } - private val ttsEngine: TextToSpeechEngine by lazy { - GlobalContext.get().get() + + private fun resolveViewModel(): OverlayViewModel? { + return try { + GlobalContext.get().get() + } catch (e: Exception) { + Log.e(TAG, "Failed to resolve OverlayViewModel from Koin", e) + null + } } override fun onCreateContentView(): View { + Log.d(TAG, "onCreateContentView called") + RuntimeLog.general("overlay", "onCreateContentView") + overlayViewModel = resolveViewModel() + if (overlayViewModel == null) { + Log.w(TAG, "ViewModel unavailable — overlay will be static") + RuntimeLog.general("overlay", "ViewModel unavailable, overlay static") + } + + // Configure window: prevent IME resize, don't cover status bar + try { + val method = VoiceInteractionSession::class.java.getDeclaredMethod("getWindow") + method.isAccessible = true + val w = method.invoke(this) as? android.view.Window + w?.apply { + setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) + clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) + clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) + addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + } + } catch (_: Exception) { } + + lifecycleRegistry.currentState = Lifecycle.State.CREATED + val vm = overlayViewModel return ComposeView(context).apply { + setViewTreeLifecycleOwner(this@CyreneVoiceInteractionSession) + setViewTreeSavedStateRegistryOwner(this@CyreneVoiceInteractionSession) setContent { CyreneTheme { - OverlayContent( - onDismiss = { finish() }, - ) + if (vm != null) { + OverlayContent( + onDismiss = { finish() }, + onNavigateToMain = { + val intent = Intent(context, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + }, + viewModel = vm, + ) + } } } } @@ -36,15 +99,24 @@ class CyreneVoiceInteractionSession(context: Context) : override fun onShow(args: Bundle?, showFlags: Int) { super.onShow(args, showFlags) - val startListening = args?.getBoolean(Constants.EXTRA_START_LISTENING, false) ?: false - if (startListening) { - speechRecognizer.startListening() + RuntimeLog.general("overlay", "onShow, vm=${overlayViewModel != null}") + lifecycleRegistry.currentState = Lifecycle.State.STARTED + + val screenContent = CyreneAccessibilityService.getScreenContent() + if (screenContent.isNotBlank()) { + overlayViewModel?.sendScreenContext(screenContent) + RuntimeLog.general("overlay", "Screen context sent, len=${screenContent.length}") } } override fun onHide() { + RuntimeLog.general("overlay", "onHide") + lifecycleRegistry.currentState = Lifecycle.State.DESTROYED super.onHide() - speechRecognizer.stopListening() - ttsEngine.stop() + overlayViewModel?.finish() + } + + companion object { + private const val TAG = "CyreneVIS-Session" } } 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 b32dee4..ada21e4 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.remote.dto.WSClientMessage import top.yeij.cyrene.data.remote.dto.WSServerMessage import java.net.URLEncoder import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger class WebSocketService( private val preferencesDataStore: PreferencesDataStore, @@ -42,9 +43,10 @@ class WebSocketService( private var webSocket: WebSocket? = null private var heartbeatJob: Job? = null - private var reconnecting = false + private var reconnectJob: Job? = null private var shouldReconnect = true private var currentSessionId: String? = null + private val connectionId = AtomicInteger(0) private var clientId: String = "" private var deviceName: String = "" @@ -52,6 +54,9 @@ class WebSocketService( private val _isConnected = MutableStateFlow(false) val isConnected: StateFlow = _isConnected.asStateFlow() + private val _connectionError = MutableStateFlow(null) + val connectionError: StateFlow = _connectionError.asStateFlow() + private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 64) val incomingMessages: SharedFlow = _incomingMessages.asSharedFlow() @@ -75,7 +80,6 @@ class WebSocketService( suspend fun connect(sessionId: String? = null) { currentSessionId = sessionId shouldReconnect = true - reconnecting = false initClientIdentity() @@ -106,50 +110,74 @@ class WebSocketService( } val url = urlBuilder.toString() - Log.i(TAG, "Connecting to $url") + val connId = connectionId.incrementAndGet() + Log.i(TAG, "[#$connId] Connecting to $url") val request = Request.Builder() .url(url) .header("User-Agent", "Cyrene-Android/${Build.MODEL ?: "Device"}") .build() + // Close previous socket silently + try { webSocket?.close(1000, "Reconnecting") } catch (_: Exception) { } cancelHeartbeat() - webSocket?.close(1000, "Reconnecting") + webSocket = httpClient.newWebSocket(request, object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { - Log.i(TAG, "Connected") - reconnecting = false + if (connectionId.get() != connId) { + Log.d(TAG, "[#$connId] onOpen ignored (stale)") + return + } + Log.i(TAG, "[#$connId] Connected") _isConnected.value = true + _connectionError.value = null startHeartbeat() } override fun onMessage(webSocket: WebSocket, text: String) { + if (connectionId.get() != connId) return try { val msg = gson.fromJson(text, WSServerMessage::class.java) _incomingMessages.tryEmit(msg) } catch (e: Exception) { - Log.w(TAG, "Failed to parse message: ${e.message}") + Log.w(TAG, "[#$connId] Failed to parse message: ${e.message}") } } override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { - Log.i(TAG, "Server closing: code=$code reason=$reason") + if (connectionId.get() != connId) return + Log.i(TAG, "[#$connId] Server closing: code=$code reason=$reason") _isConnected.value = false cancelHeartbeat() } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - Log.i(TAG, "Closed: code=$code reason=$reason") + if (connectionId.get() != connId) return + Log.i(TAG, "[#$connId] Closed: code=$code reason=$reason") _isConnected.value = false cancelHeartbeat() scheduleReconnect() } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - Log.e(TAG, "Connection failure: ${t.message} (response=${response?.code})", t) + if (connectionId.get() != connId) return + val httpCode = response?.code + Log.e(TAG, "[#$connId] Failure: ${t.message} (http=$httpCode)", t) _isConnected.value = false cancelHeartbeat() - scheduleReconnect() + + val errorMsg = when (httpCode) { + 403 -> { + Log.e(TAG, "[#$connId] WebSocket 403: 仅管理员用户可连接。请使用管理员账户登录。") + "仅管理员用户可连接" + } + 401 -> "认证失败,请重新登录" + else -> null + } + if (errorMsg != null) { + _connectionError.value = errorMsg + } + // onClosed will always follow, which triggers scheduleReconnect } }) } @@ -179,7 +207,12 @@ class WebSocketService( fun requestHistory(sessionId: String?) { val msg = buildMessage("history", sessionId) - webSocket?.send(gson.toJson(msg)) + if (webSocket != null) { + webSocket?.send(gson.toJson(msg)) + Log.i(TAG, "History requested for session=$sessionId") + } else { + Log.w(TAG, "Cannot request history: WebSocket is null") + } } fun sendPing() { @@ -187,11 +220,44 @@ class WebSocketService( webSocket?.send(gson.toJson(msg)) } + fun sendScreenContext(content: String, sessionId: String? = null) { + val msg = buildMessage("message", sessionId, mode = "text", content = content) + webSocket?.send(gson.toJson(msg)) + } + + fun sendVoiceInput(audioBase64: String, sessionId: String? = null, mode: String = "voice_msg") { + val msg = WSClientMessage( + type = "voice_input", + sessionId = sessionId ?: currentSessionId, + mode = mode, + audioData = audioBase64, + timestamp = System.currentTimeMillis(), + clientId = clientId.ifBlank { null }, + deviceName = deviceName.ifBlank { null }, + userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}", + ) + webSocket?.send(gson.toJson(msg)) + } + + fun forceReconnect() { + shouldReconnect = true + reconnectJob?.cancel() + reconnectJob = null + scope.launch { + if (!_isConnected.value) { + try { + connect(currentSessionId) + } catch (_: Exception) { } + } + } + } + fun disconnect() { shouldReconnect = false - reconnecting = false + reconnectJob?.cancel() + reconnectJob = null cancelHeartbeat() - webSocket?.close(1000, "User disconnected") + try { webSocket?.close(1000, "User disconnected") } catch (_: Exception) { } webSocket = null _isConnected.value = false } @@ -214,13 +280,15 @@ class WebSocketService( } private fun scheduleReconnect() { - if (reconnecting || !shouldReconnect) return - reconnecting = true - scope.launch { + if (reconnectJob?.isActive == true || !shouldReconnect) return + reconnectJob = scope.launch { var attempt = 0 - while (attempt < 5 && shouldReconnect && !_isConnected.value) { - val delayMs = (Math.pow(2.0, attempt.toDouble()) * 1000).toLong() - Log.i(TAG, "Reconnecting in ${delayMs}ms (attempt ${attempt + 1}/5)") + while (shouldReconnect && !_isConnected.value) { + val delayMs = minOf( + (Math.pow(2.0, attempt.toDouble()) * 1000).toLong(), + 30_000L + ) + Log.i(TAG, "Reconnecting in ${delayMs}ms (attempt ${attempt + 1})") delay(delayMs) attempt++ if (shouldReconnect && !_isConnected.value) { @@ -231,7 +299,7 @@ class WebSocketService( } } } - reconnecting = false + Log.i(TAG, "Reconnect loop ended (connected=${_isConnected.value}, shouldReconnect=$shouldReconnect)") } } 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 522b362..2652180 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 @@ -64,6 +64,7 @@ private fun ChatMessageBubble( MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, + shadowElevation = 2.dp, modifier = Modifier.widthIn(max = 300.dp), ) { Text( @@ -91,7 +92,7 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) { modifier = modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 2.dp), - horizontalArrangement = Arrangement.Center, + horizontalArrangement = Arrangement.Start, ) { Text( text = content, @@ -99,7 +100,7 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) { fontStyle = androidx.compose.ui.text.font.FontStyle.Italic, ), color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, + textAlign = TextAlign.Start, ) } } diff --git a/app/src/main/java/top/yeij/cyrene/ui/components/TypingIndicator.kt b/app/src/main/java/top/yeij/cyrene/ui/components/TypingIndicator.kt new file mode 100644 index 0000000..b9a17ea --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/ui/components/TypingIndicator.kt @@ -0,0 +1,77 @@ +package top.yeij.cyrene.ui.components + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.unit.dp + +@Composable +fun TypingIndicator(modifier: Modifier = Modifier) { + val infiniteTransition = rememberInfiniteTransition(label = "typing") + + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.Start, + ) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceVariant, + shadowElevation = 1.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "昔涟正在输入", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + repeat(3) { index -> + val alpha by infiniteTransition.animateFloat( + initialValue = 0.2f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 400, + delayMillis = index * 200, + ), + repeatMode = RepeatMode.Reverse, + ), + label = "dot_$index", + ) + Box( + modifier = Modifier + .size(5.dp) + .alpha(alpha) + .background( + MaterialTheme.colorScheme.onSurfaceVariant, + CircleShape, + ), + ) + } + } + } + } +} 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 ea73a23..0f370fd 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 @@ -1,15 +1,17 @@ package top.yeij.cyrene.ui.navigation import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.filled.DevicesOther import androidx.compose.material.icons.filled.Person import androidx.compose.material3.Icon -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.Scaffold +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -20,13 +22,12 @@ import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import org.koin.compose.koinInject import top.yeij.cyrene.ui.screens.chat.ChatScreen import top.yeij.cyrene.ui.screens.iot.IoTScreen import top.yeij.cyrene.ui.screens.login.LoginScreen +import top.yeij.cyrene.ui.screens.about.AboutScreen import top.yeij.cyrene.ui.screens.profile.ProfileScreen import top.yeij.cyrene.ui.screens.settings.SettingsScreen -import top.yeij.cyrene.viewmodel.SettingsViewModel object Routes { const val LOGIN = "login" @@ -34,6 +35,7 @@ object Routes { const val CHAT = "chat" const val IOT = "iot" const val SETTINGS = "settings" + const val ABOUT = "about" } @Composable @@ -70,6 +72,12 @@ fun CyreneNavGraph( onBack = { navController.popBackStack() }, ) } + + composable(Routes.ABOUT) { + AboutScreen( + onBack = { navController.popBackStack() }, + ) + } } } @@ -85,8 +93,6 @@ fun MainScreen( isDefaultAssistant: Boolean, onOpenAssistantSettings: () -> Unit, ) { - val settingsViewModel: SettingsViewModel = koinInject() - val items = listOf( BottomNavItem( label = "对话", @@ -107,28 +113,30 @@ fun MainScreen( var selectedTab by rememberSaveable { mutableIntStateOf(0) } - Scaffold( - bottomBar = { - NavigationBar { - items.forEachIndexed { index, item -> - NavigationBarItem( - selected = selectedTab == index, - onClick = { selectedTab = index }, - icon = item.icon, - label = { Text(item.label) }, - ) - } + Row( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding(), + ) { + NavigationRail { + items.forEachIndexed { index, item -> + NavigationRailItem( + selected = selectedTab == index, + onClick = { selectedTab = index }, + icon = item.icon, + label = { Text(item.label) }, + ) } - }, - ) { padding -> - Box(modifier = Modifier.padding(padding)) { + } + + Box(modifier = Modifier.weight(1f).fillMaxHeight()) { when (selectedTab) { 0 -> ChatScreen() 1 -> IoTScreen() 2 -> ProfileScreen( onNavigateToSettings = { navController.navigate(Routes.SETTINGS) }, + onNavigateToAbout = { navController.navigate(Routes.ABOUT) }, onLogout = { - settingsViewModel.logout() navController.navigate(Routes.LOGIN) { popUpTo(Routes.MAIN) { inclusive = true } } @@ -136,6 +144,8 @@ fun MainScreen( onNavigateToLogin = { navController.navigate(Routes.LOGIN) }, + isDefaultAssistant = isDefaultAssistant, + onOpenAssistantSettings = onOpenAssistantSettings, ) } } diff --git a/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt b/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt index 33b570d..76da014 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt @@ -1,62 +1,135 @@ package top.yeij.cyrene.ui.overlay +import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardVoice +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Mic -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay import org.koin.compose.koinInject +import top.yeij.cyrene.domain.model.Message import top.yeij.cyrene.ui.components.ChatBubble -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.viewmodel.OverlayState import top.yeij.cyrene.viewmodel.OverlayViewModel +import kotlin.math.min + +@Composable +private fun AnimatedChatBubble( + message: Message, + animIndex: Int, +) { + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(min(animIndex, 10) * 60L) + visible = true + } + AnimatedVisibility( + visible = visible, + enter = fadeIn(animationSpec = androidx.compose.animation.core.tween(300)) + + slideInVertically( + animationSpec = androidx.compose.animation.core.tween(300), + initialOffsetY = { it / 4 }, + ), + ) { + ChatBubble( + content = message.content, + role = message.role, + msgType = message.msgType, + timestamp = message.timestamp, + ) + } +} @Composable fun OverlayContent( onDismiss: () -> Unit, + onNavigateToMain: () -> Unit, viewModel: OverlayViewModel = koinInject(), ) { val state by viewModel.state.collectAsState() val messages by viewModel.messages.collectAsState() - val recognizedText by viewModel.recognizedText.collectAsState() + val inputText by viewModel.inputText.collectAsState() + val recordState by viewModel.voiceRecordState.collectAsState() + val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState() + val animIndex by viewModel.messageAnimIndex.collectAsState() val listState = rememberLazyListState() + val isProcessing = state == OverlayState.PROCESSING + val recordSec = recordDurationMs / 1000f + val isRecording = recordState == RecordState.RECORDING + val isLocked = recordState == RecordState.LOCKED + + // Animated "昔涟正在输入..." dots + val typingDots = remember { mutableStateOf("") } + LaunchedEffect(isProcessing) { + if (isProcessing) { + val dots = arrayOf("", ".", "..", "...") + var i = 0 + while (true) { + typingDots.value = dots[i % 4] + i++ + delay(400) + } + } else { + typingDots.value = "" + } + } + + val configuration = LocalConfiguration.current + val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE LaunchedEffect(messages.size) { if (messages.isNotEmpty()) { @@ -79,124 +152,430 @@ fun OverlayContent( Box( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - ) { onDismiss() }, + .statusBarsPadding() + .navigationBarsPadding(), ) { - Surface( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - ) { /* consume click */ }, - shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp), - shadowElevation = 8.dp, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - ) { - // Header - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - StatusIndicator( - status = when (state) { - OverlayState.LISTENING -> CyreneStatus.ONLINE - OverlayState.PROCESSING -> CyreneStatus.THINKING - OverlayState.SPEAKING -> CyreneStatus.SPEAKING - OverlayState.WAITING -> CyreneStatus.ONLINE - OverlayState.IDLE -> CyreneStatus.ONLINE - }, - ) - Spacer(modifier = Modifier.weight(1f)) - IconButton(onClick = { onDismiss() }) { - Icon(Icons.Filled.Close, contentDescription = "关闭") - } - } - - // Messages - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .weight(1f, fill = false) - .height(200.dp), - state = listState, - ) { - items(messages, key = { it.id }) { message -> - ChatBubble( - content = message.content, - role = message.role, - msgType = message.msgType, - timestamp = message.timestamp, - ) - } - } - - // Recognized text display - if (recognizedText.isNotEmpty()) { - Text( - text = recognizedText, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(vertical = 8.dp), - ) - } - - // Action button - Row( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center, - ) { - Button( - onClick = { - when (state) { - OverlayState.LISTENING -> { - viewModel.onSpeechFinal(recognizedText) - } - OverlayState.WAITING -> { - viewModel.startListening() - } - else -> { } - } - }, - shape = CircleShape, - modifier = Modifier.size(64.dp), - colors = ButtonDefaults.buttonColors( - containerColor = when (state) { - OverlayState.LISTENING -> MaterialTheme.colorScheme.error - OverlayState.PROCESSING -> MaterialTheme.colorScheme.secondary - else -> MaterialTheme.colorScheme.primary - }, - ), - ) { - Icon( - Icons.Filled.Mic, - contentDescription = "语音", - modifier = Modifier.size(32.dp), - ) - } - } - - Text( - text = when (state) { - OverlayState.IDLE -> "" - OverlayState.LISTENING -> "我在听…" - OverlayState.PROCESSING -> "思考中…" - OverlayState.SPEAKING -> "正在说话…" - OverlayState.WAITING -> "点击继续说话" - }, - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.fillMaxWidth(), - textAlign = androidx.compose.ui.text.style.TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + if (isLandscape) { + LandscapeContent( + state = state, + messages = messages, + inputText = inputText, + isProcessing = isProcessing, + listState = listState, + recordSec = recordSec, + isRecording = isRecording, + isLocked = isLocked, + typingDots = typingDots.value, + animIndex = animIndex, + onDismiss = onDismiss, + onNavigateToMain = onNavigateToMain, + viewModel = viewModel, + ) + } else { + PortraitContent( + state = state, + messages = messages, + inputText = inputText, + isProcessing = isProcessing, + listState = listState, + recordSec = recordSec, + isRecording = isRecording, + isLocked = isLocked, + typingDots = typingDots.value, + animIndex = animIndex, + onDismiss = onDismiss, + onNavigateToMain = onNavigateToMain, + viewModel = viewModel, + ) + } + } + } +} + +@Suppress("LongParameterList") +@Composable +private fun PortraitContent( + state: OverlayState, + messages: List, + inputText: String, + isProcessing: Boolean, + listState: androidx.compose.foundation.lazy.LazyListState, + recordSec: Float, + isRecording: Boolean, + isLocked: Boolean, + typingDots: String, + animIndex: Map, + onDismiss: () -> Unit, + onNavigateToMain: () -> Unit, + viewModel: OverlayViewModel, +) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { /* consume clicks */ }, + ) { + // Messages + top bar stay fixed at top + Column(modifier = Modifier.fillMaxSize()) { + MessageTopBar(onDismiss = onDismiss, onNavigateToMain = onNavigateToMain) + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + state = listState, + ) { + if (messages.isNotEmpty()) { + items(messages, key = { it.id }) { message -> + AnimatedChatBubble( + message = message, + animIndex = animIndex[message.id] ?: 0, + ) + } + } + if (isProcessing) { + item(key = "typing_indicator") { + TypingIndicator() + } + } + } + } + + // Input area at bottom, imePadding pushes it above full-screen IME + InputArea( + state = state, + inputText = inputText, + viewModel = viewModel, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .imePadding(), + recordSec = recordSec, + isRecording = isRecording, + isLocked = isLocked, + typingDots = typingDots, + ) + } +} + +@Suppress("LongParameterList") +@Composable +private fun LandscapeContent( + state: OverlayState, + messages: List, + inputText: String, + isProcessing: Boolean, + listState: androidx.compose.foundation.lazy.LazyListState, + recordSec: Float, + isRecording: Boolean, + isLocked: Boolean, + typingDots: String, + animIndex: Map, + onDismiss: () -> Unit, + onNavigateToMain: () -> Unit, + viewModel: OverlayViewModel, +) { + Row( + modifier = Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { /* consume clicks */ }, + ) { + // Messages + top bar on the left, stay fixed + Column(modifier = Modifier.weight(1f)) { + MessageTopBar(onDismiss = onDismiss, onNavigateToMain = onNavigateToMain) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + state = listState, + ) { + if (messages.isNotEmpty()) { + items(messages, key = { it.id }) { message -> + AnimatedChatBubble( + message = message, + animIndex = animIndex[message.id] ?: 0, + ) + } + } + if (isProcessing) { + item(key = "typing_indicator") { + TypingIndicator() + } + } + } + } + + Box( + modifier = Modifier + .width(1.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + contentAlignment = Alignment.BottomCenter, + ) { + InputArea( + state = state, + inputText = inputText, + viewModel = viewModel, + modifier = Modifier + .fillMaxWidth() + .imePadding(), + recordSec = recordSec, + isRecording = isRecording, + isLocked = isLocked, + typingDots = typingDots, + ) + } + } +} + +@Composable +private fun MessageTopBar( + onDismiss: () -> Unit, + onNavigateToMain: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.3f)) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onDismiss) { + Icon(Icons.Filled.Close, contentDescription = "关闭") + } + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = onNavigateToMain) { + Icon( + Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = "进入主界面", + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Composable +private fun InputArea( + state: OverlayState, + inputText: String, + viewModel: OverlayViewModel, + modifier: Modifier = Modifier, + recordSec: Float = 0f, + isRecording: Boolean = false, + isLocked: Boolean = false, + typingDots: String = "", +) { + // Gesture tracking state — local to InputArea + var isDragging by remember { mutableStateOf(false) } + var dragOffsetX by remember { mutableStateOf(0f) } + var dragOffsetY by remember { mutableStateOf(0f) } + val inCancelZone = isDragging && dragOffsetY < -120f + val inLockZone = isDragging && dragOffsetX > 60f + val isProcessing = state == OverlayState.PROCESSING + + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + shadowElevation = 8.dp, + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + ) { + // "昔涟正在输入..." indicator + if (isProcessing && typingDots.isNotEmpty()) { + Text( + text = "昔涟正在输入$typingDots", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + textAlign = TextAlign.Start, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isRecording && isDragging) { + // Recording with drag — show recording indicator + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(12.dp)) + .background( + if (inCancelZone) MaterialTheme.colorScheme.errorContainer + else MaterialTheme.colorScheme.surfaceVariant + ) + .padding(horizontal = 16.dp, vertical = 14.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = when { + inCancelZone -> "松手取消" + inLockZone -> "松手录音" + else -> "%.1f\" 上滑取消 右滑松手".format(recordSec) + }, + style = MaterialTheme.typography.bodyMedium, + color = if (inCancelZone) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Box( + modifier = Modifier + .padding(start = 8.dp) + .size(48.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .offset { IntOffset(dragOffsetX.toInt(), dragOffsetY.toInt()) }, + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Filled.Mic, + contentDescription = "录音中", + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } else if (isLocked) { + // Locked (hands-free) mode + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(horizontal = 16.dp, vertical = 14.dp), + contentAlignment = Alignment.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.Lock, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "%.1f\" 松手录音中 — 点击结束".format(recordSec), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + IconButton(onClick = { viewModel.finishRecord() }) { + Icon( + Icons.AutoMirrored.Filled.Send, + contentDescription = "发送", + tint = MaterialTheme.colorScheme.primary, + ) + } + } else { + // Normal input mode + OutlinedTextField( + value = inputText, + onValueChange = { viewModel.onInputChanged(it) }, + placeholder = { Text("输入消息...") }, + modifier = Modifier.weight(1f), + maxLines = 3, + shape = MaterialTheme.shapes.medium, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + if (inputText.isNotBlank()) { + IconButton( + onClick = { viewModel.sendText() }, + enabled = !isProcessing, + ) { + Icon( + Icons.AutoMirrored.Filled.Send, + contentDescription = "发送", + tint = MaterialTheme.colorScheme.primary, + ) + } + } else { + Box( + modifier = Modifier + .size(48.dp) + .pointerInput(Unit) { + detectDragGesturesAfterLongPress( + onDragStart = { _ -> + isDragging = true + dragOffsetX = 0f + dragOffsetY = 0f + viewModel.startRecord() + }, + onDrag = { change, dragAmount -> + change.consume() + dragOffsetX += dragAmount.x + dragOffsetY += dragAmount.y + }, + onDragEnd = { + isDragging = false + when { + dragOffsetY < -120f -> viewModel.cancelRecord() + dragOffsetX > 60f -> viewModel.lockRecord() + else -> viewModel.finishRecord() + } + dragOffsetX = 0f + dragOffsetY = 0f + }, + onDragCancel = { + isDragging = false + viewModel.cancelRecord() + dragOffsetX = 0f + dragOffsetY = 0f + }, + ) + }, + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Filled.KeyboardVoice, + contentDescription = "按住录音", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + // Status hint + val hint = when { + isLocked -> "" + isRecording && isDragging -> "" + else -> when (state) { + OverlayState.LISTENING -> "我在听..." + OverlayState.PROCESSING -> "思考中..." + OverlayState.SPEAKING -> "正在说话..." + OverlayState.WAITING -> "长按麦克风开始说话" + OverlayState.IDLE -> "" + } + } + if (hint.isNotEmpty()) { + Text( + text = hint, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/about/AboutScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/about/AboutScreen.kt new file mode 100644 index 0000000..563ea53 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/ui/screens/about/AboutScreen.kt @@ -0,0 +1,191 @@ +package top.yeij.cyrene.ui.screens.about + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AboutScreen(onBack: () -> Unit) { + val context = LocalContext.current + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text("关于") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = "Cyrene", + style = MaterialTheme.typography.headlineLarge.copy( + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + ), + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp), + ) + Text( + text = "昔涟", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = "v0.1.0", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "信息", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + + ListItem( + headlineContent = { Text("简介") }, + leadingContent = { + Icon(Icons.Filled.Info, contentDescription = null) + }, + supportingContent = { + Text( + text = "Cyrene 是一款基于 Android 的智能语音助手,支持实时对话、IoT 设备管理和语音交互。", + style = MaterialTheme.typography.bodyMedium, + ) + }, + ) + + ListItem( + headlineContent = { Text("开发者") }, + leadingContent = { + Icon(Icons.Filled.Person, contentDescription = null) + }, + supportingContent = { Text("AskaEth") }, + ) + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + + ListItem( + headlineContent = { Text("源代码") }, + leadingContent = { + Icon(Icons.Filled.Code, contentDescription = null) + }, + supportingContent = { Text("git.yeij.top/AskaEth/Cyrene-For-Android") }, + trailingContent = { + Icon( + Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse("https://git.yeij.top/AskaEth/Cyrene-For-Android") + } + context.startActivity(intent) + }, + ) + + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "技术栈", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + + val techStack = listOf( + "Kotlin" to "主语言", + "Jetpack Compose" to "UI 框架", + "Material Design 3" to "设计系统", + "Room" to "本地数据库", + "OkHttp + Retrofit" to "网络请求", + "WebSocket" to "实时通信", + "Koin" to "依赖注入", + ) + techStack.forEach { (name, desc) -> + ListItem( + headlineContent = { Text(name) }, + supportingContent = { Text(desc) }, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "© 2026 AskaEth. All rights reserved.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + ) + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} 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 869eb94..aec3714 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 @@ -1,56 +1,121 @@ package top.yeij.cyrene.ui.screens.chat -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.KeyboardVoice +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Mic import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import org.koin.compose.koinInject +import kotlinx.coroutines.delay +import org.koin.androidx.compose.koinViewModel +import top.yeij.cyrene.domain.model.Message import top.yeij.cyrene.ui.components.ChatBubble 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.viewmodel.ChatViewModel +import kotlin.math.min +@Composable +private fun AnimatedChatBubble( + message: Message, + animIndex: Int, +) { + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(min(animIndex, 10) * 60L) + visible = true + } + AnimatedVisibility( + visible = visible, + enter = fadeIn(animationSpec = androidx.compose.animation.core.tween(300)) + + slideInVertically( + animationSpec = androidx.compose.animation.core.tween(300), + initialOffsetY = { it / 4 }, + ), + ) { + ChatBubble( + content = message.content, + role = message.role, + msgType = message.msgType, + timestamp = message.timestamp, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( - viewModel: ChatViewModel = koinInject(), + viewModel: ChatViewModel = koinViewModel(), ) { val messages by viewModel.currentMessages.collectAsState() val inputText by viewModel.inputText.collectAsState() val isStreaming by viewModel.isStreaming.collectAsState() val isConnected by viewModel.isConnected.collectAsState() + val isRefreshing by viewModel.isRefreshing.collectAsState() + val recordState by viewModel.voiceRecordState.collectAsState() + val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState() + val animIndex by viewModel.messageAnimIndex.collectAsState() val listState = rememberLazyListState() + // Gesture tracking state + var isDragging by remember { mutableStateOf(false) } + var dragOffsetX by remember { mutableStateOf(0f) } + var dragOffsetY by remember { mutableStateOf(0f) } + var recordButtonY by remember { mutableStateOf(0f) } + + val recordSec = recordDurationMs / 1000f + val isRecording = recordState == RecordState.RECORDING + val isLocked = recordState == RecordState.LOCKED + val inCancelZone = isDragging && dragOffsetY < -120f + val inLockZone = isDragging && dragOffsetX > 60f + LaunchedEffect(messages.size, isStreaming) { if (messages.isNotEmpty()) { val targetIndex = if (isStreaming) messages.size else messages.size - 1 @@ -58,6 +123,22 @@ fun ChatScreen( } } + // Animated "昔涟正在输入..." dots + val typingDots = remember { mutableStateOf("") } + LaunchedEffect(isStreaming) { + if (isStreaming) { + val dots = arrayOf("", ".", "..", "...") + var i = 0 + while (true) { + typingDots.value = dots[i % 4] + i++ + delay(400) + } + } else { + typingDots.value = "" + } + } + val status = when { isStreaming -> CyreneStatus.THINKING isConnected -> CyreneStatus.ONLINE @@ -76,122 +157,214 @@ fun ChatScreen( } }, bottomBar = { - Row( + Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, + .navigationBarsPadding(), ) { - OutlinedTextField( - value = inputText, - onValueChange = { viewModel.onInputChanged(it) }, - placeholder = { Text("输入消息...") }, - modifier = Modifier.weight(1f), - maxLines = 4, - shape = MaterialTheme.shapes.medium, - ) - IconButton( - onClick = { viewModel.sendMessage() }, - enabled = inputText.isNotBlank() && !isStreaming, + // "昔涟正在输入..." indicator + if (isStreaming) { + Text( + text = "昔涟正在输入${typingDots.value}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 2.dp), + textAlign = TextAlign.Start, + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - if (isStreaming) { - CircularProgressIndicator( - modifier = Modifier.padding(4.dp), - strokeWidth = 2.dp, - ) + if (isRecording && isDragging) { + // Recording state with drag — show recording indicator + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(12.dp)) + .background( + if (inCancelZone) MaterialTheme.colorScheme.errorContainer + else MaterialTheme.colorScheme.surfaceVariant + ) + .padding(horizontal = 16.dp, vertical = 14.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = when { + inCancelZone -> "松手取消" + inLockZone -> "松手录音" + else -> "%.1f\" 上滑取消 右滑松手".format(recordSec) + }, + style = MaterialTheme.typography.bodyMedium, + color = if (inCancelZone) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // Record button (drag anchor) + Box( + modifier = Modifier + .padding(start = 8.dp) + .size(48.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .offset { IntOffset(dragOffsetX.toInt(), dragOffsetY.toInt()) }, + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Filled.Mic, + contentDescription = "录音中", + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } else if (isLocked) { + // Locked (hands-free) mode + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(horizontal = 16.dp, vertical = 14.dp), + contentAlignment = Alignment.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.Lock, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "%.1f\" 松手录音中 — 点击结束".format(recordSec), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + IconButton(onClick = { viewModel.finishRecord() }) { + Icon( + Icons.AutoMirrored.Filled.Send, + contentDescription = "发送", + tint = MaterialTheme.colorScheme.primary, + ) + } } else { - Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送") + // Normal input mode + OutlinedTextField( + value = inputText, + onValueChange = { viewModel.onInputChanged(it) }, + placeholder = { Text("输入消息...") }, + modifier = Modifier.weight(1f), + maxLines = 4, + shape = MaterialTheme.shapes.medium, + ) + // Voice record button with long-press gesture + Box( + modifier = Modifier + .padding(start = 4.dp) + .size(48.dp) + .onGloballyPositioned { recordButtonY = it.positionInRoot().y } + .pointerInput(Unit) { + detectDragGesturesAfterLongPress( + onDragStart = { offset -> + isDragging = true + dragOffsetX = 0f + dragOffsetY = 0f + viewModel.startRecord() + }, + onDrag = { change, dragAmount -> + change.consume() + dragOffsetX += dragAmount.x + dragOffsetY += dragAmount.y + }, + onDragEnd = { + isDragging = false + when { + dragOffsetY < -120f -> viewModel.cancelRecord() + dragOffsetX > 60f -> viewModel.lockRecord() + else -> viewModel.finishRecord() + } + dragOffsetX = 0f + dragOffsetY = 0f + }, + onDragCancel = { + isDragging = false + viewModel.cancelRecord() + dragOffsetX = 0f + dragOffsetY = 0f + }, + ) + }, + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Filled.KeyboardVoice, + contentDescription = "按住录音", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // Send button (only when text present) + if (inputText.isNotBlank()) { + IconButton( + onClick = { viewModel.sendMessage() }, + enabled = !isStreaming, + ) { + if (isStreaming) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } else { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送") + } + } + } } } } }, ) { padding -> - if (messages.isEmpty() && !isStreaming) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding), - contentAlignment = Alignment.Center, - ) { - Text( - text = "开始和昔涟对话吧", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } else { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(padding), - state = listState, - ) { - items(messages, key = { it.id }) { message -> - ChatBubble( - content = message.content, - role = message.role, - msgType = message.msgType, - timestamp = message.timestamp, + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { viewModel.refreshMessages() }, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + if (messages.isEmpty() && !isStreaming) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = "开始和昔涟对话吧", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - if (isStreaming) { - item(key = "typing_indicator") { - TypingIndicator() + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + ) { + items(messages, key = { it.id }) { message -> + AnimatedChatBubble( + message = message, + animIndex = animIndex[message.id] ?: 0, + ) + } + if (isStreaming) { + item(key = "typing_indicator") { + TypingIndicator() + } } } } } } } - -@Composable -private fun TypingIndicator(modifier: Modifier = Modifier) { - val infiniteTransition = rememberInfiniteTransition(label = "typing") - - Row( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.Start, - ) { - Surface( - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceVariant, - ) { - Row( - modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = "昔涟正在输入", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - repeat(3) { index -> - val alpha by infiniteTransition.animateFloat( - initialValue = 0.2f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = 400, - delayMillis = index * 200, - ), - repeatMode = RepeatMode.Reverse, - ), - label = "dot_$index", - ) - Box( - modifier = Modifier - .size(5.dp) - .alpha(alpha) - .background( - MaterialTheme.colorScheme.onSurfaceVariant, - CircleShape, - ), - ) - } - } - } - } -} diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/iot/IoTScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/iot/IoTScreen.kt index 5ca770a..653bf36 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/screens/iot/IoTScreen.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/screens/iot/IoTScreen.kt @@ -16,14 +16,14 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import org.koin.compose.koinInject +import org.koin.androidx.compose.koinViewModel import top.yeij.cyrene.ui.components.DeviceCard import top.yeij.cyrene.viewmodel.IoTViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun IoTScreen( - viewModel: IoTViewModel = koinInject(), + viewModel: IoTViewModel = koinViewModel(), ) { val devices by viewModel.devices.collectAsState() val isLoading by viewModel.isLoading.collectAsState() diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/profile/ProfileScreen.kt index 8549079..27a8253 100644 --- a/app/src/main/java/top/yeij/cyrene/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/top/yeij/cyrene/ui/screens/profile/ProfileScreen.kt @@ -1,133 +1,389 @@ package top.yeij.cyrene.ui.screens.profile +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn +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.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ExitToApp import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.filled.AdminPanelSettings +import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Circle import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import org.koin.compose.koinInject -import top.yeij.cyrene.viewmodel.SettingsViewModel +import top.yeij.cyrene.viewmodel.ProfileViewModel @Composable fun ProfileScreen( onNavigateToSettings: () -> Unit, onLogout: () -> Unit, onNavigateToLogin: () -> Unit, - settingsViewModel: SettingsViewModel = koinInject(), + onNavigateToAbout: () -> Unit = {}, + isDefaultAssistant: Boolean = false, + onOpenAssistantSettings: () -> Unit = {}, + profileViewModel: ProfileViewModel = koinInject(), ) { - val username by settingsViewModel.username.collectAsState() - val isLoggedIn by settingsViewModel.isLoggedIn.collectAsState() + val profile by profileViewModel.profile.collectAsState() + var showLogoutDialog by remember { mutableStateOf(false) } - LazyColumn( - modifier = Modifier.fillMaxSize(), + LaunchedEffect(Unit) { + profileViewModel.fetchFreshProfile() + } + + if (showLogoutDialog) { + AlertDialog( + onDismissRequest = { showLogoutDialog = false }, + title = { Text("退出登录") }, + text = { Text("确定要退出登录吗?") }, + confirmButton = { + TextButton( + onClick = { + showLogoutDialog = false + profileViewModel.logout() + onLogout() + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("退出") + } + }, + dismissButton = { + TextButton(onClick = { showLogoutDialog = false }) { + Text("取消") + } + }, + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), ) { // Profile header - item { - Column( + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Avatar + val initials = if (profile.nickname.isNotBlank()) { + profile.nickname.take(1) + } else if (profile.username.isNotBlank()) { + profile.username.take(1) + } else { + "?" + } + Box( modifier = Modifier - .fillMaxWidth() - .padding(24.dp), + .size(80.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, ) { Text( - text = if (isLoggedIn) username.ifEmpty { "开拓者" } else "未登录", + text = initials, + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = FontWeight.Bold, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + if (profile.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } else if (!profile.isLoggedIn) { + Text( + text = "未登录", style = MaterialTheme.typography.headlineMedium, - color = if (!isLoggedIn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, - modifier = if (!isLoggedIn) { - Modifier.clickable { onNavigateToLogin() } - } else { - Modifier - }, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable { onNavigateToLogin() }, ) Text( - text = "与昔涟同行", + text = "点击登录以查看个人信息", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + } else { + // Nickname + Text( + text = profile.nickname.ifEmpty { profile.username }, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + // Username + if (profile.nickname.isNotBlank() && profile.nickname != profile.username) { + Text( + text = "@${profile.username}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // Admin badge + if (profile.isAdmin) { + Spacer(modifier = Modifier.height(8.dp)) + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Filled.AdminPanelSettings, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "管理员", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + } } } - item { HorizontalDivider() } - item { Spacer(modifier = Modifier.height(8.dp)) } + HorizontalDivider() - // Settings - item { - ListItem( - headlineContent = { Text("设置") }, - leadingContent = { Icon(Icons.Filled.Settings, contentDescription = null) }, - trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) }, - modifier = Modifier.clickable { onNavigateToSettings() }, + if (profile.isLoggedIn) { + // User info card + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "账号信息", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) + + ProfileInfoCard( + items = listOf( + ProfileInfoItem(Icons.Filled.Tag, "用户 ID", profile.userId), + ProfileInfoItem(Icons.Filled.CalendarMonth, "注册时间", profile.createdAt.ifEmpty { "未知" }), + ), + ) + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() } - // Reminders - item { - ListItem( - headlineContent = { Text("提醒") }, - leadingContent = { Icon(Icons.Filled.Notifications, contentDescription = null) }, - trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) }, - ) + Spacer(modifier = Modifier.height(8.dp)) + + // Assistant status + Text( + text = "助手", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(16.dp), + color = if (isDefaultAssistant) + Color(0xFF4CAF50).copy(alpha = 0.1f) + else + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Filled.Circle, + contentDescription = null, + modifier = Modifier.size(10.dp), + tint = if (isDefaultAssistant) Color(0xFF4CAF50) else Color(0xFFFF5722), + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (isDefaultAssistant) "已设为默认助手" else "未设为默认助手", + style = MaterialTheme.typography.bodyLarge, + ) + if (!isDefaultAssistant) { + Text( + text = "设为默认助手后,长按电源键或Home键即可呼出昔涟", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (!isDefaultAssistant) { + TextButton(onClick = onOpenAssistantSettings) { + Text("去设置") + } + } + } } - item { HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) } + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) - // About - item { - ListItem( - headlineContent = { Text("关于") }, - leadingContent = { Icon(Icons.Filled.Info, contentDescription = null) }, - supportingContent = { Text("Cyrene v0.1.0") }, - ) - } + // Menu items + Text( + text = "其他", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) - // Help - item { - ListItem( - headlineContent = { Text("使用帮助") }, - leadingContent = { Icon(Icons.AutoMirrored.Filled.Help, contentDescription = null) }, - ) - } + ListItem( + headlineContent = { Text("设置") }, + leadingContent = { Icon(Icons.Filled.Settings, contentDescription = null) }, + trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) }, + modifier = Modifier.clickable { onNavigateToSettings() }, + ) - item { Spacer(modifier = Modifier.height(24.dp)) } + ListItem( + headlineContent = { Text("提醒") }, + leadingContent = { Icon(Icons.Filled.Notifications, contentDescription = null) }, + trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) }, + ) + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + + ListItem( + headlineContent = { Text("关于") }, + leadingContent = { Icon(Icons.Filled.Info, contentDescription = null) }, + supportingContent = { Text("Cyrene v0.1.0") }, + modifier = Modifier.clickable { onNavigateToAbout() }, + ) + + ListItem( + headlineContent = { Text("使用帮助") }, + leadingContent = { Icon(Icons.AutoMirrored.Filled.Help, contentDescription = null) }, + ) + + Spacer(modifier = Modifier.height(16.dp)) // Logout - if (isLoggedIn) { - item { - ListItem( - headlineContent = { + if (profile.isLoggedIn) { + ListItem( + headlineContent = { + Text( + text = "退出登录", + color = MaterialTheme.colorScheme.error, + ) + }, + leadingContent = { + Icon( + Icons.AutoMirrored.Filled.ExitToApp, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + modifier = Modifier.clickable { showLogoutDialog = true }, + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + } +} + +private data class ProfileInfoItem( + val icon: ImageVector, + val label: String, + val value: String, +) + +@Composable +private fun ProfileInfoCard(items: List) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ) { + Column { + items.forEachIndexed { index, item -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + item.icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { Text( - text = "退出登录", - color = MaterialTheme.colorScheme.error, + text = item.label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - }, - leadingContent = { - Icon( - Icons.AutoMirrored.Filled.ExitToApp, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, + Text( + text = item.value, + style = MaterialTheme.typography.bodyMedium, ) - }, - modifier = Modifier.clickable { onLogout() }, - ) + } + } + if (index < items.size - 1) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } } } } 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 efd7a50..aef4fce 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 @@ -2,12 +2,16 @@ package top.yeij.cyrene.ui.screens.settings import android.widget.Toast import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -16,9 +20,13 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.SettingsBrightness +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalIconButton @@ -27,13 +35,20 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +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 import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.ImeAction @@ -41,6 +56,8 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.koin.compose.koinInject +import top.yeij.cyrene.util.LogCategory +import top.yeij.cyrene.util.RuntimeLog import top.yeij.cyrene.viewmodel.SettingsViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -52,6 +69,9 @@ fun SettingsScreen( val baseUrl by viewModel.baseUrl.collectAsState() val themeMode by viewModel.themeMode.collectAsState() val wakeWord by viewModel.wakeWord.collectAsState() + val dashScopeApiKey by viewModel.dashScopeApiKey.collectAsState() + val dashScopeEndpoint by viewModel.dashScopeEndpoint.collectAsState() + val dashScopeModel by viewModel.dashScopeModel.collectAsState() val context = LocalContext.current val scope = rememberCoroutineScope() @@ -101,7 +121,7 @@ fun SettingsScreen( ) OutlinedTextField( value = baseUrl, - onValueChange = { viewModel.saveBaseUrl(it) }, + onValueChange = { viewModel.updateBaseUrlInput(it) }, label = { Text("服务器地址") }, placeholder = { Text("http://192.168.1.x:8080") }, singleLine = true, @@ -203,6 +223,287 @@ fun SettingsScreen( .padding(horizontal = 16.dp), shape = MaterialTheme.shapes.medium, ) + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + + // DashScope STT + Text( + text = "语音识别 (DashScope)", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(16.dp), + ) + + OutlinedTextField( + value = dashScopeApiKey, + onValueChange = { viewModel.updateDashScopeApiKeyInput(it) }, + label = { Text("API Key") }, + placeholder = { Text("sk-xxxxxxxxxxxxxxxx") }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + scope.launch { + viewModel.saveDashScopeApiKey(dashScopeApiKey) + Toast.makeText(context, "API Key 已保存", Toast.LENGTH_SHORT).show() + } + }, + ), + trailingIcon = { + FilledTonalIconButton(onClick = { + scope.launch { + viewModel.saveDashScopeApiKey(dashScopeApiKey) + Toast.makeText(context, "API Key 已保存", Toast.LENGTH_SHORT).show() + } + }) { + Icon(Icons.Filled.Check, contentDescription = "保存") + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = dashScopeEndpoint, + onValueChange = { viewModel.updateDashScopeEndpointInput(it) }, + label = { Text("WebSocket 端点") }, + placeholder = { Text("wss://dashscope.aliyuncs.com/api-ws/v1/inference") }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + scope.launch { + viewModel.saveDashScopeEndpoint(dashScopeEndpoint) + Toast.makeText(context, "端点已保存", Toast.LENGTH_SHORT).show() + } + }, + ), + trailingIcon = { + FilledTonalIconButton(onClick = { + scope.launch { + viewModel.saveDashScopeEndpoint(dashScopeEndpoint) + Toast.makeText(context, "端点已保存", Toast.LENGTH_SHORT).show() + } + }) { + Icon(Icons.Filled.Check, contentDescription = "保存") + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = dashScopeModel, + onValueChange = { viewModel.updateDashScopeModelInput(it) }, + label = { Text("模型") }, + placeholder = { Text("fun-asr-realtime") }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + scope.launch { + viewModel.saveDashScopeModel(dashScopeModel) + Toast.makeText(context, "模型已保存", Toast.LENGTH_SHORT).show() + } + }, + ), + trailingIcon = { + FilledTonalIconButton(onClick = { + scope.launch { + viewModel.saveDashScopeModel(dashScopeModel) + Toast.makeText(context, "模型已保存", Toast.LENGTH_SHORT).show() + } + }) { + Icon(Icons.Filled.Check, contentDescription = "保存") + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "未配置 API Key 时,语音输入将自动使用后端服务处理。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + + // Data management + Text( + text = "数据", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(16.dp), + ) + + var showClearDialog by remember { mutableStateOf(false) } + ListItem( + headlineContent = { Text("清空本地消息记录") }, + supportingContent = { Text("仅清除本地数据库,服务器消息仍保留。下次加载将只获取清除时间之后的消息。") }, + leadingContent = { Icon(Icons.Filled.DeleteForever, contentDescription = null, tint = MaterialTheme.colorScheme.error) }, + modifier = Modifier.clickable { showClearDialog = true }, + ) + + if (showClearDialog) { + AlertDialog( + onDismissRequest = { showClearDialog = false }, + title = { Text("确认清空") }, + text = { Text("将清空所有本地消息记录。服务器上的消息不会被删除,但下次加载历史时将只获取本次清除之后的消息。") }, + confirmButton = { + TextButton( + onClick = { + showClearDialog = false + viewModel.clearLocalMessages() + Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show() + }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text("清空") + } + }, + dismissButton = { + TextButton(onClick = { showClearDialog = false }) { + Text("取消") + } + }, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + + // Runtime logs + Text( + text = "运行日志", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(16.dp), + ) + + val logEntries by RuntimeLog.entries.collectAsState() + var selectedTab by remember { mutableStateOf(0) } + val tabs = listOf("全部") + LogCategory.entries.map { it.label } + val allCategories = LogCategory.entries.toList() + + // Scroll to bottom + val scrollState = rememberScrollState() + androidx.compose.runtime.LaunchedEffect(logEntries.size, selectedTab) { + if (logEntries.isNotEmpty()) { + scrollState.animateScrollTo(scrollState.maxValue) + } + } + + TabRow(selectedTabIndex = selectedTab) { + tabs.forEachIndexed { index, label -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { Text(label, maxLines = 1) }, + ) + } + } + + 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( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { + val file = RuntimeLog.exportToFile(context, currentCategory) + RuntimeLog.shareFile(context, file) + }, + ) { + Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text(if (currentCategory != null) "导出${currentCategory.label}" else "导出全部") + } + OutlinedButton( + onClick = { + val file = RuntimeLog.exportAllAsZip(context) + RuntimeLog.shareFile(context, file) + }, + ) { + Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("打包全部") + } + OutlinedButton( + onClick = { RuntimeLog.clear() }, + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text("清空") + } + } + + if (filteredLogs.isEmpty()) { + Text( + text = "暂无${tabs[selectedTab]}日志", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } else { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .weight(1f, fill = false), + ) { + Column( + modifier = Modifier.verticalScroll(scrollState), + ) { + filteredLogs.takeLast(500).forEach { entry -> + Text( + text = entry.formatted(), + style = MaterialTheme.typography.bodySmall, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 1.dp), + ) + } + } + } + } + + Spacer(modifier = Modifier.height(80.dp)) } } } diff --git a/app/src/main/java/top/yeij/cyrene/util/NotificationHelper.kt b/app/src/main/java/top/yeij/cyrene/util/NotificationHelper.kt new file mode 100644 index 0000000..aa7fda1 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/util/NotificationHelper.kt @@ -0,0 +1,64 @@ +package top.yeij.cyrene.util + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import top.yeij.cyrene.MainActivity +import top.yeij.cyrene.domain.model.Message + +class NotificationHelper(private val context: Context) { + + private val notificationManager = context.getSystemService(NotificationManager::class.java) + + init { + createChannel() + } + + private fun createChannel() { + val channel = NotificationChannel( + CHANNEL_ID, + "消息通知", + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = "昔涟的新消息通知" + enableVibration(true) + setShowBadge(true) + } + notificationManager.createNotificationChannel(channel) + } + + fun showMessageNotification(message: Message) { + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = PendingIntent.getActivity( + context, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val preview = if (message.content.length > 50) { + message.content.take(50) + "..." + } else { + message.content + } + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle("昔涟") + .setContentText(preview) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build() + + notificationManager.notify(message.id.hashCode(), notification) + } + + companion object { + private const val CHANNEL_ID = "cyrene_messages" + } +} diff --git a/app/src/main/java/top/yeij/cyrene/util/RuntimeLog.kt b/app/src/main/java/top/yeij/cyrene/util/RuntimeLog.kt new file mode 100644 index 0000000..9d5cde1 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/util/RuntimeLog.kt @@ -0,0 +1,126 @@ +package top.yeij.cyrene.util + +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +enum class LogCategory(val label: String) { + CHAT("聊天"), + WS("WebSocket"), + STT("语音识别"), + GENERAL("通用"), + HTTP("网络"), + VOICE("语音"), +} + +data class LogEntry( + val timestamp: Long, + val category: LogCategory, + val tag: String, + val message: String, +) { + fun formatted(): String { + val sdf = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()) + return "[${sdf.format(Date(timestamp))}] [${category.name}] [$tag] $message" + } +} + +object RuntimeLog { + + private const val MAX_ENTRIES = 2000 + + private val _entries = MutableStateFlow>(emptyList()) + val entries: StateFlow> = _entries.asStateFlow() + + private val buffer = ArrayDeque(MAX_ENTRIES) + + @Synchronized + fun log(category: LogCategory, tag: String, message: String) { + val entry = LogEntry(System.currentTimeMillis(), category, tag, message) + if (buffer.size >= MAX_ENTRIES) { + buffer.removeFirst() + } + buffer.addLast(entry) + _entries.value = buffer.toList() + } + + fun chat(tag: String, message: String) = log(LogCategory.CHAT, tag, message) + fun ws(tag: String, message: String) = log(LogCategory.WS, tag, message) + fun stt(tag: String, message: String) = log(LogCategory.STT, 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 voice(tag: String, message: String) = log(LogCategory.VOICE, tag, message) + + @Synchronized + fun getByCategory(category: LogCategory): List { + return buffer.filter { it.category == category } + } + + @Synchronized + fun exportAsText(category: LogCategory): String { + val entries = buffer.filter { it.category == category } + val header = "=== Cyrene ${category.label}日志 导出时间: ${ + SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()) + } ===\n\n" + return header + entries.joinToString("\n") { it.formatted() } + } + + @Synchronized + fun exportAllAsText(): String { + val header = "=== Cyrene 全部日志 导出时间: ${ + SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()) + } ===\n\n" + val byCategory = buffer.groupBy { it.category } + return header + byCategory.entries.joinToString("\n\n") { (cat, entries) -> + "--- ${cat.label} (${cat.name}) ---\n" + entries.joinToString("\n") { it.formatted() } + } + } + + fun exportToFile(context: Context, category: LogCategory? = null): File { + val text = if (category != null) exportAsText(category) else exportAllAsText() + val label = category?.name?.lowercase() ?: "all" + val dateStr = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val file = File(context.cacheDir, "cyrene_log_${label}_$dateStr.txt") + file.writeText(text) + return file + } + + fun exportAllAsZip(context: Context): File { + val dateStr = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val zipFile = File(context.cacheDir, "cyrene_logs_$dateStr.zip") + ZipOutputStream(zipFile.outputStream().buffered()).use { zos -> + LogCategory.entries.forEach { cat -> + val text = exportAsText(cat) + zos.putNextEntry(ZipEntry("${cat.name.lowercase()}.txt")) + zos.write(text.toByteArray(Charsets.UTF_8)) + zos.closeEntry() + } + } + return zipFile + } + + fun shareFile(context: Context, file: File) { + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + val intent = Intent(Intent.ACTION_SEND).apply { + type = if (file.extension == "zip") "application/zip" else "text/plain" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, "导出日志")) + } + + @Synchronized + fun clear() { + buffer.clear() + _entries.value = emptyList() + } +} diff --git a/app/src/main/java/top/yeij/cyrene/util/VoiceRecorder.kt b/app/src/main/java/top/yeij/cyrene/util/VoiceRecorder.kt new file mode 100644 index 0000000..826f0af --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/util/VoiceRecorder.kt @@ -0,0 +1,158 @@ +package top.yeij.cyrene.util + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import android.util.Base64 +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File + +enum class RecordState { + IDLE, + RECORDING, + LOCKED, // hands-free mode + CANCELLING, +} + +class VoiceRecorder(private val context: Context) { + + private var recorder: MediaRecorder? = null + private var outputFile: File? = null + private var timerJob: Job? = null + + private val _state = MutableStateFlow(RecordState.IDLE) + val state: StateFlow = _state.asStateFlow() + + private val _durationMs = MutableStateFlow(0L) + val durationMs: StateFlow = _durationMs.asStateFlow() + + fun start() { + if (_state.value != RecordState.IDLE) return + try { + outputFile = File(context.cacheDir, "voice_${System.currentTimeMillis()}.aac") + outputFile?.delete() + + recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + @Suppress("DEPRECATION") + MediaRecorder() + } + + recorder?.apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setAudioSamplingRate(16000) + setAudioEncodingBitRate(32000) + setAudioChannels(1) + setOutputFile(outputFile?.absolutePath) + prepare() + start() + } + + _state.value = RecordState.RECORDING + startTimer() + Log.i(TAG, "Recording started: ${outputFile?.absolutePath}") + } catch (e: Exception) { + Log.e(TAG, "Failed to start recording: ${e.message}", e) + cleanup() + } + } + + fun lock() { + if (_state.value == RecordState.RECORDING) { + _state.value = RecordState.LOCKED + } + } + + fun stop(): File? { + if (_state.value == RecordState.IDLE) return null + val file = outputFile + try { + recorder?.apply { + try { stop() } catch (_: Exception) { } + try { release() } catch (_: Exception) { } + } + } catch (e: Exception) { + Log.e(TAG, "Error stopping recorder: ${e.message}", e) + } + recorder = null + cancelTimer() + _state.value = RecordState.IDLE + _durationMs.value = 0L + Log.i(TAG, "Recording stopped: ${file?.absolutePath}, size=${file?.length()}") + return if (file != null && file.exists() && file.length() > 0) file else null + } + + fun cancel() { + val file = outputFile + try { + recorder?.apply { + try { stop() } catch (_: Exception) { } + try { release() } catch (_: Exception) { } + } + } catch (e: Exception) { } + recorder = null + file?.delete() + cancelTimer() + _state.value = RecordState.IDLE + _durationMs.value = 0L + Log.i(TAG, "Recording cancelled") + } + + fun cleanup() { + try { recorder?.release() } catch (_: Exception) { } + recorder = null + outputFile?.delete() + outputFile = null + cancelTimer() + _state.value = RecordState.IDLE + _durationMs.value = 0L + } + + fun getBase64(): String? { + val file = outputFile ?: return null + if (!file.exists()) return null + return try { + val bytes = file.readBytes() + Base64.encodeToString(bytes, Base64.NO_WRAP) + } catch (e: Exception) { + Log.e(TAG, "Failed to encode audio: ${e.message}", e) + null + } + } + + fun deleteFile() { + outputFile?.delete() + outputFile = null + } + + private fun startTimer() { + cancelTimer() + val startTime = System.currentTimeMillis() + timerJob = CoroutineScope(Dispatchers.Main).launch { + while (true) { + delay(100) + _durationMs.value = System.currentTimeMillis() - startTime + } + } + } + + private fun cancelTimer() { + timerJob?.cancel() + timerJob = null + } + + companion object { + private const val TAG = "VoiceRecorder" + } +} 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 c79571f..ac49f0d 100644 --- a/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt @@ -3,23 +3,64 @@ package top.yeij.cyrene.viewmodel import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import top.yeij.cyrene.domain.model.Conversation import top.yeij.cyrene.domain.model.Message import top.yeij.cyrene.domain.repository.ChatRepository +import top.yeij.cyrene.util.RecordState +import top.yeij.cyrene.util.RuntimeLog +import top.yeij.cyrene.util.VoiceRecorder + +private fun List.deduplicate(): List { + if (isEmpty()) return this + val result = mutableListOf(this[0]) + for (i in 1 until size) { + val prev = result.last() + val curr = this[i] + val isDuplicate = curr.id == prev.id || + (curr.role == prev.role && curr.content == prev.content && curr.msgType == prev.msgType) + if (!isDuplicate) { + result.add(curr) + } + } + return result +} + +private fun List.removeWrappingDuplicates(): List { + if (size < 3) return this + val toRemove = mutableSetOf() + for (msg in this) { + val containedCount = count { other -> + other.id != msg.id && + other.content.isNotBlank() && + other.content.length < msg.content.length && + msg.content.contains(other.content) && + kotlin.math.abs(other.timestamp - msg.timestamp) < 2000 + } + if (containedCount >= 2) { + toRemove.add(msg.id) + } + } + return if (toRemove.isEmpty()) this else filter { it.id !in toRemove } +} class ChatViewModel( private val chatRepository: ChatRepository, + private val voiceRecorder: VoiceRecorder, ) : ViewModel() { val isConnected: StateFlow = chatRepository.connectionState .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val connectionError: StateFlow = chatRepository.connectionError + val conversations: StateFlow> = chatRepository.getConversations() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) @@ -29,37 +70,144 @@ class ChatViewModel( private val _inputText = MutableStateFlow("") val inputText: StateFlow = _inputText.asStateFlow() - private val _isStreaming = MutableStateFlow(false) - val isStreaming: StateFlow = _isStreaming.asStateFlow() + private val _isSending = MutableStateFlow(false) + val isStreaming: StateFlow = kotlinx.coroutines.flow.combine( + _isSending, + chatRepository.isAssistantStreaming, + ) { sending, assistant -> sending || assistant } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + // Voice recording state + val voiceRecordState: StateFlow = voiceRecorder.state + val voiceRecordDurationMs: StateFlow = voiceRecorder.durationMs + + // Animation ordering for message bubbles + private var animCounter = 0 + private val _messageAnimIndex = MutableStateFlow>(emptyMap()) + val messageAnimIndex: StateFlow> = _messageAnimIndex.asStateFlow() private var currentSessionId: String? = null + private var dbObserverJob: Job? = null init { - connectAndLoad() - } - - fun connectAndLoad(sessionId: String? = null) { + // Phase 1: find/create main session, reconnect WS, load server history viewModelScope.launch { - chatRepository.connectWebSocket(sessionId) - chatRepository.loadConversationsFromServer() + try { + val sessionId = chatRepository.initializeSession() + currentSessionId = sessionId + chatRepository.currentSessionId = sessionId + chatRepository.ensureConnected() + loadMessagesFromDb(sessionId) + val serverMessages = chatRepository.loadMessagesFromServer(sessionId) + if (serverMessages.isNotEmpty()) { + val serverIds = serverMessages.map { it.id }.toSet() + _currentMessages.update { current -> + val localOnly = current.filter { it.id !in serverIds } + (serverMessages + localOnly) + .sortedBy { it.timestamp } + .deduplicate() + .removeWrappingDuplicates() + } + } + } catch (_: Exception) { } } + + // Observe incoming live messages with atomic dedup viewModelScope.launch { chatRepository.observeMessages().collect { message -> try { - val list = _currentMessages.value.toMutableList() - val existingIdx = list.indexOfLast { it.id == message.id } - if (existingIdx >= 0) { - list[existingIdx] = message - } else { - list.add(message) + _currentMessages.update { list -> + val updated = list.toMutableList() + val existingIdx = updated.indexOfLast { it.id == message.id } + if (existingIdx >= 0) { + updated[existingIdx] = message + } else { + val isDup = updated.any { + it.role == message.role && it.content == message.content && it.msgType == message.msgType + } + if (!isDup) { + updated.add(message) + val idx = _messageAnimIndex.value.toMutableMap() + idx[message.id] = animCounter++ + _messageAnimIndex.value = idx + } + } + updated.deduplicate() } - _currentMessages.value = list - _isStreaming.value = list.any { it.isStreaming } } catch (e: Exception) { Log.e("ChatViewModel", "Error processing message: ${e.message}", e) } } } + + // Observe message clear events + viewModelScope.launch { + chatRepository.messageClearEvents.collect { + _currentMessages.value = emptyList() + _messageAnimIndex.value = emptyMap() + animCounter = 0 + } + } + // Reset user-side sending state when server starts responding + viewModelScope.launch { + chatRepository.isAssistantStreaming.collect { streaming -> + if (streaming) _isSending.value = false + } + } + } + + // --- Voice recording (WeChat-style gesture) --- + + fun startRecord() { + voiceRecorder.start() + } + + fun lockRecord() { + voiceRecorder.lock() + } + + fun finishRecord() { + val file = voiceRecorder.stop() ?: return + val base64 = voiceRecorder.getBase64() + voiceRecorder.deleteFile() + if (base64.isNullOrBlank()) return + + viewModelScope.launch { + chatRepository.sendVoiceInput(base64, "voice_msg") + RuntimeLog.chat("voice", "Voice message sent, duration=${file.length()}") + } + } + + fun cancelRecord() { + voiceRecorder.cancel() + } + + private fun loadMessagesFromDb(sessionId: String) { + dbObserverJob?.cancel() + dbObserverJob = viewModelScope.launch { + try { + chatRepository.getMessages(sessionId).collect { messages -> + _currentMessages.update { current -> + val live = current.associateBy { it.id } + val db = messages.associateBy { it.id } + (db + live).values + .sortedBy { it.timestamp } + .deduplicate() + .removeWrappingDuplicates() + } + val idx = _messageAnimIndex.value.toMutableMap() + messages.forEach { m -> + if (m.id !in idx) idx[m.id] = animCounter++ + } + _messageAnimIndex.value = idx + } + } catch (e: Exception) { + Log.e("ChatViewModel", "Error loading messages: ${e.message}", e) + } + } } fun onInputChanged(text: String) { @@ -71,7 +219,7 @@ class ChatViewModel( if (text.isEmpty()) return _inputText.value = "" - _isStreaming.value = true + _isSending.value = true val sid = currentSessionId viewModelScope.launch { @@ -81,11 +229,39 @@ class ChatViewModel( fun switchSession(sessionId: String) { currentSessionId = sessionId + chatRepository.currentSessionId = sessionId + _currentMessages.value = emptyList() + _messageAnimIndex.value = emptyMap() + animCounter = 0 viewModelScope.launch { - chatRepository.disconnectWebSocket() chatRepository.connectWebSocket(sessionId) chatRepository.loadMessagesFromServer(sessionId) } + loadMessagesFromDb(sessionId) + } + + fun refreshMessages() { + val sid = currentSessionId ?: return + viewModelScope.launch { + _isRefreshing.value = true + try { + if (!isConnected.value) { + chatRepository.ensureConnected() + } + val serverMessages = chatRepository.loadMessagesFromServer(sid) + if (serverMessages.isNotEmpty()) { + val serverIds = serverMessages.map { it.id }.toSet() + _currentMessages.update { current -> + val localOnly = current.filter { it.id !in serverIds } + (serverMessages + localOnly) + .sortedBy { it.timestamp } + .deduplicate() + .removeWrappingDuplicates() + } + } + } catch (_: Exception) { } + _isRefreshing.value = false + } } fun deleteConversation(id: String) { @@ -94,10 +270,12 @@ class ChatViewModel( } } - override fun onCleared() { + fun clearLocalMessages() { viewModelScope.launch { - chatRepository.disconnectWebSocket() + chatRepository.clearLocalMessages() + _currentMessages.value = emptyList() + _messageAnimIndex.value = emptyMap() + animCounter = 0 } - super.onCleared() } } 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 1d31134..67a143c 100644 --- a/app/src/main/java/top/yeij/cyrene/viewmodel/OverlayViewModel.kt +++ b/app/src/main/java/top/yeij/cyrene/viewmodel/OverlayViewModel.kt @@ -1,5 +1,6 @@ package top.yeij.cyrene.viewmodel +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Job @@ -7,10 +8,47 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import top.yeij.cyrene.domain.model.Message import top.yeij.cyrene.domain.repository.ChatRepository import top.yeij.cyrene.util.Constants +import top.yeij.cyrene.util.RecordState +import top.yeij.cyrene.util.VoiceRecorder +import top.yeij.cyrene.voice.tts.TextToSpeechEngine + +private fun List.deduplicate(): List { + if (isEmpty()) return this + val result = mutableListOf(this[0]) + for (i in 1 until size) { + val prev = result.last() + val curr = this[i] + val isDuplicate = curr.id == prev.id || + (curr.role == prev.role && curr.content == prev.content && curr.msgType == prev.msgType) + if (!isDuplicate) { + result.add(curr) + } + } + return result +} + +private fun List.removeWrappingDuplicates(): List { + if (size < 3) return this + val toRemove = mutableSetOf() + for (msg in this) { + val containedCount = count { other -> + other.id != msg.id && + other.content.isNotBlank() && + other.content.length < msg.content.length && + msg.content.contains(other.content) && + kotlin.math.abs(other.timestamp - msg.timestamp) < 2000 + } + if (containedCount >= 2) { + toRemove.add(msg.id) + } + } + return if (toRemove.isEmpty()) this else filter { it.id !in toRemove } +} enum class OverlayState { IDLE, @@ -22,94 +60,178 @@ enum class OverlayState { class OverlayViewModel( private val chatRepository: ChatRepository, + private val voiceRecorder: VoiceRecorder, + private val ttsEngine: TextToSpeechEngine, ) : ViewModel() { - private val _state = MutableStateFlow(OverlayState.IDLE) + private val _state = MutableStateFlow(OverlayState.WAITING) val state: StateFlow = _state.asStateFlow() private val _messages = MutableStateFlow>(emptyList()) val messages: StateFlow> = _messages.asStateFlow() - private val _recognizedText = MutableStateFlow("") - val recognizedText: StateFlow = _recognizedText.asStateFlow() + private val _inputText = MutableStateFlow("") + val inputText: StateFlow = _inputText.asStateFlow() + + val voiceRecordState: StateFlow = voiceRecorder.state + val voiceRecordDurationMs: StateFlow = voiceRecorder.durationMs + + // Animation ordering for message bubbles + private var animCounter = 0 + private val _messageAnimIndex = MutableStateFlow>(emptyMap()) + val messageAnimIndex: StateFlow> = _messageAnimIndex.asStateFlow() private var silenceTimer: Job? = null + private var lastAssistantMessageId: String? = null init { viewModelScope.launch { - chatRepository.connectWebSocket(null) + chatRepository.observeMessages().collect { message -> + _messages.update { list -> + val updated = list.toMutableList() + val idx = updated.indexOfLast { it.id == message.id } + if (idx >= 0) { + updated[idx] = message + } else { + val isDup = updated.any { + it.role == message.role && it.content == message.content && it.msgType == message.msgType + } + if (!isDup) { + updated.add(message) + val animIdx = _messageAnimIndex.value.toMutableMap() + animIdx[message.id] = animCounter++ + _messageAnimIndex.value = animIdx + } + } + updated.deduplicate() + } + + if (message.role == "assistant" && !message.isStreaming && message.msgType == "chat") { + if (message.id != lastAssistantMessageId && message.content.isNotBlank()) { + lastAssistantMessageId = message.id + speakResponse(message.content) + } + } + } } viewModelScope.launch { - chatRepository.observeMessages().collect { message -> - _messages.value = _messages.value + message + ttsEngine.onDone.collect { + if (_state.value == OverlayState.SPEAKING) { + setWaiting() + } + } + } + viewModelScope.launch { + chatRepository.messageClearEvents.collect { + _messages.value = emptyList() + _messageAnimIndex.value = emptyMap() + animCounter = 0 } } } - fun startListening() { - _state.value = OverlayState.LISTENING - resetSilenceTimer() + fun onInputChanged(text: String) { + _inputText.value = text } - fun onSpeechPartial(text: String) { - _recognizedText.value = text - resetSilenceTimer() - } + fun sendText() { + val text = _inputText.value.trim() + if (text.isEmpty()) return + _inputText.value = "" - fun onSpeechFinal(text: String) { - _recognizedText.value = text _state.value = OverlayState.PROCESSING cancelSilenceTimer() - viewModelScope.launch { chatRepository.sendMessage(text, null) - _recognizedText.value = "" } } - fun sendText(text: String) { + // --- Voice recording (WeChat-style gesture) --- + + fun startRecord() { + voiceRecorder.start() + _state.value = OverlayState.LISTENING + cancelSilenceTimer() + } + + fun lockRecord() { + voiceRecorder.lock() + } + + fun finishRecord() { + val file = voiceRecorder.stop() ?: return + val base64 = voiceRecorder.getBase64() + voiceRecorder.deleteFile() + if (base64.isNullOrBlank()) return + _state.value = OverlayState.PROCESSING viewModelScope.launch { - chatRepository.sendMessage(text, null) + chatRepository.sendVoiceInput(base64, "voice_msg") } } - fun setSpeaking() { - _state.value = OverlayState.SPEAKING + fun cancelRecord() { + voiceRecorder.cancel() + setWaiting() } - fun setWaiting() { + fun cancelCurrentAction() { + if (voiceRecorder.state.value == RecordState.LOCKED) { + voiceRecorder.cancel() + setWaiting() + } + } + + private fun speakResponse(text: String) { + if (text.isBlank()) return + _state.value = OverlayState.SPEAKING + ttsEngine.speak(text) + } + + private fun setWaiting() { _state.value = OverlayState.WAITING startSilenceTimer() } + fun stopSpeaking() { + ttsEngine.stop() + if (_state.value == OverlayState.SPEAKING) { + setWaiting() + } + } + + fun sendScreenContext(content: String) { + if (content.isBlank()) return + viewModelScope.launch { + chatRepository.sendScreenContext(content) + } + } + fun finish() { _state.value = OverlayState.IDLE cancelSilenceTimer() + voiceRecorder.cancel() + ttsEngine.stop() } private fun startSilenceTimer() { cancelSilenceTimer() silenceTimer = viewModelScope.launch { delay(Constants.SILENCE_TIMEOUT_MS) - _state.value = OverlayState.IDLE + if (_state.value == OverlayState.WAITING) { + _state.value = OverlayState.IDLE + } } } - private fun resetSilenceTimer() { - cancelSilenceTimer() - startSilenceTimer() - } - private fun cancelSilenceTimer() { silenceTimer?.cancel() silenceTimer = null } override fun onCleared() { - viewModelScope.launch { - chatRepository.disconnectWebSocket() - } + voiceRecorder.cancel() + ttsEngine.shutdown() super.onCleared() } } diff --git a/app/src/main/java/top/yeij/cyrene/viewmodel/ProfileViewModel.kt b/app/src/main/java/top/yeij/cyrene/viewmodel/ProfileViewModel.kt new file mode 100644 index 0000000..a52add6 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/viewmodel/ProfileViewModel.kt @@ -0,0 +1,120 @@ +package top.yeij.cyrene.viewmodel + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import top.yeij.cyrene.data.local.PreferencesDataStore +import top.yeij.cyrene.data.remote.ApiService +import top.yeij.cyrene.domain.repository.AuthRepository + +data class ProfileState( + val userId: String = "", + val username: String = "", + val nickname: String = "", + val isAdmin: Boolean = false, + val createdAt: String = "", + val isLoading: Boolean = false, + val isLoggedIn: Boolean = false, +) + +class ProfileViewModel( + private val apiService: ApiService, + private val authRepository: AuthRepository, + private val prefs: PreferencesDataStore, +) : ViewModel() { + + private val _profile = MutableStateFlow(ProfileState()) + val profile: StateFlow = _profile.asStateFlow() + + private var loadedFromCache = false + + init { + viewModelScope.launch { + _profile.value = _profile.value.copy(isLoggedIn = authRepository.isLoggedIn()) + if (_profile.value.isLoggedIn) { + loadCachedProfile() + fetchFreshProfile() + } + } + } + + /** + * Show cached profile immediately for instant UI. + */ + private suspend fun loadCachedProfile() { + val userId = prefs.profileUserId.firstOrNull() ?: return + val nickname = prefs.profileNickname.firstOrNull() ?: "" + val isAdmin = prefs.profileIsAdmin.firstOrNull()?.toBoolean() ?: false + val createdAt = prefs.profileCreatedAt.firstOrNull() ?: "" + val username = prefs.username.firstOrNull() ?: "" + + _profile.value = ProfileState( + userId = userId, + username = username, + nickname = nickname, + isAdmin = isAdmin, + createdAt = createdAt, + isLoggedIn = true, + ) + loadedFromCache = true + } + + /** + * Fetch fresh profile from server. On success, update cache and UI. + * On failure, keep showing cached data — no error, no UI disruption. + */ + fun fetchFreshProfile() { + viewModelScope.launch { + if (!loadedFromCache) { + _profile.value = _profile.value.copy(isLoading = true) + } + try { + val response = apiService.getProfile() + if (response.isSuccessful) { + val body = response.body() + if (body != null) { + val dateStr = body.createdAt?.take(10) ?: "" + val nickname = body.nickname ?: body.username + + _profile.value = ProfileState( + userId = body.userId, + username = body.username, + nickname = nickname, + isAdmin = body.isAdmin == true, + createdAt = dateStr, + isLoading = false, + isLoggedIn = true, + ) + loadedFromCache = true + + // Update local cache + prefs.saveProfileCache(body.userId, nickname, body.isAdmin == true, dateStr) + } + } else if (!loadedFromCache) { + _profile.value = _profile.value.copy(isLoading = false) + } + // On error with cache already shown: silently ignore + } catch (e: Exception) { + Log.w("ProfileVM", "Failed to fetch fresh profile: ${e.message}") + if (!loadedFromCache) { + _profile.value = _profile.value.copy(isLoading = false) + } + // If cached data is showing, keep it silently + } + } + } + + fun logout() { + viewModelScope.launch { + authRepository.logout() + prefs.clearProfileCache() + _profile.value = ProfileState() + loadedFromCache = false + } + } +} diff --git a/app/src/main/java/top/yeij/cyrene/viewmodel/SettingsViewModel.kt b/app/src/main/java/top/yeij/cyrene/viewmodel/SettingsViewModel.kt index 21ebae2..bcab963 100644 --- a/app/src/main/java/top/yeij/cyrene/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/top/yeij/cyrene/viewmodel/SettingsViewModel.kt @@ -11,11 +11,15 @@ import kotlinx.coroutines.launch import top.yeij.cyrene.data.local.PreferencesDataStore import top.yeij.cyrene.data.remote.DynamicUrlInterceptor import top.yeij.cyrene.domain.repository.AuthRepository +import top.yeij.cyrene.domain.repository.ChatRepository +import top.yeij.cyrene.voice.stt.SttManager class SettingsViewModel( private val authRepository: AuthRepository, private val preferencesDataStore: PreferencesDataStore, private val dynamicUrlInterceptor: DynamicUrlInterceptor, + private val chatRepository: ChatRepository, + private val sttManager: SttManager, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) @@ -32,6 +36,15 @@ class SettingsViewModel( private val _username = MutableStateFlow("") val username: StateFlow = _username.asStateFlow() + private val _dashScopeApiKey = MutableStateFlow("") + val dashScopeApiKey: StateFlow = _dashScopeApiKey.asStateFlow() + + private val _dashScopeEndpoint = MutableStateFlow("wss://dashscope.aliyuncs.com/api-ws/v1/inference") + val dashScopeEndpoint: StateFlow = _dashScopeEndpoint.asStateFlow() + + private val _dashScopeModel = MutableStateFlow("fun-asr-realtime") + val dashScopeModel: StateFlow = _dashScopeModel.asStateFlow() + private val _isLoggedIn = MutableStateFlow(false) val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() @@ -46,7 +59,14 @@ class SettingsViewModel( preferencesDataStore.themeMode, preferencesDataStore.wakeWord, preferencesDataStore.username, - ) { baseUrl, themeMode, wakeWord, username -> + combine( + preferencesDataStore.dashScopeApiKey, + preferencesDataStore.dashScopeEndpoint, + preferencesDataStore.dashScopeModel, + ) { apiKey, endpoint, model -> + Triple(apiKey, endpoint, model) + }, + ) { baseUrl, themeMode, wakeWord, username, dashScope -> baseUrl?.let { url -> if (url.isNotBlank()) { _baseUrl.value = url @@ -58,14 +78,38 @@ class SettingsViewModel( if (word.isNotBlank()) _wakeWord.value = word } username?.let { _username.value = it } + val (apiKey, endpoint, model) = dashScope + apiKey?.let { key -> + if (key.isNotBlank()) _dashScopeApiKey.value = key + sttManager.updateDashScopeApiKey(key) + } + endpoint?.let { ep -> + if (ep.isNotBlank()) _dashScopeEndpoint.value = ep + } + model?.let { m -> + if (m.isNotBlank()) _dashScopeModel.value = m + } + // Push full config to STT + sttManager.configureDashScope( + apiKey = _dashScopeApiKey.value, + endpoint = _dashScopeEndpoint.value, + model = _dashScopeModel.value, + ) }.collect { } } } + fun updateBaseUrlInput(url: String) { + _baseUrl.value = url + } + fun saveBaseUrl(url: String) { _baseUrl.value = url dynamicUrlInterceptor.baseUrl = url - scope.launch { preferencesDataStore.saveBaseUrl(url) } + scope.launch { + preferencesDataStore.saveBaseUrl(url) + chatRepository.reconnectWebSocket() + } } fun saveThemeMode(mode: String) { @@ -78,6 +122,42 @@ class SettingsViewModel( scope.launch { preferencesDataStore.saveWakeWord(word) } } + fun updateDashScopeApiKeyInput(key: String) { + _dashScopeApiKey.value = key + } + + fun saveDashScopeApiKey(key: String) { + _dashScopeApiKey.value = key + sttManager.updateDashScopeApiKey(key) + scope.launch { preferencesDataStore.saveDashScopeApiKey(key) } + } + + fun updateDashScopeEndpointInput(endpoint: String) { + _dashScopeEndpoint.value = endpoint + } + + fun saveDashScopeEndpoint(endpoint: String) { + _dashScopeEndpoint.value = endpoint + sttManager.configureDashScope(_dashScopeApiKey.value, endpoint, _dashScopeModel.value) + scope.launch { preferencesDataStore.saveDashScopeEndpoint(endpoint) } + } + + fun updateDashScopeModelInput(model: String) { + _dashScopeModel.value = model + } + + fun saveDashScopeModel(model: String) { + _dashScopeModel.value = model + sttManager.configureDashScope(_dashScopeApiKey.value, _dashScopeEndpoint.value, model) + scope.launch { preferencesDataStore.saveDashScopeModel(model) } + } + + fun clearLocalMessages() { + scope.launch { + chatRepository.clearLocalMessages() + } + } + fun logout() { scope.launch { authRepository.logout() diff --git a/app/src/main/java/top/yeij/cyrene/voice/stt/BackendSttProvider.kt b/app/src/main/java/top/yeij/cyrene/voice/stt/BackendSttProvider.kt new file mode 100644 index 0000000..fbdbcf5 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/voice/stt/BackendSttProvider.kt @@ -0,0 +1,160 @@ +package top.yeij.cyrene.voice.stt + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import android.util.Base64 +import android.util.Log +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import top.yeij.cyrene.domain.repository.ChatRepository +import java.io.ByteArrayOutputStream + +class BackendSttProvider( + private val context: Context, + private val chatRepository: ChatRepository, +) : SttProvider { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var audioRecord: AudioRecord? = null + private var recordingJob: Job? = null + private val audioBuffer = ByteArrayOutputStream() + + private val _partialResult = MutableStateFlow("") + override val partialResult: StateFlow = _partialResult.asStateFlow() + + private val _finalResult = MutableSharedFlow(extraBufferCapacity = 8) + override val finalResult: SharedFlow = _finalResult.asSharedFlow() + + private val _onError = MutableSharedFlow(extraBufferCapacity = 8) + override val onError: SharedFlow = _onError.asSharedFlow() + + private val _isListening = MutableStateFlow(false) + override val isListening: StateFlow = _isListening.asStateFlow() + + private val sampleRate = 16000 + private val audioFormat = AudioFormat.ENCODING_PCM_16BIT + private val channelConfig = AudioFormat.CHANNEL_IN_MONO + private val bufferSize: Int = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat) + .coerceAtLeast(3200) + + override fun start() { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) + != PackageManager.PERMISSION_GRANTED + ) { + _onError.tryEmit("缺少录音权限") + return + } + + cancel() + _isListening.value = true + _partialResult.value = "" + audioBuffer.reset() + + try { + audioRecord = AudioRecord( + MediaRecorder.AudioSource.VOICE_RECOGNITION, + sampleRate, + channelConfig, + audioFormat, + bufferSize, + ).also { + if (it.state != AudioRecord.STATE_INITIALIZED) { + Log.e(TAG, "AudioRecord init failed") + _onError.tryEmit("麦克风初始化失败") + _isListening.value = false + return + } + it.startRecording() + } + + val readBuffer = ByteArray(bufferSize) + recordingJob = scope.launch { + while (isActive && _isListening.value) { + val bytesRead = audioRecord?.read(readBuffer, 0, readBuffer.size) ?: -1 + if (bytesRead > 0) { + audioBuffer.write(readBuffer, 0, bytesRead) + } else if (bytesRead < 0) break + } + } + } catch (e: SecurityException) { + _onError.tryEmit("缺少录音权限") + _isListening.value = false + } catch (e: Exception) { + Log.e(TAG, "Recording error", e) + _onError.tryEmit("录音失败: ${e.message}") + _isListening.value = false + } + } + + override fun stop() { + if (!_isListening.value) return + Log.d(TAG, "Stopping recording") + stopRecording() + + val audioBytes = audioBuffer.toByteArray() + if (audioBytes.isEmpty()) { + Log.w(TAG, "No audio data recorded") + _isListening.value = false + _onError.tryEmit("未录制到语音") + return + } + + _isListening.value = false + scope.launch { + try { + val base64 = Base64.encodeToString(audioBytes, Base64.NO_WRAP) + chatRepository.sendVoiceInput(base64, "voice_msg") + Log.d(TAG, "Sent ${audioBytes.size} bytes of audio to backend") + } catch (e: Exception) { + Log.e(TAG, "Failed to send voice input", e) + _onError.tryEmit("发送语音失败: ${e.message}") + } + } + } + + override fun cancel() { + if (!_isListening.value) return + Log.d(TAG, "Cancelling recording") + stopRecording() + audioBuffer.reset() + _isListening.value = false + _partialResult.value = "" + } + + private fun stopRecording() { + recordingJob?.cancel() + recordingJob = null + try { + audioRecord?.stop() + audioRecord?.release() + } catch (e: Exception) { + Log.w(TAG, "Error releasing AudioRecord: ${e.message}") + } + audioRecord = null + } + + fun shutdown() { + cancel() + scope.cancel() + } + + companion object { + private const val TAG = "BackendSTT" + } +} diff --git a/app/src/main/java/top/yeij/cyrene/voice/stt/DashScopeSttService.kt b/app/src/main/java/top/yeij/cyrene/voice/stt/DashScopeSttService.kt new file mode 100644 index 0000000..a7c52f8 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/voice/stt/DashScopeSttService.kt @@ -0,0 +1,355 @@ +package top.yeij.cyrene.voice.stt + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import android.util.Log +import androidx.core.content.ContextCompat +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString +import java.util.UUID +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +class DashScopeSttService( + private val context: Context, +) : SttProvider { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val gson = Gson() + private val connectionId = AtomicInteger(0) + + private var webSocket: WebSocket? = null + private var audioRecord: AudioRecord? = null + private var recordingJob: Job? = null + private var taskId: String = "" + + private val _partialResult = MutableStateFlow("") + override val partialResult: StateFlow = _partialResult.asStateFlow() + + private val _finalResult = MutableSharedFlow(extraBufferCapacity = 16) + override val finalResult: SharedFlow = _finalResult.asSharedFlow() + + private val _onError = MutableSharedFlow(extraBufferCapacity = 8) + override val onError: SharedFlow = _onError.asSharedFlow() + + private val _isListening = MutableStateFlow(false) + override val isListening: StateFlow = _isListening.asStateFlow() + + // Configurable via settings + var apiKey: String = "" + var endpoint: String = "wss://dashscope.aliyuncs.com/api-ws/v1/inference" + var model: String = "fun-asr-realtime" + var enableInterimResults: Boolean = true + + private val sampleRate = 16000 + private val audioFormat = AudioFormat.ENCODING_PCM_16BIT + private val channelConfig = AudioFormat.CHANNEL_IN_MONO + private val bytesPerSample = 2 + private val chunkMs = 100 + private val bufferSize: Int = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat) + .coerceAtLeast(sampleRate * bytesPerSample * chunkMs / 1000) + + override fun start() { + if (apiKey.isBlank()) { + Log.w(TAG, "DashScope API key not configured") + _onError.tryEmit("请先配置 DashScope API Key") + return + } + + if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) + != PackageManager.PERMISSION_GRANTED + ) { + _onError.tryEmit("缺少录音权限") + return + } + + cancel() + taskId = UUID.randomUUID().toString() + _isListening.value = true + _partialResult.value = "" + + connectWebSocket() + startRecording() + } + + override fun stop() { + if (!_isListening.value) return + Log.d(TAG, "Stopping recognition, taskId=$taskId") + stopRecording() + sendFinishTask() + } + + override fun cancel() { + if (!_isListening.value) return + Log.d(TAG, "Cancelling recognition, taskId=$taskId") + stopRecording() + closeWebSocket() + _isListening.value = false + _partialResult.value = "" + } + + private fun connectWebSocket() { + val client = OkHttpClient.Builder() + .readTimeout(0, TimeUnit.MILLISECONDS) + .writeTimeout(0, TimeUnit.MILLISECONDS) + .callTimeout(0, TimeUnit.MILLISECONDS) + .build() + + val request = Request.Builder() + .url(endpoint) + .header("Authorization", "Bearer $apiKey") + .build() + + val connId = connectionId.incrementAndGet() + Log.i(TAG, "[#$connId] Connecting to DashScope: $endpoint") + + webSocket = client.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + if (connectionId.get() != connId) return + Log.i(TAG, "[#$connId] Connected, sending run-task") + sendRunTask() + } + + override fun onMessage(webSocket: WebSocket, text: String) { + if (connectionId.get() != connId) return + handleServerMessage(text) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + if (connectionId.get() != connId) return + Log.e(TAG, "[#$connId] WebSocket failure: ${t.message}", t) + _isListening.value = false + _onError.tryEmit("语音识别连接失败: ${t.message}") + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + if (connectionId.get() != connId) return + Log.d(TAG, "[#$connId] Server closing: $code $reason") + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + if (connectionId.get() != connId) return + Log.d(TAG, "[#$connId] Closed: $code $reason") + _isListening.value = false + } + }) + } + + private fun sendRunTask() { + val msg = mapOf( + "header" to mapOf( + "action" to "run-task", + "task_id" to taskId, + "streaming" to "duplex", + ), + "payload" to mapOf( + "model" to model, + "parameters" to mapOf( + "format" to "pcm", + "sample_rate" to sampleRate, + ), + "input" to emptyMap(), + ), + ) + val json = gson.toJson(msg) + Log.d(TAG, "Sending run-task: $json") + webSocket?.send(json) + } + + private fun sendFinishTask() { + val msg = mapOf( + "header" to mapOf( + "action" to "finish-task", + "task_id" to taskId, + ), + "payload" to emptyMap(), + ) + val json = gson.toJson(msg) + Log.d(TAG, "Sending finish-task: $json") + webSocket?.send(json) + } + + private fun handleServerMessage(text: String) { + try { + val response = gson.fromJson(text, DashScopeResponse::class.java) + val header = response.header ?: return + val event = header.event ?: return + + when (event) { + "result-generated" -> { + val sentence = response.payload?.output?.sentence ?: return + val sentenceText = sentence.text ?: return + _partialResult.value = sentenceText + + val sttResult = SttResult( + text = sentenceText, + isFinal = sentence.sentenceEnd ?: true, + ) + if (sttResult.isFinal) { + _finalResult.tryEmit(sttResult) + } else if (enableInterimResults) { + _finalResult.tryEmit(sttResult) + } + } + + "task-finished" -> { + Log.i(TAG, "Task finished: $taskId") + // If no result-generated was received, emit what we have + val partial = _partialResult.value + if (partial.isNotBlank()) { + _finalResult.tryEmit(SttResult(text = partial, isFinal = true)) + } + closeWebSocket() + _isListening.value = false + } + + "task-failed" -> { + val error = header.errorMessage ?: "语音识别失败" + Log.e(TAG, "Task failed: $error") + _onError.tryEmit(error) + closeWebSocket() + _isListening.value = false + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to parse server message: ${e.message}") + } + } + + private fun startRecording() { + try { + audioRecord = AudioRecord( + MediaRecorder.AudioSource.VOICE_RECOGNITION, + sampleRate, + channelConfig, + audioFormat, + bufferSize, + ).also { + if (it.state != AudioRecord.STATE_INITIALIZED) { + Log.e(TAG, "AudioRecord initialization failed") + _onError.tryEmit("麦克风初始化失败") + _isListening.value = false + return + } + it.startRecording() + } + + Log.d(TAG, "Recording started, bufferSize=$bufferSize") + val readBuffer = ByteArray(bufferSize) + + recordingJob = scope.launch { + while (isActive && _isListening.value) { + val bytesRead = audioRecord?.read(readBuffer, 0, readBuffer.size) ?: -1 + if (bytesRead > 0) { + val data = readBuffer.copyOf(bytesRead) + webSocket?.let { ws -> + try { + ws.send(ByteString.of(*data)) + } catch (e: Exception) { + Log.w(TAG, "Failed to send audio: ${e.message}") + } + } + } else if (bytesRead < 0) { + Log.w(TAG, "AudioRecord read error: $bytesRead") + break + } + } + } + } catch (e: SecurityException) { + Log.e(TAG, "Missing RECORD_AUDIO permission", e) + _onError.tryEmit("缺少录音权限") + _isListening.value = false + } catch (e: Exception) { + Log.e(TAG, "Failed to start recording", e) + _onError.tryEmit("录音启动失败: ${e.message}") + _isListening.value = false + } + } + + private fun stopRecording() { + recordingJob?.cancel() + recordingJob = null + try { + audioRecord?.stop() + audioRecord?.release() + } catch (e: Exception) { + Log.w(TAG, "Error releasing AudioRecord: ${e.message}") + } + audioRecord = null + Log.d(TAG, "Recording stopped") + } + + private fun closeWebSocket() { + try { + webSocket?.close(1000, "Done") + } catch (_: Exception) { } + webSocket = null + } + + fun shutdown() { + cancel() + scope.cancel() + } + + companion object { + private const val TAG = "DashScopeSTT" + } +} + +// --- JSON DTOs for DashScope WebSocket protocol --- + +data class DashScopeResponse( + @SerializedName("header") val header: DashScopeHeader?, + @SerializedName("payload") val payload: DashScopePayload?, +) + +data class DashScopeHeader( + @SerializedName("task_id") val taskId: String?, + @SerializedName("event") val event: String?, + @SerializedName("action") val action: String?, + @SerializedName("error_message") val errorMessage: String?, +) + +data class DashScopePayload( + @SerializedName("output") val output: DashScopeOutput?, + @SerializedName("usage") val usage: Map?, +) + +data class DashScopeOutput( + @SerializedName("sentence") val sentence: DashScopeSentence?, +) + +data class DashScopeSentence( + @SerializedName("text") val text: String?, + @SerializedName("sentence_end") val sentenceEnd: Boolean?, + @SerializedName("sentence_id") val sentenceId: Int?, + @SerializedName("begin_time") val beginTime: Long?, + @SerializedName("end_time") val endTime: Long?, + @SerializedName("words") val words: List>?, + @SerializedName("emo_tag") val emoTag: String?, +) diff --git a/app/src/main/java/top/yeij/cyrene/voice/stt/SpeechRecognizer.kt b/app/src/main/java/top/yeij/cyrene/voice/stt/SpeechRecognizer.kt index e5b5811..4fe5e22 100644 --- a/app/src/main/java/top/yeij/cyrene/voice/stt/SpeechRecognizer.kt +++ b/app/src/main/java/top/yeij/cyrene/voice/stt/SpeechRecognizer.kt @@ -1,30 +1,118 @@ package top.yeij.cyrene.voice.stt +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import android.util.Log +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -class SpeechRecognizer { +class SpeechRecognizer(private val context: Context) : SttProvider { + + private var recognizer: android.speech.SpeechRecognizer? = null private val _isListening = MutableStateFlow(false) - val isListening = _isListening.asStateFlow() + override val isListening: StateFlow = _isListening.asStateFlow() private val _partialResult = MutableStateFlow("") - val partialResult = _partialResult.asStateFlow() + override val partialResult: StateFlow = _partialResult.asStateFlow() - fun startListening() { - _isListening.value = true - // Integrate Android SpeechRecognizer or server-side Whisper API + private val _finalResult = MutableSharedFlow(extraBufferCapacity = 8) + override val finalResult: SharedFlow = _finalResult.asSharedFlow() + + private val _onError = MutableSharedFlow(extraBufferCapacity = 8) + override val onError: SharedFlow = _onError.asSharedFlow() + + override fun start() { + if (!android.speech.SpeechRecognizer.isRecognitionAvailable(context)) { + Log.w(TAG, "Speech recognition not available on this device") + _onError.tryEmit("语音识别不可用") + return + } + + cancel() + recognizer = android.speech.SpeechRecognizer.createSpeechRecognizer(context).apply { + setRecognitionListener(object : RecognitionListener { + override fun onReadyForSpeech(params: Bundle?) { + _isListening.value = true + _partialResult.value = "" + } + + override fun onBeginningOfSpeech() {} + + override fun onRmsChanged(rmsdB: Float) {} + + override fun onBufferReceived(buffer: ByteArray?) {} + + override fun onEndOfSpeech() {} + + override fun onError(error: Int) { + _isListening.value = false + val msg = when (error) { + android.speech.SpeechRecognizer.ERROR_NETWORK -> "网络连接失败" + android.speech.SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "网络超时" + android.speech.SpeechRecognizer.ERROR_AUDIO -> "音频录制错误" + android.speech.SpeechRecognizer.ERROR_SERVER -> "服务器错误" + android.speech.SpeechRecognizer.ERROR_CLIENT -> "客户端错误" + android.speech.SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "未检测到语音" + android.speech.SpeechRecognizer.ERROR_NO_MATCH -> "未能识别" + android.speech.SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "语音引擎忙碌" + android.speech.SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "缺少录音权限" + else -> "未知错误 ($error)" + } + Log.w(TAG, "Recognition error: $msg") + _onError.tryEmit(msg) + } + + override fun onResults(results: Bundle?) { + _isListening.value = false + val matches = results?.getStringArrayList(android.speech.SpeechRecognizer.RESULTS_RECOGNITION) + if (!matches.isNullOrEmpty()) { + _finalResult.tryEmit(SttResult(text = matches[0], isFinal = true)) + } + } + + override fun onPartialResults(partialResults: Bundle?) { + val matches = partialResults?.getStringArrayList(android.speech.SpeechRecognizer.RESULTS_RECOGNITION) + if (!matches.isNullOrEmpty()) { + _partialResult.value = matches[0] + } + } + + override fun onEvent(eventType: Int, params: Bundle?) {} + }) + + val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_LANGUAGE, "zh-CN") + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1) + } + startListening(intent) + } } - fun stopListening(): String { + override fun stop() { + recognizer?.stopListening() _isListening.value = false - val result = _partialResult.value - _partialResult.value = "" - return result } - fun cancel() { + override fun cancel() { + recognizer?.cancel() + recognizer?.destroy() + recognizer = null _isListening.value = false _partialResult.value = "" } + + companion object { + private const val TAG = "CyreneSTT" + } } diff --git a/app/src/main/java/top/yeij/cyrene/voice/stt/SttManager.kt b/app/src/main/java/top/yeij/cyrene/voice/stt/SttManager.kt new file mode 100644 index 0000000..0cae7a9 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/voice/stt/SttManager.kt @@ -0,0 +1,128 @@ +package top.yeij.cyrene.voice.stt + +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import top.yeij.cyrene.util.RuntimeLog + +class SttManager( + private val dashScopeService: DashScopeSttService, + private val backendProvider: BackendSttProvider, + private val systemRecognizer: SpeechRecognizer, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + private val _isListening = MutableStateFlow(false) + val isListening: StateFlow = _isListening.asStateFlow() + + private val _partialResult = MutableStateFlow("") + val partialResult: StateFlow = _partialResult.asStateFlow() + + private val _finalResult = MutableSharedFlow(extraBufferCapacity = 16) + val finalResult: SharedFlow = _finalResult.asSharedFlow() + + private val _onError = MutableSharedFlow(extraBufferCapacity = 8) + val onError: SharedFlow = _onError.asSharedFlow() + + private var activeProvider: SttProvider? = null + private var dashScopeFailed = false + + fun start() { + cancel() + + val provider = if (dashScopeService.apiKey.isNotBlank() && !dashScopeFailed) { + Log.d(TAG, "Using DashScope STT") + RuntimeLog.stt("start", "Using DashScope provider") + dashScopeService.also { dashScopeFailed = false } + } else { + Log.d(TAG, "Using Backend STT (fallback)") + RuntimeLog.stt("start", "Using Backend provider (fallback)") + backendProvider + } + + activeProvider = provider + _isListening.value = true + _partialResult.value = "" + collectFrom(provider) + provider.start() + } + + fun stop() { + RuntimeLog.stt("stop", "Stopping STT") + activeProvider?.stop() + _isListening.value = false + } + + fun cancel() { + RuntimeLog.stt("cancel", "Cancelling STT") + activeProvider?.cancel() + activeProvider = null + _isListening.value = false + _partialResult.value = "" + } + + fun configureDashScope(apiKey: String, endpoint: String, model: String) { + dashScopeService.apiKey = apiKey + dashScopeService.endpoint = endpoint + dashScopeService.model = model + dashScopeFailed = false + } + + fun updateDashScopeApiKey(apiKey: String) { + dashScopeService.apiKey = apiKey + if (apiKey.isNotBlank()) dashScopeFailed = false + } + + fun shutdown() { + cancel() + dashScopeService.shutdown() + backendProvider.shutdown() + scope.cancel() + } + + private fun collectFrom(provider: SttProvider) { + scope.launch { + provider.partialResult.collect { text -> + if (provider == activeProvider && text.isNotEmpty()) { + _partialResult.value = text + } + } + } + scope.launch { + provider.finalResult.collect { result -> + if (provider == activeProvider) { + RuntimeLog.stt("result", "Final result: isFinal=${result.isFinal} text=${result.text.take(80)}") + _finalResult.tryEmit(result) + } + } + } + scope.launch { + provider.onError.collect { error -> + if (provider == activeProvider) { + Log.w(TAG, "STT error: $error") + RuntimeLog.stt("error", error) + _onError.tryEmit(error) + + if (provider == dashScopeService) { + dashScopeFailed = true + RuntimeLog.stt("fallback", "DashScope failed, will use backend next time") + Log.i(TAG, "DashScope failed, will use backend next time") + } + } + } + } + } + + companion object { + private const val TAG = "SttManager" + } +} diff --git a/app/src/main/java/top/yeij/cyrene/voice/stt/SttProvider.kt b/app/src/main/java/top/yeij/cyrene/voice/stt/SttProvider.kt new file mode 100644 index 0000000..1724525 --- /dev/null +++ b/app/src/main/java/top/yeij/cyrene/voice/stt/SttProvider.kt @@ -0,0 +1,23 @@ +package top.yeij.cyrene.voice.stt + +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow + +enum class SttProviderType { DASHSCOPE, BACKEND, SYSTEM } + +data class SttResult( + val text: String, + val isFinal: Boolean, + val isError: Boolean = false, +) + +interface SttProvider { + val partialResult: StateFlow + val finalResult: SharedFlow + val onError: SharedFlow + val isListening: StateFlow + + fun start() + fun stop() + fun cancel() +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 00d0e98..e598568 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,6 +34,7 @@ 唤醒词 账号 关于 + 昔涟使用无障碍服务读取屏幕内容,以便在唤醒时理解当前上下文并提供更精准的帮助。不会收集或上传个人隐私信息。 服务器地址 主题 浅色 diff --git a/app/src/main/res/xml/accessibility_config.xml b/app/src/main/res/xml/accessibility_config.xml new file mode 100644 index 0000000..c901923 --- /dev/null +++ b/app/src/main/res/xml/accessibility_config.xml @@ -0,0 +1,7 @@ + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..b7974d8 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/xml/voice_interaction_config.xml b/app/src/main/res/xml/voice_interaction_config.xml index 485253a..c2228fb 100644 --- a/app/src/main/res/xml/voice_interaction_config.xml +++ b/app/src/main/res/xml/voice_interaction_config.xml @@ -1,7 +1,7 @@