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 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 17:58:34 +08:00
parent a57692353c
commit 367ef7f2d6
48 changed files with 4439 additions and 540 deletions
+30
View File
@@ -64,6 +64,36 @@
</intent-filter> </intent-filter>
</service> </service>
<!-- VoiceInteractionSessionService -->
<service
android:name=".service.CyreneSessionService"
android:exported="true"
android:permission="android.permission.BIND_VOICE_INTERACTION" />
<!-- AccessibilityService:读取屏幕内容 -->
<service
android:name=".service.CyreneAccessibilityService"
android:exported="true"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_config" />
</service>
<!-- FileProvider:日志分享 -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>
@@ -1,32 +1,74 @@
package top.yeij.cyrene package top.yeij.cyrene
import android.app.Activity
import android.app.Application import android.app.Application
import android.os.Bundle
import android.util.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch 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.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.AuthInterceptor import top.yeij.cyrene.data.remote.AuthInterceptor
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
import top.yeij.cyrene.di.appModule import top.yeij.cyrene.di.appModule
import org.koin.android.ext.koin.androidContext import top.yeij.cyrene.util.NotificationHelper
import org.koin.core.context.startKoin import top.yeij.cyrene.util.RuntimeLog
import java.util.concurrent.atomic.AtomicInteger
class CyreneApplication : Application() { class CyreneApplication : Application() {
private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val activityCount = AtomicInteger(0)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
RuntimeLog.general("app", "Application onCreate")
startKoin { startKoin {
androidContext(this@CyreneApplication) androidContext(this@CyreneApplication)
modules(appModule) 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 { 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 prefs: PreferencesDataStore = koin.get()
val urlInterceptor: DynamicUrlInterceptor = koin.get() val urlInterceptor: DynamicUrlInterceptor = koin.get()
val authInterceptor: AuthInterceptor = 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"
}
} }
@@ -7,6 +7,7 @@ import android.provider.Settings
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.mutableStateOf
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import top.yeij.cyrene.service.CyreneVoiceInteractionService import top.yeij.cyrene.service.CyreneVoiceInteractionService
import top.yeij.cyrene.ui.navigation.CyreneNavGraph import top.yeij.cyrene.ui.navigation.CyreneNavGraph
@@ -16,11 +17,13 @@ import top.yeij.cyrene.util.Constants
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val isDefaultAssistant = mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
val isDefaultAssistant = checkIsDefaultAssistant() isDefaultAssistant.value = checkIsDefaultAssistant()
setContent { setContent {
CyreneTheme { CyreneTheme {
@@ -29,24 +32,31 @@ class MainActivity : ComponentActivity() {
CyreneNavGraph( CyreneNavGraph(
navController = navController, navController = navController,
startDestination = Routes.MAIN, startDestination = Routes.MAIN,
isDefaultAssistant = isDefaultAssistant, isDefaultAssistant = isDefaultAssistant.value,
onOpenAssistantSettings = { openAssistantSettings() }, onOpenAssistantSettings = { openAssistantSettings() },
) )
} }
} }
} }
override fun onResume() {
super.onResume()
isDefaultAssistant.value = checkIsDefaultAssistant()
}
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
} }
private fun checkIsDefaultAssistant(): Boolean { private fun checkIsDefaultAssistant(): Boolean {
val componentName = ComponentName(this, CyreneVoiceInteractionService::class.java) // Standard Android check
val intent = Intent("android.service.voice.VoiceInteractionService") val flat = ComponentName(this, CyreneVoiceInteractionService::class.java).flattenToString()
val services = packageManager.queryIntentServices(intent, 0) val current = Settings.Secure.getString(contentResolver, "voice_interaction_service")
return services.any { it.serviceInfo.packageName == packageName } if (current == flat) return true
&& CyreneVoiceInteractionService.isActive // Fallback for COS and other custom OS: check persisted flag from service
if (CyreneVoiceInteractionService.wasEverActive(this)) return true
return false
} }
private fun openAssistantSettings() { private fun openAssistantSettings() {
@@ -22,6 +22,15 @@ class PreferencesDataStore(private val context: Context) {
private val KEY_USERNAME = stringPreferencesKey("username") private val KEY_USERNAME = stringPreferencesKey("username")
private val KEY_CLIENT_ID = stringPreferencesKey("client_id") private val KEY_CLIENT_ID = stringPreferencesKey("client_id")
private val KEY_DEVICE_NAME = stringPreferencesKey("device_name") 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<String?> = context.dataStore.data.map { it[KEY_TOKEN] } val token: Flow<String?> = context.dataStore.data.map { it[KEY_TOKEN] }
@@ -32,6 +41,10 @@ class PreferencesDataStore(private val context: Context) {
val username: Flow<String?> = context.dataStore.data.map { it[KEY_USERNAME] } val username: Flow<String?> = context.dataStore.data.map { it[KEY_USERNAME] }
val clientId: Flow<String?> = context.dataStore.data.map { it[KEY_CLIENT_ID] } val clientId: Flow<String?> = context.dataStore.data.map { it[KEY_CLIENT_ID] }
val deviceName: Flow<String?> = context.dataStore.data.map { it[KEY_DEVICE_NAME] } val deviceName: Flow<String?> = context.dataStore.data.map { it[KEY_DEVICE_NAME] }
val currentSessionId: Flow<String?> = context.dataStore.data.map { it[KEY_CURRENT_SESSION_ID] }
val dashScopeApiKey: Flow<String?> = context.dataStore.data.map { it[KEY_DASHSCOPE_API_KEY] }
val dashScopeEndpoint: Flow<String?> = context.dataStore.data.map { it[KEY_DASHSCOPE_ENDPOINT] }
val dashScopeModel: Flow<String?> = context.dataStore.data.map { it[KEY_DASHSCOPE_MODEL] }
suspend fun saveToken(token: String) { suspend fun saveToken(token: String) {
context.dataStore.edit { it[KEY_TOKEN] = token } context.dataStore.edit { it[KEY_TOKEN] = token }
@@ -57,6 +70,18 @@ class PreferencesDataStore(private val context: Context) {
context.dataStore.edit { it[KEY_USERNAME] = username } 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) { suspend fun saveClientId(id: String) {
context.dataStore.edit { it[KEY_CLIENT_ID] = id } 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 } 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<String?> = 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<String?> = context.dataStore.data.map { it[KEY_PROFILE_USER_ID] }
val profileNickname: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_NICKNAME] }
val profileIsAdmin: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_IS_ADMIN] }
val profileCreatedAt: Flow<String?> = 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() { suspend fun clearAll() {
context.dataStore.edit { it.clear() } context.dataStore.edit { it.clear() }
} }
@@ -13,6 +13,9 @@ interface ConversationDao {
@Query("SELECT * FROM conversations ORDER BY updatedAt DESC") @Query("SELECT * FROM conversations ORDER BY updatedAt DESC")
fun getAll(): Flow<List<ConversationEntity>> fun getAll(): Flow<List<ConversationEntity>>
@Query("SELECT * FROM conversations ORDER BY updatedAt DESC")
suspend fun getAllSnapshot(): List<ConversationEntity>
@Query("SELECT * FROM conversations WHERE id = :id") @Query("SELECT * FROM conversations WHERE id = :id")
suspend fun getById(id: String): ConversationEntity? suspend fun getById(id: String): ConversationEntity?
@@ -21,4 +21,13 @@ interface MessageDao {
@Query("DELETE FROM messages WHERE conversationId = :conversationId") @Query("DELETE FROM messages WHERE conversationId = :conversationId")
suspend fun deleteByConversation(conversationId: String) 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()
} }
@@ -6,12 +6,17 @@ import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query
import top.yeij.cyrene.data.remote.dto.AuthRequest import top.yeij.cyrene.data.remote.dto.AuthRequest
import top.yeij.cyrene.data.remote.dto.AuthResponse 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.DeviceDto
import top.yeij.cyrene.data.remote.dto.IoTControlRequest 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 { interface ApiService {
@@ -20,16 +25,32 @@ interface ApiService {
suspend fun login(@Body request: AuthRequest): Response<AuthResponse> suspend fun login(@Body request: AuthRequest): Response<AuthResponse>
@POST("api/v1/auth/refresh") @POST("api/v1/auth/refresh")
suspend fun refreshToken(@Body refreshToken: String): Response<AuthResponse> suspend fun refreshToken(@Body request: RefreshTokenRequest): Response<AuthResponse>
// Conversations @GET("api/v1/profile")
@GET("api/v1/conversations") suspend fun getProfile(): Response<ProfileResponse>
suspend fun getConversations(): Response<List<ConversationDto>>
@DELETE("api/v1/conversations/{id}") // Sessions
suspend fun deleteConversation(@Path("id") id: String): Response<Unit> @GET("api/v1/sessions")
suspend fun getSessions(): Response<SessionsListResponse>
// IoT @POST("api/v1/sessions")
suspend fun createSession(@Body request: CreateSessionRequest): Response<SessionDto>
@DELETE("api/v1/sessions/{id}")
suspend fun deleteSession(@Path("id") id: String): Response<Unit>
@DELETE("api/v1/sessions/{id}/messages")
suspend fun clearSessionMessages(@Path("id") sessionId: String): Response<Unit>
@GET("api/v1/sessions/{id}/messages")
suspend fun getSessionMessages(
@Path("id") sessionId: String,
@Query("limit") limit: Int = 50,
@Query("offset") offset: Int = 0,
): Response<MessagesListResponse>
// IoT — 注意:网关 API 文档未列出 IoT 端点,需确认网关是否代理了 /api/v1/iot/*
@GET("api/v1/iot/devices") @GET("api/v1/iot/devices")
suspend fun getDevices(): Response<List<DeviceDto>> suspend fun getDevices(): Response<List<DeviceDto>>
@@ -38,11 +59,4 @@ interface ApiService {
@Path("id") deviceId: String, @Path("id") deviceId: String,
@Body request: IoTControlRequest, @Body request: IoTControlRequest,
): Response<DeviceDto> ): Response<DeviceDto>
// Reminders
@GET("api/v1/reminders")
suspend fun getReminders(): Response<List<ReminderDto>>
@DELETE("api/v1/reminders/{id}")
suspend fun deleteReminder(@Path("id") id: String): Response<Unit>
} }
@@ -11,6 +11,7 @@ object RetrofitClient {
fun provideOkHttpClient( fun provideOkHttpClient(
authInterceptor: AuthInterceptor, authInterceptor: AuthInterceptor,
dynamicUrlInterceptor: DynamicUrlInterceptor, dynamicUrlInterceptor: DynamicUrlInterceptor,
tokenAuthenticator: TokenAuthenticator,
): OkHttpClient { ): OkHttpClient {
val logging = HttpLoggingInterceptor().apply { val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY level = HttpLoggingInterceptor.Level.BODY
@@ -19,6 +20,7 @@ object RetrofitClient {
return OkHttpClient.Builder() return OkHttpClient.Builder()
.addInterceptor(dynamicUrlInterceptor) .addInterceptor(dynamicUrlInterceptor)
.addInterceptor(authInterceptor) .addInterceptor(authInterceptor)
.authenticator(tokenAuthenticator)
.addInterceptor(logging) .addInterceptor(logging)
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS)
@@ -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
}
}
}
}
@@ -11,6 +11,19 @@ data class AuthResponse(
@SerializedName("token") val token: String, @SerializedName("token") val token: String,
@SerializedName("refresh_token") val refreshToken: String?, @SerializedName("refresh_token") val refreshToken: String?,
@SerializedName("username") val username: String?, @SerializedName("username") val username: String?,
@SerializedName("nickname") val nickname: String?,
@SerializedName("user_id") val userId: String?, @SerializedName("user_id") val userId: String?,
@SerializedName("expires") val expires: Long? = null, @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,
)
@@ -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,
)
@@ -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,
)
@@ -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<SessionDto>,
)
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<SessionMessageDto>,
)
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,
)
@@ -9,12 +9,25 @@ data class WSClientMessage(
@SerializedName("session_id") val sessionId: String? = null, @SerializedName("session_id") val sessionId: String? = null,
@SerializedName("mode") val mode: String? = null, @SerializedName("mode") val mode: String? = null,
@SerializedName("content") val content: String? = null, @SerializedName("content") val content: String? = null,
@SerializedName("audio_data") val audioData: String? = null,
@SerializedName("attachments") val attachments: List<WSAttachment>? = null,
@SerializedName("timestamp") val timestamp: Long? = null, @SerializedName("timestamp") val timestamp: Long? = null,
@SerializedName("client_id") val clientId: String? = null, @SerializedName("client_id") val clientId: String? = null,
@SerializedName("device_name") val deviceName: String? = null, @SerializedName("device_name") val deviceName: String? = null,
@SerializedName("user_agent") val userAgent: 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 --- // --- Server → Client ---
data class WSClientInfo( data class WSClientInfo(
@@ -22,12 +22,13 @@ class AuthRepositoryImpl(
authInterceptor.token = body.token authInterceptor.token = body.token
preferencesDataStore.saveToken(body.token) preferencesDataStore.saveToken(body.token)
body.refreshToken?.let { preferencesDataStore.saveRefreshToken(it) } 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( Result.success(
AuthResult( AuthResult(
token = body.token, token = body.token,
refreshToken = body.refreshToken, refreshToken = body.refreshToken,
username = body.username ?: body.userId ?: "开拓者", username = displayName,
) )
) )
} else { } else {
@@ -12,16 +12,22 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch 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.ConversationDao
import top.yeij.cyrene.data.local.dao.MessageDao import top.yeij.cyrene.data.local.dao.MessageDao
import top.yeij.cyrene.data.local.entity.ConversationEntity import top.yeij.cyrene.data.local.entity.ConversationEntity
import top.yeij.cyrene.data.local.entity.MessageEntity import top.yeij.cyrene.data.local.entity.MessageEntity
import top.yeij.cyrene.data.remote.ApiService 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.data.remote.dto.WSServerMessage
import top.yeij.cyrene.domain.model.Conversation import top.yeij.cyrene.domain.model.Conversation
import top.yeij.cyrene.domain.model.Message import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.domain.repository.ChatRepository import top.yeij.cyrene.domain.repository.ChatRepository
import top.yeij.cyrene.service.WebSocketService import top.yeij.cyrene.service.WebSocketService
import top.yeij.cyrene.util.RuntimeLog
import java.util.UUID import java.util.UUID
class ChatRepositoryImpl( class ChatRepositoryImpl(
@@ -29,6 +35,7 @@ class ChatRepositoryImpl(
private val messageDao: MessageDao, private val messageDao: MessageDao,
private val webSocketService: WebSocketService, private val webSocketService: WebSocketService,
private val apiService: ApiService, private val apiService: ApiService,
private val preferencesDataStore: PreferencesDataStore,
) : ChatRepository { ) : ChatRepository {
private val exceptionHandler = CoroutineExceptionHandler { _, e -> private val exceptionHandler = CoroutineExceptionHandler { _, e ->
@@ -39,25 +46,79 @@ class ChatRepositoryImpl(
private val _connectionState = MutableStateFlow(false) private val _connectionState = MutableStateFlow(false)
override val connectionState: StateFlow<Boolean> = _connectionState.asStateFlow() override val connectionState: StateFlow<Boolean> = _connectionState.asStateFlow()
override val connectionError: StateFlow<String?> = webSocketService.connectionError
private val _incomingMessages = MutableSharedFlow<Message>(extraBufferCapacity = 64) private val _incomingMessages = MutableSharedFlow<Message>(extraBufferCapacity = 64)
override fun observeMessages(): Flow<Message> = _incomingMessages override fun observeMessages(): Flow<Message> = _incomingMessages
private val _messageClearEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 4)
override val messageClearEvents: Flow<Unit> = _messageClearEvents
private val _isAssistantStreaming = MutableStateFlow(false)
override val isAssistantStreaming: StateFlow<Boolean> = _isAssistantStreaming.asStateFlow()
private var streamingContent = "" private var streamingContent = ""
private var streamingMessageId: String? = null 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<String>()
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 { 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 { scope.launch {
webSocketService.isConnected.collect { connected -> webSocketService.isConnected.collect { connected ->
_connectionState.value = connected _connectionState.value = connected
RuntimeLog.ws("connection", "Connected=$connected")
} }
} }
scope.launch { scope.launch {
webSocketService.incomingMessages.collect { wsMsg -> webSocketService.incomingMessages.collect { wsMsg ->
try { try {
RuntimeLog.ws("receive", "type=${wsMsg.type} msgId=${wsMsg.messageId ?: "-"}")
handleServerMessage(wsMsg) handleServerMessage(wsMsg)
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ChatRepository", "Error handling ${wsMsg.type}: ${e.message}", e) 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) { override suspend fun deleteConversation(id: String) {
conversationDao.deleteById(id) 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?) { override suspend fun connectWebSocket(sessionId: String?) {
@@ -85,15 +178,26 @@ class ChatRepositoryImpl(
webSocketService.connect(sessionId) webSocketService.connect(sessionId)
} }
override suspend fun disconnectWebSocket() { override suspend fun reconnectWebSocket() {
webSocketService.disconnect() webSocketService.disconnect()
webSocketService.connect(currentSessionId)
}
override suspend fun ensureConnected() {
if (_connectionState.value) return
webSocketService.forceReconnect()
} }
override suspend fun sendMessage(content: String, sessionId: String?) { override suspend fun sendMessage(content: String, sessionId: String?) {
val messageId = UUID.randomUUID().toString() val messageId = UUID.randomUUID().toString()
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val sid = sessionId ?: currentSessionId ?: "default" val sid = sessionId ?: currentSessionId ?: "default"
if (currentSessionId == null) {
currentSessionId = sid currentSessionId = sid
scope.launch { preferencesDataStore.saveCurrentSessionId(sid) }
}
RuntimeLog.chat("send", "session=$sid msgId=$messageId content=${content.take(80)}")
conversationDao.upsert( conversationDao.upsert(
ConversationEntity( ConversationEntity(
@@ -117,7 +221,6 @@ class ChatRepositoryImpl(
) )
) )
// Emit user message to UI
emitMessage( emitMessage(
id = messageId, id = messageId,
sessionId = sid, sessionId = sid,
@@ -133,18 +236,17 @@ class ChatRepositoryImpl(
override suspend fun loadConversationsFromServer() { override suspend fun loadConversationsFromServer() {
try { try {
val response = apiService.getConversations() val response = apiService.getSessions()
if (response.isSuccessful) { if (response.isSuccessful) {
response.body()?.forEach { dto -> response.body()?.sessions?.forEach { dto ->
val timestamp = try { dto.updatedAt.toLong() } catch (_: Exception) { System.currentTimeMillis() }
conversationDao.upsert( conversationDao.upsert(
ConversationEntity( ConversationEntity(
id = dto.id, id = dto.id,
title = dto.title, title = dto.title,
lastMessage = dto.lastMessage, lastMessage = "",
lastMessageType = dto.lastMessageType, lastMessageType = "chat",
updatedAt = timestamp, updatedAt = dto.updatedAt,
createdAt = timestamp, createdAt = dto.createdAt,
) )
) )
} }
@@ -152,11 +254,103 @@ class ChatRepositoryImpl(
} catch (_: Exception) { } } 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<Message> { override suspend fun loadMessagesFromServer(sessionId: String): List<Message> {
currentSessionId = sessionId 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) webSocketService.requestHistory(sessionId)
return emptyList()
} }
private suspend fun ensureConversation(sessionId: String, lastMessage: String = "") { private suspend fun ensureConversation(sessionId: String, lastMessage: String = "") {
@@ -181,6 +375,8 @@ class ChatRepositoryImpl(
"stream_start" -> { "stream_start" -> {
streamingContent = "" streamingContent = ""
streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}" streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}"
_isAssistantStreaming.value = true
RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId")
} }
"stream_chunk" -> { "stream_chunk" -> {
@@ -202,9 +398,12 @@ class ChatRepositoryImpl(
streamingContent = "" streamingContent = ""
streamingMessageId = null streamingMessageId = null
val sid = wsMsg.sessionId ?: currentSessionId ?: "default" 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() val ts = wsMsg.timestamp ?: System.currentTimeMillis()
if (content.isNotBlank()) {
ensureConversation(sid, content) ensureConversation(sid, content)
messageDao.upsert( messageDao.upsert(
MessageEntity( MessageEntity(
@@ -216,8 +415,11 @@ class ChatRepositoryImpl(
timestamp = ts, 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" -> { "response" -> {
@@ -226,7 +428,22 @@ class ChatRepositoryImpl(
val replyMsgType = wsMsg.msgType ?: "chat" val replyMsgType = wsMsg.msgType ?: "chat"
val msgId = wsMsg.messageId ?: "r_${System.currentTimeMillis()}" val msgId = wsMsg.messageId ?: "r_${System.currentTimeMillis()}"
val sid = wsMsg.sessionId ?: currentSessionId ?: "default" 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() val ts = wsMsg.timestamp ?: System.currentTimeMillis()
ensureConversation(sid, text) 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" -> { "review" -> {
recentParsedContents.clear()
wsMsg.reviewMessages?.forEach { review -> wsMsg.reviewMessages?.forEach { review ->
val text = review.content ?: review.text ?: return@forEach val text = review.content ?: review.text ?: return@forEach
val role = review.role ?: "action" val role = review.role ?: "action"
val rvMsgType = review.msgType ?: review.role ?: "action" val rvMsgType = review.msgType ?: review.role ?: "action"
val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}" 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) 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" -> { "thinking" -> {
@@ -285,6 +511,7 @@ class ChatRepositoryImpl(
} }
"error" -> { "error" -> {
RuntimeLog.chat("error", "Server error: ${wsMsg.error ?: "未知错误"}")
emitMessage( emitMessage(
id = "err_${System.currentTimeMillis()}", id = "err_${System.currentTimeMillis()}",
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
@@ -295,47 +522,102 @@ class ChatRepositoryImpl(
) )
} }
"history_response" -> { "voice_transcript" -> {
val text = wsMsg.text ?: wsMsg.content ?: return
val sid = wsMsg.sessionId ?: currentSessionId ?: "default" val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
val msgId = wsMsg.messageId ?: "vt_${System.currentTimeMillis()}"
ensureConversation(sid) ensureConversation(sid)
wsMsg.messages?.forEach { 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()
messageDao.upsert( messageDao.upsert(
MessageEntity( MessageEntity(
id = msgId, id = msgId,
conversationId = sid, conversationId = sid,
role = role, role = "user",
content = content, content = text,
msgType = msgType, msgType = "chat",
timestamp = ts, timestamp = ts,
) )
) )
emitMessage(id = msgId, sessionId = sid, role = "user", content = text, msgType = "chat", timestamp = ts, isStreaming = false)
emitMessage(id = msgId, sessionId = sid, role = role, content = content, msgType = msgType, 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)
val messages = wsMsg.messages ?: return
val messageList = messages.map { hist ->
val msgId = hist.id ?: "hist_${System.currentTimeMillis()}_${hist.hashCode()}"
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 = msg.id,
conversationId = msg.conversationId,
role = msg.role,
content = msg.content,
msgType = msg.msgType,
timestamp = msg.timestamp,
)
)
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" -> { "multi_message" -> {
recentParsedContents.clear()
wsMsg.multiMessages?.forEach { item -> wsMsg.multiMessages?.forEach { item ->
val content = item.content ?: ""
recentParsedContents.add(content)
emitMessage( emitMessage(
id = "mm_${System.currentTimeMillis()}_${item.hashCode()}", id = "mm_${System.currentTimeMillis()}_${item.hashCode()}",
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
role = item.role ?: "assistant", role = item.role ?: "assistant",
content = item.content ?: "", content = content,
msgType = item.msgType ?: "chat", msgType = item.msgType ?: "chat",
timestamp = wsMsg.timestamp ?: System.currentTimeMillis(), timestamp = wsMsg.timestamp ?: System.currentTimeMillis(),
isStreaming = false, 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( private fun emitMessage(
id: String, id: String,
sessionId: String, sessionId: String,
@@ -344,8 +626,8 @@ class ChatRepositoryImpl(
msgType: String, msgType: String,
isStreaming: Boolean = false, isStreaming: Boolean = false,
timestamp: Long = System.currentTimeMillis(), timestamp: Long = System.currentTimeMillis(),
shouldNotify: Boolean = false,
) { ) {
// Skip messages with empty content to prevent empty bubbles
if (content.isBlank() && msgType == "chat") return if (content.isBlank() && msgType == "chat") return
val message = Message( val message = Message(
id = id, id = id,
@@ -357,6 +639,33 @@ class ChatRepositoryImpl(
isStreaming = isStreaming, isStreaming = isStreaming,
) )
_incomingMessages.tryEmit(message) _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<Message>.removeWrappingDuplicates(): List<Message> {
if (size < 3) return this
val toRemove = mutableSetOf<String>()
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( private fun ConversationEntity.toDomain() = Conversation(
@@ -9,6 +9,7 @@ import top.yeij.cyrene.data.remote.ApiService
import top.yeij.cyrene.data.remote.AuthInterceptor import top.yeij.cyrene.data.remote.AuthInterceptor
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
import top.yeij.cyrene.data.remote.RetrofitClient 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.AuthRepositoryImpl
import top.yeij.cyrene.data.repository.ChatRepositoryImpl import top.yeij.cyrene.data.repository.ChatRepositoryImpl
import top.yeij.cyrene.data.repository.IoTRepositoryImpl 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.ChatViewModel
import top.yeij.cyrene.viewmodel.IoTViewModel import top.yeij.cyrene.viewmodel.IoTViewModel
import top.yeij.cyrene.viewmodel.OverlayViewModel import top.yeij.cyrene.viewmodel.OverlayViewModel
import top.yeij.cyrene.viewmodel.ProfileViewModel
import top.yeij.cyrene.viewmodel.SettingsViewModel 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.SpeechRecognizer
import top.yeij.cyrene.voice.stt.SttManager
import top.yeij.cyrene.voice.tts.TextToSpeechEngine import top.yeij.cyrene.voice.tts.TextToSpeechEngine
val appModule = module { val appModule = module {
@@ -36,10 +42,11 @@ val appModule = module {
single { get<AppDatabase>().conversationDao() } single { get<AppDatabase>().conversationDao() }
single { get<AppDatabase>().messageDao() } single { get<AppDatabase>().messageDao() }
// Network interceptors (no runBlocking — using @Volatile caches) // Network interceptors
single { AuthInterceptor() } single { AuthInterceptor() }
single { DynamicUrlInterceptor() } single { DynamicUrlInterceptor() }
single { RetrofitClient.provideOkHttpClient(get(), get()) } single { TokenAuthenticator(get(), get(), get()) }
single { RetrofitClient.provideOkHttpClient(get(), get(), get()) }
single { RetrofitClient.provideRetrofit(get()) } single { RetrofitClient.provideRetrofit(get()) }
single { get<retrofit2.Retrofit>().create(ApiService::class.java) } single { get<retrofit2.Retrofit>().create(ApiService::class.java) }
@@ -47,12 +54,16 @@ val appModule = module {
single { WebSocketService(get()) } single { WebSocketService(get()) }
// Voice // Voice
single { SpeechRecognizer() } single { VoiceRecorder(androidContext()) }
single { SpeechRecognizer(androidContext()) }
single { TextToSpeechEngine(androidContext()) } single { TextToSpeechEngine(androidContext()) }
single { DashScopeSttService(androidContext()) }
single { BackendSttProvider(androidContext(), get()) }
single { SttManager(get(), get(), get()) }
// Repositories // Repositories
single<AuthRepository> { AuthRepositoryImpl(get(), get(), get()) } single<AuthRepository> { AuthRepositoryImpl(get(), get(), get()) }
single<ChatRepository> { ChatRepositoryImpl(get(), get(), get(), get()) } single<ChatRepository> { ChatRepositoryImpl(get(), get(), get(), get(), get()) }
single<IoTRepository> { IoTRepositoryImpl(get(), get()) } single<IoTRepository> { IoTRepositoryImpl(get(), get()) }
// UseCases // UseCases
@@ -61,8 +72,9 @@ val appModule = module {
factory { GetConversationsUseCase(get()) } factory { GetConversationsUseCase(get()) }
// ViewModels // ViewModels
viewModel { ChatViewModel(get()) } viewModel { ChatViewModel(get(), get()) }
viewModel { IoTViewModel(get()) } viewModel { IoTViewModel(get()) }
viewModel { OverlayViewModel(get()) } viewModel { OverlayViewModel(get(), get(), get()) }
single { SettingsViewModel(get(), get(), get()) } viewModel { ProfileViewModel(get(), get(), get()) }
single { SettingsViewModel(get(), get(), get(), get(), get()) }
} }
@@ -8,6 +8,10 @@ import top.yeij.cyrene.domain.model.Message
interface ChatRepository { interface ChatRepository {
val connectionState: StateFlow<Boolean> val connectionState: StateFlow<Boolean>
val connectionError: StateFlow<String?>
val isAssistantStreaming: StateFlow<Boolean>
val messageClearEvents: Flow<Unit>
var currentSessionId: String?
fun getConversations(): Flow<List<Conversation>> fun getConversations(): Flow<List<Conversation>>
@@ -17,8 +21,6 @@ interface ChatRepository {
suspend fun connectWebSocket(sessionId: String?) suspend fun connectWebSocket(sessionId: String?)
suspend fun disconnectWebSocket()
suspend fun sendMessage(content: String, sessionId: String?) suspend fun sendMessage(content: String, sessionId: String?)
fun observeMessages(): Flow<Message> fun observeMessages(): Flow<Message>
@@ -26,4 +28,19 @@ interface ChatRepository {
suspend fun loadConversationsFromServer() suspend fun loadConversationsFromServer()
suspend fun loadMessagesFromServer(sessionId: String): List<Message> suspend fun loadMessagesFromServer(sessionId: String): List<Message>
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()
} }
@@ -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()
}
}
}
@@ -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"
}
}
@@ -3,27 +3,35 @@ package top.yeij.cyrene.service
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.service.voice.VoiceInteractionService import android.service.voice.VoiceInteractionService
import android.util.Log
import top.yeij.cyrene.MainActivity import top.yeij.cyrene.MainActivity
import top.yeij.cyrene.util.Constants import top.yeij.cyrene.util.Constants
class CyreneVoiceInteractionService : VoiceInteractionService() { class CyreneVoiceInteractionService : VoiceInteractionService() {
override fun onCreate() {
super.onCreate()
Log.i(TAG, "Service created")
}
override fun onReady() { override fun onReady() {
super.onReady() super.onReady()
isActive = true 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) { override fun onPrepareToShowSession(args: Bundle, showFlags: Int) {
// Called before the session is shown — populate args for the session. Log.i(TAG, "onPrepareToShowSession")
// Starting from API 36, session creation is handled by the system
// based on android:sessionService in voice_interaction_config.xml.
} }
override fun onShowSessionFailed(args: Bundle) { override fun onShowSessionFailed(args: Bundle) {
// Session failed to show — could be due to permissions or system state. Log.e(TAG, "onShowSessionFailed")
} }
override fun onLaunchVoiceAssistFromKeyguard() { override fun onLaunchVoiceAssistFromKeyguard() {
Log.i(TAG, "onLaunchVoiceAssistFromKeyguard")
val intent = Intent(this, MainActivity::class.java).apply { val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
putExtra(Constants.EXTRA_VOICE_ASSIST, true) putExtra(Constants.EXTRA_VOICE_ASSIST, true)
@@ -34,10 +42,19 @@ class CyreneVoiceInteractionService : VoiceInteractionService() {
override fun onShutdown() { override fun onShutdown() {
isActive = false isActive = false
super.onShutdown() super.onShutdown()
Log.i(TAG, "Service shutdown")
} }
companion object { companion object {
var isActive: Boolean = false var isActive: Boolean = false
private set 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)
}
} }
} }
@@ -1,50 +1,122 @@
package top.yeij.cyrene.service package top.yeij.cyrene.service
import android.content.Context import android.content.Context
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.service.voice.VoiceInteractionSession import android.service.voice.VoiceInteractionSession
import android.util.Log
import android.view.View import android.view.View
import android.view.WindowManager
import androidx.compose.ui.platform.ComposeView 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 org.koin.core.context.GlobalContext
import top.yeij.cyrene.MainActivity
import top.yeij.cyrene.ui.overlay.OverlayContent import top.yeij.cyrene.ui.overlay.OverlayContent
import top.yeij.cyrene.ui.theme.CyreneTheme import top.yeij.cyrene.ui.theme.CyreneTheme
import top.yeij.cyrene.util.Constants import top.yeij.cyrene.util.Constants
import top.yeij.cyrene.voice.stt.SpeechRecognizer import top.yeij.cyrene.util.RuntimeLog
import top.yeij.cyrene.voice.tts.TextToSpeechEngine import top.yeij.cyrene.viewmodel.OverlayViewModel
class CyreneVoiceInteractionSession(context: Context) : class CyreneVoiceInteractionSession(context: Context) :
VoiceInteractionSession(context) { VoiceInteractionSession(context), LifecycleOwner, SavedStateRegistryOwner {
private val speechRecognizer: SpeechRecognizer by lazy { private val lifecycleRegistry = LifecycleRegistry(this)
GlobalContext.get().get() 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 fun resolveViewModel(): OverlayViewModel? {
return try {
GlobalContext.get().get<OverlayViewModel>()
} catch (e: Exception) {
Log.e(TAG, "Failed to resolve OverlayViewModel from Koin", e)
null
} }
private val ttsEngine: TextToSpeechEngine by lazy {
GlobalContext.get().get()
} }
override fun onCreateContentView(): View { 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 { return ComposeView(context).apply {
setViewTreeLifecycleOwner(this@CyreneVoiceInteractionSession)
setViewTreeSavedStateRegistryOwner(this@CyreneVoiceInteractionSession)
setContent { setContent {
CyreneTheme { CyreneTheme {
if (vm != null) {
OverlayContent( OverlayContent(
onDismiss = { finish() }, onDismiss = { finish() },
onNavigateToMain = {
val intent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
},
viewModel = vm,
) )
} }
} }
} }
} }
}
override fun onShow(args: Bundle?, showFlags: Int) { override fun onShow(args: Bundle?, showFlags: Int) {
super.onShow(args, showFlags) super.onShow(args, showFlags)
val startListening = args?.getBoolean(Constants.EXTRA_START_LISTENING, false) ?: false RuntimeLog.general("overlay", "onShow, vm=${overlayViewModel != null}")
if (startListening) { lifecycleRegistry.currentState = Lifecycle.State.STARTED
speechRecognizer.startListening()
val screenContent = CyreneAccessibilityService.getScreenContent()
if (screenContent.isNotBlank()) {
overlayViewModel?.sendScreenContext(screenContent)
RuntimeLog.general("overlay", "Screen context sent, len=${screenContent.length}")
} }
} }
override fun onHide() { override fun onHide() {
RuntimeLog.general("overlay", "onHide")
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
super.onHide() super.onHide()
speechRecognizer.stopListening() overlayViewModel?.finish()
ttsEngine.stop() }
companion object {
private const val TAG = "CyreneVIS-Session"
} }
} }
@@ -26,6 +26,7 @@ import top.yeij.cyrene.data.remote.dto.WSClientMessage
import top.yeij.cyrene.data.remote.dto.WSServerMessage import top.yeij.cyrene.data.remote.dto.WSServerMessage
import java.net.URLEncoder import java.net.URLEncoder
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
class WebSocketService( class WebSocketService(
private val preferencesDataStore: PreferencesDataStore, private val preferencesDataStore: PreferencesDataStore,
@@ -42,9 +43,10 @@ class WebSocketService(
private var webSocket: WebSocket? = null private var webSocket: WebSocket? = null
private var heartbeatJob: Job? = null private var heartbeatJob: Job? = null
private var reconnecting = false private var reconnectJob: Job? = null
private var shouldReconnect = true private var shouldReconnect = true
private var currentSessionId: String? = null private var currentSessionId: String? = null
private val connectionId = AtomicInteger(0)
private var clientId: String = "" private var clientId: String = ""
private var deviceName: String = "" private var deviceName: String = ""
@@ -52,6 +54,9 @@ class WebSocketService(
private val _isConnected = MutableStateFlow(false) private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow() val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _connectionError = MutableStateFlow<String?>(null)
val connectionError: StateFlow<String?> = _connectionError.asStateFlow()
private val _incomingMessages = MutableSharedFlow<WSServerMessage>(extraBufferCapacity = 64) private val _incomingMessages = MutableSharedFlow<WSServerMessage>(extraBufferCapacity = 64)
val incomingMessages: SharedFlow<WSServerMessage> = _incomingMessages.asSharedFlow() val incomingMessages: SharedFlow<WSServerMessage> = _incomingMessages.asSharedFlow()
@@ -75,7 +80,6 @@ class WebSocketService(
suspend fun connect(sessionId: String? = null) { suspend fun connect(sessionId: String? = null) {
currentSessionId = sessionId currentSessionId = sessionId
shouldReconnect = true shouldReconnect = true
reconnecting = false
initClientIdentity() initClientIdentity()
@@ -106,50 +110,74 @@ class WebSocketService(
} }
val url = urlBuilder.toString() 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() val request = Request.Builder()
.url(url) .url(url)
.header("User-Agent", "Cyrene-Android/${Build.MODEL ?: "Device"}") .header("User-Agent", "Cyrene-Android/${Build.MODEL ?: "Device"}")
.build() .build()
// Close previous socket silently
try { webSocket?.close(1000, "Reconnecting") } catch (_: Exception) { }
cancelHeartbeat() cancelHeartbeat()
webSocket?.close(1000, "Reconnecting")
webSocket = httpClient.newWebSocket(request, object : WebSocketListener() { webSocket = httpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) { override fun onOpen(webSocket: WebSocket, response: Response) {
Log.i(TAG, "Connected") if (connectionId.get() != connId) {
reconnecting = false Log.d(TAG, "[#$connId] onOpen ignored (stale)")
return
}
Log.i(TAG, "[#$connId] Connected")
_isConnected.value = true _isConnected.value = true
_connectionError.value = null
startHeartbeat() startHeartbeat()
} }
override fun onMessage(webSocket: WebSocket, text: String) { override fun onMessage(webSocket: WebSocket, text: String) {
if (connectionId.get() != connId) return
try { try {
val msg = gson.fromJson(text, WSServerMessage::class.java) val msg = gson.fromJson(text, WSServerMessage::class.java)
_incomingMessages.tryEmit(msg) _incomingMessages.tryEmit(msg)
} catch (e: Exception) { } 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) { 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 _isConnected.value = false
cancelHeartbeat() cancelHeartbeat()
} }
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { 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 _isConnected.value = false
cancelHeartbeat() cancelHeartbeat()
scheduleReconnect() scheduleReconnect()
} }
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { 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 _isConnected.value = false
cancelHeartbeat() 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?) { fun requestHistory(sessionId: String?) {
val msg = buildMessage("history", sessionId) val msg = buildMessage("history", sessionId)
if (webSocket != null) {
webSocket?.send(gson.toJson(msg)) 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() { fun sendPing() {
@@ -187,11 +220,44 @@ class WebSocketService(
webSocket?.send(gson.toJson(msg)) 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() { fun disconnect() {
shouldReconnect = false shouldReconnect = false
reconnecting = false reconnectJob?.cancel()
reconnectJob = null
cancelHeartbeat() cancelHeartbeat()
webSocket?.close(1000, "User disconnected") try { webSocket?.close(1000, "User disconnected") } catch (_: Exception) { }
webSocket = null webSocket = null
_isConnected.value = false _isConnected.value = false
} }
@@ -214,13 +280,15 @@ class WebSocketService(
} }
private fun scheduleReconnect() { private fun scheduleReconnect() {
if (reconnecting || !shouldReconnect) return if (reconnectJob?.isActive == true || !shouldReconnect) return
reconnecting = true reconnectJob = scope.launch {
scope.launch {
var attempt = 0 var attempt = 0
while (attempt < 5 && shouldReconnect && !_isConnected.value) { while (shouldReconnect && !_isConnected.value) {
val delayMs = (Math.pow(2.0, attempt.toDouble()) * 1000).toLong() val delayMs = minOf(
Log.i(TAG, "Reconnecting in ${delayMs}ms (attempt ${attempt + 1}/5)") (Math.pow(2.0, attempt.toDouble()) * 1000).toLong(),
30_000L
)
Log.i(TAG, "Reconnecting in ${delayMs}ms (attempt ${attempt + 1})")
delay(delayMs) delay(delayMs)
attempt++ attempt++
if (shouldReconnect && !_isConnected.value) { if (shouldReconnect && !_isConnected.value) {
@@ -231,7 +299,7 @@ class WebSocketService(
} }
} }
} }
reconnecting = false Log.i(TAG, "Reconnect loop ended (connected=${_isConnected.value}, shouldReconnect=$shouldReconnect)")
} }
} }
@@ -64,6 +64,7 @@ private fun ChatMessageBubble(
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
else else
MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.surfaceVariant,
shadowElevation = 2.dp,
modifier = Modifier.widthIn(max = 300.dp), modifier = Modifier.widthIn(max = 300.dp),
) { ) {
Text( Text(
@@ -91,7 +92,7 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp), .padding(horizontal = 12.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Start,
) { ) {
Text( Text(
text = content, text = content,
@@ -99,7 +100,7 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic, fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
), ),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center, textAlign = TextAlign.Start,
) )
} }
} }
@@ -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,
),
)
}
}
}
}
}
@@ -1,15 +1,17 @@
package top.yeij.cyrene.ui.navigation package top.yeij.cyrene.ui.navigation
import androidx.compose.foundation.layout.Box 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.Icons
import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.DevicesOther import androidx.compose.material.icons.filled.DevicesOther
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -20,13 +22,12 @@ import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import org.koin.compose.koinInject
import top.yeij.cyrene.ui.screens.chat.ChatScreen import top.yeij.cyrene.ui.screens.chat.ChatScreen
import top.yeij.cyrene.ui.screens.iot.IoTScreen import top.yeij.cyrene.ui.screens.iot.IoTScreen
import top.yeij.cyrene.ui.screens.login.LoginScreen 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.profile.ProfileScreen
import top.yeij.cyrene.ui.screens.settings.SettingsScreen import top.yeij.cyrene.ui.screens.settings.SettingsScreen
import top.yeij.cyrene.viewmodel.SettingsViewModel
object Routes { object Routes {
const val LOGIN = "login" const val LOGIN = "login"
@@ -34,6 +35,7 @@ object Routes {
const val CHAT = "chat" const val CHAT = "chat"
const val IOT = "iot" const val IOT = "iot"
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val ABOUT = "about"
} }
@Composable @Composable
@@ -70,6 +72,12 @@ fun CyreneNavGraph(
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
} }
composable(Routes.ABOUT) {
AboutScreen(
onBack = { navController.popBackStack() },
)
}
} }
} }
@@ -85,8 +93,6 @@ fun MainScreen(
isDefaultAssistant: Boolean, isDefaultAssistant: Boolean,
onOpenAssistantSettings: () -> Unit, onOpenAssistantSettings: () -> Unit,
) { ) {
val settingsViewModel: SettingsViewModel = koinInject()
val items = listOf( val items = listOf(
BottomNavItem( BottomNavItem(
label = "对话", label = "对话",
@@ -107,11 +113,14 @@ fun MainScreen(
var selectedTab by rememberSaveable { mutableIntStateOf(0) } var selectedTab by rememberSaveable { mutableIntStateOf(0) }
Scaffold( Row(
bottomBar = { modifier = Modifier
NavigationBar { .fillMaxSize()
.statusBarsPadding(),
) {
NavigationRail {
items.forEachIndexed { index, item -> items.forEachIndexed { index, item ->
NavigationBarItem( NavigationRailItem(
selected = selectedTab == index, selected = selectedTab == index,
onClick = { selectedTab = index }, onClick = { selectedTab = index },
icon = item.icon, icon = item.icon,
@@ -119,16 +128,15 @@ fun MainScreen(
) )
} }
} }
},
) { padding -> Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
Box(modifier = Modifier.padding(padding)) {
when (selectedTab) { when (selectedTab) {
0 -> ChatScreen() 0 -> ChatScreen()
1 -> IoTScreen() 1 -> IoTScreen()
2 -> ProfileScreen( 2 -> ProfileScreen(
onNavigateToSettings = { navController.navigate(Routes.SETTINGS) }, onNavigateToSettings = { navController.navigate(Routes.SETTINGS) },
onNavigateToAbout = { navController.navigate(Routes.ABOUT) },
onLogout = { onLogout = {
settingsViewModel.logout()
navController.navigate(Routes.LOGIN) { navController.navigate(Routes.LOGIN) {
popUpTo(Routes.MAIN) { inclusive = true } popUpTo(Routes.MAIN) { inclusive = true }
} }
@@ -136,6 +144,8 @@ fun MainScreen(
onNavigateToLogin = { onNavigateToLogin = {
navController.navigate(Routes.LOGIN) navController.navigate(Routes.LOGIN)
}, },
isDefaultAssistant = isDefaultAssistant,
onOpenAssistantSettings = onOpenAssistantSettings,
) )
} }
} }
@@ -1,62 +1,135 @@
package top.yeij.cyrene.ui.overlay package top.yeij.cyrene.ui.overlay
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.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.padding
import androidx.compose.foundation.layout.size 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.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.material.icons.filled.Mic
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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 androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.koin.compose.koinInject 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.ChatBubble
import top.yeij.cyrene.ui.components.CyreneStatus import top.yeij.cyrene.ui.components.TypingIndicator
import top.yeij.cyrene.ui.components.StatusIndicator import top.yeij.cyrene.util.RecordState
import top.yeij.cyrene.viewmodel.OverlayState import top.yeij.cyrene.viewmodel.OverlayState
import top.yeij.cyrene.viewmodel.OverlayViewModel 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 @Composable
fun OverlayContent( fun OverlayContent(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel = koinInject(), viewModel: OverlayViewModel = koinInject(),
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val messages by viewModel.messages.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 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) { LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) { if (messages.isNotEmpty()) {
@@ -79,125 +152,431 @@ fun OverlayContent(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)) .statusBarsPadding()
.navigationBarsPadding(),
) {
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<Message>,
inputText: String,
isProcessing: Boolean,
listState: androidx.compose.foundation.lazy.LazyListState,
recordSec: Float,
isRecording: Boolean,
isLocked: Boolean,
typingDots: String,
animIndex: Map<String, Int>,
onDismiss: () -> Unit,
onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel,
) {
Box(
modifier = Modifier
.fillMaxSize()
.clickable( .clickable(
indication = null, indication = null,
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
) { onDismiss() }, ) { /* consume clicks */ },
) { ) {
Surface( // Messages + top bar stay fixed at top
Column(modifier = Modifier.fillMaxSize()) {
MessageTopBar(onDismiss = onDismiss, onNavigateToMain = onNavigateToMain)
LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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) .align(Alignment.BottomCenter)
.fillMaxWidth()
.imePadding(),
recordSec = recordSec,
isRecording = isRecording,
isLocked = isLocked,
typingDots = typingDots,
)
}
}
@Suppress("LongParameterList")
@Composable
private fun LandscapeContent(
state: OverlayState,
messages: List<Message>,
inputText: String,
isProcessing: Boolean,
listState: androidx.compose.foundation.lazy.LazyListState,
recordSec: Float,
isRecording: Boolean,
isLocked: Boolean,
typingDots: String,
animIndex: Map<String, Int>,
onDismiss: () -> Unit,
onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel,
) {
Row(
modifier = Modifier
.fillMaxSize()
.clickable( .clickable(
indication = null, indication = null,
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
) { /* consume click */ }, ) { /* consume clicks */ },
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp), ) {
// 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, shadowElevation = 8.dp,
color = MaterialTheme.colorScheme.surface,
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(12.dp),
) { ) {
// Header // "昔涟正在输入..." 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
StatusIndicator( if (isRecording && isDragging) {
status = when (state) { // Recording with drag — show recording indicator
OverlayState.LISTENING -> CyreneStatus.ONLINE Box(
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 modifier = Modifier
.fillMaxWidth() .weight(1f)
.weight(1f, fill = false) .clip(RoundedCornerShape(12.dp))
.height(200.dp), .background(
state = listState, if (inCancelZone) MaterialTheme.colorScheme.errorContainer
) { else MaterialTheme.colorScheme.surfaceVariant
items(messages, key = { it.id }) { message ->
ChatBubble(
content = message.content,
role = message.role,
msgType = message.msgType,
timestamp = message.timestamp,
) )
} .padding(horizontal = 16.dp, vertical = 14.dp),
} contentAlignment = Alignment.Center,
) {
// Recognized text display
if (recognizedText.isNotEmpty()) {
Text( Text(
text = recognizedText, text = when {
style = MaterialTheme.typography.bodyLarge, inCancelZone -> "松手取消"
modifier = Modifier.padding(vertical = 8.dp), inLockZone -> "松手录音"
else -> "%.1f\" 上滑取消 右滑松手".format(recordSec)
},
style = MaterialTheme.typography.bodyMedium,
color = if (inCancelZone) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
Box(
// Action button modifier = Modifier
Row( .padding(start = 8.dp)
modifier = Modifier.fillMaxWidth().padding(top = 8.dp), .size(48.dp)
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center, .clip(CircleShape)
) { .background(MaterialTheme.colorScheme.primary)
Button( .offset { IntOffset(dragOffsetX.toInt(), dragOffsetY.toInt()) },
onClick = { contentAlignment = Alignment.Center,
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( Icon(
Icons.Filled.Mic, Icons.Filled.Mic,
contentDescription = "语音", contentDescription = "录音中",
modifier = Modifier.size(32.dp), 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,
)
Text( Spacer(modifier = Modifier.width(8.dp))
text = when (state) {
OverlayState.IDLE -> "" if (inputText.isNotBlank()) {
OverlayState.LISTENING -> "我在听…" IconButton(
OverlayState.PROCESSING -> "思考中…" onClick = { viewModel.sendText() },
OverlayState.SPEAKING -> "正在说话…" enabled = !isProcessing,
OverlayState.WAITING -> "点击继续说话" ) {
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, style = MaterialTheme.typography.labelMedium,
modifier = Modifier.fillMaxWidth(), modifier = Modifier
textAlign = androidx.compose.ui.text.style.TextAlign.Center, .fillMaxWidth()
.padding(top = 4.dp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
} }
}
} }
@@ -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))
}
}
}
@@ -1,56 +1,121 @@
package top.yeij.cyrene.ui.screens.chat package top.yeij.cyrene.ui.screens.chat
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.fadeIn
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.slideInVertically
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background 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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send 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.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 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.ChatBubble
import top.yeij.cyrene.ui.components.CyreneStatus import top.yeij.cyrene.ui.components.CyreneStatus
import top.yeij.cyrene.ui.components.StatusIndicator import top.yeij.cyrene.ui.components.StatusIndicator
import top.yeij.cyrene.ui.components.TypingIndicator
import top.yeij.cyrene.util.RecordState
import top.yeij.cyrene.viewmodel.ChatViewModel 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 @Composable
fun ChatScreen( fun ChatScreen(
viewModel: ChatViewModel = koinInject(), viewModel: ChatViewModel = koinViewModel(),
) { ) {
val messages by viewModel.currentMessages.collectAsState() val messages by viewModel.currentMessages.collectAsState()
val inputText by viewModel.inputText.collectAsState() val inputText by viewModel.inputText.collectAsState()
val isStreaming by viewModel.isStreaming.collectAsState() val isStreaming by viewModel.isStreaming.collectAsState()
val isConnected by viewModel.isConnected.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() 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) { LaunchedEffect(messages.size, isStreaming) {
if (messages.isNotEmpty()) { if (messages.isNotEmpty()) {
val targetIndex = if (isStreaming) messages.size else messages.size - 1 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 { val status = when {
isStreaming -> CyreneStatus.THINKING isStreaming -> CyreneStatus.THINKING
isConnected -> CyreneStatus.ONLINE isConnected -> CyreneStatus.ONLINE
@@ -76,12 +157,104 @@ fun ChatScreen(
} }
}, },
bottomBar = { bottomBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding(),
) {
// "昔涟正在输入..." 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
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 {
// Normal input mode
OutlinedTextField( OutlinedTextField(
value = inputText, value = inputText,
onValueChange = { viewModel.onInputChanged(it) }, onValueChange = { viewModel.onInputChanged(it) },
@@ -90,13 +263,60 @@ fun ChatScreen(
maxLines = 4, maxLines = 4,
shape = MaterialTheme.shapes.medium, 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( IconButton(
onClick = { viewModel.sendMessage() }, onClick = { viewModel.sendMessage() },
enabled = inputText.isNotBlank() && !isStreaming, enabled = !isStreaming,
) { ) {
if (isStreaming) { if (isStreaming) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.padding(4.dp), modifier = Modifier.size(24.dp),
strokeWidth = 2.dp, strokeWidth = 2.dp,
) )
} else { } else {
@@ -104,13 +324,21 @@ fun ChatScreen(
} }
} }
} }
}
}
}
}, },
) { padding -> ) { padding ->
if (messages.isEmpty() && !isStreaming) { PullToRefreshBox(
Box( isRefreshing = isRefreshing,
onRefresh = { viewModel.refreshMessages() },
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding), .padding(padding),
) {
if (messages.isEmpty() && !isStreaming) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
@@ -121,17 +349,13 @@ fun ChatScreen(
} }
} else { } else {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.padding(padding),
state = listState, state = listState,
) { ) {
items(messages, key = { it.id }) { message -> items(messages, key = { it.id }) { message ->
ChatBubble( AnimatedChatBubble(
content = message.content, message = message,
role = message.role, animIndex = animIndex[message.id] ?: 0,
msgType = message.msgType,
timestamp = message.timestamp,
) )
} }
if (isStreaming) { if (isStreaming) {
@@ -142,56 +366,5 @@ fun ChatScreen(
} }
} }
} }
}
@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,
),
)
}
}
}
} }
} }
@@ -16,14 +16,14 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp 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.ui.components.DeviceCard
import top.yeij.cyrene.viewmodel.IoTViewModel import top.yeij.cyrene.viewmodel.IoTViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun IoTScreen( fun IoTScreen(
viewModel: IoTViewModel = koinInject(), viewModel: IoTViewModel = koinViewModel(),
) { ) {
val devices by viewModel.devices.collectAsState() val devices by viewModel.devices.collectAsState()
val isLoading by viewModel.isLoading.collectAsState() val isLoading by viewModel.isLoading.collectAsState()
@@ -1,117 +1,319 @@
package top.yeij.cyrene.ui.screens.profile package top.yeij.cyrene.ui.screens.profile
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Icons
import androidx.compose.material.icons.automirrored.filled.ExitToApp import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.automirrored.filled.Help 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.ChevronRight
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Notifications 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.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.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 androidx.compose.ui.unit.dp
import org.koin.compose.koinInject import org.koin.compose.koinInject
import top.yeij.cyrene.viewmodel.SettingsViewModel import top.yeij.cyrene.viewmodel.ProfileViewModel
@Composable @Composable
fun ProfileScreen( fun ProfileScreen(
onNavigateToSettings: () -> Unit, onNavigateToSettings: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onNavigateToLogin: () -> Unit, onNavigateToLogin: () -> Unit,
settingsViewModel: SettingsViewModel = koinInject(), onNavigateToAbout: () -> Unit = {},
isDefaultAssistant: Boolean = false,
onOpenAssistantSettings: () -> Unit = {},
profileViewModel: ProfileViewModel = koinInject(),
) { ) {
val username by settingsViewModel.username.collectAsState() val profile by profileViewModel.profile.collectAsState()
val isLoggedIn by settingsViewModel.isLoggedIn.collectAsState() var showLogoutDialog by remember { mutableStateOf(false) }
LazyColumn( LaunchedEffect(Unit) {
modifier = Modifier.fillMaxSize(), 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 // Profile header
item {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(24.dp), .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
.size(80.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) { ) {
Text( 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, style = MaterialTheme.typography.headlineMedium,
color = if (!isLoggedIn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.primary,
modifier = if (!isLoggedIn) { modifier = Modifier.clickable { onNavigateToLogin() },
Modifier.clickable { onNavigateToLogin() }
} else {
Modifier
},
) )
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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() } HorizontalDivider()
item { Spacer(modifier = Modifier.height(8.dp)) }
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()
}
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("去设置")
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
// Menu items
Text(
text = "其他",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
// Settings
item {
ListItem( ListItem(
headlineContent = { Text("设置") }, headlineContent = { Text("设置") },
leadingContent = { Icon(Icons.Filled.Settings, contentDescription = null) }, leadingContent = { Icon(Icons.Filled.Settings, contentDescription = null) },
trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) }, trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
modifier = Modifier.clickable { onNavigateToSettings() }, modifier = Modifier.clickable { onNavigateToSettings() },
) )
}
// Reminders
item {
ListItem( ListItem(
headlineContent = { Text("提醒") }, headlineContent = { Text("提醒") },
leadingContent = { Icon(Icons.Filled.Notifications, contentDescription = null) }, leadingContent = { Icon(Icons.Filled.Notifications, contentDescription = null) },
trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) }, trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
) )
}
item { HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) } HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// About
item {
ListItem( ListItem(
headlineContent = { Text("关于") }, headlineContent = { Text("关于") },
leadingContent = { Icon(Icons.Filled.Info, contentDescription = null) }, leadingContent = { Icon(Icons.Filled.Info, contentDescription = null) },
supportingContent = { Text("Cyrene v0.1.0") }, supportingContent = { Text("Cyrene v0.1.0") },
modifier = Modifier.clickable { onNavigateToAbout() },
) )
}
// Help
item {
ListItem( ListItem(
headlineContent = { Text("使用帮助") }, headlineContent = { Text("使用帮助") },
leadingContent = { Icon(Icons.AutoMirrored.Filled.Help, contentDescription = null) }, leadingContent = { Icon(Icons.AutoMirrored.Filled.Help, contentDescription = null) },
) )
}
item { Spacer(modifier = Modifier.height(24.dp)) } Spacer(modifier = Modifier.height(16.dp))
// Logout // Logout
if (isLoggedIn) { if (profile.isLoggedIn) {
item {
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
@@ -126,9 +328,63 @@ fun ProfileScreen(
tint = MaterialTheme.colorScheme.error, tint = MaterialTheme.colorScheme.error,
) )
}, },
modifier = Modifier.clickable { onLogout() }, 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<ProfileInfoItem>) {
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 = item.label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = item.value,
style = MaterialTheme.typography.bodyMedium,
)
}
}
if (index < items.size - 1) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
) )
} }
} }
} }
}
} }
@@ -2,12 +2,16 @@ package top.yeij.cyrene.ui.screens.settings
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions 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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DarkMode 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.LightMode
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.SettingsBrightness 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.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilledTonalIconButton
@@ -27,13 +35,20 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction 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 androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import top.yeij.cyrene.util.LogCategory
import top.yeij.cyrene.util.RuntimeLog
import top.yeij.cyrene.viewmodel.SettingsViewModel import top.yeij.cyrene.viewmodel.SettingsViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -52,6 +69,9 @@ fun SettingsScreen(
val baseUrl by viewModel.baseUrl.collectAsState() val baseUrl by viewModel.baseUrl.collectAsState()
val themeMode by viewModel.themeMode.collectAsState() val themeMode by viewModel.themeMode.collectAsState()
val wakeWord by viewModel.wakeWord.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 context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -101,7 +121,7 @@ fun SettingsScreen(
) )
OutlinedTextField( OutlinedTextField(
value = baseUrl, value = baseUrl,
onValueChange = { viewModel.saveBaseUrl(it) }, onValueChange = { viewModel.updateBaseUrlInput(it) },
label = { Text("服务器地址") }, label = { Text("服务器地址") },
placeholder = { Text("http://192.168.1.x:8080") }, placeholder = { Text("http://192.168.1.x:8080") },
singleLine = true, singleLine = true,
@@ -203,6 +223,287 @@ fun SettingsScreen(
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
shape = MaterialTheme.shapes.medium, 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))
} }
} }
} }
@@ -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"
}
}
@@ -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<List<LogEntry>>(emptyList())
val entries: StateFlow<List<LogEntry>> = _entries.asStateFlow()
private val buffer = ArrayDeque<LogEntry>(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<LogEntry> {
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()
}
}
@@ -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<RecordState> = _state.asStateFlow()
private val _durationMs = MutableStateFlow(0L)
val durationMs: StateFlow<Long> = _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"
}
}
@@ -3,23 +3,64 @@ package top.yeij.cyrene.viewmodel
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import top.yeij.cyrene.domain.model.Conversation import top.yeij.cyrene.domain.model.Conversation
import top.yeij.cyrene.domain.model.Message import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.domain.repository.ChatRepository 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<Message>.deduplicate(): List<Message> {
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<Message>.removeWrappingDuplicates(): List<Message> {
if (size < 3) return this
val toRemove = mutableSetOf<String>()
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( class ChatViewModel(
private val chatRepository: ChatRepository, private val chatRepository: ChatRepository,
private val voiceRecorder: VoiceRecorder,
) : ViewModel() { ) : ViewModel() {
val isConnected: StateFlow<Boolean> = chatRepository.connectionState val isConnected: StateFlow<Boolean> = chatRepository.connectionState
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val connectionError: StateFlow<String?> = chatRepository.connectionError
val conversations: StateFlow<List<Conversation>> = chatRepository.getConversations() val conversations: StateFlow<List<Conversation>> = chatRepository.getConversations()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
@@ -29,37 +70,144 @@ class ChatViewModel(
private val _inputText = MutableStateFlow("") private val _inputText = MutableStateFlow("")
val inputText: StateFlow<String> = _inputText.asStateFlow() val inputText: StateFlow<String> = _inputText.asStateFlow()
private val _isStreaming = MutableStateFlow(false) private val _isSending = MutableStateFlow(false)
val isStreaming: StateFlow<Boolean> = _isStreaming.asStateFlow() val isStreaming: StateFlow<Boolean> = 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<Boolean> = _isRefreshing.asStateFlow()
// Voice recording state
val voiceRecordState: StateFlow<RecordState> = voiceRecorder.state
val voiceRecordDurationMs: StateFlow<Long> = voiceRecorder.durationMs
// Animation ordering for message bubbles
private var animCounter = 0
private val _messageAnimIndex = MutableStateFlow<Map<String, Int>>(emptyMap())
val messageAnimIndex: StateFlow<Map<String, Int>> = _messageAnimIndex.asStateFlow()
private var currentSessionId: String? = null private var currentSessionId: String? = null
private var dbObserverJob: Job? = null
init { init {
connectAndLoad() // Phase 1: find/create main session, reconnect WS, load server history
viewModelScope.launch {
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) { }
} }
fun connectAndLoad(sessionId: String? = null) { // Observe incoming live messages with atomic dedup
viewModelScope.launch {
chatRepository.connectWebSocket(sessionId)
chatRepository.loadConversationsFromServer()
}
viewModelScope.launch { viewModelScope.launch {
chatRepository.observeMessages().collect { message -> chatRepository.observeMessages().collect { message ->
try { try {
val list = _currentMessages.value.toMutableList() _currentMessages.update { list ->
val existingIdx = list.indexOfLast { it.id == message.id } val updated = list.toMutableList()
val existingIdx = updated.indexOfLast { it.id == message.id }
if (existingIdx >= 0) { if (existingIdx >= 0) {
list[existingIdx] = message updated[existingIdx] = message
} else { } else {
list.add(message) 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) { } catch (e: Exception) {
Log.e("ChatViewModel", "Error processing message: ${e.message}", e) 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) { fun onInputChanged(text: String) {
@@ -71,7 +219,7 @@ class ChatViewModel(
if (text.isEmpty()) return if (text.isEmpty()) return
_inputText.value = "" _inputText.value = ""
_isStreaming.value = true _isSending.value = true
val sid = currentSessionId val sid = currentSessionId
viewModelScope.launch { viewModelScope.launch {
@@ -81,11 +229,39 @@ class ChatViewModel(
fun switchSession(sessionId: String) { fun switchSession(sessionId: String) {
currentSessionId = sessionId currentSessionId = sessionId
chatRepository.currentSessionId = sessionId
_currentMessages.value = emptyList()
_messageAnimIndex.value = emptyMap()
animCounter = 0
viewModelScope.launch { viewModelScope.launch {
chatRepository.disconnectWebSocket()
chatRepository.connectWebSocket(sessionId) chatRepository.connectWebSocket(sessionId)
chatRepository.loadMessagesFromServer(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) { fun deleteConversation(id: String) {
@@ -94,10 +270,12 @@ class ChatViewModel(
} }
} }
override fun onCleared() { fun clearLocalMessages() {
viewModelScope.launch { viewModelScope.launch {
chatRepository.disconnectWebSocket() chatRepository.clearLocalMessages()
_currentMessages.value = emptyList()
_messageAnimIndex.value = emptyMap()
animCounter = 0
} }
super.onCleared()
} }
} }
@@ -1,5 +1,6 @@
package top.yeij.cyrene.viewmodel package top.yeij.cyrene.viewmodel
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -7,10 +8,47 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import top.yeij.cyrene.domain.model.Message import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.domain.repository.ChatRepository import top.yeij.cyrene.domain.repository.ChatRepository
import top.yeij.cyrene.util.Constants 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<Message>.deduplicate(): List<Message> {
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<Message>.removeWrappingDuplicates(): List<Message> {
if (size < 3) return this
val toRemove = mutableSetOf<String>()
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 { enum class OverlayState {
IDLE, IDLE,
@@ -22,83 +60,168 @@ enum class OverlayState {
class OverlayViewModel( class OverlayViewModel(
private val chatRepository: ChatRepository, private val chatRepository: ChatRepository,
private val voiceRecorder: VoiceRecorder,
private val ttsEngine: TextToSpeechEngine,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(OverlayState.IDLE) private val _state = MutableStateFlow(OverlayState.WAITING)
val state: StateFlow<OverlayState> = _state.asStateFlow() val state: StateFlow<OverlayState> = _state.asStateFlow()
private val _messages = MutableStateFlow<List<Message>>(emptyList()) private val _messages = MutableStateFlow<List<Message>>(emptyList())
val messages: StateFlow<List<Message>> = _messages.asStateFlow() val messages: StateFlow<List<Message>> = _messages.asStateFlow()
private val _recognizedText = MutableStateFlow("") private val _inputText = MutableStateFlow("")
val recognizedText: StateFlow<String> = _recognizedText.asStateFlow() val inputText: StateFlow<String> = _inputText.asStateFlow()
val voiceRecordState: StateFlow<RecordState> = voiceRecorder.state
val voiceRecordDurationMs: StateFlow<Long> = voiceRecorder.durationMs
// Animation ordering for message bubbles
private var animCounter = 0
private val _messageAnimIndex = MutableStateFlow<Map<String, Int>>(emptyMap())
val messageAnimIndex: StateFlow<Map<String, Int>> = _messageAnimIndex.asStateFlow()
private var silenceTimer: Job? = null private var silenceTimer: Job? = null
private var lastAssistantMessageId: String? = null
init { init {
viewModelScope.launch { 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 { viewModelScope.launch {
chatRepository.observeMessages().collect { message -> ttsEngine.onDone.collect {
_messages.value = _messages.value + message if (_state.value == OverlayState.SPEAKING) {
setWaiting()
}
}
}
viewModelScope.launch {
chatRepository.messageClearEvents.collect {
_messages.value = emptyList()
_messageAnimIndex.value = emptyMap()
animCounter = 0
} }
} }
} }
fun startListening() { fun onInputChanged(text: String) {
_state.value = OverlayState.LISTENING _inputText.value = text
resetSilenceTimer()
} }
fun onSpeechPartial(text: String) { fun sendText() {
_recognizedText.value = text val text = _inputText.value.trim()
resetSilenceTimer() if (text.isEmpty()) return
} _inputText.value = ""
fun onSpeechFinal(text: String) {
_recognizedText.value = text
_state.value = OverlayState.PROCESSING _state.value = OverlayState.PROCESSING
cancelSilenceTimer() cancelSilenceTimer()
viewModelScope.launch { viewModelScope.launch {
chatRepository.sendMessage(text, null) 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 _state.value = OverlayState.PROCESSING
viewModelScope.launch { viewModelScope.launch {
chatRepository.sendMessage(text, null) chatRepository.sendVoiceInput(base64, "voice_msg")
} }
} }
fun setSpeaking() { fun cancelRecord() {
voiceRecorder.cancel()
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 _state.value = OverlayState.SPEAKING
ttsEngine.speak(text)
} }
fun setWaiting() { private fun setWaiting() {
_state.value = OverlayState.WAITING _state.value = OverlayState.WAITING
startSilenceTimer() 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() { fun finish() {
_state.value = OverlayState.IDLE _state.value = OverlayState.IDLE
cancelSilenceTimer() cancelSilenceTimer()
voiceRecorder.cancel()
ttsEngine.stop()
} }
private fun startSilenceTimer() { private fun startSilenceTimer() {
cancelSilenceTimer() cancelSilenceTimer()
silenceTimer = viewModelScope.launch { silenceTimer = viewModelScope.launch {
delay(Constants.SILENCE_TIMEOUT_MS) delay(Constants.SILENCE_TIMEOUT_MS)
if (_state.value == OverlayState.WAITING) {
_state.value = OverlayState.IDLE _state.value = OverlayState.IDLE
} }
} }
private fun resetSilenceTimer() {
cancelSilenceTimer()
startSilenceTimer()
} }
private fun cancelSilenceTimer() { private fun cancelSilenceTimer() {
@@ -107,9 +230,8 @@ class OverlayViewModel(
} }
override fun onCleared() { override fun onCleared() {
viewModelScope.launch { voiceRecorder.cancel()
chatRepository.disconnectWebSocket() ttsEngine.shutdown()
}
super.onCleared() super.onCleared()
} }
} }
@@ -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<ProfileState> = _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
}
}
}
@@ -11,11 +11,15 @@ import kotlinx.coroutines.launch
import top.yeij.cyrene.data.local.PreferencesDataStore import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
import top.yeij.cyrene.domain.repository.AuthRepository import top.yeij.cyrene.domain.repository.AuthRepository
import top.yeij.cyrene.domain.repository.ChatRepository
import top.yeij.cyrene.voice.stt.SttManager
class SettingsViewModel( class SettingsViewModel(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val preferencesDataStore: PreferencesDataStore, private val preferencesDataStore: PreferencesDataStore,
private val dynamicUrlInterceptor: DynamicUrlInterceptor, private val dynamicUrlInterceptor: DynamicUrlInterceptor,
private val chatRepository: ChatRepository,
private val sttManager: SttManager,
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
@@ -32,6 +36,15 @@ class SettingsViewModel(
private val _username = MutableStateFlow("") private val _username = MutableStateFlow("")
val username: StateFlow<String> = _username.asStateFlow() val username: StateFlow<String> = _username.asStateFlow()
private val _dashScopeApiKey = MutableStateFlow("")
val dashScopeApiKey: StateFlow<String> = _dashScopeApiKey.asStateFlow()
private val _dashScopeEndpoint = MutableStateFlow("wss://dashscope.aliyuncs.com/api-ws/v1/inference")
val dashScopeEndpoint: StateFlow<String> = _dashScopeEndpoint.asStateFlow()
private val _dashScopeModel = MutableStateFlow("fun-asr-realtime")
val dashScopeModel: StateFlow<String> = _dashScopeModel.asStateFlow()
private val _isLoggedIn = MutableStateFlow(false) private val _isLoggedIn = MutableStateFlow(false)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow() val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
@@ -46,7 +59,14 @@ class SettingsViewModel(
preferencesDataStore.themeMode, preferencesDataStore.themeMode,
preferencesDataStore.wakeWord, preferencesDataStore.wakeWord,
preferencesDataStore.username, 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 -> baseUrl?.let { url ->
if (url.isNotBlank()) { if (url.isNotBlank()) {
_baseUrl.value = url _baseUrl.value = url
@@ -58,14 +78,38 @@ class SettingsViewModel(
if (word.isNotBlank()) _wakeWord.value = word if (word.isNotBlank()) _wakeWord.value = word
} }
username?.let { _username.value = it } 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 { } }.collect { }
} }
} }
fun updateBaseUrlInput(url: String) {
_baseUrl.value = url
}
fun saveBaseUrl(url: String) { fun saveBaseUrl(url: String) {
_baseUrl.value = url _baseUrl.value = url
dynamicUrlInterceptor.baseUrl = url dynamicUrlInterceptor.baseUrl = url
scope.launch { preferencesDataStore.saveBaseUrl(url) } scope.launch {
preferencesDataStore.saveBaseUrl(url)
chatRepository.reconnectWebSocket()
}
} }
fun saveThemeMode(mode: String) { fun saveThemeMode(mode: String) {
@@ -78,6 +122,42 @@ class SettingsViewModel(
scope.launch { preferencesDataStore.saveWakeWord(word) } 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() { fun logout() {
scope.launch { scope.launch {
authRepository.logout() authRepository.logout()
@@ -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<String> = _partialResult.asStateFlow()
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 8)
override val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
override val onError: SharedFlow<String> = _onError.asSharedFlow()
private val _isListening = MutableStateFlow(false)
override val isListening: StateFlow<Boolean> = _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"
}
}
@@ -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<String> = _partialResult.asStateFlow()
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 16)
override val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
override val onError: SharedFlow<String> = _onError.asSharedFlow()
private val _isListening = MutableStateFlow(false)
override val isListening: StateFlow<Boolean> = _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<String, String>(),
),
)
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<String, String>(),
)
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<String, Any>?,
)
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<Map<String, Any>>?,
@SerializedName("emo_tag") val emoTag: String?,
)
@@ -1,30 +1,118 @@
package top.yeij.cyrene.voice.stt 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.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow 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) private val _isListening = MutableStateFlow(false)
val isListening = _isListening.asStateFlow() override val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
private val _partialResult = MutableStateFlow("") private val _partialResult = MutableStateFlow("")
val partialResult = _partialResult.asStateFlow() override val partialResult: StateFlow<String> = _partialResult.asStateFlow()
fun startListening() { private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 8)
override val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
override val onError: SharedFlow<String> = _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 _isListening.value = true
// Integrate Android SpeechRecognizer or server-side Whisper API _partialResult.value = ""
} }
fun stopListening(): String { override fun onBeginningOfSpeech() {}
override fun onRmsChanged(rmsdB: Float) {}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onEndOfSpeech() {}
override fun onError(error: Int) {
_isListening.value = false _isListening.value = false
val result = _partialResult.value val msg = when (error) {
_partialResult.value = "" android.speech.SpeechRecognizer.ERROR_NETWORK -> "网络连接失败"
return result 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)
} }
fun cancel() { 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)
}
}
override fun stop() {
recognizer?.stopListening()
_isListening.value = false
}
override fun cancel() {
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
_isListening.value = false _isListening.value = false
_partialResult.value = "" _partialResult.value = ""
} }
companion object {
private const val TAG = "CyreneSTT"
}
} }
@@ -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<Boolean> = _isListening.asStateFlow()
private val _partialResult = MutableStateFlow("")
val partialResult: StateFlow<String> = _partialResult.asStateFlow()
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 16)
val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
val onError: SharedFlow<String> = _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"
}
}
@@ -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<String>
val finalResult: SharedFlow<SttResult>
val onError: SharedFlow<String>
val isListening: StateFlow<Boolean>
fun start()
fun stop()
fun cancel()
}
+1
View File
@@ -34,6 +34,7 @@
<string name="wake_word">唤醒词</string> <string name="wake_word">唤醒词</string>
<string name="account">账号</string> <string name="account">账号</string>
<string name="about">关于</string> <string name="about">关于</string>
<string name="accessibility_service_description">昔涟使用无障碍服务读取屏幕内容,以便在唤醒时理解当前上下文并提供更精准的帮助。不会收集或上传个人隐私信息。</string>
<string name="server_address">服务器地址</string> <string name="server_address">服务器地址</string>
<string name="theme">主题</string> <string name="theme">主题</string>
<string name="theme_light">浅色</string> <string name="theme_light">浅色</string>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:canRetrieveWindowContent="true"
android:description="@string/accessibility_service_description"
android:notificationTimeout="500" />
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="cache" path="/" />
</paths>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<voice-interaction-service <voice-interaction-service
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
android:sessionService=".service.CyreneVoiceInteractionSession" android:sessionService="top.yeij.cyrene.service.CyreneSessionService"
android:recognitionService=".service.CyreneRecognitionService" android:recognitionService=".service.CyreneRecognitionService"
android:supportsAssist="true" android:supportsAssist="true"
android:supportsLaunchVoiceAssistFromKeyguard="true" android:supportsLaunchVoiceAssistFromKeyguard="true"