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:
@@ -64,6 +64,36 @@
|
||||
</intent-filter>
|
||||
</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>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,32 +1,74 @@
|
||||
package top.yeij.cyrene
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.context.GlobalContext
|
||||
import org.koin.core.context.startKoin
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.remote.AuthInterceptor
|
||||
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
|
||||
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
|
||||
import top.yeij.cyrene.di.appModule
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.context.startKoin
|
||||
import top.yeij.cyrene.util.NotificationHelper
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class CyreneApplication : Application() {
|
||||
|
||||
private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val activityCount = AtomicInteger(0)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
RuntimeLog.general("app", "Application onCreate")
|
||||
|
||||
startKoin {
|
||||
androidContext(this@CyreneApplication)
|
||||
modules(appModule)
|
||||
}
|
||||
|
||||
// Track foreground/background state
|
||||
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
|
||||
override fun onActivityStarted(activity: Activity) {
|
||||
if (activityCount.incrementAndGet() == 1) {
|
||||
RuntimeLog.general("app", "App in foreground")
|
||||
getRepo()?.onAppForeground()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityStopped(activity: Activity) {
|
||||
if (activityCount.decrementAndGet() == 0) {
|
||||
RuntimeLog.general("app", "App in background")
|
||||
getRepo()?.onAppBackground()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
||||
override fun onActivityResumed(activity: Activity) {}
|
||||
override fun onActivityPaused(activity: Activity) {}
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||
override fun onActivityDestroyed(activity: Activity) {}
|
||||
})
|
||||
|
||||
// Set up background notification callback once Koin is ready
|
||||
initScope.launch {
|
||||
val koin = org.koin.core.context.GlobalContext.get()
|
||||
val notificationHelper = NotificationHelper(this@CyreneApplication)
|
||||
val repo = getRepo()
|
||||
repo?.setNotificationCallback { message ->
|
||||
notificationHelper.showMessageNotification(message)
|
||||
}
|
||||
}
|
||||
|
||||
initScope.launch {
|
||||
val koin = GlobalContext.get()
|
||||
val prefs: PreferencesDataStore = koin.get()
|
||||
val urlInterceptor: DynamicUrlInterceptor = koin.get()
|
||||
val authInterceptor: AuthInterceptor = koin.get()
|
||||
@@ -39,4 +81,16 @@ class CyreneApplication : Application() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getRepo(): ChatRepositoryImpl? {
|
||||
return try {
|
||||
GlobalContext.get().get()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CyreneApp"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.provider.Settings
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import top.yeij.cyrene.service.CyreneVoiceInteractionService
|
||||
import top.yeij.cyrene.ui.navigation.CyreneNavGraph
|
||||
@@ -16,11 +17,13 @@ import top.yeij.cyrene.util.Constants
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val isDefaultAssistant = mutableStateOf(false)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
val isDefaultAssistant = checkIsDefaultAssistant()
|
||||
isDefaultAssistant.value = checkIsDefaultAssistant()
|
||||
|
||||
setContent {
|
||||
CyreneTheme {
|
||||
@@ -29,24 +32,31 @@ class MainActivity : ComponentActivity() {
|
||||
CyreneNavGraph(
|
||||
navController = navController,
|
||||
startDestination = Routes.MAIN,
|
||||
isDefaultAssistant = isDefaultAssistant,
|
||||
isDefaultAssistant = isDefaultAssistant.value,
|
||||
onOpenAssistantSettings = { openAssistantSettings() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
isDefaultAssistant.value = checkIsDefaultAssistant()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
}
|
||||
|
||||
private fun checkIsDefaultAssistant(): Boolean {
|
||||
val componentName = ComponentName(this, CyreneVoiceInteractionService::class.java)
|
||||
val intent = Intent("android.service.voice.VoiceInteractionService")
|
||||
val services = packageManager.queryIntentServices(intent, 0)
|
||||
return services.any { it.serviceInfo.packageName == packageName }
|
||||
&& CyreneVoiceInteractionService.isActive
|
||||
// Standard Android check
|
||||
val flat = ComponentName(this, CyreneVoiceInteractionService::class.java).flattenToString()
|
||||
val current = Settings.Secure.getString(contentResolver, "voice_interaction_service")
|
||||
if (current == flat) return true
|
||||
// Fallback for COS and other custom OS: check persisted flag from service
|
||||
if (CyreneVoiceInteractionService.wasEverActive(this)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
private fun openAssistantSettings() {
|
||||
|
||||
@@ -22,6 +22,15 @@ class PreferencesDataStore(private val context: Context) {
|
||||
private val KEY_USERNAME = stringPreferencesKey("username")
|
||||
private val KEY_CLIENT_ID = stringPreferencesKey("client_id")
|
||||
private val KEY_DEVICE_NAME = stringPreferencesKey("device_name")
|
||||
private val KEY_CURRENT_SESSION_ID = stringPreferencesKey("current_session_id")
|
||||
private val KEY_DASHSCOPE_API_KEY = stringPreferencesKey("dashscope_api_key")
|
||||
private val KEY_DASHSCOPE_ENDPOINT = stringPreferencesKey("dashscope_endpoint")
|
||||
private val KEY_DASHSCOPE_MODEL = stringPreferencesKey("dashscope_model")
|
||||
private val KEY_LAST_CLEARED_TIMESTAMP = stringPreferencesKey("last_cleared_timestamp")
|
||||
private val KEY_PROFILE_USER_ID = stringPreferencesKey("profile_user_id")
|
||||
private val KEY_PROFILE_NICKNAME = stringPreferencesKey("profile_nickname")
|
||||
private val KEY_PROFILE_IS_ADMIN = stringPreferencesKey("profile_is_admin")
|
||||
private val KEY_PROFILE_CREATED_AT = stringPreferencesKey("profile_created_at")
|
||||
}
|
||||
|
||||
val token: Flow<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 clientId: Flow<String?> = context.dataStore.data.map { it[KEY_CLIENT_ID] }
|
||||
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) {
|
||||
context.dataStore.edit { it[KEY_TOKEN] = token }
|
||||
@@ -57,6 +70,18 @@ class PreferencesDataStore(private val context: Context) {
|
||||
context.dataStore.edit { it[KEY_USERNAME] = username }
|
||||
}
|
||||
|
||||
suspend fun saveDashScopeApiKey(key: String) {
|
||||
context.dataStore.edit { it[KEY_DASHSCOPE_API_KEY] = key }
|
||||
}
|
||||
|
||||
suspend fun saveDashScopeEndpoint(endpoint: String) {
|
||||
context.dataStore.edit { it[KEY_DASHSCOPE_ENDPOINT] = endpoint }
|
||||
}
|
||||
|
||||
suspend fun saveDashScopeModel(model: String) {
|
||||
context.dataStore.edit { it[KEY_DASHSCOPE_MODEL] = model }
|
||||
}
|
||||
|
||||
suspend fun saveClientId(id: String) {
|
||||
context.dataStore.edit { it[KEY_CLIENT_ID] = id }
|
||||
}
|
||||
@@ -65,6 +90,40 @@ class PreferencesDataStore(private val context: Context) {
|
||||
context.dataStore.edit { it[KEY_DEVICE_NAME] = name }
|
||||
}
|
||||
|
||||
suspend fun saveCurrentSessionId(id: String) {
|
||||
context.dataStore.edit { it[KEY_CURRENT_SESSION_ID] = id }
|
||||
}
|
||||
|
||||
val lastClearedTimestamp: Flow<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() {
|
||||
context.dataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ interface ConversationDao {
|
||||
@Query("SELECT * FROM conversations ORDER BY updatedAt DESC")
|
||||
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")
|
||||
suspend fun getById(id: String): ConversationEntity?
|
||||
|
||||
|
||||
@@ -21,4 +21,13 @@ interface MessageDao {
|
||||
|
||||
@Query("DELETE FROM messages WHERE conversationId = :conversationId")
|
||||
suspend fun deleteByConversation(conversationId: String)
|
||||
|
||||
@Query("UPDATE messages SET conversationId = :newId WHERE conversationId = :oldId")
|
||||
suspend fun migrateConversationId(oldId: String, newId: String)
|
||||
|
||||
@Query("DELETE FROM messages WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
|
||||
@Query("DELETE FROM messages")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
|
||||
@@ -6,12 +6,17 @@ import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import top.yeij.cyrene.data.remote.dto.AuthRequest
|
||||
import top.yeij.cyrene.data.remote.dto.AuthResponse
|
||||
import top.yeij.cyrene.data.remote.dto.ConversationDto
|
||||
import top.yeij.cyrene.data.remote.dto.ProfileResponse
|
||||
import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
|
||||
import top.yeij.cyrene.data.remote.dto.DeviceDto
|
||||
import top.yeij.cyrene.data.remote.dto.IoTControlRequest
|
||||
import top.yeij.cyrene.data.remote.dto.ReminderDto
|
||||
import top.yeij.cyrene.data.remote.dto.MessagesListResponse
|
||||
import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest
|
||||
import top.yeij.cyrene.data.remote.dto.SessionDto
|
||||
import top.yeij.cyrene.data.remote.dto.SessionsListResponse
|
||||
|
||||
interface ApiService {
|
||||
|
||||
@@ -20,16 +25,32 @@ interface ApiService {
|
||||
suspend fun login(@Body request: AuthRequest): Response<AuthResponse>
|
||||
|
||||
@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/conversations")
|
||||
suspend fun getConversations(): Response<List<ConversationDto>>
|
||||
@GET("api/v1/profile")
|
||||
suspend fun getProfile(): Response<ProfileResponse>
|
||||
|
||||
@DELETE("api/v1/conversations/{id}")
|
||||
suspend fun deleteConversation(@Path("id") id: String): Response<Unit>
|
||||
// Sessions
|
||||
@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")
|
||||
suspend fun getDevices(): Response<List<DeviceDto>>
|
||||
|
||||
@@ -38,11 +59,4 @@ interface ApiService {
|
||||
@Path("id") deviceId: String,
|
||||
@Body request: IoTControlRequest,
|
||||
): 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(
|
||||
authInterceptor: AuthInterceptor,
|
||||
dynamicUrlInterceptor: DynamicUrlInterceptor,
|
||||
tokenAuthenticator: TokenAuthenticator,
|
||||
): OkHttpClient {
|
||||
val logging = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
@@ -19,6 +20,7 @@ object RetrofitClient {
|
||||
return OkHttpClient.Builder()
|
||||
.addInterceptor(dynamicUrlInterceptor)
|
||||
.addInterceptor(authInterceptor)
|
||||
.authenticator(tokenAuthenticator)
|
||||
.addInterceptor(logging)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
|
||||
@@ -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("refresh_token") val refreshToken: String?,
|
||||
@SerializedName("username") val username: String?,
|
||||
@SerializedName("nickname") val nickname: String?,
|
||||
@SerializedName("user_id") val userId: String?,
|
||||
@SerializedName("expires") val expires: Long? = null,
|
||||
)
|
||||
|
||||
data class RefreshTokenRequest(
|
||||
@SerializedName("refresh_token") val refreshToken: String,
|
||||
)
|
||||
|
||||
data class ProfileResponse(
|
||||
@SerializedName("user_id") val userId: String,
|
||||
@SerializedName("username") val username: String,
|
||||
@SerializedName("nickname") val nickname: String?,
|
||||
@SerializedName("is_admin") val isAdmin: Boolean? = false,
|
||||
@SerializedName("created_at") val createdAt: String? = null,
|
||||
)
|
||||
|
||||
@@ -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("mode") val mode: 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("client_id") val clientId: String? = null,
|
||||
@SerializedName("device_name") val deviceName: String? = null,
|
||||
@SerializedName("user_agent") val userAgent: String? = null,
|
||||
)
|
||||
|
||||
data class WSAttachment(
|
||||
@SerializedName("type") val type: String,
|
||||
@SerializedName("url") val url: String? = null,
|
||||
@SerializedName("thumbnail_url") val thumbnailUrl: String? = null,
|
||||
@SerializedName("filename") val filename: String? = null,
|
||||
@SerializedName("width") val width: Int? = null,
|
||||
@SerializedName("height") val height: Int? = null,
|
||||
@SerializedName("size") val size: Long? = null,
|
||||
@SerializedName("description") val description: String? = null,
|
||||
)
|
||||
|
||||
// --- Server → Client ---
|
||||
|
||||
data class WSClientInfo(
|
||||
|
||||
@@ -22,12 +22,13 @@ class AuthRepositoryImpl(
|
||||
authInterceptor.token = body.token
|
||||
preferencesDataStore.saveToken(body.token)
|
||||
body.refreshToken?.let { preferencesDataStore.saveRefreshToken(it) }
|
||||
preferencesDataStore.saveUsername(body.username ?: body.userId ?: "开拓者")
|
||||
val displayName = body.nickname ?: body.username ?: body.userId ?: "开拓者"
|
||||
preferencesDataStore.saveUsername(displayName)
|
||||
Result.success(
|
||||
AuthResult(
|
||||
token = body.token,
|
||||
refreshToken = body.refreshToken,
|
||||
username = body.username ?: body.userId ?: "开拓者",
|
||||
username = displayName,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -12,16 +12,22 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.local.dao.ConversationDao
|
||||
import top.yeij.cyrene.data.local.dao.MessageDao
|
||||
import top.yeij.cyrene.data.local.entity.ConversationEntity
|
||||
import top.yeij.cyrene.data.local.entity.MessageEntity
|
||||
import top.yeij.cyrene.data.remote.ApiService
|
||||
import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
|
||||
import top.yeij.cyrene.data.remote.dto.WSServerMessage
|
||||
import top.yeij.cyrene.domain.model.Conversation
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
import top.yeij.cyrene.service.WebSocketService
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import java.util.UUID
|
||||
|
||||
class ChatRepositoryImpl(
|
||||
@@ -29,6 +35,7 @@ class ChatRepositoryImpl(
|
||||
private val messageDao: MessageDao,
|
||||
private val webSocketService: WebSocketService,
|
||||
private val apiService: ApiService,
|
||||
private val preferencesDataStore: PreferencesDataStore,
|
||||
) : ChatRepository {
|
||||
|
||||
private val exceptionHandler = CoroutineExceptionHandler { _, e ->
|
||||
@@ -39,25 +46,79 @@ class ChatRepositoryImpl(
|
||||
private val _connectionState = MutableStateFlow(false)
|
||||
override val connectionState: StateFlow<Boolean> = _connectionState.asStateFlow()
|
||||
|
||||
override val connectionError: StateFlow<String?> = webSocketService.connectionError
|
||||
|
||||
private val _incomingMessages = MutableSharedFlow<Message>(extraBufferCapacity = 64)
|
||||
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 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 {
|
||||
// Restore persisted session ID, then connect and load history
|
||||
scope.launch {
|
||||
val persistedSid = preferencesDataStore.currentSessionId.firstOrNull()
|
||||
if (!persistedSid.isNullOrBlank()) {
|
||||
currentSessionId = persistedSid
|
||||
}
|
||||
RuntimeLog.ws("init", "Connecting WebSocket session=$currentSessionId")
|
||||
webSocketService.connect(currentSessionId)
|
||||
loadConversationsFromServer()
|
||||
}
|
||||
scope.launch {
|
||||
webSocketService.isConnected.collect { connected ->
|
||||
_connectionState.value = connected
|
||||
RuntimeLog.ws("connection", "Connected=$connected")
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
webSocketService.incomingMessages.collect { wsMsg ->
|
||||
try {
|
||||
RuntimeLog.ws("receive", "type=${wsMsg.type} msgId=${wsMsg.messageId ?: "-"}")
|
||||
handleServerMessage(wsMsg)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatRepository", "Error handling ${wsMsg.type}: ${e.message}", e)
|
||||
RuntimeLog.ws("error", "Handle error type=${wsMsg.type}: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,7 +138,39 @@ class ChatRepositoryImpl(
|
||||
|
||||
override suspend fun deleteConversation(id: String) {
|
||||
conversationDao.deleteById(id)
|
||||
try { apiService.deleteConversation(id) } catch (_: Exception) { }
|
||||
try { apiService.deleteSession(id) } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override suspend fun clearLocalMessages() {
|
||||
val now = System.currentTimeMillis()
|
||||
messageDao.deleteAll()
|
||||
preferencesDataStore.saveLastClearedTimestamp(now)
|
||||
|
||||
// Also clear server-side messages for all known sessions
|
||||
try {
|
||||
val sessions = conversationDao.getAllSnapshot()
|
||||
sessions.forEach { session ->
|
||||
try {
|
||||
apiService.clearSessionMessages(session.id)
|
||||
RuntimeLog.chat("clear", "Server messages cleared for session=${session.id}")
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
|
||||
_messageClearEvents.tryEmit(Unit)
|
||||
|
||||
RuntimeLog.chat("clear", "Local messages cleared, timestamp=$now")
|
||||
Log.i("ChatRepository", "Local messages cleared, timestamp: $now")
|
||||
}
|
||||
|
||||
private suspend fun changeSessionId(newId: String) {
|
||||
val oldId = currentSessionId
|
||||
if (oldId != null && oldId != newId) {
|
||||
messageDao.migrateConversationId(oldId, newId)
|
||||
conversationDao.deleteById(oldId)
|
||||
}
|
||||
currentSessionId = newId
|
||||
preferencesDataStore.saveCurrentSessionId(newId)
|
||||
}
|
||||
|
||||
override suspend fun connectWebSocket(sessionId: String?) {
|
||||
@@ -85,15 +178,26 @@ class ChatRepositoryImpl(
|
||||
webSocketService.connect(sessionId)
|
||||
}
|
||||
|
||||
override suspend fun disconnectWebSocket() {
|
||||
override suspend fun reconnectWebSocket() {
|
||||
webSocketService.disconnect()
|
||||
webSocketService.connect(currentSessionId)
|
||||
}
|
||||
|
||||
override suspend fun ensureConnected() {
|
||||
if (_connectionState.value) return
|
||||
webSocketService.forceReconnect()
|
||||
}
|
||||
|
||||
override suspend fun sendMessage(content: String, sessionId: String?) {
|
||||
val messageId = UUID.randomUUID().toString()
|
||||
val now = System.currentTimeMillis()
|
||||
val sid = sessionId ?: currentSessionId ?: "default"
|
||||
currentSessionId = sid
|
||||
if (currentSessionId == null) {
|
||||
currentSessionId = sid
|
||||
scope.launch { preferencesDataStore.saveCurrentSessionId(sid) }
|
||||
}
|
||||
|
||||
RuntimeLog.chat("send", "session=$sid msgId=$messageId content=${content.take(80)}")
|
||||
|
||||
conversationDao.upsert(
|
||||
ConversationEntity(
|
||||
@@ -117,7 +221,6 @@ class ChatRepositoryImpl(
|
||||
)
|
||||
)
|
||||
|
||||
// Emit user message to UI
|
||||
emitMessage(
|
||||
id = messageId,
|
||||
sessionId = sid,
|
||||
@@ -133,18 +236,17 @@ class ChatRepositoryImpl(
|
||||
|
||||
override suspend fun loadConversationsFromServer() {
|
||||
try {
|
||||
val response = apiService.getConversations()
|
||||
val response = apiService.getSessions()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.forEach { dto ->
|
||||
val timestamp = try { dto.updatedAt.toLong() } catch (_: Exception) { System.currentTimeMillis() }
|
||||
response.body()?.sessions?.forEach { dto ->
|
||||
conversationDao.upsert(
|
||||
ConversationEntity(
|
||||
id = dto.id,
|
||||
title = dto.title,
|
||||
lastMessage = dto.lastMessage,
|
||||
lastMessageType = dto.lastMessageType,
|
||||
updatedAt = timestamp,
|
||||
createdAt = timestamp,
|
||||
lastMessage = "",
|
||||
lastMessageType = "chat",
|
||||
updatedAt = dto.updatedAt,
|
||||
createdAt = dto.createdAt,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -152,11 +254,103 @@ class ChatRepositoryImpl(
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override suspend fun sendScreenContext(content: String) {
|
||||
webSocketService.sendScreenContext(content, currentSessionId)
|
||||
}
|
||||
|
||||
override suspend fun sendVoiceInput(audioBase64: String, mode: String) {
|
||||
webSocketService.sendVoiceInput(audioBase64, currentSessionId, mode)
|
||||
}
|
||||
|
||||
override suspend fun initializeSession(): String {
|
||||
// Try to find an existing main session on the server
|
||||
try {
|
||||
val response = apiService.getSessions()
|
||||
if (response.isSuccessful) {
|
||||
val sessions = response.body()?.sessions ?: emptyList()
|
||||
val mainSession = sessions.find { it.isMain == true }
|
||||
if (mainSession != null) {
|
||||
currentSessionId = mainSession.id
|
||||
preferencesDataStore.saveCurrentSessionId(mainSession.id)
|
||||
Log.i("ChatRepository", "Found main session: ${mainSession.id}")
|
||||
return mainSession.id
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// No main session found on server, create one with deterministic ID
|
||||
val sessionId = "session_admin_main"
|
||||
try {
|
||||
apiService.createSession(
|
||||
CreateSessionRequest(
|
||||
sessionId = sessionId,
|
||||
title = "主对话",
|
||||
isMain = true,
|
||||
)
|
||||
)
|
||||
Log.i("ChatRepository", "Created main session: $sessionId")
|
||||
} catch (e: Exception) {
|
||||
Log.w("ChatRepository", "Failed to create main session: ${e.message}")
|
||||
}
|
||||
|
||||
currentSessionId = sessionId
|
||||
preferencesDataStore.saveCurrentSessionId(sessionId)
|
||||
return sessionId
|
||||
}
|
||||
|
||||
override suspend fun loadMessagesFromServer(sessionId: String): List<Message> {
|
||||
currentSessionId = sessionId
|
||||
// Send history request via WebSocket
|
||||
return try {
|
||||
val response = apiService.getSessionMessages(sessionId)
|
||||
if (response.isSuccessful) {
|
||||
val messageDtos = response.body()?.messages ?: emptyList()
|
||||
val lastCleared = preferencesDataStore.lastClearedTimestamp.firstOrNull()
|
||||
?.toLongOrNull() ?: 0L
|
||||
val filteredDtos = messageDtos.filter { it.createdAt > lastCleared }
|
||||
ensureConversation(sessionId)
|
||||
filteredDtos.forEach { dto ->
|
||||
messageDao.upsert(
|
||||
MessageEntity(
|
||||
id = "db_${dto.id}",
|
||||
conversationId = sessionId,
|
||||
role = dto.role,
|
||||
content = dto.content,
|
||||
msgType = dto.msgType ?: "chat",
|
||||
timestamp = dto.createdAt,
|
||||
)
|
||||
)
|
||||
}
|
||||
RuntimeLog.http("loadMessages", "HTTP loaded ${filteredDtos.size} messages for session=$sessionId")
|
||||
filteredDtos.map { dto ->
|
||||
Message(
|
||||
id = "db_${dto.id}",
|
||||
conversationId = sessionId,
|
||||
role = dto.role,
|
||||
content = dto.content,
|
||||
msgType = dto.msgType ?: "chat",
|
||||
timestamp = dto.createdAt,
|
||||
)
|
||||
}.removeWrappingDuplicates()
|
||||
} else {
|
||||
RuntimeLog.http("loadMessages", "HTTP failed: ${response.code()} ${response.message()}, trying WS")
|
||||
requestHistoryViaWs(sessionId)
|
||||
emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
RuntimeLog.http("loadMessages", "HTTP error: ${e.message}, trying WS")
|
||||
requestHistoryViaWs(sessionId)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun requestHistoryViaWs(sessionId: String) {
|
||||
// Wait up to 5s for WS to connect
|
||||
if (!webSocketService.isConnected.value) {
|
||||
withTimeoutOrNull(5000) {
|
||||
webSocketService.isConnected.first { it }
|
||||
}
|
||||
}
|
||||
webSocketService.requestHistory(sessionId)
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
private suspend fun ensureConversation(sessionId: String, lastMessage: String = "") {
|
||||
@@ -181,6 +375,8 @@ class ChatRepositoryImpl(
|
||||
"stream_start" -> {
|
||||
streamingContent = ""
|
||||
streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}"
|
||||
_isAssistantStreaming.value = true
|
||||
RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId")
|
||||
}
|
||||
|
||||
"stream_chunk" -> {
|
||||
@@ -202,22 +398,28 @@ class ChatRepositoryImpl(
|
||||
streamingContent = ""
|
||||
streamingMessageId = null
|
||||
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
|
||||
currentSessionId = sid
|
||||
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
|
||||
changeSessionId(sid)
|
||||
}
|
||||
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
|
||||
|
||||
ensureConversation(sid, content)
|
||||
messageDao.upsert(
|
||||
MessageEntity(
|
||||
id = msgId,
|
||||
conversationId = sid,
|
||||
role = "assistant",
|
||||
content = content,
|
||||
msgType = "chat",
|
||||
timestamp = ts,
|
||||
if (content.isNotBlank()) {
|
||||
ensureConversation(sid, content)
|
||||
messageDao.upsert(
|
||||
MessageEntity(
|
||||
id = msgId,
|
||||
conversationId = sid,
|
||||
role = "assistant",
|
||||
content = content,
|
||||
msgType = "chat",
|
||||
timestamp = ts,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = "chat", timestamp = ts, isStreaming = false)
|
||||
emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = "chat", timestamp = ts, isStreaming = false, shouldNotify = true)
|
||||
_isAssistantStreaming.value = false
|
||||
RuntimeLog.chat("stream", "Stream end msgId=$msgId content=${content.take(80)}")
|
||||
}
|
||||
|
||||
"response" -> {
|
||||
@@ -226,7 +428,22 @@ class ChatRepositoryImpl(
|
||||
val replyMsgType = wsMsg.msgType ?: "chat"
|
||||
val msgId = wsMsg.messageId ?: "r_${System.currentTimeMillis()}"
|
||||
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
|
||||
currentSessionId = sid
|
||||
|
||||
// Suppress response if it wraps recently emitted review/multi_message items
|
||||
val timeSinceParsed = System.currentTimeMillis() - lastParsedTime
|
||||
if (timeSinceParsed < 3000 && recentParsedContents.isNotEmpty()) {
|
||||
val allContained = recentParsedContents.all { text.contains(it) }
|
||||
if (allContained) {
|
||||
RuntimeLog.chat("dedup", "Suppressed wrapping response, ${recentParsedContents.size} items already shown")
|
||||
recentParsedContents.clear()
|
||||
return
|
||||
}
|
||||
}
|
||||
recentParsedContents.clear()
|
||||
|
||||
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
|
||||
changeSessionId(sid)
|
||||
}
|
||||
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
|
||||
|
||||
ensureConversation(sid, text)
|
||||
@@ -241,18 +458,27 @@ class ChatRepositoryImpl(
|
||||
)
|
||||
)
|
||||
|
||||
emitMessage(id = msgId, sessionId = sid, role = role, content = text, msgType = replyMsgType, timestamp = ts, isStreaming = false)
|
||||
lastResponseId = msgId
|
||||
lastResponseContent = text
|
||||
lastResponseTime = System.currentTimeMillis()
|
||||
|
||||
emitMessage(id = msgId, sessionId = sid, role = role, content = text, msgType = replyMsgType, timestamp = ts, isStreaming = false, shouldNotify = true)
|
||||
RuntimeLog.chat("receive", "Response msgId=$msgId role=$role msgType=$replyMsgType content=${text.take(80)}")
|
||||
}
|
||||
|
||||
"review" -> {
|
||||
recentParsedContents.clear()
|
||||
wsMsg.reviewMessages?.forEach { review ->
|
||||
val text = review.content ?: review.text ?: return@forEach
|
||||
val role = review.role ?: "action"
|
||||
val rvMsgType = review.msgType ?: review.role ?: "action"
|
||||
val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}"
|
||||
|
||||
recentParsedContents.add(text)
|
||||
emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = text, msgType = rvMsgType, isStreaming = false)
|
||||
}
|
||||
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
|
||||
// Clean up wrapping response that arrived before this review
|
||||
cleanupWrappingResponse()
|
||||
}
|
||||
|
||||
"thinking" -> {
|
||||
@@ -285,6 +511,7 @@ class ChatRepositoryImpl(
|
||||
}
|
||||
|
||||
"error" -> {
|
||||
RuntimeLog.chat("error", "Server error: ${wsMsg.error ?: "未知错误"}")
|
||||
emitMessage(
|
||||
id = "err_${System.currentTimeMillis()}",
|
||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||
@@ -295,47 +522,102 @@ class ChatRepositoryImpl(
|
||||
)
|
||||
}
|
||||
|
||||
"voice_transcript" -> {
|
||||
val text = wsMsg.text ?: wsMsg.content ?: return
|
||||
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
|
||||
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
|
||||
val msgId = wsMsg.messageId ?: "vt_${System.currentTimeMillis()}"
|
||||
ensureConversation(sid)
|
||||
messageDao.upsert(
|
||||
MessageEntity(
|
||||
id = msgId,
|
||||
conversationId = sid,
|
||||
role = "user",
|
||||
content = text,
|
||||
msgType = "chat",
|
||||
timestamp = ts,
|
||||
)
|
||||
)
|
||||
emitMessage(id = msgId, sessionId = sid, role = "user", content = text, msgType = "chat", timestamp = ts, isStreaming = false)
|
||||
}
|
||||
|
||||
"history_response" -> {
|
||||
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
|
||||
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
|
||||
changeSessionId(sid)
|
||||
}
|
||||
ensureConversation(sid)
|
||||
wsMsg.messages?.forEach { hist ->
|
||||
val messages = wsMsg.messages ?: return
|
||||
val messageList = messages.map { hist ->
|
||||
val msgId = hist.id ?: "hist_${System.currentTimeMillis()}_${hist.hashCode()}"
|
||||
val role = hist.role ?: "system"
|
||||
val content = hist.content ?: ""
|
||||
val msgType = hist.msgType ?: "chat"
|
||||
val ts = hist.timestamp ?: System.currentTimeMillis()
|
||||
|
||||
Message(
|
||||
id = msgId,
|
||||
conversationId = sid,
|
||||
role = hist.role ?: "system",
|
||||
content = hist.content ?: "",
|
||||
msgType = hist.msgType ?: "chat",
|
||||
timestamp = hist.timestamp ?: System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
val deduped = messageList.removeWrappingDuplicates()
|
||||
deduped.forEach { msg ->
|
||||
messageDao.upsert(
|
||||
MessageEntity(
|
||||
id = msgId,
|
||||
conversationId = sid,
|
||||
role = role,
|
||||
content = content,
|
||||
msgType = msgType,
|
||||
timestamp = ts,
|
||||
id = msg.id,
|
||||
conversationId = msg.conversationId,
|
||||
role = msg.role,
|
||||
content = msg.content,
|
||||
msgType = msg.msgType,
|
||||
timestamp = msg.timestamp,
|
||||
)
|
||||
)
|
||||
|
||||
emitMessage(id = msgId, sessionId = sid, role = role, content = content, msgType = msgType, timestamp = ts, isStreaming = false)
|
||||
emitMessage(
|
||||
id = msg.id,
|
||||
sessionId = msg.conversationId,
|
||||
role = msg.role,
|
||||
content = msg.content,
|
||||
msgType = msg.msgType,
|
||||
timestamp = msg.timestamp,
|
||||
isStreaming = false,
|
||||
shouldNotify = false,
|
||||
)
|
||||
}
|
||||
RuntimeLog.chat("history", "Loaded ${deduped.size} messages from server history")
|
||||
}
|
||||
|
||||
"multi_message" -> {
|
||||
recentParsedContents.clear()
|
||||
wsMsg.multiMessages?.forEach { item ->
|
||||
val content = item.content ?: ""
|
||||
recentParsedContents.add(content)
|
||||
emitMessage(
|
||||
id = "mm_${System.currentTimeMillis()}_${item.hashCode()}",
|
||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||
role = item.role ?: "assistant",
|
||||
content = item.content ?: "",
|
||||
content = content,
|
||||
msgType = item.msgType ?: "chat",
|
||||
timestamp = wsMsg.timestamp ?: System.currentTimeMillis(),
|
||||
isStreaming = false,
|
||||
)
|
||||
}
|
||||
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
|
||||
cleanupWrappingResponse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun cleanupWrappingResponse() {
|
||||
val respId = lastResponseId ?: return
|
||||
val respContent = lastResponseContent ?: return
|
||||
val timeSinceResponse = System.currentTimeMillis() - lastResponseTime
|
||||
if (timeSinceResponse > 5000 || recentParsedContents.size < 2) return
|
||||
val allContained = recentParsedContents.all { respContent.contains(it) }
|
||||
if (allContained) {
|
||||
messageDao.deleteById(respId)
|
||||
RuntimeLog.chat("dedup", "Cleaned up wrapping response from DB id=$respId")
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitMessage(
|
||||
id: String,
|
||||
sessionId: String,
|
||||
@@ -344,8 +626,8 @@ class ChatRepositoryImpl(
|
||||
msgType: String,
|
||||
isStreaming: Boolean = false,
|
||||
timestamp: Long = System.currentTimeMillis(),
|
||||
shouldNotify: Boolean = false,
|
||||
) {
|
||||
// Skip messages with empty content to prevent empty bubbles
|
||||
if (content.isBlank() && msgType == "chat") return
|
||||
val message = Message(
|
||||
id = id,
|
||||
@@ -357,6 +639,33 @@ class ChatRepositoryImpl(
|
||||
isStreaming = isStreaming,
|
||||
)
|
||||
_incomingMessages.tryEmit(message)
|
||||
|
||||
if (shouldNotify && !isAppInForeground && role == "assistant" && !isStreaming) {
|
||||
onBackgroundNotification?.invoke(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove wrapper messages whose content contains the content of 2+ other messages.
|
||||
* This handles the case where the server sends both a combined "response" and
|
||||
* parsed "review"/"multi_message" items for the same turn.
|
||||
*/
|
||||
private fun List<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(
|
||||
|
||||
@@ -9,6 +9,7 @@ import top.yeij.cyrene.data.remote.ApiService
|
||||
import top.yeij.cyrene.data.remote.AuthInterceptor
|
||||
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
|
||||
import top.yeij.cyrene.data.remote.RetrofitClient
|
||||
import top.yeij.cyrene.data.remote.TokenAuthenticator
|
||||
import top.yeij.cyrene.data.repository.AuthRepositoryImpl
|
||||
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
|
||||
import top.yeij.cyrene.data.repository.IoTRepositoryImpl
|
||||
@@ -22,8 +23,13 @@ import top.yeij.cyrene.service.WebSocketService
|
||||
import top.yeij.cyrene.viewmodel.ChatViewModel
|
||||
import top.yeij.cyrene.viewmodel.IoTViewModel
|
||||
import top.yeij.cyrene.viewmodel.OverlayViewModel
|
||||
import top.yeij.cyrene.viewmodel.ProfileViewModel
|
||||
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||
import top.yeij.cyrene.util.VoiceRecorder
|
||||
import top.yeij.cyrene.voice.stt.BackendSttProvider
|
||||
import top.yeij.cyrene.voice.stt.DashScopeSttService
|
||||
import top.yeij.cyrene.voice.stt.SpeechRecognizer
|
||||
import top.yeij.cyrene.voice.stt.SttManager
|
||||
import top.yeij.cyrene.voice.tts.TextToSpeechEngine
|
||||
|
||||
val appModule = module {
|
||||
@@ -36,10 +42,11 @@ val appModule = module {
|
||||
single { get<AppDatabase>().conversationDao() }
|
||||
single { get<AppDatabase>().messageDao() }
|
||||
|
||||
// Network interceptors (no runBlocking — using @Volatile caches)
|
||||
// Network interceptors
|
||||
single { AuthInterceptor() }
|
||||
single { DynamicUrlInterceptor() }
|
||||
single { RetrofitClient.provideOkHttpClient(get(), get()) }
|
||||
single { TokenAuthenticator(get(), get(), get()) }
|
||||
single { RetrofitClient.provideOkHttpClient(get(), get(), get()) }
|
||||
single { RetrofitClient.provideRetrofit(get()) }
|
||||
single { get<retrofit2.Retrofit>().create(ApiService::class.java) }
|
||||
|
||||
@@ -47,12 +54,16 @@ val appModule = module {
|
||||
single { WebSocketService(get()) }
|
||||
|
||||
// Voice
|
||||
single { SpeechRecognizer() }
|
||||
single { VoiceRecorder(androidContext()) }
|
||||
single { SpeechRecognizer(androidContext()) }
|
||||
single { TextToSpeechEngine(androidContext()) }
|
||||
single { DashScopeSttService(androidContext()) }
|
||||
single { BackendSttProvider(androidContext(), get()) }
|
||||
single { SttManager(get(), get(), get()) }
|
||||
|
||||
// Repositories
|
||||
single<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()) }
|
||||
|
||||
// UseCases
|
||||
@@ -61,8 +72,9 @@ val appModule = module {
|
||||
factory { GetConversationsUseCase(get()) }
|
||||
|
||||
// ViewModels
|
||||
viewModel { ChatViewModel(get()) }
|
||||
viewModel { ChatViewModel(get(), get()) }
|
||||
viewModel { IoTViewModel(get()) }
|
||||
viewModel { OverlayViewModel(get()) }
|
||||
single { SettingsViewModel(get(), get(), get()) }
|
||||
viewModel { OverlayViewModel(get(), get(), get()) }
|
||||
viewModel { ProfileViewModel(get(), get(), get()) }
|
||||
single { SettingsViewModel(get(), get(), get(), get(), get()) }
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ import top.yeij.cyrene.domain.model.Message
|
||||
interface ChatRepository {
|
||||
|
||||
val connectionState: StateFlow<Boolean>
|
||||
val connectionError: StateFlow<String?>
|
||||
val isAssistantStreaming: StateFlow<Boolean>
|
||||
val messageClearEvents: Flow<Unit>
|
||||
var currentSessionId: String?
|
||||
|
||||
fun getConversations(): Flow<List<Conversation>>
|
||||
|
||||
@@ -17,8 +21,6 @@ interface ChatRepository {
|
||||
|
||||
suspend fun connectWebSocket(sessionId: String?)
|
||||
|
||||
suspend fun disconnectWebSocket()
|
||||
|
||||
suspend fun sendMessage(content: String, sessionId: String?)
|
||||
|
||||
fun observeMessages(): Flow<Message>
|
||||
@@ -26,4 +28,19 @@ interface ChatRepository {
|
||||
suspend fun loadConversationsFromServer()
|
||||
|
||||
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.os.Bundle
|
||||
import android.service.voice.VoiceInteractionService
|
||||
import android.util.Log
|
||||
import top.yeij.cyrene.MainActivity
|
||||
import top.yeij.cyrene.util.Constants
|
||||
|
||||
class CyreneVoiceInteractionService : VoiceInteractionService() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.i(TAG, "Service created")
|
||||
}
|
||||
|
||||
override fun onReady() {
|
||||
super.onReady()
|
||||
isActive = true
|
||||
getSharedPreferences(PREF_NAME, MODE_PRIVATE)
|
||||
.edit().putBoolean(KEY_WAS_ACTIVE, true).apply()
|
||||
Log.i(TAG, "Service ready")
|
||||
}
|
||||
|
||||
override fun onPrepareToShowSession(args: Bundle, showFlags: Int) {
|
||||
// Called before the session is shown — populate args for the session.
|
||||
// Starting from API 36, session creation is handled by the system
|
||||
// based on android:sessionService in voice_interaction_config.xml.
|
||||
Log.i(TAG, "onPrepareToShowSession")
|
||||
}
|
||||
|
||||
override fun onShowSessionFailed(args: Bundle) {
|
||||
// Session failed to show — could be due to permissions or system state.
|
||||
Log.e(TAG, "onShowSessionFailed")
|
||||
}
|
||||
|
||||
override fun onLaunchVoiceAssistFromKeyguard() {
|
||||
Log.i(TAG, "onLaunchVoiceAssistFromKeyguard")
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
putExtra(Constants.EXTRA_VOICE_ASSIST, true)
|
||||
@@ -34,10 +42,19 @@ class CyreneVoiceInteractionService : VoiceInteractionService() {
|
||||
override fun onShutdown() {
|
||||
isActive = false
|
||||
super.onShutdown()
|
||||
Log.i(TAG, "Service shutdown")
|
||||
}
|
||||
|
||||
companion object {
|
||||
var isActive: Boolean = false
|
||||
private set
|
||||
private const val TAG = "CyreneVIS"
|
||||
private const val PREF_NAME = "cyrene_assistant"
|
||||
private const val KEY_WAS_ACTIVE = "was_assistant_active"
|
||||
|
||||
fun wasEverActive(context: android.content.Context): Boolean {
|
||||
return context.getSharedPreferences(PREF_NAME, android.content.Context.MODE_PRIVATE)
|
||||
.getBoolean(KEY_WAS_ACTIVE, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,97 @@
|
||||
package top.yeij.cyrene.service
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.service.voice.VoiceInteractionSession
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||
import androidx.savedstate.SavedStateRegistry
|
||||
import androidx.savedstate.SavedStateRegistryController
|
||||
import androidx.savedstate.SavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import org.koin.core.context.GlobalContext
|
||||
import top.yeij.cyrene.MainActivity
|
||||
import top.yeij.cyrene.ui.overlay.OverlayContent
|
||||
import top.yeij.cyrene.ui.theme.CyreneTheme
|
||||
import top.yeij.cyrene.util.Constants
|
||||
import top.yeij.cyrene.voice.stt.SpeechRecognizer
|
||||
import top.yeij.cyrene.voice.tts.TextToSpeechEngine
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import top.yeij.cyrene.viewmodel.OverlayViewModel
|
||||
|
||||
class CyreneVoiceInteractionSession(context: Context) :
|
||||
VoiceInteractionSession(context) {
|
||||
VoiceInteractionSession(context), LifecycleOwner, SavedStateRegistryOwner {
|
||||
|
||||
private val speechRecognizer: SpeechRecognizer by lazy {
|
||||
GlobalContext.get().get()
|
||||
private val lifecycleRegistry = LifecycleRegistry(this)
|
||||
override val lifecycle: Lifecycle get() = lifecycleRegistry
|
||||
|
||||
private val savedStateRegistryController = SavedStateRegistryController.create(this)
|
||||
override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
|
||||
|
||||
// Resolved eagerly with fallback — lazy would silently crash composition on failure
|
||||
private var overlayViewModel: OverlayViewModel? = null
|
||||
private set
|
||||
|
||||
init {
|
||||
savedStateRegistryController.performAttach()
|
||||
savedStateRegistryController.performRestore(null)
|
||||
}
|
||||
private val ttsEngine: TextToSpeechEngine by lazy {
|
||||
GlobalContext.get().get()
|
||||
|
||||
private fun resolveViewModel(): OverlayViewModel? {
|
||||
return try {
|
||||
GlobalContext.get().get<OverlayViewModel>()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to resolve OverlayViewModel from Koin", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateContentView(): View {
|
||||
Log.d(TAG, "onCreateContentView called")
|
||||
RuntimeLog.general("overlay", "onCreateContentView")
|
||||
overlayViewModel = resolveViewModel()
|
||||
if (overlayViewModel == null) {
|
||||
Log.w(TAG, "ViewModel unavailable — overlay will be static")
|
||||
RuntimeLog.general("overlay", "ViewModel unavailable, overlay static")
|
||||
}
|
||||
|
||||
// Configure window: prevent IME resize, don't cover status bar
|
||||
try {
|
||||
val method = VoiceInteractionSession::class.java.getDeclaredMethod("getWindow")
|
||||
method.isAccessible = true
|
||||
val w = method.invoke(this) as? android.view.Window
|
||||
w?.apply {
|
||||
setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
|
||||
clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
|
||||
addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
|
||||
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||
val vm = overlayViewModel
|
||||
return ComposeView(context).apply {
|
||||
setViewTreeLifecycleOwner(this@CyreneVoiceInteractionSession)
|
||||
setViewTreeSavedStateRegistryOwner(this@CyreneVoiceInteractionSession)
|
||||
setContent {
|
||||
CyreneTheme {
|
||||
OverlayContent(
|
||||
onDismiss = { finish() },
|
||||
)
|
||||
if (vm != null) {
|
||||
OverlayContent(
|
||||
onDismiss = { finish() },
|
||||
onNavigateToMain = {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
},
|
||||
viewModel = vm,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,15 +99,24 @@ class CyreneVoiceInteractionSession(context: Context) :
|
||||
|
||||
override fun onShow(args: Bundle?, showFlags: Int) {
|
||||
super.onShow(args, showFlags)
|
||||
val startListening = args?.getBoolean(Constants.EXTRA_START_LISTENING, false) ?: false
|
||||
if (startListening) {
|
||||
speechRecognizer.startListening()
|
||||
RuntimeLog.general("overlay", "onShow, vm=${overlayViewModel != null}")
|
||||
lifecycleRegistry.currentState = Lifecycle.State.STARTED
|
||||
|
||||
val screenContent = CyreneAccessibilityService.getScreenContent()
|
||||
if (screenContent.isNotBlank()) {
|
||||
overlayViewModel?.sendScreenContext(screenContent)
|
||||
RuntimeLog.general("overlay", "Screen context sent, len=${screenContent.length}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onHide() {
|
||||
RuntimeLog.general("overlay", "onHide")
|
||||
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
|
||||
super.onHide()
|
||||
speechRecognizer.stopListening()
|
||||
ttsEngine.stop()
|
||||
overlayViewModel?.finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CyreneVIS-Session"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import top.yeij.cyrene.data.remote.dto.WSClientMessage
|
||||
import top.yeij.cyrene.data.remote.dto.WSServerMessage
|
||||
import java.net.URLEncoder
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class WebSocketService(
|
||||
private val preferencesDataStore: PreferencesDataStore,
|
||||
@@ -42,9 +43,10 @@ class WebSocketService(
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
private var heartbeatJob: Job? = null
|
||||
private var reconnecting = false
|
||||
private var reconnectJob: Job? = null
|
||||
private var shouldReconnect = true
|
||||
private var currentSessionId: String? = null
|
||||
private val connectionId = AtomicInteger(0)
|
||||
|
||||
private var clientId: String = ""
|
||||
private var deviceName: String = ""
|
||||
@@ -52,6 +54,9 @@ class WebSocketService(
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
|
||||
private val _connectionError = MutableStateFlow<String?>(null)
|
||||
val connectionError: StateFlow<String?> = _connectionError.asStateFlow()
|
||||
|
||||
private val _incomingMessages = MutableSharedFlow<WSServerMessage>(extraBufferCapacity = 64)
|
||||
val incomingMessages: SharedFlow<WSServerMessage> = _incomingMessages.asSharedFlow()
|
||||
|
||||
@@ -75,7 +80,6 @@ class WebSocketService(
|
||||
suspend fun connect(sessionId: String? = null) {
|
||||
currentSessionId = sessionId
|
||||
shouldReconnect = true
|
||||
reconnecting = false
|
||||
|
||||
initClientIdentity()
|
||||
|
||||
@@ -106,50 +110,74 @@ class WebSocketService(
|
||||
}
|
||||
|
||||
val url = urlBuilder.toString()
|
||||
Log.i(TAG, "Connecting to $url")
|
||||
val connId = connectionId.incrementAndGet()
|
||||
Log.i(TAG, "[#$connId] Connecting to $url")
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("User-Agent", "Cyrene-Android/${Build.MODEL ?: "Device"}")
|
||||
.build()
|
||||
|
||||
// Close previous socket silently
|
||||
try { webSocket?.close(1000, "Reconnecting") } catch (_: Exception) { }
|
||||
cancelHeartbeat()
|
||||
webSocket?.close(1000, "Reconnecting")
|
||||
|
||||
webSocket = httpClient.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
Log.i(TAG, "Connected")
|
||||
reconnecting = false
|
||||
if (connectionId.get() != connId) {
|
||||
Log.d(TAG, "[#$connId] onOpen ignored (stale)")
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "[#$connId] Connected")
|
||||
_isConnected.value = true
|
||||
_connectionError.value = null
|
||||
startHeartbeat()
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (connectionId.get() != connId) return
|
||||
try {
|
||||
val msg = gson.fromJson(text, WSServerMessage::class.java)
|
||||
_incomingMessages.tryEmit(msg)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse message: ${e.message}")
|
||||
Log.w(TAG, "[#$connId] Failed to parse message: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.i(TAG, "Server closing: code=$code reason=$reason")
|
||||
if (connectionId.get() != connId) return
|
||||
Log.i(TAG, "[#$connId] Server closing: code=$code reason=$reason")
|
||||
_isConnected.value = false
|
||||
cancelHeartbeat()
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.i(TAG, "Closed: code=$code reason=$reason")
|
||||
if (connectionId.get() != connId) return
|
||||
Log.i(TAG, "[#$connId] Closed: code=$code reason=$reason")
|
||||
_isConnected.value = false
|
||||
cancelHeartbeat()
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
Log.e(TAG, "Connection failure: ${t.message} (response=${response?.code})", t)
|
||||
if (connectionId.get() != connId) return
|
||||
val httpCode = response?.code
|
||||
Log.e(TAG, "[#$connId] Failure: ${t.message} (http=$httpCode)", t)
|
||||
_isConnected.value = false
|
||||
cancelHeartbeat()
|
||||
scheduleReconnect()
|
||||
|
||||
val errorMsg = when (httpCode) {
|
||||
403 -> {
|
||||
Log.e(TAG, "[#$connId] WebSocket 403: 仅管理员用户可连接。请使用管理员账户登录。")
|
||||
"仅管理员用户可连接"
|
||||
}
|
||||
401 -> "认证失败,请重新登录"
|
||||
else -> null
|
||||
}
|
||||
if (errorMsg != null) {
|
||||
_connectionError.value = errorMsg
|
||||
}
|
||||
// onClosed will always follow, which triggers scheduleReconnect
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -179,7 +207,12 @@ class WebSocketService(
|
||||
|
||||
fun requestHistory(sessionId: String?) {
|
||||
val msg = buildMessage("history", sessionId)
|
||||
webSocket?.send(gson.toJson(msg))
|
||||
if (webSocket != null) {
|
||||
webSocket?.send(gson.toJson(msg))
|
||||
Log.i(TAG, "History requested for session=$sessionId")
|
||||
} else {
|
||||
Log.w(TAG, "Cannot request history: WebSocket is null")
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPing() {
|
||||
@@ -187,11 +220,44 @@ class WebSocketService(
|
||||
webSocket?.send(gson.toJson(msg))
|
||||
}
|
||||
|
||||
fun sendScreenContext(content: String, sessionId: String? = null) {
|
||||
val msg = buildMessage("message", sessionId, mode = "text", content = content)
|
||||
webSocket?.send(gson.toJson(msg))
|
||||
}
|
||||
|
||||
fun sendVoiceInput(audioBase64: String, sessionId: String? = null, mode: String = "voice_msg") {
|
||||
val msg = WSClientMessage(
|
||||
type = "voice_input",
|
||||
sessionId = sessionId ?: currentSessionId,
|
||||
mode = mode,
|
||||
audioData = audioBase64,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
clientId = clientId.ifBlank { null },
|
||||
deviceName = deviceName.ifBlank { null },
|
||||
userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}",
|
||||
)
|
||||
webSocket?.send(gson.toJson(msg))
|
||||
}
|
||||
|
||||
fun forceReconnect() {
|
||||
shouldReconnect = true
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
scope.launch {
|
||||
if (!_isConnected.value) {
|
||||
try {
|
||||
connect(currentSessionId)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
shouldReconnect = false
|
||||
reconnecting = false
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
cancelHeartbeat()
|
||||
webSocket?.close(1000, "User disconnected")
|
||||
try { webSocket?.close(1000, "User disconnected") } catch (_: Exception) { }
|
||||
webSocket = null
|
||||
_isConnected.value = false
|
||||
}
|
||||
@@ -214,13 +280,15 @@ class WebSocketService(
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (reconnecting || !shouldReconnect) return
|
||||
reconnecting = true
|
||||
scope.launch {
|
||||
if (reconnectJob?.isActive == true || !shouldReconnect) return
|
||||
reconnectJob = scope.launch {
|
||||
var attempt = 0
|
||||
while (attempt < 5 && shouldReconnect && !_isConnected.value) {
|
||||
val delayMs = (Math.pow(2.0, attempt.toDouble()) * 1000).toLong()
|
||||
Log.i(TAG, "Reconnecting in ${delayMs}ms (attempt ${attempt + 1}/5)")
|
||||
while (shouldReconnect && !_isConnected.value) {
|
||||
val delayMs = minOf(
|
||||
(Math.pow(2.0, attempt.toDouble()) * 1000).toLong(),
|
||||
30_000L
|
||||
)
|
||||
Log.i(TAG, "Reconnecting in ${delayMs}ms (attempt ${attempt + 1})")
|
||||
delay(delayMs)
|
||||
attempt++
|
||||
if (shouldReconnect && !_isConnected.value) {
|
||||
@@ -231,7 +299,7 @@ class WebSocketService(
|
||||
}
|
||||
}
|
||||
}
|
||||
reconnecting = false
|
||||
Log.i(TAG, "Reconnect loop ended (connected=${_isConnected.value}, shouldReconnect=$shouldReconnect)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ private fun ChatMessageBubble(
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
shadowElevation = 2.dp,
|
||||
modifier = Modifier.widthIn(max = 300.dp),
|
||||
) {
|
||||
Text(
|
||||
@@ -91,7 +92,7 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
Text(
|
||||
text = content,
|
||||
@@ -99,7 +100,7 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.filled.DevicesOther
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -20,13 +22,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import org.koin.compose.koinInject
|
||||
import top.yeij.cyrene.ui.screens.chat.ChatScreen
|
||||
import top.yeij.cyrene.ui.screens.iot.IoTScreen
|
||||
import top.yeij.cyrene.ui.screens.login.LoginScreen
|
||||
import top.yeij.cyrene.ui.screens.about.AboutScreen
|
||||
import top.yeij.cyrene.ui.screens.profile.ProfileScreen
|
||||
import top.yeij.cyrene.ui.screens.settings.SettingsScreen
|
||||
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||
|
||||
object Routes {
|
||||
const val LOGIN = "login"
|
||||
@@ -34,6 +35,7 @@ object Routes {
|
||||
const val CHAT = "chat"
|
||||
const val IOT = "iot"
|
||||
const val SETTINGS = "settings"
|
||||
const val ABOUT = "about"
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -70,6 +72,12 @@ fun CyreneNavGraph(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.ABOUT) {
|
||||
AboutScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,8 +93,6 @@ fun MainScreen(
|
||||
isDefaultAssistant: Boolean,
|
||||
onOpenAssistantSettings: () -> Unit,
|
||||
) {
|
||||
val settingsViewModel: SettingsViewModel = koinInject()
|
||||
|
||||
val items = listOf(
|
||||
BottomNavItem(
|
||||
label = "对话",
|
||||
@@ -107,28 +113,30 @@ fun MainScreen(
|
||||
|
||||
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
items.forEachIndexed { index, item ->
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
icon = item.icon,
|
||||
label = { Text(item.label) },
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding(),
|
||||
) {
|
||||
NavigationRail {
|
||||
items.forEachIndexed { index, item ->
|
||||
NavigationRailItem(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
icon = item.icon,
|
||||
label = { Text(item.label) },
|
||||
)
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Box(modifier = Modifier.padding(padding)) {
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||
when (selectedTab) {
|
||||
0 -> ChatScreen()
|
||||
1 -> IoTScreen()
|
||||
2 -> ProfileScreen(
|
||||
onNavigateToSettings = { navController.navigate(Routes.SETTINGS) },
|
||||
onNavigateToAbout = { navController.navigate(Routes.ABOUT) },
|
||||
onLogout = {
|
||||
settingsViewModel.logout()
|
||||
navController.navigate(Routes.LOGIN) {
|
||||
popUpTo(Routes.MAIN) { inclusive = true }
|
||||
}
|
||||
@@ -136,6 +144,8 @@ fun MainScreen(
|
||||
onNavigateToLogin = {
|
||||
navController.navigate(Routes.LOGIN)
|
||||
},
|
||||
isDefaultAssistant = isDefaultAssistant,
|
||||
onOpenAssistantSettings = onOpenAssistantSettings,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +1,135 @@
|
||||
package top.yeij.cyrene.ui.overlay
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardVoice
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import org.koin.compose.koinInject
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
import top.yeij.cyrene.ui.components.ChatBubble
|
||||
import top.yeij.cyrene.ui.components.CyreneStatus
|
||||
import top.yeij.cyrene.ui.components.StatusIndicator
|
||||
import top.yeij.cyrene.ui.components.TypingIndicator
|
||||
import top.yeij.cyrene.util.RecordState
|
||||
import top.yeij.cyrene.viewmodel.OverlayState
|
||||
import top.yeij.cyrene.viewmodel.OverlayViewModel
|
||||
import kotlin.math.min
|
||||
|
||||
@Composable
|
||||
private fun AnimatedChatBubble(
|
||||
message: Message,
|
||||
animIndex: Int,
|
||||
) {
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
delay(min(animIndex, 10) * 60L)
|
||||
visible = true
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(animationSpec = androidx.compose.animation.core.tween(300)) +
|
||||
slideInVertically(
|
||||
animationSpec = androidx.compose.animation.core.tween(300),
|
||||
initialOffsetY = { it / 4 },
|
||||
),
|
||||
) {
|
||||
ChatBubble(
|
||||
content = message.content,
|
||||
role = message.role,
|
||||
msgType = message.msgType,
|
||||
timestamp = message.timestamp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OverlayContent(
|
||||
onDismiss: () -> Unit,
|
||||
onNavigateToMain: () -> Unit,
|
||||
viewModel: OverlayViewModel = koinInject(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val messages by viewModel.messages.collectAsState()
|
||||
val recognizedText by viewModel.recognizedText.collectAsState()
|
||||
val inputText by viewModel.inputText.collectAsState()
|
||||
val recordState by viewModel.voiceRecordState.collectAsState()
|
||||
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
|
||||
val animIndex by viewModel.messageAnimIndex.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
val isProcessing = state == OverlayState.PROCESSING
|
||||
val recordSec = recordDurationMs / 1000f
|
||||
val isRecording = recordState == RecordState.RECORDING
|
||||
val isLocked = recordState == RecordState.LOCKED
|
||||
|
||||
// Animated "昔涟正在输入..." dots
|
||||
val typingDots = remember { mutableStateOf("") }
|
||||
LaunchedEffect(isProcessing) {
|
||||
if (isProcessing) {
|
||||
val dots = arrayOf("", ".", "..", "...")
|
||||
var i = 0
|
||||
while (true) {
|
||||
typingDots.value = dots[i % 4]
|
||||
i++
|
||||
delay(400)
|
||||
}
|
||||
} else {
|
||||
typingDots.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
val configuration = LocalConfiguration.current
|
||||
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) {
|
||||
@@ -79,124 +152,430 @@ fun OverlayContent(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f))
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
) { onDismiss() },
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
) { /* consume click */ },
|
||||
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp),
|
||||
shadowElevation = 8.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
StatusIndicator(
|
||||
status = when (state) {
|
||||
OverlayState.LISTENING -> CyreneStatus.ONLINE
|
||||
OverlayState.PROCESSING -> CyreneStatus.THINKING
|
||||
OverlayState.SPEAKING -> CyreneStatus.SPEAKING
|
||||
OverlayState.WAITING -> CyreneStatus.ONLINE
|
||||
OverlayState.IDLE -> CyreneStatus.ONLINE
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = { onDismiss() }) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "关闭")
|
||||
}
|
||||
}
|
||||
|
||||
// Messages
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, fill = false)
|
||||
.height(200.dp),
|
||||
state = listState,
|
||||
) {
|
||||
items(messages, key = { it.id }) { message ->
|
||||
ChatBubble(
|
||||
content = message.content,
|
||||
role = message.role,
|
||||
msgType = message.msgType,
|
||||
timestamp = message.timestamp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Recognized text display
|
||||
if (recognizedText.isNotEmpty()) {
|
||||
Text(
|
||||
text = recognizedText,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Action button
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
when (state) {
|
||||
OverlayState.LISTENING -> {
|
||||
viewModel.onSpeechFinal(recognizedText)
|
||||
}
|
||||
OverlayState.WAITING -> {
|
||||
viewModel.startListening()
|
||||
}
|
||||
else -> { }
|
||||
}
|
||||
},
|
||||
shape = CircleShape,
|
||||
modifier = Modifier.size(64.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = when (state) {
|
||||
OverlayState.LISTENING -> MaterialTheme.colorScheme.error
|
||||
OverlayState.PROCESSING -> MaterialTheme.colorScheme.secondary
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
},
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Mic,
|
||||
contentDescription = "语音",
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = when (state) {
|
||||
OverlayState.IDLE -> ""
|
||||
OverlayState.LISTENING -> "我在听…"
|
||||
OverlayState.PROCESSING -> "思考中…"
|
||||
OverlayState.SPEAKING -> "正在说话…"
|
||||
OverlayState.WAITING -> "点击继续说话"
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (isLandscape) {
|
||||
LandscapeContent(
|
||||
state = state,
|
||||
messages = messages,
|
||||
inputText = inputText,
|
||||
isProcessing = isProcessing,
|
||||
listState = listState,
|
||||
recordSec = recordSec,
|
||||
isRecording = isRecording,
|
||||
isLocked = isLocked,
|
||||
typingDots = typingDots.value,
|
||||
animIndex = animIndex,
|
||||
onDismiss = onDismiss,
|
||||
onNavigateToMain = onNavigateToMain,
|
||||
viewModel = viewModel,
|
||||
)
|
||||
} else {
|
||||
PortraitContent(
|
||||
state = state,
|
||||
messages = messages,
|
||||
inputText = inputText,
|
||||
isProcessing = isProcessing,
|
||||
listState = listState,
|
||||
recordSec = recordSec,
|
||||
isRecording = isRecording,
|
||||
isLocked = isLocked,
|
||||
typingDots = typingDots.value,
|
||||
animIndex = animIndex,
|
||||
onDismiss = onDismiss,
|
||||
onNavigateToMain = onNavigateToMain,
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
private fun PortraitContent(
|
||||
state: OverlayState,
|
||||
messages: List<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(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
) { /* consume clicks */ },
|
||||
) {
|
||||
// Messages + top bar stay fixed at top
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
MessageTopBar(onDismiss = onDismiss, onNavigateToMain = onNavigateToMain)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
state = listState,
|
||||
) {
|
||||
if (messages.isNotEmpty()) {
|
||||
items(messages, key = { it.id }) { message ->
|
||||
AnimatedChatBubble(
|
||||
message = message,
|
||||
animIndex = animIndex[message.id] ?: 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isProcessing) {
|
||||
item(key = "typing_indicator") {
|
||||
TypingIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Input area at bottom, imePadding pushes it above full-screen IME
|
||||
InputArea(
|
||||
state = state,
|
||||
inputText = inputText,
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.imePadding(),
|
||||
recordSec = recordSec,
|
||||
isRecording = isRecording,
|
||||
isLocked = isLocked,
|
||||
typingDots = typingDots,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
private fun LandscapeContent(
|
||||
state: OverlayState,
|
||||
messages: List<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(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
) { /* consume clicks */ },
|
||||
) {
|
||||
// Messages + top bar on the left, stay fixed
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
MessageTopBar(onDismiss = onDismiss, onNavigateToMain = onNavigateToMain)
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
state = listState,
|
||||
) {
|
||||
if (messages.isNotEmpty()) {
|
||||
items(messages, key = { it.id }) { message ->
|
||||
AnimatedChatBubble(
|
||||
message = message,
|
||||
animIndex = animIndex[message.id] ?: 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isProcessing) {
|
||||
item(key = "typing_indicator") {
|
||||
TypingIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
InputArea(
|
||||
state = state,
|
||||
inputText = inputText,
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.imePadding(),
|
||||
recordSec = recordSec,
|
||||
isRecording = isRecording,
|
||||
isLocked = isLocked,
|
||||
typingDots = typingDots,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageTopBar(
|
||||
onDismiss: () -> Unit,
|
||||
onNavigateToMain: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.3f))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "关闭")
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = onNavigateToMain) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = "进入主界面",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InputArea(
|
||||
state: OverlayState,
|
||||
inputText: String,
|
||||
viewModel: OverlayViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
recordSec: Float = 0f,
|
||||
isRecording: Boolean = false,
|
||||
isLocked: Boolean = false,
|
||||
typingDots: String = "",
|
||||
) {
|
||||
// Gesture tracking state — local to InputArea
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
var dragOffsetX by remember { mutableStateOf(0f) }
|
||||
var dragOffsetY by remember { mutableStateOf(0f) }
|
||||
val inCancelZone = isDragging && dragOffsetY < -120f
|
||||
val inLockZone = isDragging && dragOffsetX > 60f
|
||||
val isProcessing = state == OverlayState.PROCESSING
|
||||
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
shadowElevation = 8.dp,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
) {
|
||||
// "昔涟正在输入..." indicator
|
||||
if (isProcessing && typingDots.isNotEmpty()) {
|
||||
Text(
|
||||
text = "昔涟正在输入$typingDots",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 4.dp),
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isRecording && isDragging) {
|
||||
// Recording with drag — show recording indicator
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
if (inCancelZone) MaterialTheme.colorScheme.errorContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = when {
|
||||
inCancelZone -> "松手取消"
|
||||
inLockZone -> "松手录音"
|
||||
else -> "%.1f\" 上滑取消 右滑松手".format(recordSec)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (inCancelZone) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.offset { IntOffset(dragOffsetX.toInt(), dragOffsetY.toInt()) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Mic,
|
||||
contentDescription = "录音中",
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
} else if (isLocked) {
|
||||
// Locked (hands-free) mode
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "%.1f\" 松手录音中 — 点击结束".format(recordSec),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { viewModel.finishRecord() }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "发送",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Normal input mode
|
||||
OutlinedTextField(
|
||||
value = inputText,
|
||||
onValueChange = { viewModel.onInputChanged(it) },
|
||||
placeholder = { Text("输入消息...") },
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 3,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
if (inputText.isNotBlank()) {
|
||||
IconButton(
|
||||
onClick = { viewModel.sendText() },
|
||||
enabled = !isProcessing,
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "发送",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { _ ->
|
||||
isDragging = true
|
||||
dragOffsetX = 0f
|
||||
dragOffsetY = 0f
|
||||
viewModel.startRecord()
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffsetX += dragAmount.x
|
||||
dragOffsetY += dragAmount.y
|
||||
},
|
||||
onDragEnd = {
|
||||
isDragging = false
|
||||
when {
|
||||
dragOffsetY < -120f -> viewModel.cancelRecord()
|
||||
dragOffsetX > 60f -> viewModel.lockRecord()
|
||||
else -> viewModel.finishRecord()
|
||||
}
|
||||
dragOffsetX = 0f
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
isDragging = false
|
||||
viewModel.cancelRecord()
|
||||
dragOffsetX = 0f
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.KeyboardVoice,
|
||||
contentDescription = "按住录音",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status hint
|
||||
val hint = when {
|
||||
isLocked -> ""
|
||||
isRecording && isDragging -> ""
|
||||
else -> when (state) {
|
||||
OverlayState.LISTENING -> "我在听..."
|
||||
OverlayState.PROCESSING -> "思考中..."
|
||||
OverlayState.SPEAKING -> "正在说话..."
|
||||
OverlayState.WAITING -> "长按麦克风开始说话"
|
||||
OverlayState.IDLE -> ""
|
||||
}
|
||||
}
|
||||
if (hint.isNotEmpty()) {
|
||||
Text(
|
||||
text = hint,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.KeyboardVoice
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.koin.compose.koinInject
|
||||
import kotlinx.coroutines.delay
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
import top.yeij.cyrene.ui.components.ChatBubble
|
||||
import top.yeij.cyrene.ui.components.CyreneStatus
|
||||
import top.yeij.cyrene.ui.components.StatusIndicator
|
||||
import top.yeij.cyrene.ui.components.TypingIndicator
|
||||
import top.yeij.cyrene.util.RecordState
|
||||
import top.yeij.cyrene.viewmodel.ChatViewModel
|
||||
import kotlin.math.min
|
||||
|
||||
@Composable
|
||||
private fun AnimatedChatBubble(
|
||||
message: Message,
|
||||
animIndex: Int,
|
||||
) {
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
delay(min(animIndex, 10) * 60L)
|
||||
visible = true
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(animationSpec = androidx.compose.animation.core.tween(300)) +
|
||||
slideInVertically(
|
||||
animationSpec = androidx.compose.animation.core.tween(300),
|
||||
initialOffsetY = { it / 4 },
|
||||
),
|
||||
) {
|
||||
ChatBubble(
|
||||
content = message.content,
|
||||
role = message.role,
|
||||
msgType = message.msgType,
|
||||
timestamp = message.timestamp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
viewModel: ChatViewModel = koinInject(),
|
||||
viewModel: ChatViewModel = koinViewModel(),
|
||||
) {
|
||||
val messages by viewModel.currentMessages.collectAsState()
|
||||
val inputText by viewModel.inputText.collectAsState()
|
||||
val isStreaming by viewModel.isStreaming.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val isRefreshing by viewModel.isRefreshing.collectAsState()
|
||||
val recordState by viewModel.voiceRecordState.collectAsState()
|
||||
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
|
||||
val animIndex by viewModel.messageAnimIndex.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// Gesture tracking state
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
var dragOffsetX by remember { mutableStateOf(0f) }
|
||||
var dragOffsetY by remember { mutableStateOf(0f) }
|
||||
var recordButtonY by remember { mutableStateOf(0f) }
|
||||
|
||||
val recordSec = recordDurationMs / 1000f
|
||||
val isRecording = recordState == RecordState.RECORDING
|
||||
val isLocked = recordState == RecordState.LOCKED
|
||||
val inCancelZone = isDragging && dragOffsetY < -120f
|
||||
val inLockZone = isDragging && dragOffsetX > 60f
|
||||
|
||||
LaunchedEffect(messages.size, isStreaming) {
|
||||
if (messages.isNotEmpty()) {
|
||||
val targetIndex = if (isStreaming) messages.size else messages.size - 1
|
||||
@@ -58,6 +123,22 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Animated "昔涟正在输入..." dots
|
||||
val typingDots = remember { mutableStateOf("") }
|
||||
LaunchedEffect(isStreaming) {
|
||||
if (isStreaming) {
|
||||
val dots = arrayOf("", ".", "..", "...")
|
||||
var i = 0
|
||||
while (true) {
|
||||
typingDots.value = dots[i % 4]
|
||||
i++
|
||||
delay(400)
|
||||
}
|
||||
} else {
|
||||
typingDots.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
val status = when {
|
||||
isStreaming -> CyreneStatus.THINKING
|
||||
isConnected -> CyreneStatus.ONLINE
|
||||
@@ -76,122 +157,214 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
Row(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = inputText,
|
||||
onValueChange = { viewModel.onInputChanged(it) },
|
||||
placeholder = { Text("输入消息...") },
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 4,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
IconButton(
|
||||
onClick = { viewModel.sendMessage() },
|
||||
enabled = inputText.isNotBlank() && !isStreaming,
|
||||
// "昔涟正在输入..." indicator
|
||||
if (isStreaming) {
|
||||
Text(
|
||||
text = "昔涟正在输入${typingDots.value}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 2.dp),
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isStreaming) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
if (isRecording && isDragging) {
|
||||
// Recording state with drag — show recording indicator
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
if (inCancelZone) MaterialTheme.colorScheme.errorContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = when {
|
||||
inCancelZone -> "松手取消"
|
||||
inLockZone -> "松手录音"
|
||||
else -> "%.1f\" 上滑取消 右滑松手".format(recordSec)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (inCancelZone) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// Record button (drag anchor)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.offset { IntOffset(dragOffsetX.toInt(), dragOffsetY.toInt()) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Mic,
|
||||
contentDescription = "录音中",
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
} else if (isLocked) {
|
||||
// Locked (hands-free) mode
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "%.1f\" 松手录音中 — 点击结束".format(recordSec),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { viewModel.finishRecord() }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "发送",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送")
|
||||
// Normal input mode
|
||||
OutlinedTextField(
|
||||
value = inputText,
|
||||
onValueChange = { viewModel.onInputChanged(it) },
|
||||
placeholder = { Text("输入消息...") },
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 4,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
// Voice record button with long-press gesture
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 4.dp)
|
||||
.size(48.dp)
|
||||
.onGloballyPositioned { recordButtonY = it.positionInRoot().y }
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { offset ->
|
||||
isDragging = true
|
||||
dragOffsetX = 0f
|
||||
dragOffsetY = 0f
|
||||
viewModel.startRecord()
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffsetX += dragAmount.x
|
||||
dragOffsetY += dragAmount.y
|
||||
},
|
||||
onDragEnd = {
|
||||
isDragging = false
|
||||
when {
|
||||
dragOffsetY < -120f -> viewModel.cancelRecord()
|
||||
dragOffsetX > 60f -> viewModel.lockRecord()
|
||||
else -> viewModel.finishRecord()
|
||||
}
|
||||
dragOffsetX = 0f
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
isDragging = false
|
||||
viewModel.cancelRecord()
|
||||
dragOffsetX = 0f
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.KeyboardVoice,
|
||||
contentDescription = "按住录音",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// Send button (only when text present)
|
||||
if (inputText.isNotBlank()) {
|
||||
IconButton(
|
||||
onClick = { viewModel.sendMessage() },
|
||||
enabled = !isStreaming,
|
||||
) {
|
||||
if (isStreaming) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
if (messages.isEmpty() && !isStreaming) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "开始和昔涟对话吧",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
state = listState,
|
||||
) {
|
||||
items(messages, key = { it.id }) { message ->
|
||||
ChatBubble(
|
||||
content = message.content,
|
||||
role = message.role,
|
||||
msgType = message.msgType,
|
||||
timestamp = message.timestamp,
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = { viewModel.refreshMessages() },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
if (messages.isEmpty() && !isStreaming) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "开始和昔涟对话吧",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (isStreaming) {
|
||||
item(key = "typing_indicator") {
|
||||
TypingIndicator()
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
) {
|
||||
items(messages, key = { it.id }) { message ->
|
||||
AnimatedChatBubble(
|
||||
message = message,
|
||||
animIndex = animIndex[message.id] ?: 0,
|
||||
)
|
||||
}
|
||||
if (isStreaming) {
|
||||
item(key = "typing_indicator") {
|
||||
TypingIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TypingIndicator(modifier: Modifier = Modifier) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "typing")
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "昔涟正在输入",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
repeat(3) { index ->
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.2f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(
|
||||
durationMillis = 400,
|
||||
delayMillis = index * 200,
|
||||
),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "dot_$index",
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(5.dp)
|
||||
.alpha(alpha)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
CircleShape,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,14 +16,14 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import top.yeij.cyrene.ui.components.DeviceCard
|
||||
import top.yeij.cyrene.viewmodel.IoTViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun IoTScreen(
|
||||
viewModel: IoTViewModel = koinInject(),
|
||||
viewModel: IoTViewModel = koinViewModel(),
|
||||
) {
|
||||
val devices by viewModel.devices.collectAsState()
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
|
||||
@@ -1,133 +1,389 @@
|
||||
package top.yeij.cyrene.ui.screens.profile
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ExitToApp
|
||||
import androidx.compose.material.icons.automirrored.filled.Help
|
||||
import androidx.compose.material.icons.filled.AdminPanelSettings
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Tag
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.koin.compose.koinInject
|
||||
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||
import top.yeij.cyrene.viewmodel.ProfileViewModel
|
||||
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
onNavigateToSettings: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onNavigateToLogin: () -> Unit,
|
||||
settingsViewModel: SettingsViewModel = koinInject(),
|
||||
onNavigateToAbout: () -> Unit = {},
|
||||
isDefaultAssistant: Boolean = false,
|
||||
onOpenAssistantSettings: () -> Unit = {},
|
||||
profileViewModel: ProfileViewModel = koinInject(),
|
||||
) {
|
||||
val username by settingsViewModel.username.collectAsState()
|
||||
val isLoggedIn by settingsViewModel.isLoggedIn.collectAsState()
|
||||
val profile by profileViewModel.profile.collectAsState()
|
||||
var showLogoutDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
LaunchedEffect(Unit) {
|
||||
profileViewModel.fetchFreshProfile()
|
||||
}
|
||||
|
||||
if (showLogoutDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showLogoutDialog = false },
|
||||
title = { Text("退出登录") },
|
||||
text = { Text("确定要退出登录吗?") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showLogoutDialog = false
|
||||
profileViewModel.logout()
|
||||
onLogout()
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Text("退出")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showLogoutDialog = false }) {
|
||||
Text("取消")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
// Profile header
|
||||
item {
|
||||
Column(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// Avatar
|
||||
val initials = if (profile.nickname.isNotBlank()) {
|
||||
profile.nickname.take(1)
|
||||
} else if (profile.username.isNotBlank()) {
|
||||
profile.username.take(1)
|
||||
} else {
|
||||
"?"
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
.size(80.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = if (isLoggedIn) username.ifEmpty { "开拓者" } else "未登录",
|
||||
text = initials,
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (profile.isLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
} else if (!profile.isLoggedIn) {
|
||||
Text(
|
||||
text = "未登录",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = if (!isLoggedIn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||
modifier = if (!isLoggedIn) {
|
||||
Modifier.clickable { onNavigateToLogin() }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable { onNavigateToLogin() },
|
||||
)
|
||||
Text(
|
||||
text = "与昔涟同行",
|
||||
text = "点击登录以查看个人信息",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
// Nickname
|
||||
Text(
|
||||
text = profile.nickname.ifEmpty { profile.username },
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
// Username
|
||||
if (profile.nickname.isNotBlank() && profile.nickname != profile.username) {
|
||||
Text(
|
||||
text = "@${profile.username}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// Admin badge
|
||||
if (profile.isAdmin) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.AdminPanelSettings,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "管理员",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
item { Spacer(modifier = Modifier.height(8.dp)) }
|
||||
HorizontalDivider()
|
||||
|
||||
// Settings
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("设置") },
|
||||
leadingContent = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
||||
trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
|
||||
modifier = Modifier.clickable { onNavigateToSettings() },
|
||||
if (profile.isLoggedIn) {
|
||||
// User info card
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "账号信息",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
ProfileInfoCard(
|
||||
items = listOf(
|
||||
ProfileInfoItem(Icons.Filled.Tag, "用户 ID", profile.userId),
|
||||
ProfileInfoItem(Icons.Filled.CalendarMonth, "注册时间", profile.createdAt.ifEmpty { "未知" }),
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
// Reminders
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("提醒") },
|
||||
leadingContent = { Icon(Icons.Filled.Notifications, contentDescription = null) },
|
||||
trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Assistant status
|
||||
Text(
|
||||
text = "助手",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = if (isDefaultAssistant)
|
||||
Color(0xFF4CAF50).copy(alpha = 0.1f)
|
||||
else
|
||||
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Circle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(10.dp),
|
||||
tint = if (isDefaultAssistant) Color(0xFF4CAF50) else Color(0xFFFF5722),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = if (isDefaultAssistant) "已设为默认助手" else "未设为默认助手",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
if (!isDefaultAssistant) {
|
||||
Text(
|
||||
text = "设为默认助手后,长按电源键或Home键即可呼出昔涟",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!isDefaultAssistant) {
|
||||
TextButton(onClick = onOpenAssistantSettings) {
|
||||
Text("去设置")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) }
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// About
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("关于") },
|
||||
leadingContent = { Icon(Icons.Filled.Info, contentDescription = null) },
|
||||
supportingContent = { Text("Cyrene v0.1.0") },
|
||||
)
|
||||
}
|
||||
// Menu items
|
||||
Text(
|
||||
text = "其他",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
// Help
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("使用帮助") },
|
||||
leadingContent = { Icon(Icons.AutoMirrored.Filled.Help, contentDescription = null) },
|
||||
)
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = { Text("设置") },
|
||||
leadingContent = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
||||
trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
|
||||
modifier = Modifier.clickable { onNavigateToSettings() },
|
||||
)
|
||||
|
||||
item { Spacer(modifier = Modifier.height(24.dp)) }
|
||||
ListItem(
|
||||
headlineContent = { Text("提醒") },
|
||||
leadingContent = { Icon(Icons.Filled.Notifications, contentDescription = null) },
|
||||
trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("关于") },
|
||||
leadingContent = { Icon(Icons.Filled.Info, contentDescription = null) },
|
||||
supportingContent = { Text("Cyrene v0.1.0") },
|
||||
modifier = Modifier.clickable { onNavigateToAbout() },
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("使用帮助") },
|
||||
leadingContent = { Icon(Icons.AutoMirrored.Filled.Help, contentDescription = null) },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Logout
|
||||
if (isLoggedIn) {
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
if (profile.isLoggedIn) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = "退出登录",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ExitToApp,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { showLogoutDialog = true },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
private data class ProfileInfoItem(
|
||||
val icon: ImageVector,
|
||||
val label: String,
|
||||
val value: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun ProfileInfoCard(items: List<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 = "退出登录",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ExitToApp,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
Text(
|
||||
text = item.value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { onLogout() },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (index < items.size - 1) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,16 @@ package top.yeij.cyrene.ui.screens.settings
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
@@ -16,9 +20,13 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.DarkMode
|
||||
import androidx.compose.material.icons.filled.DeleteForever
|
||||
import androidx.compose.material.icons.filled.LightMode
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.SettingsBrightness
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
@@ -27,13 +35,20 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
@@ -41,6 +56,8 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import top.yeij.cyrene.util.LogCategory
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -52,6 +69,9 @@ fun SettingsScreen(
|
||||
val baseUrl by viewModel.baseUrl.collectAsState()
|
||||
val themeMode by viewModel.themeMode.collectAsState()
|
||||
val wakeWord by viewModel.wakeWord.collectAsState()
|
||||
val dashScopeApiKey by viewModel.dashScopeApiKey.collectAsState()
|
||||
val dashScopeEndpoint by viewModel.dashScopeEndpoint.collectAsState()
|
||||
val dashScopeModel by viewModel.dashScopeModel.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -101,7 +121,7 @@ fun SettingsScreen(
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = baseUrl,
|
||||
onValueChange = { viewModel.saveBaseUrl(it) },
|
||||
onValueChange = { viewModel.updateBaseUrlInput(it) },
|
||||
label = { Text("服务器地址") },
|
||||
placeholder = { Text("http://192.168.1.x:8080") },
|
||||
singleLine = true,
|
||||
@@ -203,6 +223,287 @@ fun SettingsScreen(
|
||||
.padding(horizontal = 16.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// DashScope STT
|
||||
Text(
|
||||
text = "语音识别 (DashScope)",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = dashScopeApiKey,
|
||||
onValueChange = { viewModel.updateDashScopeApiKeyInput(it) },
|
||||
label = { Text("API Key") },
|
||||
placeholder = { Text("sk-xxxxxxxxxxxxxxxx") },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
scope.launch {
|
||||
viewModel.saveDashScopeApiKey(dashScopeApiKey)
|
||||
Toast.makeText(context, "API Key 已保存", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
),
|
||||
trailingIcon = {
|
||||
FilledTonalIconButton(onClick = {
|
||||
scope.launch {
|
||||
viewModel.saveDashScopeApiKey(dashScopeApiKey)
|
||||
Toast.makeText(context, "API Key 已保存", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Filled.Check, contentDescription = "保存")
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = dashScopeEndpoint,
|
||||
onValueChange = { viewModel.updateDashScopeEndpointInput(it) },
|
||||
label = { Text("WebSocket 端点") },
|
||||
placeholder = { Text("wss://dashscope.aliyuncs.com/api-ws/v1/inference") },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
scope.launch {
|
||||
viewModel.saveDashScopeEndpoint(dashScopeEndpoint)
|
||||
Toast.makeText(context, "端点已保存", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
),
|
||||
trailingIcon = {
|
||||
FilledTonalIconButton(onClick = {
|
||||
scope.launch {
|
||||
viewModel.saveDashScopeEndpoint(dashScopeEndpoint)
|
||||
Toast.makeText(context, "端点已保存", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Filled.Check, contentDescription = "保存")
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = dashScopeModel,
|
||||
onValueChange = { viewModel.updateDashScopeModelInput(it) },
|
||||
label = { Text("模型") },
|
||||
placeholder = { Text("fun-asr-realtime") },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
scope.launch {
|
||||
viewModel.saveDashScopeModel(dashScopeModel)
|
||||
Toast.makeText(context, "模型已保存", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
),
|
||||
trailingIcon = {
|
||||
FilledTonalIconButton(onClick = {
|
||||
scope.launch {
|
||||
viewModel.saveDashScopeModel(dashScopeModel)
|
||||
Toast.makeText(context, "模型已保存", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Filled.Check, contentDescription = "保存")
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = "未配置 API Key 时,语音输入将自动使用后端服务处理。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Data management
|
||||
Text(
|
||||
text = "数据",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
var showClearDialog by remember { mutableStateOf(false) }
|
||||
ListItem(
|
||||
headlineContent = { Text("清空本地消息记录") },
|
||||
supportingContent = { Text("仅清除本地数据库,服务器消息仍保留。下次加载将只获取清除时间之后的消息。") },
|
||||
leadingContent = { Icon(Icons.Filled.DeleteForever, contentDescription = null, tint = MaterialTheme.colorScheme.error) },
|
||||
modifier = Modifier.clickable { showClearDialog = true },
|
||||
)
|
||||
|
||||
if (showClearDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showClearDialog = false },
|
||||
title = { Text("确认清空") },
|
||||
text = { Text("将清空所有本地消息记录。服务器上的消息不会被删除,但下次加载历史时将只获取本次清除之后的消息。") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showClearDialog = false
|
||||
viewModel.clearLocalMessages()
|
||||
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
) {
|
||||
Text("清空")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showClearDialog = false }) {
|
||||
Text("取消")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Runtime logs
|
||||
Text(
|
||||
text = "运行日志",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
val logEntries by RuntimeLog.entries.collectAsState()
|
||||
var selectedTab by remember { mutableStateOf(0) }
|
||||
val tabs = listOf("全部") + LogCategory.entries.map { it.label }
|
||||
val allCategories = LogCategory.entries.toList()
|
||||
|
||||
// Scroll to bottom
|
||||
val scrollState = rememberScrollState()
|
||||
androidx.compose.runtime.LaunchedEffect(logEntries.size, selectedTab) {
|
||||
if (logEntries.isNotEmpty()) {
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
}
|
||||
|
||||
TabRow(selectedTabIndex = selectedTab) {
|
||||
tabs.forEachIndexed { index, label ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = { Text(label, maxLines = 1) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val filteredLogs = if (selectedTab == 0) {
|
||||
logEntries
|
||||
} else {
|
||||
val cat = allCategories.getOrNull(selectedTab - 1)
|
||||
if (cat != null) logEntries.filter { it.category == cat } else emptyList()
|
||||
}
|
||||
|
||||
val currentCategory = if (selectedTab == 0) null else allCategories.getOrNull(selectedTab - 1)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val file = RuntimeLog.exportToFile(context, currentCategory)
|
||||
RuntimeLog.shareFile(context, file)
|
||||
},
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(if (currentCategory != null) "导出${currentCategory.label}" else "导出全部")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val file = RuntimeLog.exportAllAsZip(context)
|
||||
RuntimeLog.shareFile(context, file)
|
||||
},
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("打包全部")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { RuntimeLog.clear() },
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
) {
|
||||
Text("清空")
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredLogs.isEmpty()) {
|
||||
Text(
|
||||
text = "暂无${tabs[selectedTab]}日志",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.weight(1f, fill = false),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(scrollState),
|
||||
) {
|
||||
filteredLogs.takeLast(500).forEach { entry ->
|
||||
Text(
|
||||
text = entry.formatted(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(80.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.domain.model.Conversation
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
import top.yeij.cyrene.util.RecordState
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import top.yeij.cyrene.util.VoiceRecorder
|
||||
|
||||
private fun List<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(
|
||||
private val chatRepository: ChatRepository,
|
||||
private val voiceRecorder: VoiceRecorder,
|
||||
) : ViewModel() {
|
||||
|
||||
val isConnected: StateFlow<Boolean> = chatRepository.connectionState
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
val connectionError: StateFlow<String?> = chatRepository.connectionError
|
||||
|
||||
val conversations: StateFlow<List<Conversation>> = chatRepository.getConversations()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
@@ -29,37 +70,144 @@ class ChatViewModel(
|
||||
private val _inputText = MutableStateFlow("")
|
||||
val inputText: StateFlow<String> = _inputText.asStateFlow()
|
||||
|
||||
private val _isStreaming = MutableStateFlow(false)
|
||||
val isStreaming: StateFlow<Boolean> = _isStreaming.asStateFlow()
|
||||
private val _isSending = MutableStateFlow(false)
|
||||
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 dbObserverJob: Job? = null
|
||||
|
||||
init {
|
||||
connectAndLoad()
|
||||
}
|
||||
|
||||
fun connectAndLoad(sessionId: String? = null) {
|
||||
// Phase 1: find/create main session, reconnect WS, load server history
|
||||
viewModelScope.launch {
|
||||
chatRepository.connectWebSocket(sessionId)
|
||||
chatRepository.loadConversationsFromServer()
|
||||
try {
|
||||
val sessionId = chatRepository.initializeSession()
|
||||
currentSessionId = sessionId
|
||||
chatRepository.currentSessionId = sessionId
|
||||
chatRepository.ensureConnected()
|
||||
loadMessagesFromDb(sessionId)
|
||||
val serverMessages = chatRepository.loadMessagesFromServer(sessionId)
|
||||
if (serverMessages.isNotEmpty()) {
|
||||
val serverIds = serverMessages.map { it.id }.toSet()
|
||||
_currentMessages.update { current ->
|
||||
val localOnly = current.filter { it.id !in serverIds }
|
||||
(serverMessages + localOnly)
|
||||
.sortedBy { it.timestamp }
|
||||
.deduplicate()
|
||||
.removeWrappingDuplicates()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
// Observe incoming live messages with atomic dedup
|
||||
viewModelScope.launch {
|
||||
chatRepository.observeMessages().collect { message ->
|
||||
try {
|
||||
val list = _currentMessages.value.toMutableList()
|
||||
val existingIdx = list.indexOfLast { it.id == message.id }
|
||||
if (existingIdx >= 0) {
|
||||
list[existingIdx] = message
|
||||
} else {
|
||||
list.add(message)
|
||||
_currentMessages.update { list ->
|
||||
val updated = list.toMutableList()
|
||||
val existingIdx = updated.indexOfLast { it.id == message.id }
|
||||
if (existingIdx >= 0) {
|
||||
updated[existingIdx] = message
|
||||
} else {
|
||||
val isDup = updated.any {
|
||||
it.role == message.role && it.content == message.content && it.msgType == message.msgType
|
||||
}
|
||||
if (!isDup) {
|
||||
updated.add(message)
|
||||
val idx = _messageAnimIndex.value.toMutableMap()
|
||||
idx[message.id] = animCounter++
|
||||
_messageAnimIndex.value = idx
|
||||
}
|
||||
}
|
||||
updated.deduplicate()
|
||||
}
|
||||
_currentMessages.value = list
|
||||
_isStreaming.value = list.any { it.isStreaming }
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatViewModel", "Error processing message: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Observe message clear events
|
||||
viewModelScope.launch {
|
||||
chatRepository.messageClearEvents.collect {
|
||||
_currentMessages.value = emptyList()
|
||||
_messageAnimIndex.value = emptyMap()
|
||||
animCounter = 0
|
||||
}
|
||||
}
|
||||
// Reset user-side sending state when server starts responding
|
||||
viewModelScope.launch {
|
||||
chatRepository.isAssistantStreaming.collect { streaming ->
|
||||
if (streaming) _isSending.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Voice recording (WeChat-style gesture) ---
|
||||
|
||||
fun startRecord() {
|
||||
voiceRecorder.start()
|
||||
}
|
||||
|
||||
fun lockRecord() {
|
||||
voiceRecorder.lock()
|
||||
}
|
||||
|
||||
fun finishRecord() {
|
||||
val file = voiceRecorder.stop() ?: return
|
||||
val base64 = voiceRecorder.getBase64()
|
||||
voiceRecorder.deleteFile()
|
||||
if (base64.isNullOrBlank()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
chatRepository.sendVoiceInput(base64, "voice_msg")
|
||||
RuntimeLog.chat("voice", "Voice message sent, duration=${file.length()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelRecord() {
|
||||
voiceRecorder.cancel()
|
||||
}
|
||||
|
||||
private fun loadMessagesFromDb(sessionId: String) {
|
||||
dbObserverJob?.cancel()
|
||||
dbObserverJob = viewModelScope.launch {
|
||||
try {
|
||||
chatRepository.getMessages(sessionId).collect { messages ->
|
||||
_currentMessages.update { current ->
|
||||
val live = current.associateBy { it.id }
|
||||
val db = messages.associateBy { it.id }
|
||||
(db + live).values
|
||||
.sortedBy { it.timestamp }
|
||||
.deduplicate()
|
||||
.removeWrappingDuplicates()
|
||||
}
|
||||
val idx = _messageAnimIndex.value.toMutableMap()
|
||||
messages.forEach { m ->
|
||||
if (m.id !in idx) idx[m.id] = animCounter++
|
||||
}
|
||||
_messageAnimIndex.value = idx
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatViewModel", "Error loading messages: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onInputChanged(text: String) {
|
||||
@@ -71,7 +219,7 @@ class ChatViewModel(
|
||||
if (text.isEmpty()) return
|
||||
|
||||
_inputText.value = ""
|
||||
_isStreaming.value = true
|
||||
_isSending.value = true
|
||||
val sid = currentSessionId
|
||||
|
||||
viewModelScope.launch {
|
||||
@@ -81,11 +229,39 @@ class ChatViewModel(
|
||||
|
||||
fun switchSession(sessionId: String) {
|
||||
currentSessionId = sessionId
|
||||
chatRepository.currentSessionId = sessionId
|
||||
_currentMessages.value = emptyList()
|
||||
_messageAnimIndex.value = emptyMap()
|
||||
animCounter = 0
|
||||
viewModelScope.launch {
|
||||
chatRepository.disconnectWebSocket()
|
||||
chatRepository.connectWebSocket(sessionId)
|
||||
chatRepository.loadMessagesFromServer(sessionId)
|
||||
}
|
||||
loadMessagesFromDb(sessionId)
|
||||
}
|
||||
|
||||
fun refreshMessages() {
|
||||
val sid = currentSessionId ?: return
|
||||
viewModelScope.launch {
|
||||
_isRefreshing.value = true
|
||||
try {
|
||||
if (!isConnected.value) {
|
||||
chatRepository.ensureConnected()
|
||||
}
|
||||
val serverMessages = chatRepository.loadMessagesFromServer(sid)
|
||||
if (serverMessages.isNotEmpty()) {
|
||||
val serverIds = serverMessages.map { it.id }.toSet()
|
||||
_currentMessages.update { current ->
|
||||
val localOnly = current.filter { it.id !in serverIds }
|
||||
(serverMessages + localOnly)
|
||||
.sortedBy { it.timestamp }
|
||||
.deduplicate()
|
||||
.removeWrappingDuplicates()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
_isRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteConversation(id: String) {
|
||||
@@ -94,10 +270,12 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
fun clearLocalMessages() {
|
||||
viewModelScope.launch {
|
||||
chatRepository.disconnectWebSocket()
|
||||
chatRepository.clearLocalMessages()
|
||||
_currentMessages.value = emptyList()
|
||||
_messageAnimIndex.value = emptyMap()
|
||||
animCounter = 0
|
||||
}
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package top.yeij.cyrene.viewmodel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -7,10 +8,47 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
import top.yeij.cyrene.util.Constants
|
||||
import top.yeij.cyrene.util.RecordState
|
||||
import top.yeij.cyrene.util.VoiceRecorder
|
||||
import top.yeij.cyrene.voice.tts.TextToSpeechEngine
|
||||
|
||||
private fun List<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 {
|
||||
IDLE,
|
||||
@@ -22,94 +60,178 @@ enum class OverlayState {
|
||||
|
||||
class OverlayViewModel(
|
||||
private val chatRepository: ChatRepository,
|
||||
private val voiceRecorder: VoiceRecorder,
|
||||
private val ttsEngine: TextToSpeechEngine,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(OverlayState.IDLE)
|
||||
private val _state = MutableStateFlow(OverlayState.WAITING)
|
||||
val state: StateFlow<OverlayState> = _state.asStateFlow()
|
||||
|
||||
private val _messages = MutableStateFlow<List<Message>>(emptyList())
|
||||
val messages: StateFlow<List<Message>> = _messages.asStateFlow()
|
||||
|
||||
private val _recognizedText = MutableStateFlow("")
|
||||
val recognizedText: StateFlow<String> = _recognizedText.asStateFlow()
|
||||
private val _inputText = MutableStateFlow("")
|
||||
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 lastAssistantMessageId: String? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
chatRepository.connectWebSocket(null)
|
||||
chatRepository.observeMessages().collect { message ->
|
||||
_messages.update { list ->
|
||||
val updated = list.toMutableList()
|
||||
val idx = updated.indexOfLast { it.id == message.id }
|
||||
if (idx >= 0) {
|
||||
updated[idx] = message
|
||||
} else {
|
||||
val isDup = updated.any {
|
||||
it.role == message.role && it.content == message.content && it.msgType == message.msgType
|
||||
}
|
||||
if (!isDup) {
|
||||
updated.add(message)
|
||||
val animIdx = _messageAnimIndex.value.toMutableMap()
|
||||
animIdx[message.id] = animCounter++
|
||||
_messageAnimIndex.value = animIdx
|
||||
}
|
||||
}
|
||||
updated.deduplicate()
|
||||
}
|
||||
|
||||
if (message.role == "assistant" && !message.isStreaming && message.msgType == "chat") {
|
||||
if (message.id != lastAssistantMessageId && message.content.isNotBlank()) {
|
||||
lastAssistantMessageId = message.id
|
||||
speakResponse(message.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
chatRepository.observeMessages().collect { message ->
|
||||
_messages.value = _messages.value + message
|
||||
ttsEngine.onDone.collect {
|
||||
if (_state.value == OverlayState.SPEAKING) {
|
||||
setWaiting()
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
chatRepository.messageClearEvents.collect {
|
||||
_messages.value = emptyList()
|
||||
_messageAnimIndex.value = emptyMap()
|
||||
animCounter = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startListening() {
|
||||
_state.value = OverlayState.LISTENING
|
||||
resetSilenceTimer()
|
||||
fun onInputChanged(text: String) {
|
||||
_inputText.value = text
|
||||
}
|
||||
|
||||
fun onSpeechPartial(text: String) {
|
||||
_recognizedText.value = text
|
||||
resetSilenceTimer()
|
||||
}
|
||||
fun sendText() {
|
||||
val text = _inputText.value.trim()
|
||||
if (text.isEmpty()) return
|
||||
_inputText.value = ""
|
||||
|
||||
fun onSpeechFinal(text: String) {
|
||||
_recognizedText.value = text
|
||||
_state.value = OverlayState.PROCESSING
|
||||
cancelSilenceTimer()
|
||||
|
||||
viewModelScope.launch {
|
||||
chatRepository.sendMessage(text, null)
|
||||
_recognizedText.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
fun sendText(text: String) {
|
||||
// --- Voice recording (WeChat-style gesture) ---
|
||||
|
||||
fun startRecord() {
|
||||
voiceRecorder.start()
|
||||
_state.value = OverlayState.LISTENING
|
||||
cancelSilenceTimer()
|
||||
}
|
||||
|
||||
fun lockRecord() {
|
||||
voiceRecorder.lock()
|
||||
}
|
||||
|
||||
fun finishRecord() {
|
||||
val file = voiceRecorder.stop() ?: return
|
||||
val base64 = voiceRecorder.getBase64()
|
||||
voiceRecorder.deleteFile()
|
||||
if (base64.isNullOrBlank()) return
|
||||
|
||||
_state.value = OverlayState.PROCESSING
|
||||
viewModelScope.launch {
|
||||
chatRepository.sendMessage(text, null)
|
||||
chatRepository.sendVoiceInput(base64, "voice_msg")
|
||||
}
|
||||
}
|
||||
|
||||
fun setSpeaking() {
|
||||
_state.value = OverlayState.SPEAKING
|
||||
fun cancelRecord() {
|
||||
voiceRecorder.cancel()
|
||||
setWaiting()
|
||||
}
|
||||
|
||||
fun setWaiting() {
|
||||
fun cancelCurrentAction() {
|
||||
if (voiceRecorder.state.value == RecordState.LOCKED) {
|
||||
voiceRecorder.cancel()
|
||||
setWaiting()
|
||||
}
|
||||
}
|
||||
|
||||
private fun speakResponse(text: String) {
|
||||
if (text.isBlank()) return
|
||||
_state.value = OverlayState.SPEAKING
|
||||
ttsEngine.speak(text)
|
||||
}
|
||||
|
||||
private fun setWaiting() {
|
||||
_state.value = OverlayState.WAITING
|
||||
startSilenceTimer()
|
||||
}
|
||||
|
||||
fun stopSpeaking() {
|
||||
ttsEngine.stop()
|
||||
if (_state.value == OverlayState.SPEAKING) {
|
||||
setWaiting()
|
||||
}
|
||||
}
|
||||
|
||||
fun sendScreenContext(content: String) {
|
||||
if (content.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
chatRepository.sendScreenContext(content)
|
||||
}
|
||||
}
|
||||
|
||||
fun finish() {
|
||||
_state.value = OverlayState.IDLE
|
||||
cancelSilenceTimer()
|
||||
voiceRecorder.cancel()
|
||||
ttsEngine.stop()
|
||||
}
|
||||
|
||||
private fun startSilenceTimer() {
|
||||
cancelSilenceTimer()
|
||||
silenceTimer = viewModelScope.launch {
|
||||
delay(Constants.SILENCE_TIMEOUT_MS)
|
||||
_state.value = OverlayState.IDLE
|
||||
if (_state.value == OverlayState.WAITING) {
|
||||
_state.value = OverlayState.IDLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetSilenceTimer() {
|
||||
cancelSilenceTimer()
|
||||
startSilenceTimer()
|
||||
}
|
||||
|
||||
private fun cancelSilenceTimer() {
|
||||
silenceTimer?.cancel()
|
||||
silenceTimer = null
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
viewModelScope.launch {
|
||||
chatRepository.disconnectWebSocket()
|
||||
}
|
||||
voiceRecorder.cancel()
|
||||
ttsEngine.shutdown()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.remote.DynamicUrlInterceptor
|
||||
import top.yeij.cyrene.domain.repository.AuthRepository
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
import top.yeij.cyrene.voice.stt.SttManager
|
||||
|
||||
class SettingsViewModel(
|
||||
private val authRepository: AuthRepository,
|
||||
private val preferencesDataStore: PreferencesDataStore,
|
||||
private val dynamicUrlInterceptor: DynamicUrlInterceptor,
|
||||
private val chatRepository: ChatRepository,
|
||||
private val sttManager: SttManager,
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
@@ -32,6 +36,15 @@ class SettingsViewModel(
|
||||
private val _username = MutableStateFlow("")
|
||||
val username: StateFlow<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)
|
||||
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
|
||||
|
||||
@@ -46,7 +59,14 @@ class SettingsViewModel(
|
||||
preferencesDataStore.themeMode,
|
||||
preferencesDataStore.wakeWord,
|
||||
preferencesDataStore.username,
|
||||
) { baseUrl, themeMode, wakeWord, username ->
|
||||
combine(
|
||||
preferencesDataStore.dashScopeApiKey,
|
||||
preferencesDataStore.dashScopeEndpoint,
|
||||
preferencesDataStore.dashScopeModel,
|
||||
) { apiKey, endpoint, model ->
|
||||
Triple(apiKey, endpoint, model)
|
||||
},
|
||||
) { baseUrl, themeMode, wakeWord, username, dashScope ->
|
||||
baseUrl?.let { url ->
|
||||
if (url.isNotBlank()) {
|
||||
_baseUrl.value = url
|
||||
@@ -58,14 +78,38 @@ class SettingsViewModel(
|
||||
if (word.isNotBlank()) _wakeWord.value = word
|
||||
}
|
||||
username?.let { _username.value = it }
|
||||
val (apiKey, endpoint, model) = dashScope
|
||||
apiKey?.let { key ->
|
||||
if (key.isNotBlank()) _dashScopeApiKey.value = key
|
||||
sttManager.updateDashScopeApiKey(key)
|
||||
}
|
||||
endpoint?.let { ep ->
|
||||
if (ep.isNotBlank()) _dashScopeEndpoint.value = ep
|
||||
}
|
||||
model?.let { m ->
|
||||
if (m.isNotBlank()) _dashScopeModel.value = m
|
||||
}
|
||||
// Push full config to STT
|
||||
sttManager.configureDashScope(
|
||||
apiKey = _dashScopeApiKey.value,
|
||||
endpoint = _dashScopeEndpoint.value,
|
||||
model = _dashScopeModel.value,
|
||||
)
|
||||
}.collect { }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBaseUrlInput(url: String) {
|
||||
_baseUrl.value = url
|
||||
}
|
||||
|
||||
fun saveBaseUrl(url: String) {
|
||||
_baseUrl.value = url
|
||||
dynamicUrlInterceptor.baseUrl = url
|
||||
scope.launch { preferencesDataStore.saveBaseUrl(url) }
|
||||
scope.launch {
|
||||
preferencesDataStore.saveBaseUrl(url)
|
||||
chatRepository.reconnectWebSocket()
|
||||
}
|
||||
}
|
||||
|
||||
fun saveThemeMode(mode: String) {
|
||||
@@ -78,6 +122,42 @@ class SettingsViewModel(
|
||||
scope.launch { preferencesDataStore.saveWakeWord(word) }
|
||||
}
|
||||
|
||||
fun updateDashScopeApiKeyInput(key: String) {
|
||||
_dashScopeApiKey.value = key
|
||||
}
|
||||
|
||||
fun saveDashScopeApiKey(key: String) {
|
||||
_dashScopeApiKey.value = key
|
||||
sttManager.updateDashScopeApiKey(key)
|
||||
scope.launch { preferencesDataStore.saveDashScopeApiKey(key) }
|
||||
}
|
||||
|
||||
fun updateDashScopeEndpointInput(endpoint: String) {
|
||||
_dashScopeEndpoint.value = endpoint
|
||||
}
|
||||
|
||||
fun saveDashScopeEndpoint(endpoint: String) {
|
||||
_dashScopeEndpoint.value = endpoint
|
||||
sttManager.configureDashScope(_dashScopeApiKey.value, endpoint, _dashScopeModel.value)
|
||||
scope.launch { preferencesDataStore.saveDashScopeEndpoint(endpoint) }
|
||||
}
|
||||
|
||||
fun updateDashScopeModelInput(model: String) {
|
||||
_dashScopeModel.value = model
|
||||
}
|
||||
|
||||
fun saveDashScopeModel(model: String) {
|
||||
_dashScopeModel.value = model
|
||||
sttManager.configureDashScope(_dashScopeApiKey.value, _dashScopeEndpoint.value, model)
|
||||
scope.launch { preferencesDataStore.saveDashScopeModel(model) }
|
||||
}
|
||||
|
||||
fun clearLocalMessages() {
|
||||
scope.launch {
|
||||
chatRepository.clearLocalMessages()
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
scope.launch {
|
||||
authRepository.logout()
|
||||
|
||||
@@ -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
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.speech.RecognitionListener
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class SpeechRecognizer {
|
||||
class SpeechRecognizer(private val context: Context) : SttProvider {
|
||||
|
||||
private var recognizer: android.speech.SpeechRecognizer? = null
|
||||
|
||||
private val _isListening = MutableStateFlow(false)
|
||||
val isListening = _isListening.asStateFlow()
|
||||
override val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
|
||||
|
||||
private val _partialResult = MutableStateFlow("")
|
||||
val partialResult = _partialResult.asStateFlow()
|
||||
override val partialResult: StateFlow<String> = _partialResult.asStateFlow()
|
||||
|
||||
fun startListening() {
|
||||
_isListening.value = true
|
||||
// Integrate Android SpeechRecognizer or server-side Whisper API
|
||||
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
|
||||
_partialResult.value = ""
|
||||
}
|
||||
|
||||
override fun onBeginningOfSpeech() {}
|
||||
|
||||
override fun onRmsChanged(rmsdB: Float) {}
|
||||
|
||||
override fun onBufferReceived(buffer: ByteArray?) {}
|
||||
|
||||
override fun onEndOfSpeech() {}
|
||||
|
||||
override fun onError(error: Int) {
|
||||
_isListening.value = false
|
||||
val msg = when (error) {
|
||||
android.speech.SpeechRecognizer.ERROR_NETWORK -> "网络连接失败"
|
||||
android.speech.SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "网络超时"
|
||||
android.speech.SpeechRecognizer.ERROR_AUDIO -> "音频录制错误"
|
||||
android.speech.SpeechRecognizer.ERROR_SERVER -> "服务器错误"
|
||||
android.speech.SpeechRecognizer.ERROR_CLIENT -> "客户端错误"
|
||||
android.speech.SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "未检测到语音"
|
||||
android.speech.SpeechRecognizer.ERROR_NO_MATCH -> "未能识别"
|
||||
android.speech.SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "语音引擎忙碌"
|
||||
android.speech.SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "缺少录音权限"
|
||||
else -> "未知错误 ($error)"
|
||||
}
|
||||
Log.w(TAG, "Recognition error: $msg")
|
||||
_onError.tryEmit(msg)
|
||||
}
|
||||
|
||||
override fun onResults(results: Bundle?) {
|
||||
_isListening.value = false
|
||||
val matches = results?.getStringArrayList(android.speech.SpeechRecognizer.RESULTS_RECOGNITION)
|
||||
if (!matches.isNullOrEmpty()) {
|
||||
_finalResult.tryEmit(SttResult(text = matches[0], isFinal = true))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPartialResults(partialResults: Bundle?) {
|
||||
val matches = partialResults?.getStringArrayList(android.speech.SpeechRecognizer.RESULTS_RECOGNITION)
|
||||
if (!matches.isNullOrEmpty()) {
|
||||
_partialResult.value = matches[0]
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEvent(eventType: Int, params: Bundle?) {}
|
||||
})
|
||||
|
||||
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "zh-CN")
|
||||
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
|
||||
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
|
||||
}
|
||||
startListening(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopListening(): String {
|
||||
override fun stop() {
|
||||
recognizer?.stopListening()
|
||||
_isListening.value = false
|
||||
val result = _partialResult.value
|
||||
_partialResult.value = ""
|
||||
return result
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
override fun cancel() {
|
||||
recognizer?.cancel()
|
||||
recognizer?.destroy()
|
||||
recognizer = null
|
||||
_isListening.value = false
|
||||
_partialResult.value = ""
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CyreneSTT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -34,6 +34,7 @@
|
||||
<string name="wake_word">唤醒词</string>
|
||||
<string name="account">账号</string>
|
||||
<string name="about">关于</string>
|
||||
<string name="accessibility_service_description">昔涟使用无障碍服务读取屏幕内容,以便在唤醒时理解当前上下文并提供更精准的帮助。不会收集或上传个人隐私信息。</string>
|
||||
<string name="server_address">服务器地址</string>
|
||||
<string name="theme">主题</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" />
|
||||
@@ -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"?>
|
||||
<voice-interaction-service
|
||||
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:supportsAssist="true"
|
||||
android:supportsLaunchVoiceAssistFromKeyguard="true"
|
||||
|
||||
Reference in New Issue
Block a user