fix: delete agent 500 error + dynamic personality + deployment guide
- Fix delete agent 500: clean up FK records (agent_llm_logs, permissions, schedules, executions, team_members) and unbind goals/tasks before delete - Remove hardcoded personality templates in Android, replace with dynamic system prompt generation from name + description - Set promptSectionsEnabled=false to bypass PromptComposer for personality - Add Tencent Cloud Linux deployment guide (Docker Compose) - Accumulated backend service updates, frontend UI fixes, Android app changes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
129
android/app/build.gradle.kts
Normal file
129
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,129 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.kotlin.kapt)
|
||||
alias(libs.plugins.google.services)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tiangong.aiagent"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.tiangong.aiagent"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
|
||||
// 真机调试用本机 WiFi IP,模拟器用 10.0.2.2
|
||||
buildConfigField("String", "BASE_URL", "\"http://192.168.31.135:8037/\"")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Compose
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.graphics)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
implementation(libs.compose.material.icons)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
|
||||
// Navigation
|
||||
implementation(libs.navigation.compose)
|
||||
|
||||
// Lifecycle
|
||||
implementation(libs.lifecycle.viewmodel.compose)
|
||||
implementation(libs.lifecycle.runtime.compose)
|
||||
|
||||
// Activity
|
||||
implementation(libs.activity.compose)
|
||||
|
||||
// Core
|
||||
implementation(libs.core.ktx)
|
||||
|
||||
// Network
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.retrofit.converter.gson)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.logging)
|
||||
implementation(libs.gson)
|
||||
|
||||
// Room
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.ktx)
|
||||
kapt(libs.room.compiler)
|
||||
|
||||
// DataStore
|
||||
implementation(libs.datastore.preferences)
|
||||
|
||||
// Hilt
|
||||
implementation(libs.hilt.android)
|
||||
kapt(libs.hilt.compiler)
|
||||
implementation(libs.hilt.navigation.compose)
|
||||
|
||||
// Coil
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
// Media3
|
||||
implementation(libs.media3.exoplayer)
|
||||
|
||||
// Splash Screen
|
||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||
|
||||
// Security
|
||||
implementation(libs.security.crypto)
|
||||
|
||||
// Markdown
|
||||
implementation(libs.markwon.core)
|
||||
implementation(libs.markwon.image)
|
||||
implementation(libs.markwon.linkify)
|
||||
|
||||
// Firebase (requires google-services.json in app/ to function at runtime)
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.messaging)
|
||||
|
||||
// Bugly (Tencent crash reporting)
|
||||
implementation(libs.bugly.crashreport)
|
||||
implementation(libs.bugly.native)
|
||||
}
|
||||
29
android/app/google-services.json
Normal file
29
android/app/google-services.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "000000000000",
|
||||
"project_id": "tiangong-placeholder",
|
||||
"storage_bucket": "tiangong-placeholder.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
|
||||
"android_client_info": {
|
||||
"package_name": "com.tiangong.aiagent"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "PLACEHOLDER_API_KEY"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
7
android/app/proguard-rules.pro
vendored
Normal file
7
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
-keepattributes *Annotation*
|
||||
-keep class com.tiangong.aiagent.data.remote.dto.** { *; }
|
||||
|
||||
# Bugly
|
||||
-dontwarn com.tencent.bugly.**
|
||||
-keep public class com.tencent.bugly.** { *; }
|
||||
49
android/app/src/main/AndroidManifest.xml
Normal file
49
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".TiangongApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.TiangongAgent"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="34">
|
||||
|
||||
<!-- Bugly crash reporting -->
|
||||
<meta-data
|
||||
android:name="BUGLY_APPID"
|
||||
android:value="babf4cb53e" />
|
||||
<meta-data
|
||||
android:name="BUGLY_APP_KEY"
|
||||
android:value="c62623aa-cd02-4dcc-a820-23468a201f76" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.TiangongAgent.Splash">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Firebase Cloud Messaging — requires google-services.json in app/ -->
|
||||
<service
|
||||
android:name=".util.TiangongFirebaseMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.tiangong.aiagent
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.tiangong.aiagent.data.local.TokenDataStore
|
||||
import com.tiangong.aiagent.ui.navigation.NavGraph
|
||||
import com.tiangong.aiagent.ui.theme.TiangongTheme
|
||||
import com.tiangong.aiagent.util.FcmTokenManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var tokenDataStore: TokenDataStore
|
||||
|
||||
@Inject
|
||||
lateinit var fcmTokenManager: FcmTokenManager
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Initialize FCM push (no-op if google-services.json is missing)
|
||||
fcmTokenManager.initialize()
|
||||
|
||||
// Handle notification click deep link
|
||||
val notificationIdFromNotification = intent?.getStringExtra("notification_id")
|
||||
|
||||
setContent {
|
||||
val themeMode by tokenDataStore.themeMode.collectAsState(initial = "system")
|
||||
val token by tokenDataStore.token.collectAsState(initial = null)
|
||||
|
||||
TiangongTheme(themeMode = themeMode) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
NavGraph(
|
||||
token = token,
|
||||
initialNotificationId = notificationIdFromNotification
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.tiangong.aiagent
|
||||
|
||||
import android.app.Application
|
||||
import com.tencent.bugly.crashreport.CrashReport
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class TiangongApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Tencent Bugly crash report
|
||||
CrashReport.initCrashReport(
|
||||
applicationContext,
|
||||
"babf4cb53e",
|
||||
com.tiangong.aiagent.BuildConfig.DEBUG
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.tiangong.aiagent.data.local
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(
|
||||
entities = [MessageEntity::class, ConversationEntity::class, PendingMessageEntity::class],
|
||||
version = 3,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun messageDao(): MessageDao
|
||||
abstract fun conversationDao(): ConversationDao
|
||||
abstract fun pendingMessageDao(): PendingMessageDao
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.tiangong.aiagent.data.local
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface ConversationDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(conversation: ConversationEntity)
|
||||
|
||||
@Update
|
||||
suspend fun update(conversation: ConversationEntity)
|
||||
|
||||
@Query("SELECT * FROM conversations ORDER BY lastMessageAt DESC")
|
||||
fun getAllConversations(): Flow<List<ConversationEntity>>
|
||||
|
||||
@Query("SELECT * FROM conversations ORDER BY lastMessageAt DESC")
|
||||
suspend fun getAllConversationsSync(): List<ConversationEntity>
|
||||
|
||||
@Query("SELECT * FROM conversations WHERE agentId = :agentId ORDER BY lastMessageAt DESC")
|
||||
suspend fun getByAgentId(agentId: String): List<ConversationEntity>
|
||||
|
||||
@Query("SELECT * FROM conversations WHERE sessionId = :sessionId LIMIT 1")
|
||||
suspend fun getById(sessionId: String): ConversationEntity?
|
||||
|
||||
@Query("DELETE FROM conversations WHERE sessionId = :sessionId")
|
||||
suspend fun deleteById(sessionId: String)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.tiangong.aiagent.data.local
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "conversations")
|
||||
data class ConversationEntity(
|
||||
@PrimaryKey val sessionId: String,
|
||||
val agentId: String?,
|
||||
val agentName: String?,
|
||||
val title: String?,
|
||||
val lastMessage: String?,
|
||||
val lastMessageAt: Long,
|
||||
val messageCount: Int
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.tiangong.aiagent.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKeys
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class CredentialStore @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val prefs by lazy {
|
||||
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
|
||||
EncryptedSharedPreferences.create(
|
||||
"tiangong_secure_prefs",
|
||||
masterKeyAlias,
|
||||
context,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
|
||||
fun saveCredentials(username: String, password: String) {
|
||||
prefs.edit()
|
||||
.putString("username", username)
|
||||
.putString("password", password)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getCredentials(): Pair<String, String>? {
|
||||
val username = prefs.getString("username", null) ?: return null
|
||||
val password = prefs.getString("password", null) ?: return null
|
||||
return Pair(username, password)
|
||||
}
|
||||
|
||||
fun clearCredentials() {
|
||||
prefs.edit()
|
||||
.remove("username")
|
||||
.remove("password")
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.tiangong.aiagent.data.local
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface MessageDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(message: MessageEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(messages: List<MessageEntity>)
|
||||
|
||||
@Query("SELECT * FROM messages WHERE conversationId = :conversationId ORDER BY createdAt ASC")
|
||||
fun getMessagesByConversation(conversationId: String): Flow<List<MessageEntity>>
|
||||
|
||||
@Query("SELECT * FROM messages WHERE conversationId = :conversationId ORDER BY createdAt ASC LIMIT 1")
|
||||
suspend fun getFirstMessage(conversationId: String): MessageEntity?
|
||||
|
||||
@Query("DELETE FROM messages WHERE conversationId = :conversationId")
|
||||
suspend fun deleteByConversation(conversationId: String)
|
||||
|
||||
@Query("DELETE FROM messages")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.tiangong.aiagent.data.local
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "messages")
|
||||
data class MessageEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val conversationId: String,
|
||||
val agentId: String?,
|
||||
val role: String,
|
||||
val content: String,
|
||||
val toolName: String? = null,
|
||||
val toolInput: String? = null,
|
||||
val toolOutput: String? = null,
|
||||
val tokenUsageJson: String? = null,
|
||||
val createdAt: Long
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.tiangong.aiagent.data.local
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface PendingMessageDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(entity: PendingMessageEntity)
|
||||
|
||||
@Query("SELECT * FROM pending_messages ORDER BY createdAt ASC")
|
||||
suspend fun getAll(): List<PendingMessageEntity>
|
||||
|
||||
@Query("DELETE FROM pending_messages WHERE id = :id")
|
||||
suspend fun deleteById(id: Long)
|
||||
|
||||
@Query("DELETE FROM pending_messages")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.tiangong.aiagent.data.local
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "pending_messages")
|
||||
data class PendingMessageEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val agentId: String?,
|
||||
val message: String,
|
||||
val sessionId: String?,
|
||||
val createdAt: Long
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.tiangong.aiagent.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.*
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "tiangong_prefs")
|
||||
|
||||
@Singleton
|
||||
class TokenDataStore @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
companion object {
|
||||
private val KEY_TOKEN = stringPreferencesKey("access_token")
|
||||
private val KEY_SERVER_URL = stringPreferencesKey("server_url")
|
||||
private val KEY_CURRENT_AGENT_ID = stringPreferencesKey("current_agent_id")
|
||||
private val KEY_CURRENT_AGENT_NAME = stringPreferencesKey("current_agent_name")
|
||||
private val KEY_TTS_ENABLED = booleanPreferencesKey("tts_enabled")
|
||||
private val KEY_THEME_MODE = stringPreferencesKey("theme_mode")
|
||||
private val KEY_LAST_SESSION_ID = stringPreferencesKey("last_session_id")
|
||||
private val KEY_TTS_VOICE = stringPreferencesKey("tts_voice")
|
||||
private val KEY_NOTIFICATION_ENABLED = booleanPreferencesKey("notification_enabled")
|
||||
private val KEY_NOTIFICATION_QUIET_START = stringPreferencesKey("notification_quiet_start")
|
||||
private val KEY_NOTIFICATION_QUIET_END = stringPreferencesKey("notification_quiet_end")
|
||||
}
|
||||
|
||||
// ─── Token ───
|
||||
val token: Flow<String?> = context.dataStore.data.map { it[KEY_TOKEN] }
|
||||
|
||||
suspend fun saveToken(token: String) {
|
||||
context.dataStore.edit { it[KEY_TOKEN] = token }
|
||||
}
|
||||
|
||||
suspend fun getToken(): String? {
|
||||
return context.dataStore.data.first()[KEY_TOKEN]
|
||||
}
|
||||
|
||||
suspend fun clearToken() {
|
||||
context.dataStore.edit { it.remove(KEY_TOKEN) }
|
||||
}
|
||||
|
||||
// ─── Server URL ───
|
||||
val serverUrl: Flow<String> = context.dataStore.data.map { prefs ->
|
||||
prefs[KEY_SERVER_URL] ?: com.tiangong.aiagent.BuildConfig.BASE_URL
|
||||
}
|
||||
|
||||
suspend fun getServerUrl(): String? {
|
||||
return context.dataStore.data.first()[KEY_SERVER_URL]
|
||||
?: com.tiangong.aiagent.BuildConfig.BASE_URL
|
||||
}
|
||||
|
||||
suspend fun saveServerUrl(url: String) {
|
||||
context.dataStore.edit { it[KEY_SERVER_URL] = url }
|
||||
}
|
||||
|
||||
// ─── Current Agent ───
|
||||
val currentAgentId: Flow<String?> = context.dataStore.data.map { it[KEY_CURRENT_AGENT_ID] }
|
||||
val currentAgentName: Flow<String?> = context.dataStore.data.map { it[KEY_CURRENT_AGENT_NAME] }
|
||||
|
||||
suspend fun saveCurrentAgent(id: String, name: String) {
|
||||
context.dataStore.edit {
|
||||
it[KEY_CURRENT_AGENT_ID] = id
|
||||
it[KEY_CURRENT_AGENT_NAME] = name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getCurrentAgentId(): String? {
|
||||
return context.dataStore.data.first()[KEY_CURRENT_AGENT_ID]
|
||||
}
|
||||
|
||||
// ─── Session persistence ───
|
||||
suspend fun saveLastSessionId(sessionId: String) {
|
||||
context.dataStore.edit { it[KEY_LAST_SESSION_ID] = sessionId }
|
||||
}
|
||||
|
||||
suspend fun getLastSessionId(): String? {
|
||||
return context.dataStore.data.first()[KEY_LAST_SESSION_ID]
|
||||
}
|
||||
|
||||
suspend fun clearLastSessionId() {
|
||||
context.dataStore.edit { it.remove(KEY_LAST_SESSION_ID) }
|
||||
}
|
||||
|
||||
// ─── Preferences ───
|
||||
val ttsEnabled: Flow<Boolean> = context.dataStore.data.map { it[KEY_TTS_ENABLED] ?: false }
|
||||
val ttsVoice: Flow<String> = context.dataStore.data.map { it[KEY_TTS_VOICE] ?: "alloy" }
|
||||
val themeMode: Flow<String> = context.dataStore.data.map { it[KEY_THEME_MODE] ?: "system" }
|
||||
|
||||
suspend fun setTtsEnabled(enabled: Boolean) {
|
||||
context.dataStore.edit { it[KEY_TTS_ENABLED] = enabled }
|
||||
}
|
||||
|
||||
// ─── Notification preferences (v1.2.0) ───
|
||||
val notificationEnabled: Flow<Boolean> = context.dataStore.data.map { it[KEY_NOTIFICATION_ENABLED] ?: true }
|
||||
val notificationQuietStart: Flow<String> = context.dataStore.data.map { it[KEY_NOTIFICATION_QUIET_START] ?: "22:00" }
|
||||
val notificationQuietEnd: Flow<String> = context.dataStore.data.map { it[KEY_NOTIFICATION_QUIET_END] ?: "07:00" }
|
||||
|
||||
suspend fun setNotificationEnabled(enabled: Boolean) {
|
||||
context.dataStore.edit { it[KEY_NOTIFICATION_ENABLED] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setNotificationQuietHours(start: String, end: String) {
|
||||
context.dataStore.edit {
|
||||
it[KEY_NOTIFICATION_QUIET_START] = start
|
||||
it[KEY_NOTIFICATION_QUIET_END] = end
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setTtsVoice(voice: String) {
|
||||
context.dataStore.edit { it[KEY_TTS_VOICE] = voice }
|
||||
}
|
||||
|
||||
suspend fun setThemeMode(mode: String) {
|
||||
context.dataStore.edit { it[KEY_THEME_MODE] = mode }
|
||||
}
|
||||
|
||||
// ─── Clear All ───
|
||||
suspend fun clearAll() {
|
||||
context.dataStore.edit { prefs ->
|
||||
// Save user preferences that should survive logout
|
||||
val savedUrl = prefs[KEY_SERVER_URL]
|
||||
val savedTheme = prefs[KEY_THEME_MODE]
|
||||
val savedVoice = prefs[KEY_TTS_VOICE]
|
||||
val savedTts = prefs[KEY_TTS_ENABLED]
|
||||
prefs.clear()
|
||||
// Restore preferences that should persist across logouts
|
||||
if (savedUrl != null) prefs[KEY_SERVER_URL] = savedUrl
|
||||
if (savedTheme != null) prefs[KEY_THEME_MODE] = savedTheme
|
||||
if (savedVoice != null) prefs[KEY_TTS_VOICE] = savedVoice
|
||||
if (savedTts != null) prefs[KEY_TTS_ENABLED] = savedTts
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAllIncludingPrefs() {
|
||||
context.dataStore.edit { it.clear() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.tiangong.aiagent.data.remote
|
||||
|
||||
import com.tiangong.aiagent.data.remote.dto.*
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.*
|
||||
|
||||
interface ApiService {
|
||||
|
||||
// ─── Auth ───
|
||||
@FormUrlEncoded
|
||||
@POST("api/v1/auth/login")
|
||||
suspend fun login(
|
||||
@Field("username") username: String,
|
||||
@Field("password") password: String,
|
||||
@Query("client_type") clientType: String = "android"
|
||||
): LoginResponse
|
||||
|
||||
@GET("api/v1/auth/me")
|
||||
suspend fun getCurrentUser(): UserResponse
|
||||
|
||||
// ─── Agents ───
|
||||
@GET("api/v1/agents")
|
||||
suspend fun getAgents(
|
||||
@Query("skip") skip: Int = 0,
|
||||
@Query("limit") limit: Int = 100,
|
||||
@Query("search") search: String? = null
|
||||
): Response<List<AgentResponse>>
|
||||
|
||||
@GET("api/v1/agents/{agentId}")
|
||||
suspend fun getAgent(@Path("agentId") agentId: String): AgentResponse
|
||||
|
||||
@POST("api/v1/agents")
|
||||
suspend fun createAgent(@Body request: CreateAgentRequest): AgentResponse
|
||||
|
||||
@PUT("api/v1/agents/{agentId}")
|
||||
suspend fun updateAgent(
|
||||
@Path("agentId") agentId: String,
|
||||
@Body request: UpdateAgentRequest
|
||||
): AgentResponse
|
||||
|
||||
@DELETE("api/v1/agents/{agentId}")
|
||||
suspend fun deleteAgent(@Path("agentId") agentId: String): Response<Unit>
|
||||
|
||||
// ─── Chat (non-streaming) ───
|
||||
@POST("api/v1/agent-chat/bare")
|
||||
suspend fun chatBare(@Body request: ChatRequest): ChatResponse
|
||||
|
||||
@POST("api/v1/agent-chat/{agentId}")
|
||||
suspend fun chat(
|
||||
@Path("agentId") agentId: String,
|
||||
@Body request: ChatRequest
|
||||
): ChatResponse
|
||||
|
||||
// ─── Voice ───
|
||||
@Multipart
|
||||
@POST("api/v1/voice/asr")
|
||||
suspend fun transcribeAudio(
|
||||
@Part file: MultipartBody.Part,
|
||||
@Query("language") language: String = "zh"
|
||||
): AsrResponse
|
||||
|
||||
@POST("api/v1/voice/tts")
|
||||
suspend fun synthesizeSpeech(@Body request: TtsRequest): TtsResponse
|
||||
|
||||
// ─── Notifications ───
|
||||
@GET("api/v1/notifications/unread-count")
|
||||
suspend fun getUnreadCount(): UnreadCountResponse
|
||||
|
||||
@GET("api/v1/notifications")
|
||||
suspend fun getNotifications(
|
||||
@Query("unread_only") unreadOnly: Boolean = false,
|
||||
@Query("limit") limit: Int = 50,
|
||||
@Query("offset") offset: Int = 0
|
||||
): List<NotificationResponse>
|
||||
|
||||
@PUT("api/v1/notifications/{notificationId}/read")
|
||||
suspend fun markNotificationRead(@Path("notificationId") notificationId: String)
|
||||
|
||||
// ─── Upload ───
|
||||
@Multipart
|
||||
@POST("api/v1/uploads/preview")
|
||||
suspend fun uploadFile(@Part file: MultipartBody.Part): UploadResponse
|
||||
|
||||
// ─── FCM Push ───
|
||||
@POST("api/v1/fcm/register")
|
||||
suspend fun registerFcmToken(@Body request: FcmRegisterRequest): FcmRegisterResponse
|
||||
|
||||
@DELETE("api/v1/fcm/unregister")
|
||||
suspend fun unregisterFcmToken(@Query("token") token: String): FcmRegisterResponse
|
||||
|
||||
// ─── Register ───
|
||||
@POST("api/v1/auth/register")
|
||||
suspend fun register(@Body request: RegisterRequest): RegisterResponse
|
||||
|
||||
// ─── Feedback ───
|
||||
@POST("api/v1/feedback")
|
||||
suspend fun submitFeedback(@Body request: FeedbackRequest): FeedbackResponse
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.tiangong.aiagent.data.remote
|
||||
|
||||
import com.google.gson.JsonParser
|
||||
import com.tiangong.aiagent.data.local.CredentialStore
|
||||
import com.tiangong.aiagent.data.local.TokenDataStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.*
|
||||
import java.io.IOException
|
||||
|
||||
class AuthInterceptor(
|
||||
private val tokenDataStore: TokenDataStore,
|
||||
private val credentialStore: CredentialStore
|
||||
) : Interceptor {
|
||||
|
||||
@Volatile
|
||||
private var cachedToken: String? = null
|
||||
@Volatile
|
||||
private var cachedServerUrl: String? = null
|
||||
|
||||
// Pre-load token and server URL into memory to avoid blocking OkHttp dispatcher threads
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
cachedToken = tokenDataStore.getToken()
|
||||
cachedServerUrl = tokenDataStore.getServerUrl()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateToken(token: String?) {
|
||||
cachedToken = token
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
|
||||
// Use memory-cached token — no runBlocking needed
|
||||
val request = if (cachedToken != null && !originalRequest.url.encodedPath.endsWith("/login")) {
|
||||
originalRequest.newBuilder()
|
||||
.header("Authorization", "Bearer $cachedToken")
|
||||
.build()
|
||||
} else {
|
||||
originalRequest
|
||||
}
|
||||
|
||||
var response = chain.proceed(request)
|
||||
|
||||
// 401 auto re-login (with retry limit to prevent infinite loops)
|
||||
if (response.code == 401 && !originalRequest.url.encodedPath.endsWith("/login")) {
|
||||
val credentials = credentialStore.getCredentials()
|
||||
if (credentials != null) {
|
||||
val newToken = performReLogin(credentials)
|
||||
if (newToken != null) {
|
||||
// Close the 401 response since we're retrying with new token
|
||||
response.close()
|
||||
cachedToken = newToken
|
||||
val retryRequest = request.newBuilder()
|
||||
.header("Authorization", "Bearer $newToken")
|
||||
.build()
|
||||
return chain.proceed(retryRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private fun performReLogin(credentials: Pair<String, String>): String? {
|
||||
return try {
|
||||
val client = OkHttpClient()
|
||||
val body = FormBody.Builder()
|
||||
.add("username", credentials.first)
|
||||
.add("password", credentials.second)
|
||||
.build()
|
||||
val loginUrl = (cachedServerUrl ?: com.tiangong.aiagent.BuildConfig.BASE_URL).trimEnd('/') + "/api/v1/auth/login"
|
||||
val loginRequest = Request.Builder()
|
||||
.url(loginUrl)
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
val loginResponse = client.newCall(loginRequest).execute()
|
||||
if (loginResponse.isSuccessful) {
|
||||
val json = loginResponse.body?.string() ?: return null
|
||||
val obj = JsonParser.parseString(json).asJsonObject
|
||||
val newToken = obj["access_token"].asString
|
||||
// Persist the new token
|
||||
scope.launch {
|
||||
tokenDataStore.saveToken(newToken)
|
||||
}
|
||||
newToken
|
||||
} else null
|
||||
} catch (_: IOException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.tiangong.aiagent.data.remote
|
||||
|
||||
import com.tiangong.aiagent.data.local.TokenDataStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Dynamically resolves the base URL from TokenDataStore on every request,
|
||||
* allowing server URL changes without app restart (v1.2.0).
|
||||
*/
|
||||
@Singleton
|
||||
class DynamicUrlInterceptor @Inject constructor(
|
||||
private val tokenDataStore: TokenDataStore
|
||||
) : Interceptor {
|
||||
|
||||
@Volatile
|
||||
private var cachedBaseUrl: String? = null
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
cachedBaseUrl = tokenDataStore.getServerUrl()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBaseUrl(url: String) {
|
||||
cachedBaseUrl = url
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
// Fall back to BuildConfig default if no URL saved yet (first launch)
|
||||
val baseUrl = (cachedBaseUrl ?: com.tiangong.aiagent.BuildConfig.BASE_URL).trimEnd('/')
|
||||
|
||||
// Reconstruct URL with the dynamic base
|
||||
val originalUrl = originalRequest.url
|
||||
val pathSegments = originalUrl.encodedPath.removePrefix("/")
|
||||
val newUrl = "${baseUrl}/$pathSegments".toHttpUrlOrNull()?.newBuilder()
|
||||
?.apply {
|
||||
originalUrl.encodedQuery?.let { encodedQuery(it) }
|
||||
}
|
||||
?.build() ?: return chain.proceed(originalRequest)
|
||||
|
||||
val newRequest = originalRequest.newBuilder()
|
||||
.url(newUrl)
|
||||
.build()
|
||||
|
||||
return chain.proceed(newRequest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package com.tiangong.aiagent.data.remote
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonParser
|
||||
import com.tiangong.aiagent.data.local.TokenDataStore
|
||||
import com.tiangong.aiagent.data.remote.dto.ChatRequest
|
||||
import com.tiangong.aiagent.domain.model.SseEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* SSE streaming client with exponential-backoff automatic reconnection.
|
||||
*
|
||||
* Reconnection strategy:
|
||||
* - Initial delay: 1 second
|
||||
* - Backoff multiplier: 2.0
|
||||
* - Maximum delay: 30 seconds
|
||||
* - Maximum retries: 3
|
||||
* - Total timeout: 60 seconds (wall-clock)
|
||||
* - 401: no retry (auth expired)
|
||||
* - 5xx: trigger backoff retry
|
||||
* - IOException: trigger backoff retry
|
||||
*
|
||||
* Thread safety: all retry delays use coroutine delay() (non-blocking).
|
||||
* DataStore reads use first() directly within the suspend callbackFlow lambda.
|
||||
*/
|
||||
@Singleton
|
||||
class SseClient @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val gson: Gson,
|
||||
private val tokenDataStore: TokenDataStore
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val INITIAL_BACKOFF_MS = 1_000L
|
||||
private const val MAX_BACKOFF_MS = 30_000L
|
||||
private const val BACKOFF_MULTIPLIER = 2.0
|
||||
private const val MAX_RETRIES = 3
|
||||
private const val TOTAL_TIMEOUT_MS = 60_000L
|
||||
}
|
||||
|
||||
fun connect(
|
||||
agentId: String?,
|
||||
token: String,
|
||||
request: ChatRequest
|
||||
): Flow<SseEvent> = callbackFlow {
|
||||
// callbackFlow lambda is suspend — no runBlocking needed
|
||||
val baseUrl = tokenDataStore.serverUrl.first()
|
||||
val url = if (agentId != null) {
|
||||
"${baseUrl}api/v1/agent-chat/$agentId/stream"
|
||||
} else {
|
||||
"${baseUrl}api/v1/agent-chat/bare/stream"
|
||||
}
|
||||
|
||||
val jsonBody = gson.toJson(request)
|
||||
val requestBody = jsonBody.toRequestBody("application/json".toMediaType())
|
||||
|
||||
// Mutable token so reconnections pick up refreshed tokens (v1.2.0)
|
||||
// Writes happen from reconnectScope coroutine, reads from OkHttp thread;
|
||||
// AtomicReference ensures visibility across threads without @Volatile.
|
||||
val currentTokenRef = java.util.concurrent.atomic.AtomicReference(token)
|
||||
|
||||
val httpRequest = Request.Builder()
|
||||
.url(url)
|
||||
.header("Authorization", "Bearer ${currentTokenRef.get()}")
|
||||
.header("Accept", "text/event-stream")
|
||||
.header("Content-Type", "application/json")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
var currentCall: Call? = null
|
||||
var retryCount = 0
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
// Shared coroutine scope tied to the callbackFlow lifetime; avoids creating new
|
||||
// scopes on every reconnection attempt.
|
||||
val reconnectScope = CoroutineScope(coroutineContext + Job())
|
||||
|
||||
fun calculateBackoff(attempt: Int): Long {
|
||||
val backoff = (INITIAL_BACKOFF_MS * BACKOFF_MULTIPLIER.pow(attempt.toDouble())).toLong()
|
||||
return min(backoff, MAX_BACKOFF_MS)
|
||||
}
|
||||
|
||||
// Mutable reference to allow mutual recursion between executeStream ↔ scheduleReconnect.
|
||||
// executeStream is defined first so scheduleReconnect can capture it by reference.
|
||||
var scheduleReconnect: ((String) -> Unit)? = null
|
||||
|
||||
fun buildRequestWithToken(): Request {
|
||||
return httpRequest.newBuilder()
|
||||
.header("Authorization", "Bearer ${currentTokenRef.get()}")
|
||||
.build()
|
||||
}
|
||||
|
||||
fun executeStream() {
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
if (elapsed > TOTAL_TIMEOUT_MS) {
|
||||
close(IOException("SSE connection timed out after ${elapsed}ms"))
|
||||
return
|
||||
}
|
||||
|
||||
val call = okHttpClient.newCall(buildRequestWithToken())
|
||||
currentCall = call
|
||||
|
||||
call.enqueue(object : okhttp3.Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
if (retryCount < MAX_RETRIES && !isClosedForSend) {
|
||||
scheduleReconnect?.invoke("连接断开")
|
||||
} else {
|
||||
close(IOException("SSE connection failed after $MAX_RETRIES retries: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
if (!response.isSuccessful) {
|
||||
when (response.code) {
|
||||
401 -> {
|
||||
close(IOException("认证已过期"))
|
||||
return
|
||||
}
|
||||
in 500..599 -> {
|
||||
if (retryCount < MAX_RETRIES && !isClosedForSend) {
|
||||
scheduleReconnect?.invoke("服务器错误")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
close(IOException("SSE connection failed: HTTP ${response.code}"))
|
||||
return
|
||||
}
|
||||
|
||||
// Reset counter on successful connection
|
||||
retryCount = 0
|
||||
|
||||
val source = response.body?.source() ?: run {
|
||||
trySend(SseEvent.Error("服务器响应异常,请重试"))
|
||||
close(IOException("SSE response body is null"))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
var eventType = ""
|
||||
var data = ""
|
||||
|
||||
while (!source.exhausted()) {
|
||||
val line = source.readUtf8Line() ?: break
|
||||
|
||||
when {
|
||||
line.startsWith("event: ") -> {
|
||||
eventType = line.removePrefix("event: ").trim()
|
||||
}
|
||||
line.startsWith("data: ") -> {
|
||||
data = line.removePrefix("data: ").trim()
|
||||
}
|
||||
line.isEmpty() -> {
|
||||
if (data.isNotEmpty()) {
|
||||
val event = parseEvent(eventType, data)
|
||||
if (event != null) {
|
||||
trySend(event)
|
||||
}
|
||||
}
|
||||
eventType = ""
|
||||
data = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trySend(SseEvent.Done(sessionId = request.sessionId ?: ""))
|
||||
close()
|
||||
} catch (e: Exception) {
|
||||
if (retryCount < MAX_RETRIES && !isClosedForSend) {
|
||||
scheduleReconnect?.invoke("读取中断")
|
||||
} else {
|
||||
close(IOException("SSE stream read error: ${e.message}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
scheduleReconnect = { reason ->
|
||||
retryCount++
|
||||
val delayMs = calculateBackoff(retryCount)
|
||||
trySend(SseEvent.ConnectionError(
|
||||
message = "$reason,${delayMs / 1000}s 后自动重连 (${retryCount}/${MAX_RETRIES})",
|
||||
attempt = retryCount,
|
||||
maxAttempts = MAX_RETRIES
|
||||
))
|
||||
reconnectScope.launch {
|
||||
kotlinx.coroutines.delay(delayMs)
|
||||
// Refresh token before reconnecting so AuthInterceptor re-login is picked up
|
||||
tokenDataStore.getToken()?.let { currentTokenRef.set(it) }
|
||||
executeStream()
|
||||
}
|
||||
}
|
||||
|
||||
executeStream()
|
||||
|
||||
awaitClose {
|
||||
reconnectScope.cancel()
|
||||
currentCall?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEvent(type: String, json: String): SseEvent? {
|
||||
return try {
|
||||
val obj = JsonParser.parseString(json).asJsonObject
|
||||
when (type) {
|
||||
"plan" -> {
|
||||
val title = obj.get("title")?.asString ?: ""
|
||||
val steps = obj.get("steps")?.asJsonArray?.map { it.asString } ?: emptyList()
|
||||
SseEvent.Plan(title = title, steps = steps)
|
||||
}
|
||||
"think" -> SseEvent.Think(
|
||||
content = obj.get("content")?.asString ?: "",
|
||||
iteration = obj.get("iteration")?.asInt ?: 0
|
||||
)
|
||||
"tool_call" -> SseEvent.ToolCall(
|
||||
toolName = obj.get("tool_name")?.asString ?: "unknown",
|
||||
toolInput = obj.get("tool_input")?.toString() ?: ""
|
||||
)
|
||||
"tool_result" -> SseEvent.ToolResult(
|
||||
toolName = obj.get("tool_name")?.asString ?: "unknown",
|
||||
toolOutput = obj.get("tool_result")?.asString ?: "",
|
||||
success = obj.get("success")?.asBoolean ?: true
|
||||
)
|
||||
"final" -> {
|
||||
val content = obj.get("content")?.asString
|
||||
if (content == null) Log.w("SseClient", "final event missing content field")
|
||||
SseEvent.Final(
|
||||
content = content ?: "",
|
||||
sessionId = obj.get("session_id")?.asString ?: "",
|
||||
iterationsUsed = obj.get("iterations_used")?.asInt ?: 0,
|
||||
toolCallsMade = obj.get("tool_calls_made")?.asInt ?: 0
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val content = obj.get("content")?.asString
|
||||
if (content != null) {
|
||||
SseEvent.Message(content = content)
|
||||
} else {
|
||||
SseEvent.Unknown(type = type, rawJson = json)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
SseEvent.Error(error = "Parse error for event '$type': ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package com.tiangong.aiagent.data.remote.dto
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
// ─────────── Auth ───────────
|
||||
|
||||
data class LoginResponse(
|
||||
@SerializedName("access_token") val accessToken: String,
|
||||
@SerializedName("token_type") val tokenType: String
|
||||
)
|
||||
|
||||
data class UserResponse(
|
||||
val id: String,
|
||||
val username: String,
|
||||
val email: String,
|
||||
val role: String
|
||||
)
|
||||
|
||||
// ─────────── Agent ───────────
|
||||
|
||||
data class AgentResponse(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String?,
|
||||
@SerializedName("workflow_config") val workflowConfig: Map<String, Any?>?,
|
||||
@SerializedName("budget_config") val budgetConfig: Map<String, Any?>?,
|
||||
val version: Int = 1,
|
||||
val status: String,
|
||||
@SerializedName("user_id") val userId: String?,
|
||||
@SerializedName("created_at") val createdAt: String,
|
||||
@SerializedName("updated_at") val updatedAt: String
|
||||
)
|
||||
|
||||
data class CreateAgentRequest(
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val status: String = "draft",
|
||||
@SerializedName("workflow_config") val workflowConfig: Map<String, Any?>,
|
||||
@SerializedName("budget_config") val budgetConfig: Map<String, Any?> = Companion.PRO_MAX_BUDGET
|
||||
) {
|
||||
companion object {
|
||||
val ALL_TOOLS = listOf(
|
||||
"file_read", "file_write", "list_files", "grep_search",
|
||||
"http_request", "web_search", "check_website", "url_parse", "browser_use",
|
||||
"text_analyze", "text_summarize", "json_process", "json_tool",
|
||||
"math_calculate", "random_generate", "regex_test",
|
||||
"csv_processor", "excel_process", "pdf_generate",
|
||||
"base64_codec", "crypto_util", "html_to_markdown",
|
||||
"database_query", "system_info", "datetime", "timestamp",
|
||||
"code_execute", "execute_code", "code_tool_create",
|
||||
"git_operation", "git_log", "project_scaffold", "project_scan",
|
||||
"docker_manage", "deploy_push", "adb_log",
|
||||
"agent_call", "agent_create", "tool_register",
|
||||
"task_plan", "self_review", "capability_check", "extension_log",
|
||||
"create_task", "assign_task", "check_progress", "notify_user",
|
||||
"knowledge_graph_search", "knowledge_graph_add",
|
||||
"entity_search", "learning_path",
|
||||
"image_ocr", "image_vision", "speech_to_text", "text_to_speech",
|
||||
"feishu_create_doc", "feishu_create_sheet", "feishu_create_calendar_event",
|
||||
"feishu_search_contacts", "feishu_send_approval",
|
||||
"feishu_read_messages", "feishu_upload_file",
|
||||
"create_gitea_issue", "parse_test_result_file",
|
||||
"schedule_create", "schedule_list", "schedule_delete",
|
||||
"ip_info", "shorten_url", "extract_info",
|
||||
)
|
||||
|
||||
// ─── 动态系统提示词生成 ───
|
||||
|
||||
// 通用工具 & 安全指引
|
||||
private const val TOOL_USAGE = """
|
||||
|
||||
## 可用工具
|
||||
你拥有文件读写、网络搜索、代码执行、数据库查询、图像识别、语音处理、
|
||||
飞书集成、知识图谱、Git操作、项目管理等 60+ 工具。
|
||||
遇到需要外部操作的任务时主动调用对应工具完成。"""
|
||||
|
||||
private const val SAFETY = """
|
||||
|
||||
## 安全边界
|
||||
- 拒绝生成违法、暴力、色情等有害内容
|
||||
- 医疗建议只提供通用信息,提醒用户咨询专业医生
|
||||
- 金融建议只提供基础知识,不构成投资建议
|
||||
- 涉及隐私信息时主动提醒用户注意保护"""
|
||||
|
||||
/**
|
||||
* 根据名称和描述动态生成系统提示词。
|
||||
* 不再使用硬编码模板,人格由用户描述直接定义。
|
||||
*/
|
||||
fun buildSystemPrompt(name: String, description: String?): String {
|
||||
return buildString {
|
||||
append("你是${name}。")
|
||||
if (!description.isNullOrBlank()) {
|
||||
append("\n\n")
|
||||
append(description)
|
||||
} else {
|
||||
append("你是一个智能AI助手。")
|
||||
}
|
||||
append(TOOL_USAGE)
|
||||
append(SAFETY)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 Pro Max 工作流,系统提示词由 buildSystemPrompt() 动态生成。
|
||||
*/
|
||||
fun buildWorkflow(name: String, description: String?): Map<String, Any?> {
|
||||
val systemPrompt = buildSystemPrompt(name, description)
|
||||
|
||||
val startId = "start-1"
|
||||
val llmId = "llm-pro-max"
|
||||
val endId = "end-1"
|
||||
|
||||
return mapOf(
|
||||
"nodes" to listOf(
|
||||
mapOf(
|
||||
"id" to startId,
|
||||
"type" to "start",
|
||||
"position" to mapOf("x" to 100, "y" to 200),
|
||||
"data" to mapOf("label" to "开始"),
|
||||
),
|
||||
mapOf(
|
||||
"id" to llmId,
|
||||
"type" to "llm",
|
||||
"position" to mapOf("x" to 380, "y" to 200),
|
||||
"data" to mapOf<String, Any?>(
|
||||
"label" to name,
|
||||
"model" to "deepseek-v4-pro",
|
||||
"provider" to "deepseek",
|
||||
"temperature" to 0.8,
|
||||
"max_iterations" to 15,
|
||||
"tools" to ALL_TOOLS,
|
||||
"selected_tools" to ALL_TOOLS,
|
||||
"enable_tools" to true,
|
||||
"memory" to true,
|
||||
"memory_persist" to true,
|
||||
"memory_vector_enabled" to true,
|
||||
"memory_vector_top_k" to 20,
|
||||
"memory_learning" to true,
|
||||
"memory_max_history" to 25,
|
||||
"system_prompt" to systemPrompt,
|
||||
),
|
||||
),
|
||||
mapOf(
|
||||
"id" to endId,
|
||||
"type" to "end",
|
||||
"position" to mapOf("x" to 640, "y" to 200),
|
||||
"data" to mapOf("label" to "结束"),
|
||||
),
|
||||
),
|
||||
"edges" to listOf(
|
||||
mapOf("id" to "e-pro-start", "source" to startId, "target" to llmId),
|
||||
mapOf("id" to "e-pro-end", "source" to llmId, "target" to endId),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val PRO_MAX_BUDGET: Map<String, Any?> = mapOf(
|
||||
"max_llm_invocations" to 200,
|
||||
"max_tool_calls" to 500,
|
||||
"timeout_seconds" to 300,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class UpdateAgentRequest(
|
||||
val name: String? = null,
|
||||
val description: String? = null,
|
||||
val status: String? = null,
|
||||
@SerializedName("workflow_config") val workflowConfig: Map<String, Any?>? = null,
|
||||
@SerializedName("budget_config") val budgetConfig: Map<String, Any?>? = null
|
||||
)
|
||||
|
||||
// ─────────── Chat ───────────
|
||||
|
||||
data class ChatRequest(
|
||||
val message: String,
|
||||
@SerializedName("session_id") val sessionId: String? = null,
|
||||
val streamlined: Boolean = false,
|
||||
val model: String? = null,
|
||||
val temperature: Double? = null,
|
||||
@SerializedName("max_iterations") val maxIterations: Int? = null,
|
||||
@SerializedName("prompt_sections_enabled") val promptSectionsEnabled: Boolean = false
|
||||
)
|
||||
|
||||
data class ChatResponse(
|
||||
val content: String,
|
||||
@SerializedName("iterations_used") val iterationsUsed: Int = 0,
|
||||
@SerializedName("tool_calls_made") val toolCallsMade: Int = 0,
|
||||
val truncated: Boolean = false,
|
||||
@SerializedName("session_id") val sessionId: String? = null,
|
||||
@SerializedName("agent_id") val agentId: String? = null,
|
||||
val steps: List<AgentStepDto> = emptyList(),
|
||||
@SerializedName("token_usage") val tokenUsage: TokenUsageDto? = null
|
||||
)
|
||||
|
||||
data class AgentStepDto(
|
||||
val iteration: Int,
|
||||
val type: String, // think, tool_call, tool_result, final
|
||||
val content: String = "",
|
||||
@SerializedName("tool_name") val toolName: String? = null,
|
||||
@SerializedName("tool_input") val toolInput: Map<String, Any?>? = null,
|
||||
@SerializedName("tool_result") val toolResult: String? = null,
|
||||
val reasoning: String? = null
|
||||
)
|
||||
|
||||
data class TokenUsageDto(
|
||||
@SerializedName("input_tokens") val inputTokens: Int = 0,
|
||||
@SerializedName("input_remaining") val inputRemaining: Int = 0,
|
||||
@SerializedName("input_usage_pct") val inputUsagePct: Double = 0.0,
|
||||
@SerializedName("effective_window") val effectiveWindow: Int = 128000,
|
||||
@SerializedName("context_window") val contextWindow: Int = 128000,
|
||||
@SerializedName("cumulative_total") val cumulativeTotal: Int = 0,
|
||||
@SerializedName("llm_call_count") val llmCallCount: Int = 0,
|
||||
@SerializedName("is_warning") val isWarning: Boolean = false,
|
||||
@SerializedName("is_exhausted") val isExhausted: Boolean = false
|
||||
)
|
||||
|
||||
// ─────────── Notification ───────────
|
||||
|
||||
data class UnreadCountResponse(
|
||||
val count: Int
|
||||
)
|
||||
|
||||
data class NotificationResponse(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val body: String,
|
||||
@SerializedName("is_read") val isRead: Boolean,
|
||||
@SerializedName("created_at") val createdAt: String,
|
||||
val url: String? = null
|
||||
)
|
||||
|
||||
// ─────────── Voice ───────────
|
||||
|
||||
data class AsrResponse(
|
||||
val text: String,
|
||||
val language: String = "zh"
|
||||
)
|
||||
|
||||
data class TtsRequest(
|
||||
val text: String,
|
||||
val voice: String = "alloy"
|
||||
)
|
||||
|
||||
data class TtsResponse(
|
||||
@SerializedName("audio_url") val audioUrl: String,
|
||||
@SerializedName("text_length") val textLength: Int,
|
||||
val voice: String
|
||||
)
|
||||
|
||||
// ─────────── FCM Push ───────────
|
||||
|
||||
data class FcmRegisterRequest(
|
||||
val token: String,
|
||||
val platform: String = "android"
|
||||
)
|
||||
|
||||
data class FcmRegisterResponse(
|
||||
val status: String,
|
||||
val id: String? = null
|
||||
)
|
||||
|
||||
// ─────────── Register ───────────
|
||||
|
||||
data class RegisterRequest(
|
||||
val username: String,
|
||||
val password: String,
|
||||
val email: String = "",
|
||||
@SerializedName("display_name") val displayName: String? = null
|
||||
)
|
||||
|
||||
data class RegisterResponse(
|
||||
val id: String,
|
||||
val username: String,
|
||||
val message: String? = null
|
||||
)
|
||||
|
||||
// ─────────── Feedback ───────────
|
||||
|
||||
data class FeedbackRequest(
|
||||
@SerializedName("message_id") val messageId: String,
|
||||
val rating: String, // "like" or "dislike"
|
||||
val comment: String? = null,
|
||||
@SerializedName("session_id") val sessionId: String? = null
|
||||
)
|
||||
|
||||
data class FeedbackResponse(
|
||||
val id: String? = null,
|
||||
val status: String? = null
|
||||
)
|
||||
|
||||
// ─────────── Upload ───────────
|
||||
|
||||
data class UploadResponse(
|
||||
@SerializedName("relative_path") val relativePath: String,
|
||||
val filename: String,
|
||||
val size: Int,
|
||||
@SerializedName("content_type") val contentType: String? = null
|
||||
)
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.tiangong.aiagent.data.repository
|
||||
|
||||
import com.google.gson.JsonParser
|
||||
import com.tiangong.aiagent.data.local.TokenDataStore
|
||||
import com.tiangong.aiagent.data.remote.ApiService
|
||||
import com.tiangong.aiagent.data.remote.dto.CreateAgentRequest
|
||||
import com.tiangong.aiagent.data.remote.dto.UpdateAgentRequest
|
||||
import com.tiangong.aiagent.domain.model.Agent
|
||||
import retrofit2.HttpException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AgentRepository @Inject constructor(
|
||||
private val apiService: ApiService,
|
||||
private val tokenDataStore: TokenDataStore
|
||||
) {
|
||||
|
||||
suspend fun getAgents(skip: Int = 0, limit: Int = 100, search: String? = null): Result<List<Agent>> {
|
||||
return try {
|
||||
val response = apiService.getAgents(skip, limit, search)
|
||||
if (response.isSuccessful) {
|
||||
val agents = response.body()?.map { dto ->
|
||||
Agent(
|
||||
id = dto.id,
|
||||
name = dto.name,
|
||||
description = dto.description ?: "",
|
||||
status = dto.status,
|
||||
userId = dto.userId,
|
||||
version = dto.version,
|
||||
createdAt = dto.createdAt,
|
||||
updatedAt = dto.updatedAt
|
||||
)
|
||||
} ?: emptyList()
|
||||
val totalCount = response.headers()["X-Total-Count"]?.toIntOrNull() ?: agents.size
|
||||
Result.success(agents)
|
||||
} else {
|
||||
Result.failure(Exception("Failed to fetch agents: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getAgent(agentId: String): Result<Agent> {
|
||||
return try {
|
||||
val dto = apiService.getAgent(agentId)
|
||||
Result.success(
|
||||
Agent(
|
||||
id = dto.id,
|
||||
name = dto.name,
|
||||
description = dto.description ?: "",
|
||||
status = dto.status,
|
||||
userId = dto.userId,
|
||||
version = dto.version,
|
||||
createdAt = dto.createdAt,
|
||||
updatedAt = dto.updatedAt
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveCurrentAgent(agentId: String, agentName: String) {
|
||||
tokenDataStore.saveCurrentAgent(agentId, agentName)
|
||||
}
|
||||
|
||||
suspend fun getCurrentAgentId(): String? {
|
||||
return tokenDataStore.getCurrentAgentId()
|
||||
}
|
||||
|
||||
suspend fun createAgent(name: String, description: String?, status: String = "draft"): Result<Agent> {
|
||||
return try {
|
||||
val workflow = CreateAgentRequest.buildWorkflow(name, description)
|
||||
val request = CreateAgentRequest(
|
||||
name = name,
|
||||
description = description,
|
||||
status = status,
|
||||
workflowConfig = workflow,
|
||||
)
|
||||
val dto = apiService.createAgent(request)
|
||||
// Auto-publish after creation (matches seed_max_agent.py behavior)
|
||||
try {
|
||||
apiService.updateAgent(dto.id, UpdateAgentRequest(status = "published"))
|
||||
} catch (_: Exception) { /* publish is best-effort */ }
|
||||
Result.success(dto.toDomain())
|
||||
} catch (e: HttpException) {
|
||||
Result.failure(Exception(parseServerError(e)))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateAgent(agentId: String, name: String?, description: String?, status: String?): Result<Agent> {
|
||||
return try {
|
||||
val dto = apiService.updateAgent(agentId, UpdateAgentRequest(name, description, status))
|
||||
Result.success(dto.toDomain())
|
||||
} catch (e: HttpException) {
|
||||
Result.failure(Exception(parseServerError(e)))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteAgent(agentId: String): Result<Unit> {
|
||||
return try {
|
||||
val response = apiService.deleteAgent(agentId)
|
||||
if (response.isSuccessful) {
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
Result.failure(Exception("删除失败: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseServerError(e: HttpException): String {
|
||||
val errorBody = try {
|
||||
e.response()?.errorBody()?.string()
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
val serverMsg = errorBody?.let { body ->
|
||||
try {
|
||||
val json = JsonParser.parseString(body).asJsonObject
|
||||
val details = json.getAsJsonArray("details")
|
||||
if (details != null && details.size() > 0) {
|
||||
details.map { detail ->
|
||||
val obj = detail.asJsonObject
|
||||
val field = obj.get("field")?.asString?.removePrefix("body.") ?: ""
|
||||
val msg = obj.get("message")?.asString ?: ""
|
||||
if (field.isNotEmpty()) "$field: $msg" else msg
|
||||
}.joinToString("; ")
|
||||
} else {
|
||||
json.get("message")?.asString
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
return when (e.code()) {
|
||||
422 -> serverMsg ?: "请求参数不符合要求"
|
||||
404 -> "智能体不存在"
|
||||
in 500..599 -> "服务器繁忙,请稍后重试"
|
||||
else -> serverMsg ?: "请求失败 (${e.code()})"
|
||||
}
|
||||
}
|
||||
|
||||
private fun com.tiangong.aiagent.data.remote.dto.AgentResponse.toDomain() = Agent(
|
||||
id = id,
|
||||
name = name,
|
||||
description = description ?: "",
|
||||
status = status,
|
||||
userId = userId,
|
||||
version = version,
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.tiangong.aiagent.data.repository
|
||||
|
||||
import com.tiangong.aiagent.data.local.CredentialStore
|
||||
import com.tiangong.aiagent.data.local.TokenDataStore
|
||||
import com.tiangong.aiagent.data.remote.ApiService
|
||||
import com.tiangong.aiagent.data.remote.dto.LoginResponse
|
||||
import com.tiangong.aiagent.data.remote.dto.RegisterRequest
|
||||
import com.tiangong.aiagent.data.remote.dto.RegisterResponse
|
||||
import com.google.gson.JsonParser
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AuthRepository @Inject constructor(
|
||||
private val apiService: ApiService,
|
||||
private val tokenDataStore: TokenDataStore,
|
||||
private val credentialStore: CredentialStore,
|
||||
private val authInterceptor: com.tiangong.aiagent.data.remote.AuthInterceptor
|
||||
) {
|
||||
|
||||
suspend fun login(username: String, password: String): Result<LoginResponse> {
|
||||
return try {
|
||||
val response = apiService.login(username, password)
|
||||
tokenDataStore.saveToken(response.accessToken)
|
||||
credentialStore.saveCredentials(username, password)
|
||||
// Update AuthInterceptor memory cache so subsequent requests carry the token
|
||||
authInterceptor.updateToken(response.accessToken)
|
||||
Result.success(response)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user account.
|
||||
* Handles 409 Conflict (username taken), 422 Validation, and network errors.
|
||||
*/
|
||||
suspend fun register(request: RegisterRequest): Result<RegisterResponse> {
|
||||
return try {
|
||||
val response = apiService.register(request)
|
||||
Result.success(response)
|
||||
} catch (e: HttpException) {
|
||||
val message = parseRegisterError(e)
|
||||
Result.failure(Exception(message))
|
||||
} catch (e: IOException) {
|
||||
Result.failure(Exception("网络连接失败,请检查网络设置"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseRegisterError(e: HttpException): String {
|
||||
val errorBody = try {
|
||||
e.response()?.errorBody()?.string()
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
// Try to extract useful server message
|
||||
val serverMsg = errorBody?.let { body ->
|
||||
try {
|
||||
val json = JsonParser.parseString(body).asJsonObject
|
||||
// Format: {"error":"...", "message":"...", "details":[{"field":"...","message":"..."}]}
|
||||
val details = json.getAsJsonArray("details")
|
||||
if (details != null && details.size() > 0) {
|
||||
details.map { detail ->
|
||||
val obj = detail.asJsonObject
|
||||
val field = obj.get("field")?.asString?.removePrefix("body.") ?: ""
|
||||
val msg = obj.get("message")?.asString ?: ""
|
||||
if (field.isNotEmpty()) "${field}: $msg" else msg
|
||||
}.joinToString("; ")
|
||||
} else {
|
||||
json.get("message")?.asString
|
||||
?: json.get("detail")?.asString
|
||||
?: json.entrySet().firstOrNull()?.value?.asString
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
return when (e.code()) {
|
||||
409 -> serverMsg ?: "用户名或邮箱已被注册"
|
||||
422 -> serverMsg ?: "注册信息不符合要求"
|
||||
in 500..599 -> "服务器繁忙,请稍后重试"
|
||||
else -> serverMsg ?: "请求失败 (${e.code()})"
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun logout() {
|
||||
tokenDataStore.clearAll()
|
||||
credentialStore.clearCredentials()
|
||||
authInterceptor.updateToken(null)
|
||||
}
|
||||
|
||||
suspend fun getToken(): String? {
|
||||
return tokenDataStore.getToken()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package com.tiangong.aiagent.data.repository
|
||||
|
||||
import com.tiangong.aiagent.data.local.AppDatabase
|
||||
import com.tiangong.aiagent.data.local.ConversationEntity
|
||||
import com.tiangong.aiagent.data.local.MessageEntity
|
||||
import com.tiangong.aiagent.data.local.TokenDataStore
|
||||
import com.tiangong.aiagent.data.remote.ApiService
|
||||
import com.tiangong.aiagent.data.remote.SseClient
|
||||
import com.tiangong.aiagent.data.remote.dto.ChatRequest
|
||||
import com.tiangong.aiagent.data.remote.dto.ChatResponse
|
||||
import com.tiangong.aiagent.data.remote.dto.TtsRequest
|
||||
import com.tiangong.aiagent.domain.model.Conversation
|
||||
import com.tiangong.aiagent.domain.model.Message
|
||||
import com.tiangong.aiagent.domain.model.SseEvent
|
||||
import com.tiangong.aiagent.domain.model.TokenUsage
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ChatRepository @Inject constructor(
|
||||
private val apiService: ApiService,
|
||||
private val sseClient: SseClient,
|
||||
private val tokenDataStore: TokenDataStore,
|
||||
private val database: AppDatabase
|
||||
) {
|
||||
|
||||
private val saveMutex = Mutex()
|
||||
|
||||
suspend fun chat(agentId: String?, message: String, sessionId: String? = null): Result<ChatResponse> {
|
||||
return try {
|
||||
val request = ChatRequest(message = message, sessionId = sessionId)
|
||||
val response = if (agentId != null) {
|
||||
apiService.chat(agentId, request)
|
||||
} else {
|
||||
apiService.chatBare(request)
|
||||
}
|
||||
saveMessagesFromResponse(response, agentId)
|
||||
Result.success(response)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun chatStream(agentId: String?, message: String, sessionId: String? = null): Flow<SseEvent> {
|
||||
val token = tokenDataStore.getToken() ?: ""
|
||||
val request = ChatRequest(message = message, sessionId = sessionId)
|
||||
return sseClient.connect(agentId, token, request)
|
||||
}
|
||||
|
||||
// ─── Local Storage ───
|
||||
|
||||
suspend fun saveMessage(
|
||||
id: String,
|
||||
conversationId: String,
|
||||
agentId: String?,
|
||||
role: String,
|
||||
content: String,
|
||||
toolName: String? = null,
|
||||
toolInput: String? = null,
|
||||
toolOutput: String? = null
|
||||
) {
|
||||
saveMutex.withLock {
|
||||
val now = System.currentTimeMillis()
|
||||
database.messageDao().insert(
|
||||
MessageEntity(
|
||||
id = id,
|
||||
conversationId = conversationId,
|
||||
agentId = agentId,
|
||||
role = role,
|
||||
content = content,
|
||||
toolName = toolName,
|
||||
toolInput = toolInput,
|
||||
toolOutput = toolOutput,
|
||||
tokenUsageJson = null,
|
||||
createdAt = now
|
||||
)
|
||||
)
|
||||
|
||||
// Upsert conversation (protected by mutex to prevent TOCTOU race)
|
||||
val existing = database.conversationDao().getById(conversationId)
|
||||
val title = existing?.title ?: content.take(50)
|
||||
val count = (existing?.messageCount ?: 0) + 1
|
||||
database.conversationDao().insert(
|
||||
ConversationEntity(
|
||||
sessionId = conversationId,
|
||||
agentId = agentId ?: existing?.agentId,
|
||||
agentName = existing?.agentName,
|
||||
title = title,
|
||||
lastMessage = content.take(100),
|
||||
lastMessageAt = now,
|
||||
messageCount = count
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getConversationsByAgent(agentId: String?): List<Conversation> {
|
||||
val entities = if (agentId != null) {
|
||||
database.conversationDao().getByAgentId(agentId)
|
||||
} else {
|
||||
database.conversationDao().getAllConversationsSync()
|
||||
}
|
||||
return entities.map { entity ->
|
||||
Conversation(
|
||||
sessionId = entity.sessionId,
|
||||
agentId = entity.agentId,
|
||||
agentName = entity.agentName,
|
||||
title = entity.title,
|
||||
lastMessage = entity.lastMessage,
|
||||
lastMessageAt = entity.lastMessageAt,
|
||||
messageCount = entity.messageCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveMessagesFromResponse(response: ChatResponse, agentId: String?) {
|
||||
val conversationId = response.sessionId ?: return
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
// Save conversation
|
||||
val title = response.steps.firstOrNull()?.content?.take(50) ?: "New Chat"
|
||||
database.conversationDao().insert(
|
||||
ConversationEntity(
|
||||
sessionId = conversationId,
|
||||
agentId = agentId,
|
||||
agentName = null,
|
||||
title = title,
|
||||
lastMessage = response.content.take(100),
|
||||
lastMessageAt = now,
|
||||
messageCount = response.steps.size
|
||||
)
|
||||
)
|
||||
|
||||
// Save messages from steps
|
||||
val messages = response.steps.mapIndexed { index, step ->
|
||||
MessageEntity(
|
||||
id = "${conversationId}_$index",
|
||||
conversationId = conversationId,
|
||||
agentId = agentId,
|
||||
role = when (step.type) {
|
||||
"tool_call", "tool_result" -> "tool"
|
||||
"final" -> "assistant"
|
||||
else -> step.type
|
||||
},
|
||||
content = step.content,
|
||||
toolName = step.toolName,
|
||||
toolInput = step.toolInput?.toString(),
|
||||
toolOutput = step.toolResult,
|
||||
tokenUsageJson = null,
|
||||
createdAt = now + index
|
||||
)
|
||||
}
|
||||
database.messageDao().insertAll(messages)
|
||||
}
|
||||
|
||||
fun getConversations(): Flow<List<Conversation>> {
|
||||
return database.conversationDao().getAllConversations().map { entities ->
|
||||
entities.map { entity ->
|
||||
Conversation(
|
||||
sessionId = entity.sessionId,
|
||||
agentId = entity.agentId,
|
||||
agentName = entity.agentName,
|
||||
title = entity.title,
|
||||
lastMessage = entity.lastMessage,
|
||||
lastMessageAt = entity.lastMessageAt,
|
||||
messageCount = entity.messageCount
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getMessages(conversationId: String): Flow<List<Message>> {
|
||||
return database.messageDao().getMessagesByConversation(conversationId).map { entities ->
|
||||
entities.map { entity ->
|
||||
Message(
|
||||
id = entity.id,
|
||||
conversationId = entity.conversationId,
|
||||
agentId = entity.agentId,
|
||||
role = try {
|
||||
Message.Role.valueOf(entity.role.uppercase())
|
||||
} catch (e: Exception) {
|
||||
Message.Role.SYSTEM
|
||||
},
|
||||
content = entity.content,
|
||||
toolName = entity.toolName,
|
||||
toolInput = entity.toolInput,
|
||||
toolOutput = entity.toolOutput,
|
||||
createdAt = entity.createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v1.2.0: Update conversation title
|
||||
suspend fun updateConversationTitle(sessionId: String, newTitle: String) {
|
||||
val entity = database.conversationDao().getById(sessionId) ?: return
|
||||
database.conversationDao().update(entity.copy(title = newTitle))
|
||||
}
|
||||
|
||||
suspend fun deleteConversation(sessionId: String) {
|
||||
database.messageDao().deleteByConversation(sessionId)
|
||||
database.conversationDao().deleteById(sessionId)
|
||||
}
|
||||
|
||||
// ─── Media operations (v1.2.0: moved from ChatViewModel to repository layer) ───
|
||||
|
||||
/** Synthesize speech and return the absolute audio URL. */
|
||||
suspend fun synthesizeSpeech(text: String): Result<String> {
|
||||
return try {
|
||||
val ttsEnabled = tokenDataStore.ttsEnabled.first()
|
||||
if (!ttsEnabled) return Result.failure(Exception("TTS disabled"))
|
||||
val voice = tokenDataStore.ttsVoice.first()
|
||||
val response = apiService.synthesizeSpeech(TtsRequest(text = text, voice = voice))
|
||||
val baseUrl = tokenDataStore.serverUrl.first()
|
||||
val audioUrl = if (response.audioUrl.startsWith("http")) {
|
||||
response.audioUrl
|
||||
} else {
|
||||
baseUrl.trimEnd('/') + "/" + response.audioUrl.trimStart('/')
|
||||
}
|
||||
Result.success(audioUrl)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Transcribe voice audio file, returning the recognized text. */
|
||||
suspend fun transcribeVoice(file: File): Result<String> {
|
||||
return try {
|
||||
val requestBody = file.asRequestBody("audio/aac".toMediaType())
|
||||
val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
|
||||
val response = apiService.transcribeAudio(part)
|
||||
Result.success(response.text.trim())
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload an image file, returning the Markdown image reference. */
|
||||
suspend fun uploadImage(file: File): Result<String> {
|
||||
return try {
|
||||
val requestBody = file.asRequestBody("image/*".toMediaType())
|
||||
val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
|
||||
val response = apiService.uploadFile(part)
|
||||
val baseUrl = tokenDataStore.serverUrl.first()
|
||||
val imageUrl = "${baseUrl.trimEnd('/')}/api/v1/uploads/preview/file?file_path=${response.relativePath}"
|
||||
Result.success("[图片]($imageUrl)")
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.tiangong.aiagent.data.repository
|
||||
|
||||
import com.tiangong.aiagent.data.remote.ApiService
|
||||
import com.tiangong.aiagent.data.remote.dto.FeedbackRequest
|
||||
import com.tiangong.aiagent.data.remote.dto.FeedbackResponse
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class FeedbackRepository @Inject constructor(
|
||||
private val apiService: ApiService
|
||||
) {
|
||||
suspend fun submitFeedback(
|
||||
messageId: String,
|
||||
rating: String,
|
||||
comment: String? = null,
|
||||
sessionId: String? = null
|
||||
): Result<FeedbackResponse> {
|
||||
return try {
|
||||
val request = FeedbackRequest(
|
||||
messageId = messageId,
|
||||
rating = rating,
|
||||
comment = comment,
|
||||
sessionId = sessionId
|
||||
)
|
||||
val response = apiService.submitFeedback(request)
|
||||
Result.success(response)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.tiangong.aiagent.data.repository
|
||||
|
||||
import com.tiangong.aiagent.data.remote.ApiService
|
||||
import com.tiangong.aiagent.data.remote.dto.NotificationResponse
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NotificationRepository @Inject constructor(
|
||||
private val apiService: ApiService
|
||||
) {
|
||||
|
||||
suspend fun getUnreadCount(): Result<Int> {
|
||||
return try {
|
||||
val response = apiService.getUnreadCount()
|
||||
Result.success(response.count)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getNotifications(
|
||||
unreadOnly: Boolean = false,
|
||||
limit: Int = 50,
|
||||
offset: Int = 0
|
||||
): Result<List<NotificationResponse>> {
|
||||
return try {
|
||||
val notifications = apiService.getNotifications(unreadOnly, limit, offset)
|
||||
Result.success(notifications)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markAsRead(notificationId: String) {
|
||||
apiService.markNotificationRead(notificationId)
|
||||
}
|
||||
}
|
||||
147
android/app/src/main/java/com/tiangong/aiagent/di/AppModule.kt
Normal file
147
android/app/src/main/java/com/tiangong/aiagent/di/AppModule.kt
Normal file
@@ -0,0 +1,147 @@
|
||||
package com.tiangong.aiagent.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.tiangong.aiagent.BuildConfig
|
||||
import com.tiangong.aiagent.data.local.AppDatabase
|
||||
import com.tiangong.aiagent.data.local.CredentialStore
|
||||
import com.tiangong.aiagent.data.local.TokenDataStore
|
||||
import com.tiangong.aiagent.data.remote.ApiService
|
||||
import com.tiangong.aiagent.data.remote.AuthInterceptor
|
||||
import com.tiangong.aiagent.data.remote.DynamicUrlInterceptor
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGson(): Gson {
|
||||
return GsonBuilder()
|
||||
.create()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLoggingInterceptor(): HttpLoggingInterceptor {
|
||||
return HttpLoggingInterceptor().apply {
|
||||
level = if (BuildConfig.DEBUG) {
|
||||
HttpLoggingInterceptor.Level.BODY
|
||||
} else {
|
||||
HttpLoggingInterceptor.Level.NONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthInterceptor(
|
||||
tokenDataStore: TokenDataStore,
|
||||
credentialStore: CredentialStore
|
||||
): AuthInterceptor {
|
||||
return AuthInterceptor(tokenDataStore, credentialStore)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDynamicUrlInterceptor(
|
||||
tokenDataStore: TokenDataStore
|
||||
): DynamicUrlInterceptor {
|
||||
return DynamicUrlInterceptor(tokenDataStore)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(
|
||||
authInterceptor: AuthInterceptor,
|
||||
dynamicUrlInterceptor: DynamicUrlInterceptor,
|
||||
loggingInterceptor: HttpLoggingInterceptor
|
||||
): OkHttpClient {
|
||||
return OkHttpClient.Builder()
|
||||
.addInterceptor(dynamicUrlInterceptor) // v1.2.0: dynamic base URL, no restart needed
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS) // No timeout for SSE streaming
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(
|
||||
okHttpClient: OkHttpClient,
|
||||
gson: Gson
|
||||
): Retrofit {
|
||||
// Use placeholder URL — DynamicUrlInterceptor handles actual routing at request time.
|
||||
// This eliminates the runBlocking call that previously blocked the calling thread.
|
||||
return Retrofit.Builder()
|
||||
.baseUrl("http://localhost/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideApiService(retrofit: Retrofit): ApiService {
|
||||
return retrofit.create(ApiService::class.java)
|
||||
}
|
||||
|
||||
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("""
|
||||
CREATE TABLE IF NOT EXISTS pending_messages (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
agentId TEXT,
|
||||
message TEXT NOT NULL,
|
||||
sessionId TEXT,
|
||||
createdAt INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent())
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// Recreate table with correct schema (NOT NULL id column)
|
||||
db.execSQL("DROP TABLE IF EXISTS pending_messages")
|
||||
db.execSQL("""
|
||||
CREATE TABLE pending_messages (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
agentId TEXT,
|
||||
message TEXT NOT NULL,
|
||||
sessionId TEXT,
|
||||
createdAt INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent())
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRoomDatabase(@ApplicationContext context: Context): AppDatabase {
|
||||
return Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"tiangong_db"
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.tiangong.aiagent.domain.model
|
||||
|
||||
data class Agent(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String = "",
|
||||
val status: String = "published", // draft, published, running, stopped
|
||||
val userId: String? = null,
|
||||
val version: Int = 1,
|
||||
val createdAt: String = "",
|
||||
val updatedAt: String = ""
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.tiangong.aiagent.domain.model
|
||||
|
||||
data class Conversation(
|
||||
val sessionId: String,
|
||||
val agentId: String? = null,
|
||||
val agentName: String? = null,
|
||||
val title: String? = null,
|
||||
val lastMessage: String? = null,
|
||||
val lastMessageAt: Long = System.currentTimeMillis(),
|
||||
val messageCount: Int = 0
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.tiangong.aiagent.domain.model
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class Message(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val conversationId: String,
|
||||
val agentId: String? = null,
|
||||
val role: Role,
|
||||
val content: String = "",
|
||||
val toolName: String? = null,
|
||||
val toolInput: String? = null,
|
||||
val toolOutput: String? = null,
|
||||
val tokenUsage: TokenUsage? = null,
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
) {
|
||||
enum class Role { USER, ASSISTANT, TOOL, SYSTEM }
|
||||
}
|
||||
|
||||
data class TokenUsage(
|
||||
val inputTokens: Int = 0,
|
||||
val cumulativeTotal: Int = 0,
|
||||
val llmCallCount: Int = 0
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.tiangong.aiagent.domain.model
|
||||
|
||||
sealed class SseEvent {
|
||||
data class Message(val content: String) : SseEvent()
|
||||
data class Think(val content: String, val iteration: Int) : SseEvent()
|
||||
data class ToolCall(val toolName: String, val toolInput: String) : SseEvent()
|
||||
data class ToolResult(val toolName: String, val toolOutput: String, val success: Boolean) : SseEvent()
|
||||
data class Final(
|
||||
val content: String,
|
||||
val sessionId: String,
|
||||
val iterationsUsed: Int = 0,
|
||||
val toolCallsMade: Int = 0
|
||||
) : SseEvent()
|
||||
data class Plan(val title: String, val steps: List<String>) : SseEvent()
|
||||
data class Done(val sessionId: String) : SseEvent()
|
||||
data class Error(val error: String) : SseEvent()
|
||||
data class Unknown(val type: String, val rawJson: String) : SseEvent()
|
||||
|
||||
/** Emitted during reconnection attempts so the UI can show progress. */
|
||||
data class ConnectionError(
|
||||
val message: String,
|
||||
val attempt: Int,
|
||||
val maxAttempts: Int
|
||||
) : SseEvent()
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package com.tiangong.aiagent.ui.agents
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.tiangong.aiagent.ui.common.SkeletonAgentList
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AgentListScreen(
|
||||
onBack: () -> Unit,
|
||||
onAgentSelected: (com.tiangong.aiagent.domain.model.Agent) -> Unit,
|
||||
viewModel: AgentListViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("选择智能体") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "返回"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = { showCreateDialog = true },
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = "创建智能体"
|
||||
)
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
// Search bar
|
||||
OutlinedTextField(
|
||||
value = uiState.searchQuery,
|
||||
onValueChange = viewModel::onSearchQueryChange,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
placeholder = { Text("搜索智能体...") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Search, contentDescription = null)
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
if (uiState.isLoading && uiState.agents.isEmpty()) {
|
||||
SkeletonAgentList(count = 6)
|
||||
} else {
|
||||
uiState.error?.let { errorText ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = errorText,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextButton(onClick = { viewModel.loadAgents() }) {
|
||||
Text("重试")
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: run {
|
||||
if (uiState.agents.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "暂无可用智能体",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
items(uiState.agents) { agent ->
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
AgentListItem(
|
||||
agent = agent,
|
||||
onClick = { onAgentSelected(agent) },
|
||||
onLongClick = { showDeleteDialog = true }
|
||||
)
|
||||
if (showDeleteDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = { Text("删除智能体") },
|
||||
text = { Text("确定要删除「${agent.name}」吗?此操作不可撤销。") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.deleteAgent(agent.id)
|
||||
showDeleteDialog = false
|
||||
}
|
||||
) {
|
||||
Text("删除", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create agent dialog
|
||||
if (showCreateDialog) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var description by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showCreateDialog = false },
|
||||
title = { Text("创建智能体") },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("名称") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("描述 (可选)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 3
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.createAgent(name, description)
|
||||
showCreateDialog = false
|
||||
},
|
||||
enabled = name.isNotBlank()
|
||||
) {
|
||||
Text("创建")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showCreateDialog = false }) {
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun AgentListItem(
|
||||
agent: com.tiangong.aiagent.domain.model.Agent,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit = {}
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 1.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Avatar
|
||||
Surface(
|
||||
modifier = Modifier.size(44.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.primaryContainer
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = agent.name.take(1),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = agent.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.extraSmall,
|
||||
color = when (agent.status) {
|
||||
"published", "running" -> MaterialTheme.colorScheme.primaryContainer
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = when (agent.status) {
|
||||
"published" -> "已发布"
|
||||
"running" -> "运行中"
|
||||
"draft" -> "草稿"
|
||||
"stopped" -> "已停止"
|
||||
else -> agent.status
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
if (agent.description.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = agent.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.tiangong.aiagent.ui.agents
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tiangong.aiagent.data.repository.AgentRepository
|
||||
import com.tiangong.aiagent.domain.model.Agent
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AgentListUiState(
|
||||
val agents: List<Agent> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val searchQuery: String = ""
|
||||
)
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@HiltViewModel
|
||||
class AgentListViewModel @Inject constructor(
|
||||
private val agentRepository: AgentRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(AgentListUiState())
|
||||
val uiState: StateFlow<AgentListUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val searchQueryFlow = MutableStateFlow("")
|
||||
|
||||
init {
|
||||
loadAgents()
|
||||
// Debounced search: 300ms delay prevents request storm on fast typing
|
||||
viewModelScope.launch {
|
||||
searchQueryFlow
|
||||
.debounce(300)
|
||||
.distinctUntilChanged()
|
||||
.collect { query ->
|
||||
loadAgentsInternal(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadAgentsInternal(query: String) {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
agentRepository.getAgents(search = query.ifBlank { null })
|
||||
.fold(
|
||||
onSuccess = { agents ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
agents = agents,
|
||||
isLoading = false
|
||||
)
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "加载失败"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun loadAgents() {
|
||||
viewModelScope.launch {
|
||||
loadAgentsInternal(_uiState.value.searchQuery)
|
||||
}
|
||||
}
|
||||
|
||||
fun onSearchQueryChange(query: String) {
|
||||
_uiState.value = _uiState.value.copy(searchQuery = query)
|
||||
searchQueryFlow.value = query
|
||||
}
|
||||
|
||||
fun createAgent(name: String, description: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
agentRepository.createAgent(name, description.ifBlank { null })
|
||||
.fold(
|
||||
onSuccess = {
|
||||
loadAgents()
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "创建失败"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAgent(agentId: String) {
|
||||
viewModelScope.launch {
|
||||
agentRepository.deleteAgent(agentId)
|
||||
.fold(
|
||||
onSuccess = { loadAgents() },
|
||||
onFailure = { e ->
|
||||
_uiState.value = _uiState.value.copy(error = e.message ?: "删除失败")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
package com.tiangong.aiagent.ui.chat
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.ThumbDown
|
||||
import androidx.compose.material.icons.outlined.ThumbUp
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import com.tiangong.aiagent.ui.chat.components.MessageBubble
|
||||
import com.tiangong.aiagent.ui.chat.components.NavigationDrawerContent
|
||||
import com.tiangong.aiagent.ui.chat.components.StreamingText
|
||||
import com.tiangong.aiagent.ui.chat.components.ThinkTracePanel
|
||||
import com.tiangong.aiagent.ui.chat.components.ToolCallCard
|
||||
import com.tiangong.aiagent.ui.chat.components.VoiceInputButton
|
||||
import com.tiangong.aiagent.ui.common.SkeletonChat
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
onNavigateToAgents: () -> Unit,
|
||||
onNavigateToSettings: () -> Unit,
|
||||
onNavigateToHistory: () -> Unit = {},
|
||||
onNavigateToNotifications: () -> Unit = {},
|
||||
onLogout: () -> Unit = {},
|
||||
viewModel: ChatViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var inputText by remember { mutableStateOf("") }
|
||||
var firstLoaded by remember { mutableStateOf(false) }
|
||||
var isOffline by remember { mutableStateOf(false) }
|
||||
|
||||
// Drawer state
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
|
||||
// Track first load for skeleton
|
||||
LaunchedEffect(uiState.messages.size, uiState.isStreaming) {
|
||||
if (uiState.messages.isNotEmpty() || uiState.isStreaming || uiState.currentAgent != null) {
|
||||
firstLoaded = true
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh unread count on resume (return from notifications, app foreground, etc.)
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
viewModel.loadUnreadCount()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
// Auto-scroll when new messages arrive
|
||||
LaunchedEffect(uiState.messages.size) {
|
||||
if (uiState.messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(uiState.messages.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-fill input when editing a message
|
||||
LaunchedEffect(uiState.editingMessageId) {
|
||||
uiState.editingMessageContent?.let { inputText = it }
|
||||
}
|
||||
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
|
||||
// Image picker
|
||||
val imagePickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent()
|
||||
) { uri: Uri? ->
|
||||
uri?.let { selectedUri ->
|
||||
scope.launch {
|
||||
try {
|
||||
val inputStream = context.contentResolver.openInputStream(selectedUri)
|
||||
val ext = context.contentResolver.getType(selectedUri)?.let { mime ->
|
||||
when {
|
||||
mime.contains("png") -> ".png"
|
||||
mime.contains("gif") -> ".gif"
|
||||
mime.contains("webp") -> ".webp"
|
||||
else -> ".jpg"
|
||||
}
|
||||
} ?: ".jpg"
|
||||
val tempFile = java.io.File(context.cacheDir, "upload_${System.currentTimeMillis()}$ext")
|
||||
inputStream?.use { input ->
|
||||
tempFile.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
viewModel.uploadImage(tempFile)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatScreen", "Image selection failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error auto-dismiss
|
||||
LaunchedEffect(uiState.error) {
|
||||
uiState.error?.let { error ->
|
||||
kotlinx.coroutines.delay(5000)
|
||||
viewModel.dismissError()
|
||||
}
|
||||
}
|
||||
|
||||
// Voice transcription result
|
||||
LaunchedEffect(uiState.transcribedText) {
|
||||
uiState.transcribedText?.let { text ->
|
||||
inputText = text
|
||||
viewModel.clearTranscribedText()
|
||||
}
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
NavigationDrawerContent(
|
||||
currentAgent = uiState.currentAgent,
|
||||
recentConversations = uiState.recentConversations,
|
||||
onConversationSelected = { sessionId ->
|
||||
viewModel.loadConversation(sessionId)
|
||||
},
|
||||
onHistoryClick = onNavigateToHistory,
|
||||
onSettingsClick = onNavigateToSettings,
|
||||
onLogoutClick = onLogout,
|
||||
onCloseDrawer = {
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
if (drawerState.isClosed) drawerState.open() else drawerState.close()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Default.Menu, contentDescription = "菜单")
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = uiState.currentAgent?.name ?: "天工智能体",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
IconButton(onClick = onNavigateToAgents) {
|
||||
Icon(Icons.Default.ExpandMore, contentDescription = "切换智能体")
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onNavigateToNotifications) {
|
||||
if (uiState.unreadCount > 0) {
|
||||
BadgedBox(badge = {
|
||||
Badge { Text(if (uiState.unreadCount > 99) "99+" else "${uiState.unreadCount}") }
|
||||
}) {
|
||||
Icon(Icons.Default.Notifications, contentDescription = "通知")
|
||||
}
|
||||
} else {
|
||||
Icon(Icons.Default.Notifications, contentDescription = "通知")
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
Surface(shadowElevation = 8.dp, color = MaterialTheme.colorScheme.surface) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp)) {
|
||||
// v1.2.0: Quick action bar above input
|
||||
if (uiState.isStreaming) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.stopGeneration() },
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Stop,
|
||||
contentDescription = "停止生成",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("停止生成", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Input row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
VoiceInputButton(
|
||||
audioRecorder = viewModel.audioRecorder,
|
||||
onRecordingComplete = { file ->
|
||||
if (file != null) {
|
||||
viewModel.transcribeVoice(file)
|
||||
}
|
||||
},
|
||||
onRecordingStarted = { viewModel.onRecordingStarted() },
|
||||
onRecordingStopped = { viewModel.onRecordingStopped() }
|
||||
)
|
||||
IconButton(
|
||||
onClick = { imagePickerLauncher.launch("image/*") },
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "添加附件",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = inputText,
|
||||
onValueChange = { inputText = it },
|
||||
placeholder = {
|
||||
Text(if (uiState.editingMessageId != null) "编辑消息..." else "输入消息...")
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 4,
|
||||
enabled = !uiState.isLoading && !uiState.isStreaming
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// v1.2.0: Cancel edit button
|
||||
if (uiState.editingMessageId != null) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
inputText = ""
|
||||
viewModel.cancelEdit()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "取消编辑",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (inputText.isNotBlank()) {
|
||||
val text = inputText
|
||||
inputText = ""
|
||||
if (uiState.editingMessageId != null) {
|
||||
viewModel.sendEditMessage(text)
|
||||
} else {
|
||||
viewModel.sendMessage(text)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = inputText.isNotBlank() && !uiState.isLoading && !uiState.isStreaming && !uiState.isOffline
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "发送",
|
||||
tint = if (inputText.isNotBlank() && !uiState.isLoading && !uiState.isStreaming && !uiState.isOffline)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues)
|
||||
) {
|
||||
// ── Reconnection banner ─────────────────────────────────────
|
||||
when (val rs = uiState.reconnectionState) {
|
||||
is ReconnectionState.Reconnecting -> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Text(
|
||||
"正在重新连接... (${rs.attempt}/${rs.maxAttempts})",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is ReconnectionState.Failed -> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.errorContainer
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("连接失败", style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer)
|
||||
TextButton(onClick = { viewModel.dismissError() }) {
|
||||
Text("关闭")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ReconnectionState.Idle -> { /* no banner */ }
|
||||
}
|
||||
|
||||
// ── Offline banner (v1.2.0) ─────────────────────────────────
|
||||
if (uiState.isOffline) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.errorContainer
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.WifiOff,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
"当前无网络连接",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error banner ───────────────────────────────────────────
|
||||
val errorText = uiState.error
|
||||
if (errorText != null && uiState.reconnectionState is ReconnectionState.Idle) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.errorContainer
|
||||
) {
|
||||
Text(
|
||||
text = errorText,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tool approval dialog (v1.2.0) ──────────────────────────
|
||||
uiState.pendingToolCall?.let { tool ->
|
||||
if (tool.isApproved == null) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.primaryContainer
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Build,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "工具调用: ${tool.toolName}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = tool.toolInput.take(100),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.approveToolCall() },
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("允许")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.rejectToolCall() }
|
||||
) {
|
||||
Icon(Icons.Default.Close, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("拒绝")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feedback error snackbar ────────────────────────────────
|
||||
if (uiState.feedbackError != null) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.errorContainer
|
||||
) {
|
||||
Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(uiState.feedbackError!!, modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
TextButton(onClick = { viewModel.clearFeedbackError() }) { Text("关闭") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Message list ───────────────────────────────────────────
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().weight(1f),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
// Skeleton loading on first load
|
||||
if (!firstLoaded && uiState.messages.isEmpty() && !uiState.isStreaming) {
|
||||
item { SkeletonChat() }
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (firstLoaded && uiState.messages.isEmpty() && !uiState.isStreaming) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "你好,我是 ${uiState.currentAgent?.name ?: "天工智能助手"}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = uiState.currentAgent?.description ?: "有什么可以帮你的?",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
val suggestions = listOf("介绍一下你自己", "你能帮我做什么?", "给我讲个笑话")
|
||||
suggestions.forEach { suggestion ->
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.sendMessage(suggestion) },
|
||||
modifier = Modifier.padding(vertical = 2.dp)
|
||||
) { Text(suggestion) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(uiState.messages, key = { it.id }) { message ->
|
||||
if (message.role == com.tiangong.aiagent.domain.model.Message.Role.TOOL) {
|
||||
ToolCallCard(
|
||||
toolName = message.toolName ?: "",
|
||||
toolInput = message.toolInput ?: "",
|
||||
toolOutput = message.toolOutput,
|
||||
success = true
|
||||
)
|
||||
} else {
|
||||
Column {
|
||||
// Message bubble with feedback for completed assistant msgs
|
||||
val showFeedback = message.role == com.tiangong.aiagent.domain.model.Message.Role.ASSISTANT
|
||||
&& !message.isStreaming
|
||||
&& message.content.isNotEmpty()
|
||||
|
||||
val isLastAssistant = showFeedback &&
|
||||
message.id == uiState.messages.lastOrNull { it.role == com.tiangong.aiagent.domain.model.Message.Role.ASSISTANT }?.id
|
||||
|
||||
if (showFeedback) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
Column {
|
||||
MessageBubbleFromUi(message = message)
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 56.dp, bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// v1.2.0: Edit (only for USER messages)
|
||||
if (message.role == com.tiangong.aiagent.domain.model.Message.Role.USER) {
|
||||
var showEditMenu by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { viewModel.startEditMessage(message.id) },
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Create,
|
||||
contentDescription = "编辑",
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy
|
||||
IconButton(
|
||||
onClick = { clipboardManager.setText(AnnotatedString(message.content)) },
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
contentDescription = "复制",
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
// Read aloud
|
||||
IconButton(
|
||||
onClick = { viewModel.speakText(message.content) },
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.VolumeUp,
|
||||
contentDescription = "朗读",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
// Like
|
||||
IconButton(
|
||||
onClick = { viewModel.submitFeedback(message.id, FeedbackRating.LIKE) },
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (message.feedbackRating == FeedbackRating.LIKE)
|
||||
Icons.Filled.ThumbUp else Icons.Outlined.ThumbUp,
|
||||
contentDescription = "赞",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (message.feedbackRating == FeedbackRating.LIKE)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { viewModel.submitFeedback(message.id, FeedbackRating.DISLIKE) },
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (message.feedbackRating == FeedbackRating.DISLIKE)
|
||||
Icons.Filled.ThumbDown else Icons.Outlined.ThumbDown,
|
||||
contentDescription = "踩",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (message.feedbackRating == FeedbackRating.DISLIKE)
|
||||
MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// v1.2.0: Regenerate (only for last assistant message)
|
||||
if (isLastAssistant) {
|
||||
IconButton(
|
||||
onClick = { viewModel.regenerateLast() },
|
||||
modifier = Modifier.size(28.dp),
|
||||
enabled = !uiState.isStreaming
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = "重新生成",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (!uiState.isStreaming)
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
message.feedbackComment?.let { comment ->
|
||||
Text(
|
||||
text = comment,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
modifier = Modifier.align(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
MessageBubbleFromUi(message = message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v1.1.0: Think trace panel (collapsible reasoning visualization)
|
||||
if (uiState.thinkTraces.isNotEmpty()) {
|
||||
item {
|
||||
ThinkTracePanel(traces = uiState.thinkTraces)
|
||||
}
|
||||
}
|
||||
|
||||
// Tool call in progress
|
||||
uiState.currentToolCall?.let { (toolName, toolInput) ->
|
||||
item {
|
||||
ToolCallCard(
|
||||
toolName = toolName,
|
||||
toolInput = toolInput,
|
||||
toolOutput = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming text
|
||||
if (uiState.isStreaming && uiState.streamingContent.isNotEmpty()) {
|
||||
item { StreamingText(content = uiState.streamingContent) }
|
||||
}
|
||||
|
||||
// Loading indicator
|
||||
if (uiState.isLoading && uiState.streamingContent.isEmpty()) {
|
||||
item { StreamingText(content = "") }
|
||||
}
|
||||
|
||||
// v1.1.0: Retry button for failed last message
|
||||
val lastAsstMsg = uiState.messages.lastOrNull {
|
||||
it.role == com.tiangong.aiagent.domain.model.Message.Role.ASSISTANT
|
||||
}
|
||||
if (lastAsstMsg != null && lastAsstMsg.content.isEmpty() && !lastAsstMsg.isStreaming
|
||||
&& !uiState.isLoading && !uiState.isStreaming
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 56.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.retryLastMessage() }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("重试")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Thin adapter to render UiMessage via existing MessageBubble. */
|
||||
@Composable
|
||||
private fun MessageBubbleFromUi(message: UiMessage) {
|
||||
com.tiangong.aiagent.ui.chat.components.MessageBubble(message = message.toDomainMessage())
|
||||
}
|
||||
|
||||
private fun UiMessage.toDomainMessage(): com.tiangong.aiagent.domain.model.Message {
|
||||
return com.tiangong.aiagent.domain.model.Message(
|
||||
id = id,
|
||||
conversationId = conversationId,
|
||||
role = role,
|
||||
content = content,
|
||||
toolName = toolName,
|
||||
toolInput = toolInput,
|
||||
toolOutput = toolOutput,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,799 @@
|
||||
package com.tiangong.aiagent.ui.chat
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.gson.Gson
|
||||
import com.tiangong.aiagent.data.local.TokenDataStore
|
||||
import com.tiangong.aiagent.data.repository.AgentRepository
|
||||
import com.tiangong.aiagent.data.repository.ChatRepository
|
||||
import com.tiangong.aiagent.data.repository.FeedbackRepository
|
||||
import com.tiangong.aiagent.data.repository.NotificationRepository
|
||||
import com.tiangong.aiagent.domain.model.Agent
|
||||
import com.tiangong.aiagent.domain.model.Message
|
||||
import com.tiangong.aiagent.domain.model.SseEvent
|
||||
import com.tiangong.aiagent.util.AudioPlayer
|
||||
import com.tiangong.aiagent.util.AudioRecorder
|
||||
import com.tiangong.aiagent.util.OfflineQueueManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ToolCallData(
|
||||
val toolName: String,
|
||||
val toolInput: String,
|
||||
val isApproved: Boolean? = null // null = pending, true = approved, false = rejected
|
||||
)
|
||||
|
||||
data class ChatUiState(
|
||||
val messages: List<UiMessage> = emptyList(),
|
||||
val currentAgent: Agent? = null,
|
||||
val availableAgents: List<Agent> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val isStreaming: Boolean = false,
|
||||
val streamingContent: String = "",
|
||||
val currentToolCall: Pair<String, String>? = null,
|
||||
val error: String? = null,
|
||||
val sessionId: String? = null,
|
||||
val isRecording: Boolean = false,
|
||||
val transcribedText: String? = null,
|
||||
val isTranscribing: Boolean = false,
|
||||
val unreadCount: Int = 0,
|
||||
|
||||
// SSE reconnection state
|
||||
val reconnectionState: ReconnectionState = ReconnectionState.Idle,
|
||||
|
||||
// Feedback submission
|
||||
val feedbackSubmittingMsgId: String? = null,
|
||||
val feedbackError: String? = null,
|
||||
|
||||
// Edit message
|
||||
val editingMessageId: String? = null,
|
||||
val editingMessageContent: String? = null,
|
||||
|
||||
// Tool approval (v1.2.0)
|
||||
val pendingToolCall: ToolCallData? = null,
|
||||
|
||||
// Recent conversations (for drawer navigation)
|
||||
val recentConversations: List<com.tiangong.aiagent.domain.model.Conversation> = emptyList(),
|
||||
|
||||
// Network state (v1.2.0)
|
||||
val isOffline: Boolean = false,
|
||||
|
||||
// Offline queue count
|
||||
val pendingQueueCount: Int = 0,
|
||||
|
||||
// Think trace entries (v1.1.0)
|
||||
val thinkTraces: List<ThinkTraceEntry> = emptyList()
|
||||
)
|
||||
|
||||
data class ThinkTraceEntry(
|
||||
val iteration: Int,
|
||||
val content: String
|
||||
)
|
||||
|
||||
/** UI wrapper for a domain Message with feedback state. */
|
||||
data class UiMessage(
|
||||
val id: String,
|
||||
val conversationId: String,
|
||||
val role: Message.Role,
|
||||
val content: String,
|
||||
val toolName: String? = null,
|
||||
val toolInput: String? = null,
|
||||
val toolOutput: String? = null,
|
||||
val isStreaming: Boolean = false,
|
||||
val feedbackRating: FeedbackRating? = null,
|
||||
val feedbackComment: String? = null,
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
enum class FeedbackRating { LIKE, DISLIKE }
|
||||
|
||||
sealed class ReconnectionState {
|
||||
data object Idle : ReconnectionState()
|
||||
data class Reconnecting(val attempt: Int, val maxAttempts: Int, val nextRetrySec: Int) : ReconnectionState()
|
||||
data class Failed(val error: String) : ReconnectionState()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class ChatViewModel @Inject constructor(
|
||||
private val chatRepository: ChatRepository,
|
||||
private val agentRepository: AgentRepository,
|
||||
private val feedbackRepository: FeedbackRepository,
|
||||
private val notificationRepository: NotificationRepository,
|
||||
val audioRecorder: AudioRecorder,
|
||||
private val audioPlayer: AudioPlayer,
|
||||
private val tokenDataStore: TokenDataStore,
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
private val gson: Gson,
|
||||
private val networkMonitor: com.tiangong.aiagent.util.NetworkMonitor,
|
||||
private val offlineQueueManager: OfflineQueueManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ChatUiState())
|
||||
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var sseJob: Job? = null
|
||||
private var historyFlowJob: Job? = null
|
||||
|
||||
companion object {
|
||||
private const val KEY_SESSION_STATE = "chat_session_state"
|
||||
}
|
||||
|
||||
init {
|
||||
loadAgents()
|
||||
loadCurrentAgent()
|
||||
loadUnreadCount()
|
||||
restoreSession()
|
||||
loadRecentConversations()
|
||||
|
||||
// v1.2.0: Monitor network state
|
||||
viewModelScope.launch {
|
||||
networkMonitor.isOnline.collect { online ->
|
||||
_uiState.value = _uiState.value.copy(isOffline = !online)
|
||||
}
|
||||
}
|
||||
|
||||
// v1.2.0: Observe offline queue count
|
||||
viewModelScope.launch {
|
||||
offlineQueueManager.pendingCount.collect { count ->
|
||||
_uiState.value = _uiState.value.copy(pendingQueueCount = count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun restoreSession() {
|
||||
val json = savedStateHandle.get<String>(KEY_SESSION_STATE) ?: return
|
||||
try {
|
||||
val map = gson.fromJson(json, Map::class.java) as? Map<*, *> ?: return
|
||||
val sid = map["sessionId"] as? String ?: return
|
||||
viewModelScope.launch {
|
||||
tokenDataStore.saveLastSessionId(sid)
|
||||
restoreLastSession()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
savedStateHandle.remove<Any>(KEY_SESSION_STATE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistSession() {
|
||||
val state = _uiState.value
|
||||
val map = mapOf(
|
||||
"sessionId" to (state.sessionId ?: ""),
|
||||
"agentId" to (state.currentAgent?.id ?: ""),
|
||||
"agentName" to (state.currentAgent?.name ?: "")
|
||||
)
|
||||
savedStateHandle[KEY_SESSION_STATE] = gson.toJson(map)
|
||||
}
|
||||
|
||||
private suspend fun restoreLastSession() {
|
||||
historyFlowJob?.cancel()
|
||||
val sessionId = tokenDataStore.getLastSessionId() ?: return
|
||||
val currentAgentId = _uiState.value.currentAgent?.id
|
||||
historyFlowJob = viewModelScope.launch {
|
||||
chatRepository.getMessages(sessionId).collect { messages ->
|
||||
if (messages.isNotEmpty() && _uiState.value.currentAgent?.id == currentAgentId) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
messages = messages.map { it.toUiMessage() },
|
||||
sessionId = sessionId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadUnreadCount() {
|
||||
viewModelScope.launch {
|
||||
notificationRepository.getUnreadCount()
|
||||
.onSuccess { count ->
|
||||
_uiState.value = _uiState.value.copy(unreadCount = count)
|
||||
}
|
||||
.onFailure { e ->
|
||||
Log.w("ChatViewModel", "Failed to load unread count", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAgents() {
|
||||
viewModelScope.launch {
|
||||
agentRepository.getAgents().fold(
|
||||
onSuccess = { agents ->
|
||||
_uiState.value = _uiState.value.copy(availableAgents = agents)
|
||||
},
|
||||
onFailure = { e ->
|
||||
Log.w("ChatViewModel", "Failed to load agents", e)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCurrentAgent() {
|
||||
viewModelScope.launch {
|
||||
val agentId = agentRepository.getCurrentAgentId()
|
||||
if (agentId != null) {
|
||||
agentRepository.getAgent(agentId).fold(
|
||||
onSuccess = { agent ->
|
||||
// Only set if no agent loaded yet (prevents overwriting user's switchAgent choice)
|
||||
if (_uiState.value.currentAgent == null) {
|
||||
_uiState.value = _uiState.value.copy(currentAgent = agent)
|
||||
loadConversationHistory(agent.id)
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
Log.w("ChatViewModel", "Failed to load current agent", e)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadConversationHistory(agentId: String) {
|
||||
historyFlowJob?.cancel()
|
||||
historyFlowJob = viewModelScope.launch {
|
||||
val conversations = chatRepository.getConversationsByAgent(agentId)
|
||||
val latestConversation = conversations.firstOrNull() ?: return@launch
|
||||
chatRepository.getMessages(latestConversation.sessionId).collect { messages ->
|
||||
if (messages.isNotEmpty() && _uiState.value.currentAgent?.id == agentId) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
messages = messages.map { it.toUiMessage() },
|
||||
sessionId = latestConversation.sessionId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun switchAgent(agent: Agent) {
|
||||
sseJob?.cancel()
|
||||
historyFlowJob?.cancel()
|
||||
audioPlayer.stop()
|
||||
viewModelScope.launch {
|
||||
agentRepository.saveCurrentAgent(agent.id, agent.name)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
currentAgent = agent,
|
||||
messages = emptyList(),
|
||||
sessionId = null,
|
||||
streamingContent = "",
|
||||
error = null,
|
||||
reconnectionState = ReconnectionState.Idle,
|
||||
thinkTraces = emptyList()
|
||||
)
|
||||
loadConversationHistory(agent.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadConversation(sessionId: String) {
|
||||
sseJob?.cancel()
|
||||
viewModelScope.launch {
|
||||
tokenDataStore.saveLastSessionId(sessionId)
|
||||
chatRepository.getMessages(sessionId).collect { messages ->
|
||||
if (messages.isNotEmpty()) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
messages = messages.map { it.toUiMessage() },
|
||||
sessionId = sessionId,
|
||||
streamingContent = "",
|
||||
isLoading = false,
|
||||
isStreaming = false,
|
||||
error = null,
|
||||
reconnectionState = ReconnectionState.Idle
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissError() {
|
||||
_uiState.value = _uiState.value.copy(error = null)
|
||||
}
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
if (text.isBlank()) return
|
||||
audioPlayer.stop()
|
||||
|
||||
// Capture agent context at call time to avoid any race condition
|
||||
val agentId = _uiState.value.currentAgent?.id
|
||||
val sessionId = _uiState.value.sessionId ?: UUID.randomUUID().toString()
|
||||
|
||||
// v1.2.0: Queue message when offline
|
||||
if (_uiState.value.isOffline) {
|
||||
val userMsg = UiMessage(
|
||||
id = "user_${System.currentTimeMillis()}",
|
||||
conversationId = sessionId,
|
||||
role = Message.Role.USER,
|
||||
content = text
|
||||
)
|
||||
val currentMessages = _uiState.value.messages.toMutableList()
|
||||
currentMessages.add(userMsg)
|
||||
_uiState.value = _uiState.value.copy(messages = currentMessages, sessionId = sessionId)
|
||||
|
||||
viewModelScope.launch {
|
||||
offlineQueueManager.enqueue(agentId, text, sessionId)
|
||||
chatRepository.saveMessage(
|
||||
id = userMsg.id, conversationId = sessionId,
|
||||
agentId = agentId, role = "user", content = text
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val userMsg = UiMessage(
|
||||
id = "user_${System.currentTimeMillis()}",
|
||||
conversationId = sessionId,
|
||||
role = Message.Role.USER,
|
||||
content = text
|
||||
)
|
||||
|
||||
val currentMessages = _uiState.value.messages.toMutableList()
|
||||
currentMessages.add(userMsg)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
messages = currentMessages,
|
||||
isLoading = true,
|
||||
isStreaming = true,
|
||||
streamingContent = "",
|
||||
error = null,
|
||||
reconnectionState = ReconnectionState.Idle,
|
||||
sessionId = sessionId,
|
||||
thinkTraces = emptyList()
|
||||
)
|
||||
|
||||
viewModelScope.launch {
|
||||
chatRepository.saveMessage(
|
||||
id = userMsg.id,
|
||||
conversationId = sessionId,
|
||||
agentId = agentId,
|
||||
role = "user",
|
||||
content = text
|
||||
)
|
||||
}
|
||||
|
||||
val assistantMsgId = "asst_${System.currentTimeMillis()}"
|
||||
val assistantMsg = UiMessage(
|
||||
id = assistantMsgId,
|
||||
conversationId = sessionId,
|
||||
role = Message.Role.ASSISTANT,
|
||||
content = "",
|
||||
isStreaming = true
|
||||
)
|
||||
currentMessages.add(assistantMsg)
|
||||
_uiState.value = _uiState.value.copy(messages = currentMessages.toList())
|
||||
|
||||
sseJob = viewModelScope.launch {
|
||||
var fullContent = ""
|
||||
|
||||
chatRepository.chatStream(agentId, text, sessionId)
|
||||
.catch { e ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
isStreaming = false,
|
||||
streamingContent = "",
|
||||
error = e.message ?: "连接失败",
|
||||
reconnectionState = ReconnectionState.Failed(e.message ?: "连接失败")
|
||||
)
|
||||
}
|
||||
.collect { event ->
|
||||
when (event) {
|
||||
is SseEvent.Message -> {
|
||||
fullContent += event.content
|
||||
updateAssistantMsg(assistantMsgId, fullContent)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
streamingContent = fullContent,
|
||||
reconnectionState = ReconnectionState.Idle
|
||||
)
|
||||
}
|
||||
is SseEvent.Plan -> {
|
||||
fullContent += "\n[计划] ${event.title}: ${event.steps.joinToString(" → ")}"
|
||||
updateAssistantMsg(assistantMsgId, fullContent)
|
||||
}
|
||||
is SseEvent.Think -> {
|
||||
val traces = _uiState.value.thinkTraces.toMutableList()
|
||||
traces.add(ThinkTraceEntry(event.iteration, event.content))
|
||||
_uiState.value = _uiState.value.copy(thinkTraces = traces)
|
||||
}
|
||||
is SseEvent.ToolCall -> {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
currentToolCall = Pair(event.toolName, event.toolInput),
|
||||
pendingToolCall = ToolCallData(
|
||||
toolName = event.toolName,
|
||||
toolInput = event.toolInput
|
||||
)
|
||||
)
|
||||
}
|
||||
is SseEvent.ToolResult -> {
|
||||
_uiState.value = _uiState.value.copy(currentToolCall = null)
|
||||
val toolMsg = UiMessage(
|
||||
id = "tool_${System.currentTimeMillis()}",
|
||||
conversationId = sessionId,
|
||||
role = Message.Role.TOOL,
|
||||
content = "[工具] ${event.toolName}: ${event.toolOutput.take(200)}",
|
||||
toolName = event.toolName,
|
||||
toolOutput = event.toolOutput
|
||||
)
|
||||
val msgs = _uiState.value.messages.toMutableList()
|
||||
msgs.add(msgs.size - 1, toolMsg)
|
||||
_uiState.value = _uiState.value.copy(messages = msgs)
|
||||
|
||||
viewModelScope.launch {
|
||||
chatRepository.saveMessage(
|
||||
id = toolMsg.id, conversationId = sessionId,
|
||||
agentId = agentId,
|
||||
role = "tool", content = toolMsg.content,
|
||||
toolName = event.toolName, toolOutput = event.toolOutput
|
||||
)
|
||||
// Checkpoint: persist intermediate assistant content so
|
||||
// already-streamed text survives crashes before Final/Done.
|
||||
if (fullContent.isNotEmpty()) {
|
||||
chatRepository.saveMessage(
|
||||
id = assistantMsgId, conversationId = sessionId,
|
||||
agentId = agentId,
|
||||
role = "assistant", content = fullContent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is SseEvent.Final -> {
|
||||
fullContent = event.content
|
||||
updateAssistantMsg(assistantMsgId, fullContent, streaming = false)
|
||||
val sid = event.sessionId.ifEmpty { sessionId }
|
||||
tokenDataStore.saveLastSessionId(sid)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false, isStreaming = false,
|
||||
streamingContent = "", sessionId = sid,
|
||||
reconnectionState = ReconnectionState.Idle
|
||||
)
|
||||
persistSession()
|
||||
viewModelScope.launch {
|
||||
chatRepository.saveMessage(
|
||||
id = assistantMsgId, conversationId = sid,
|
||||
agentId = agentId,
|
||||
role = "assistant", content = fullContent
|
||||
)
|
||||
}
|
||||
speakText(fullContent)
|
||||
}
|
||||
is SseEvent.Done -> {
|
||||
tokenDataStore.saveLastSessionId(sessionId)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false, isStreaming = false,
|
||||
streamingContent = "",
|
||||
reconnectionState = ReconnectionState.Idle
|
||||
)
|
||||
persistSession()
|
||||
if (fullContent.isNotEmpty()) {
|
||||
viewModelScope.launch {
|
||||
chatRepository.saveMessage(
|
||||
id = assistantMsgId, conversationId = sessionId,
|
||||
agentId = agentId,
|
||||
role = "assistant", content = fullContent
|
||||
)
|
||||
}
|
||||
}
|
||||
updateAssistantMsg(assistantMsgId, fullContent, streaming = false)
|
||||
}
|
||||
is SseEvent.ConnectionError -> {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
reconnectionState = ReconnectionState.Reconnecting(
|
||||
attempt = event.attempt,
|
||||
maxAttempts = event.maxAttempts,
|
||||
nextRetrySec = (event.attempt * 3).coerceAtMost(30)
|
||||
),
|
||||
error = event.message
|
||||
)
|
||||
}
|
||||
is SseEvent.Error -> {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false, isStreaming = false,
|
||||
streamingContent = "", error = event.error,
|
||||
reconnectionState = ReconnectionState.Failed(event.error)
|
||||
)
|
||||
}
|
||||
is SseEvent.Unknown -> { }
|
||||
}
|
||||
}
|
||||
|
||||
// Stream ended without Final/Done — persist whatever we received
|
||||
if (fullContent.isNotEmpty()) {
|
||||
updateAssistantMsg(assistantMsgId, fullContent, streaming = false)
|
||||
viewModelScope.launch {
|
||||
chatRepository.saveMessage(
|
||||
id = assistantMsgId, conversationId = sessionId,
|
||||
agentId = agentId,
|
||||
role = "assistant", content = fullContent
|
||||
)
|
||||
}
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false, isStreaming = false, streamingContent = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAssistantMsg(msgId: String, content: String, streaming: Boolean = true) {
|
||||
val msgs = _uiState.value.messages.toMutableList()
|
||||
val idx = msgs.indexOfLast { it.id == msgId }
|
||||
if (idx >= 0) {
|
||||
msgs[idx] = msgs[idx].copy(content = content, isStreaming = streaming)
|
||||
_uiState.value = _uiState.value.copy(messages = msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Feedback ────────────────────────────────────────────────────────
|
||||
|
||||
fun submitFeedback(messageId: String, rating: FeedbackRating) {
|
||||
val sessionId = _uiState.value.sessionId ?: return
|
||||
|
||||
// Optimistic UI update
|
||||
_uiState.value = _uiState.value.copy(feedbackSubmittingMsgId = messageId)
|
||||
updateMessageFeedback(messageId, rating)
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = feedbackRepository.submitFeedback(
|
||||
messageId = messageId,
|
||||
rating = if (rating == FeedbackRating.LIKE) "like" else "dislike",
|
||||
sessionId = sessionId
|
||||
)
|
||||
_uiState.value = _uiState.value.copy(feedbackSubmittingMsgId = null)
|
||||
if (result.isFailure) {
|
||||
// Rollback
|
||||
updateMessageFeedback(messageId, null)
|
||||
_uiState.value = _uiState.value.copy(feedbackError = "反馈提交失败,请重试")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun submitFeedbackComment(messageId: String, comment: String) {
|
||||
val sessionId = _uiState.value.sessionId ?: return
|
||||
val existingRating = _uiState.value.messages.find { it.id == messageId }?.feedbackRating ?: return
|
||||
|
||||
_uiState.value = _uiState.value.copy(feedbackSubmittingMsgId = messageId)
|
||||
setMessageComment(messageId, comment)
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = feedbackRepository.submitFeedback(
|
||||
messageId = messageId,
|
||||
rating = if (existingRating == FeedbackRating.LIKE) "like" else "dislike",
|
||||
comment = comment,
|
||||
sessionId = sessionId
|
||||
)
|
||||
_uiState.value = _uiState.value.copy(feedbackSubmittingMsgId = null)
|
||||
if (result.isFailure) {
|
||||
setMessageComment(messageId, null)
|
||||
_uiState.value = _uiState.value.copy(feedbackError = "评论提交失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearFeedbackError() {
|
||||
_uiState.value = _uiState.value.copy(feedbackError = null)
|
||||
}
|
||||
|
||||
private fun updateMessageFeedback(msgId: String, rating: FeedbackRating?) {
|
||||
val msgs = _uiState.value.messages.toMutableList()
|
||||
val idx = msgs.indexOfFirst { it.id == msgId }
|
||||
if (idx >= 0) {
|
||||
msgs[idx] = msgs[idx].copy(feedbackRating = rating)
|
||||
_uiState.value = _uiState.value.copy(messages = msgs)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setMessageComment(msgId: String, comment: String?) {
|
||||
val msgs = _uiState.value.messages.toMutableList()
|
||||
val idx = msgs.indexOfFirst { it.id == msgId }
|
||||
if (idx >= 0) {
|
||||
msgs[idx] = msgs[idx].copy(feedbackComment = comment)
|
||||
_uiState.value = _uiState.value.copy(messages = msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Voice & Image ───────────────────────────────────────────────────
|
||||
|
||||
fun speakText(text: String) {
|
||||
if (text.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
chatRepository.synthesizeSpeech(text)
|
||||
.onSuccess { audioUrl -> audioPlayer.play(audioUrl) }
|
||||
.onFailure { Log.w("ChatViewModel", "TTS failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun stopSpeaking() {
|
||||
audioPlayer.stop()
|
||||
}
|
||||
|
||||
fun transcribeVoice(file: File) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isTranscribing = true)
|
||||
chatRepository.transcribeVoice(file)
|
||||
.onSuccess { text ->
|
||||
if (text.isNotEmpty()) {
|
||||
_uiState.value = _uiState.value.copy(transcribedText = text)
|
||||
}
|
||||
}
|
||||
.onFailure { e ->
|
||||
Log.e("ChatViewModel", "Voice transcription failed", e)
|
||||
_uiState.value = _uiState.value.copy(error = "语音识别失败: ${e.message}")
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(isTranscribing = false)
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
fun uploadImage(file: File) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isTranscribing = true)
|
||||
chatRepository.uploadImage(file)
|
||||
.onSuccess { markdownUrl ->
|
||||
_uiState.value = _uiState.value.copy(transcribedText = markdownUrl)
|
||||
}
|
||||
.onFailure { e ->
|
||||
Log.e("ChatViewModel", "Image upload failed", e)
|
||||
_uiState.value = _uiState.value.copy(error = "图片上传失败: ${e.message}")
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(isTranscribing = false)
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearTranscribedText() {
|
||||
_uiState.value = _uiState.value.copy(transcribedText = null)
|
||||
}
|
||||
|
||||
fun onRecordingStarted() {
|
||||
_uiState.value = _uiState.value.copy(isRecording = true)
|
||||
}
|
||||
|
||||
fun onRecordingStopped() {
|
||||
_uiState.value = _uiState.value.copy(isRecording = false)
|
||||
}
|
||||
|
||||
// ─── v1.2.0: Stop Generation ──────────────────────────────────────
|
||||
|
||||
fun stopGeneration() {
|
||||
sseJob?.cancel()
|
||||
sseJob = null
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
isStreaming = false,
|
||||
streamingContent = "",
|
||||
currentToolCall = null,
|
||||
pendingToolCall = null,
|
||||
reconnectionState = ReconnectionState.Idle,
|
||||
thinkTraces = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
// ─── v1.1.0: Retry Failed Message ─────────────────────────────────
|
||||
|
||||
fun retryLastMessage() {
|
||||
if (_uiState.value.isStreaming || _uiState.value.isLoading) return
|
||||
val msgs = _uiState.value.messages
|
||||
val lastUserMsg = msgs.lastOrNull { it.role == Message.Role.USER } ?: return
|
||||
val lastAsstMsg = msgs.lastOrNull { it.role == Message.Role.ASSISTANT }
|
||||
|
||||
// Remove the failed (empty) assistant message
|
||||
val trimmed = if (lastAsstMsg != null && lastAsstMsg.content.isEmpty()) {
|
||||
msgs.filter { it.id != lastAsstMsg.id }
|
||||
} else {
|
||||
msgs
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
messages = trimmed,
|
||||
error = null,
|
||||
reconnectionState = ReconnectionState.Idle
|
||||
)
|
||||
sendMessage(lastUserMsg.content)
|
||||
}
|
||||
|
||||
// ─── v1.2.0: Regenerate Last ──────────────────────────────────────
|
||||
|
||||
fun regenerateLast() {
|
||||
if (_uiState.value.isStreaming || _uiState.value.isLoading) return
|
||||
val msgs = _uiState.value.messages
|
||||
val lastUserMsg = msgs.lastOrNull { it.role == Message.Role.USER } ?: return
|
||||
// Remove last assistant message(s) before regenerating
|
||||
val userIdx = msgs.indexOfLast { it.id == lastUserMsg.id }
|
||||
val trimmed = msgs.subList(0, userIdx + 1)
|
||||
_uiState.value = _uiState.value.copy(messages = trimmed)
|
||||
sendMessage(lastUserMsg.content)
|
||||
}
|
||||
|
||||
// ─── v1.2.0: Edit Message ─────────────────────────────────────────
|
||||
|
||||
fun startEditMessage(messageId: String) {
|
||||
val msg = _uiState.value.messages.find { it.id == messageId } ?: return
|
||||
if (msg.role != Message.Role.USER) return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingMessageId = messageId,
|
||||
editingMessageContent = msg.content
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelEdit() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingMessageId = null,
|
||||
editingMessageContent = null
|
||||
)
|
||||
}
|
||||
|
||||
fun sendEditMessage(newText: String) {
|
||||
val editId = _uiState.value.editingMessageId ?: return
|
||||
if (newText.isBlank()) return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingMessageId = null,
|
||||
editingMessageContent = null
|
||||
)
|
||||
// Remove the edited message and all subsequent messages, then resend
|
||||
val msgs = _uiState.value.messages.toMutableList()
|
||||
val idx = msgs.indexOfFirst { it.id == editId }
|
||||
if (idx >= 0) {
|
||||
msgs.subList(idx, msgs.size).clear()
|
||||
_uiState.value = _uiState.value.copy(messages = msgs)
|
||||
}
|
||||
sendMessage(newText)
|
||||
}
|
||||
|
||||
// ─── v1.2.0: Tool Approval ────────────────────────────────────────
|
||||
|
||||
fun approveToolCall() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
pendingToolCall = _uiState.value.pendingToolCall?.copy(isApproved = true)
|
||||
)
|
||||
// Auto-dismiss after 1.5s so user can see the approval animation
|
||||
viewModelScope.launch {
|
||||
kotlinx.coroutines.delay(1500)
|
||||
if (_uiState.value.pendingToolCall?.isApproved == true) {
|
||||
_uiState.value = _uiState.value.copy(pendingToolCall = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun rejectToolCall() {
|
||||
_uiState.value = _uiState.value.copy(pendingToolCall = null)
|
||||
// Add a system message indicating rejection
|
||||
val rejectMsg = UiMessage(
|
||||
id = "system_${System.currentTimeMillis()}",
|
||||
conversationId = _uiState.value.sessionId ?: "",
|
||||
role = Message.Role.SYSTEM,
|
||||
content = "已拒绝工具调用"
|
||||
)
|
||||
val msgs = _uiState.value.messages.toMutableList()
|
||||
msgs.add(rejectMsg)
|
||||
_uiState.value = _uiState.value.copy(messages = msgs)
|
||||
}
|
||||
|
||||
// ─── v1.2.0: Load Recent Conversations (for drawer) ───────────────
|
||||
|
||||
fun loadRecentConversations() {
|
||||
viewModelScope.launch {
|
||||
chatRepository.getConversations().collect { conversations ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
recentConversations = conversations.take(10)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
sseJob?.cancel()
|
||||
historyFlowJob?.cancel()
|
||||
audioRecorder.cleanup()
|
||||
persistSession()
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert domain Message to UI message. */
|
||||
private fun Message.toUiMessage(): UiMessage = UiMessage(
|
||||
id = id,
|
||||
conversationId = conversationId,
|
||||
role = role,
|
||||
content = content,
|
||||
toolName = toolName,
|
||||
toolInput = toolInput,
|
||||
toolOutput = toolOutput,
|
||||
createdAt = createdAt
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.tiangong.aiagent.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SmartToy
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tiangong.aiagent.domain.model.Message
|
||||
import com.tiangong.aiagent.util.MarkdownRenderer
|
||||
|
||||
@Composable
|
||||
fun MessageBubble(message: Message) {
|
||||
val isUser = message.role == Message.Role.USER
|
||||
val isTool = message.role == Message.Role.TOOL
|
||||
val alignment = if (isUser) Alignment.End else Alignment.Start
|
||||
val bubbleColor = when {
|
||||
isTool -> MaterialTheme.colorScheme.surfaceVariant
|
||||
isUser -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.secondaryContainer
|
||||
}
|
||||
val contentColor = when {
|
||||
isTool -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
isUser -> MaterialTheme.colorScheme.onPrimary
|
||||
else -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 12.dp),
|
||||
horizontalAlignment = alignment
|
||||
) {
|
||||
if (!isUser && !isTool) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SmartToy,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "助手",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.widthIn(max = 340.dp),
|
||||
shape = RoundedCornerShape(
|
||||
topStart = 16.dp,
|
||||
topEnd = 16.dp,
|
||||
bottomStart = if (isUser) 16.dp else 4.dp,
|
||||
bottomEnd = if (isUser) 4.dp else 16.dp
|
||||
),
|
||||
color = bubbleColor
|
||||
) {
|
||||
// Use Markdown renderer for assistant messages, plain text for others
|
||||
if (!isUser && !isTool) {
|
||||
MarkdownRenderer.MarkdownText(
|
||||
text = message.content.ifEmpty { "..." },
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = message.content.ifEmpty { "..." },
|
||||
modifier = Modifier.padding(12.dp),
|
||||
color = contentColor,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.tiangong.aiagent.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.SmartToy
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tiangong.aiagent.domain.model.Agent
|
||||
import com.tiangong.aiagent.domain.model.Conversation
|
||||
import com.tiangong.aiagent.util.formatTimestamp
|
||||
|
||||
@Composable
|
||||
fun NavigationDrawerContent(
|
||||
currentAgent: Agent?,
|
||||
recentConversations: List<Conversation>,
|
||||
onConversationSelected: (String) -> Unit,
|
||||
onHistoryClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
onLogoutClick: () -> Unit,
|
||||
onCloseDrawer: () -> Unit
|
||||
) {
|
||||
ModalDrawerSheet(
|
||||
modifier = Modifier.width(300.dp)
|
||||
) {
|
||||
// Agent info header
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 24.dp)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(48.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SmartToy,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(28.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = currentAgent?.name ?: "天工智能助手",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
if (currentAgent != null) {
|
||||
Text(
|
||||
text = currentAgent.description.ifEmpty { "AI Agent" },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Recent conversations
|
||||
if (recentConversations.isNotEmpty()) {
|
||||
Text(
|
||||
text = "最近对话",
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
items(recentConversations, key = { it.sessionId }) { conversation ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
.clickable {
|
||||
onConversationSelected(conversation.sessionId)
|
||||
onCloseDrawer()
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Chat,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = conversation.title ?: "新对话",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = formatTimestamp(conversation.lastMessageAt),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
} else {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
// Bottom actions
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(Icons.Default.History, contentDescription = null) },
|
||||
label = { Text("全部对话历史") },
|
||||
selected = false,
|
||||
onClick = {
|
||||
onHistoryClick()
|
||||
onCloseDrawer()
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
|
||||
label = { Text("设置") },
|
||||
selected = false,
|
||||
onClick = {
|
||||
onSettingsClick()
|
||||
onCloseDrawer()
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
NavigationDrawerItem(
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Logout,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
label = { Text("退出登录", color = MaterialTheme.colorScheme.error) },
|
||||
selected = false,
|
||||
onClick = onLogoutClick,
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.tiangong.aiagent.ui.chat.components
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.tiangong.aiagent.util.MarkdownRenderer
|
||||
|
||||
@Composable
|
||||
fun StreamingText(
|
||||
content: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (content.isEmpty()) {
|
||||
// Show thinking indicator
|
||||
Row(
|
||||
modifier = modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "思考中",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
// Simple dot animation
|
||||
Text(
|
||||
text = "▌",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.widthIn(max = 320.dp)
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer
|
||||
) {
|
||||
MarkdownRenderer.MarkdownText(
|
||||
text = content,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.tiangong.aiagent.ui.chat.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Psychology
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tiangong.aiagent.ui.chat.ThinkTraceEntry
|
||||
|
||||
/**
|
||||
* Collapsible thinking trace card showing LLM reasoning process.
|
||||
* Displays each iteration's thought content in an expandable panel.
|
||||
*/
|
||||
@Composable
|
||||
fun ThinkTracePanel(
|
||||
traces: List<ThinkTraceEntry>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (traces.isEmpty()) return
|
||||
|
||||
var expanded by remember { mutableStateOf(true) }
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
tonalElevation = 1.dp
|
||||
) {
|
||||
Column {
|
||||
// Header row — clickable to collapse/expand
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Psychology,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = if (expanded) "推理过程" else "推理过程 (${traces.size} 步)",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = if (expanded) "收起" else "展开",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Expanded content
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 10.dp)
|
||||
) {
|
||||
traces.forEachIndexed { index, trace ->
|
||||
ThinkTraceItem(
|
||||
iteration = trace.iteration,
|
||||
content = trace.content,
|
||||
isLast = index == traces.lastIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThinkTraceItem(
|
||||
iteration: Int,
|
||||
content: String,
|
||||
isLast: Boolean
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
// Vertical timeline indicator
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.width(24.dp)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(8.dp),
|
||||
shape = MaterialTheme.shapes.extraSmall,
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
|
||||
) {}
|
||||
if (!isLast) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.width(2.dp)
|
||||
.height(40.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
|
||||
) {}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "第 $iteration 轮思考",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = content,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.SansSerif
|
||||
)
|
||||
}
|
||||
|
||||
if (!isLast) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.tiangong.aiagent.ui.chat.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun ToolCallCard(
|
||||
toolName: String,
|
||||
toolInput: String,
|
||||
toolOutput: String? = null,
|
||||
success: Boolean? = null
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.85f)
|
||||
.padding(vertical = 4.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Build,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = if (toolOutput != null) "已调用: $toolName" else "正在调用: $toolName...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
IconButton(
|
||||
onClick = { expanded = !expanded },
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ExpandMore,
|
||||
contentDescription = "展开/收起",
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Loading indicator for ongoing tool calls
|
||||
if (toolOutput == null) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Expanded details
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column(modifier = Modifier.padding(top = 8.dp)) {
|
||||
if (toolInput.isNotEmpty()) {
|
||||
Text(
|
||||
text = "输入:",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = toolInput.take(500),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
}
|
||||
|
||||
if (toolOutput != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "输出:",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = when (success) {
|
||||
true -> MaterialTheme.colorScheme.primary
|
||||
false -> MaterialTheme.colorScheme.error
|
||||
null -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
Text(
|
||||
text = toolOutput.take(500),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.tiangong.aiagent.ui.chat.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.tiangong.aiagent.util.AudioRecorder
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun VoiceInputButton(
|
||||
audioRecorder: AudioRecorder,
|
||||
onRecordingComplete: (File) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onRecordingStarted: (() -> Unit)? = null,
|
||||
onRecordingStopped: (() -> Unit)? = null
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var isRecording by remember { mutableStateOf(false) }
|
||||
var isCancelling by remember { mutableStateOf(false) }
|
||||
val cancelThreshold = with(LocalDensity.current) { 80.dp.toPx() }
|
||||
|
||||
var hasPermission by remember {
|
||||
mutableStateOf(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
hasPermission = granted
|
||||
}
|
||||
|
||||
// Pulse animation when recording
|
||||
val infiniteTransition = rememberInfiniteTransition()
|
||||
val pulseScale by infiniteTransition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 1.3f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(600, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
)
|
||||
)
|
||||
|
||||
val scaledSize = if (isRecording) pulseScale else 1f
|
||||
val buttonColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isCancelling -> MaterialTheme.colorScheme.error
|
||||
isRecording -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f)
|
||||
else -> MaterialTheme.colorScheme.primaryContainer
|
||||
}
|
||||
)
|
||||
|
||||
var cumulativeDragY by remember { mutableStateOf(0f) }
|
||||
|
||||
Box(modifier = modifier) {
|
||||
// Cancel hint above button when recording
|
||||
if (isRecording) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset { IntOffset(0, -110) }
|
||||
.background(
|
||||
color = if (isCancelling)
|
||||
MaterialTheme.colorScheme.error.copy(alpha = 0.85f)
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
)
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (isCancelling) "松开取消" else "上滑取消",
|
||||
fontSize = 13.sp,
|
||||
color = if (isCancelling)
|
||||
MaterialTheme.colorScheme.onError
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (!hasPermission) {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
return@IconButton
|
||||
}
|
||||
// Tap toggles recording start/stop (for accessibility)
|
||||
if (isRecording) {
|
||||
val file = audioRecorder.stopRecording()
|
||||
isRecording = false
|
||||
isCancelling = false
|
||||
onRecordingStopped?.invoke()
|
||||
file?.let { onRecordingComplete(it) }
|
||||
} else {
|
||||
try {
|
||||
audioRecorder.startRecording()
|
||||
isRecording = true
|
||||
isCancelling = false
|
||||
onRecordingStarted?.invoke()
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, "录音启动失败: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.scale(scaledSize)
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { offset ->
|
||||
if (!hasPermission) return@detectDragGesturesAfterLongPress
|
||||
try {
|
||||
audioRecorder.startRecording()
|
||||
isRecording = true
|
||||
isCancelling = false
|
||||
cumulativeDragY = 0f
|
||||
onRecordingStarted?.invoke()
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, "长按录音启动失败: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
if (!isRecording) return@detectDragGesturesAfterLongPress
|
||||
if (isCancelling) {
|
||||
audioRecorder.cancelRecording()
|
||||
} else {
|
||||
val file = audioRecorder.stopRecording()
|
||||
file?.let { onRecordingComplete(it) }
|
||||
}
|
||||
isRecording = false
|
||||
isCancelling = false
|
||||
cumulativeDragY = 0f
|
||||
onRecordingStopped?.invoke()
|
||||
},
|
||||
onDragCancel = {
|
||||
if (isRecording) {
|
||||
audioRecorder.cancelRecording()
|
||||
isRecording = false
|
||||
isCancelling = false
|
||||
cumulativeDragY = 0f
|
||||
onRecordingStopped?.invoke()
|
||||
}
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
cumulativeDragY += dragAmount.y
|
||||
isCancelling = cumulativeDragY < -cancelThreshold
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(buttonColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Mic,
|
||||
contentDescription = if (isRecording) "停止录音" else "开始录音",
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = if (isRecording || isCancelling)
|
||||
MaterialTheme.colorScheme.onError
|
||||
else
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.tiangong.aiagent.ui.common
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
// ─── Shimmer Animation ──────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun shimmerBrush(colors: List<Color> = defaultShimmerColors()): Brush {
|
||||
val transition = rememberInfiniteTransition(label = "shimmer")
|
||||
val translateAnim by transition.animateFloat(
|
||||
initialValue = -300f,
|
||||
targetValue = 900f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1500, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "shimmer_translate"
|
||||
)
|
||||
return Brush.linearGradient(
|
||||
colors = colors,
|
||||
start = Offset(translateAnim, 0f),
|
||||
end = Offset(translateAnim + 300f, 0f)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun defaultShimmerColors(): List<Color> {
|
||||
val base = MaterialTheme.colorScheme.surfaceVariant
|
||||
val shimmer = MaterialTheme.colorScheme.surface
|
||||
return listOf(base, shimmer, base)
|
||||
}
|
||||
|
||||
// ─── Basic Skeleton Primitives ──────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun SkeletonBox(
|
||||
modifier: Modifier = Modifier,
|
||||
shape: androidx.compose.ui.graphics.Shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(shimmerBrush())
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SkeletonText(
|
||||
widthFraction: Float = 1f,
|
||||
height: Dp = 14.dp,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth(widthFraction)
|
||||
.height(height)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(shimmerBrush())
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SkeletonCircle(
|
||||
size: Dp = 40.dp,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(shimmerBrush())
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SkeletonRow(
|
||||
avatarSize: Dp = 40.dp,
|
||||
lines: Int = 2,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
SkeletonCircle(size = avatarSize)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
SkeletonText(widthFraction = 0.6f, height = 16.dp)
|
||||
repeat(lines - 1) {
|
||||
SkeletonText(widthFraction = 0.9f - it * 0.2f, height = 13.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Chat Skeleton ─────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun SkeletonChat(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Assistant bubble 1
|
||||
SkeletonBubble(start = true, lines = 3)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// User bubble
|
||||
SkeletonBubble(start = false, lines = 2)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Assistant bubble 2 (with tool)
|
||||
Column(horizontalAlignment = Alignment.Start) {
|
||||
SkeletonBubble(start = true, lines = 2)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
SkeletonToolCard(modifier = Modifier.padding(start = 40.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Streaming text skeleton
|
||||
SkeletonStreamingText()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkeletonBubble(start: Boolean, lines: Int) {
|
||||
val alignment = if (start) Alignment.Start else Alignment.End
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = alignment
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.8f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
Brush.linearGradient(defaultShimmerColors())
|
||||
)
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
repeat(lines) { i ->
|
||||
SkeletonText(
|
||||
widthFraction = (0.85f - i * 0.2f).coerceAtLeast(0.4f),
|
||||
height = 13.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Agent List Skeleton ───────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun SkeletonAgentList(
|
||||
count: Int = 5,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
|
||||
repeat(count) {
|
||||
SkeletonRow(avatarSize = 48.dp, lines = 2)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Streaming Text Skeleton (v1.2.0) ──────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun SkeletonStreamingText(modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SkeletonCircle(size = 8.dp)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
SkeletonText(widthFraction = 0.4f, height = 14.dp)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tool Card Skeleton (v1.2.0) ────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun SkeletonToolCard(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.fillMaxWidth(0.7f)
|
||||
.height(56.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(shimmerBrush())
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Conversation List Skeleton ────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun SkeletonConversationList(
|
||||
count: Int = 5,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
|
||||
repeat(count) {
|
||||
SkeletonRow(avatarSize = 36.dp, lines = 2)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package com.tiangong.aiagent.ui.history
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tiangong.aiagent.data.repository.ChatRepository
|
||||
import com.tiangong.aiagent.domain.model.Conversation
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import com.tiangong.aiagent.ui.common.SkeletonConversationList
|
||||
import com.tiangong.aiagent.util.formatTimestamp
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ConversationListState(
|
||||
val conversations: List<Conversation> = emptyList(),
|
||||
val allConversations: List<Conversation> = emptyList(),
|
||||
val isLoading: Boolean = true,
|
||||
val searchQuery: String = ""
|
||||
)
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@HiltViewModel
|
||||
class ConversationListViewModel @Inject constructor(
|
||||
private val chatRepository: ChatRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ConversationListState())
|
||||
val uiState: StateFlow<ConversationListState> = _uiState.asStateFlow()
|
||||
|
||||
private var filterAgentId: String? = null
|
||||
private var loadJob: Job? = null
|
||||
|
||||
private val searchQueryFlow = MutableStateFlow("")
|
||||
|
||||
init {
|
||||
loadConversations(null)
|
||||
// Debounced search (v1.2.0)
|
||||
viewModelScope.launch {
|
||||
searchQueryFlow
|
||||
.debounce(300)
|
||||
.distinctUntilChanged()
|
||||
.collect { query ->
|
||||
applySearch(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadConversations(agentId: String?) {
|
||||
filterAgentId = agentId
|
||||
loadJob?.cancel()
|
||||
loadJob = viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
chatRepository.getConversations().collect { conversations ->
|
||||
val filtered = if (agentId != null) {
|
||||
conversations.filter { it.agentId == agentId }
|
||||
} else {
|
||||
conversations
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
allConversations = filtered,
|
||||
isLoading = false
|
||||
)
|
||||
applySearch(_uiState.value.searchQuery)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSearchQueryChange(query: String) {
|
||||
_uiState.value = _uiState.value.copy(searchQuery = query)
|
||||
searchQueryFlow.value = query
|
||||
}
|
||||
|
||||
private fun applySearch(query: String) {
|
||||
val all = _uiState.value.allConversations
|
||||
val filtered = if (query.isBlank()) {
|
||||
all
|
||||
} else {
|
||||
val q = query.lowercase()
|
||||
all.filter { conv ->
|
||||
(conv.title?.lowercase()?.contains(q) == true) ||
|
||||
(conv.lastMessage?.lowercase()?.contains(q) == true) ||
|
||||
(conv.agentName?.lowercase()?.contains(q) == true)
|
||||
}
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(conversations = filtered)
|
||||
}
|
||||
|
||||
fun deleteConversation(sessionId: String) {
|
||||
viewModelScope.launch {
|
||||
chatRepository.deleteConversation(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
// v1.2.0: Rename conversation
|
||||
fun renameConversation(sessionId: String, newTitle: String) {
|
||||
viewModelScope.launch {
|
||||
// Update via Room — find the entity and update title
|
||||
val current = _uiState.value.allConversations.find { it.sessionId == sessionId } ?: return@launch
|
||||
chatRepository.updateConversationTitle(sessionId, newTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConversationListScreen(
|
||||
onBack: () -> Unit,
|
||||
onConversationSelected: (String) -> Unit,
|
||||
currentAgentId: String? = null,
|
||||
viewModel: ConversationListViewModel = hiltViewModel()
|
||||
) {
|
||||
LaunchedEffect(currentAgentId) {
|
||||
viewModel.loadConversations(currentAgentId)
|
||||
}
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("对话历史") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "返回"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
// v1.2.0: Search bar
|
||||
OutlinedTextField(
|
||||
value = uiState.searchQuery,
|
||||
onValueChange = viewModel::onSearchQueryChange,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
placeholder = { Text("搜索对话...") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Search, contentDescription = null)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (uiState.searchQuery.isNotEmpty()) {
|
||||
IconButton(onClick = {
|
||||
viewModel.onSearchQueryChange("")
|
||||
focusManager.clearFocus()
|
||||
}) {
|
||||
Icon(Icons.Default.Close, contentDescription = "清除")
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() })
|
||||
)
|
||||
|
||||
when {
|
||||
uiState.isLoading && uiState.conversations.isEmpty() -> {
|
||||
SkeletonConversationList(count = 5)
|
||||
}
|
||||
uiState.conversations.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
imageVector = if (uiState.searchQuery.isNotEmpty())
|
||||
Icons.Default.Search else Icons.AutoMirrored.Filled.Chat,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = if (uiState.searchQuery.isNotEmpty()) "未找到匹配的对话" else "暂无对话记录",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = if (uiState.searchQuery.isNotEmpty()) "尝试其他关键词" else "开始一个新对话吧",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
text = "${uiState.conversations.size} 个对话",
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 4.dp)
|
||||
) {
|
||||
items(uiState.conversations, key = { it.sessionId }) { conversation ->
|
||||
ConversationListItem(
|
||||
conversation = conversation,
|
||||
onClick = { onConversationSelected(conversation.sessionId) },
|
||||
onDelete = { viewModel.deleteConversation(conversation.sessionId) },
|
||||
onRename = { newTitle ->
|
||||
viewModel.renameConversation(conversation.sessionId, newTitle)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConversationListItem(
|
||||
conversation: Conversation,
|
||||
onClick: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onRename: (String) -> Unit = {}
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
var showRenameDialog by remember { mutableStateOf(false) }
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.clickable(onClick = onClick),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 1.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(40.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.primaryContainer
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SmartToy,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = conversation.title ?: "新对话",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (conversation.lastMessage != null) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = conversation.lastMessage,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row {
|
||||
Text(
|
||||
text = formatTimestamp(conversation.lastMessageAt),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
Text(
|
||||
text = " · ${conversation.messageCount} 条消息",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// v1.2.0: More menu (rename + delete)
|
||||
Box {
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = "更多操作",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("重命名") },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
showRenameDialog = true
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Create, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("删除", color = MaterialTheme.colorScheme.error) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
showDeleteDialog = true
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rename dialog (v1.2.0)
|
||||
if (showRenameDialog) {
|
||||
var newTitle by remember { mutableStateOf(conversation.title ?: "") }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRenameDialog = false },
|
||||
title = { Text("重命名对话") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = newTitle,
|
||||
onValueChange = { newTitle = it },
|
||||
label = { Text("新名称") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (newTitle.isNotBlank()) {
|
||||
onRename(newTitle.trim())
|
||||
showRenameDialog = false
|
||||
}
|
||||
}
|
||||
) { Text("确定") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showRenameDialog = false }) { Text("取消") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showDeleteDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = { Text("删除对话") },
|
||||
text = { Text("确定要删除这个对话吗?此操作不可撤销。") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDeleteDialog = false
|
||||
onDelete()
|
||||
}
|
||||
) {
|
||||
Text("删除", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.tiangong.aiagent.ui.login
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
onLoginSuccess: () -> Unit,
|
||||
onNavigateToRegister: () -> Unit = {},
|
||||
viewModel: LoginViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val focusManager = LocalFocusManager.current
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(uiState.isLoggedIn) {
|
||||
if (uiState.isLoggedIn) {
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "天工智能体",
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp
|
||||
),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "AI Agent Platform",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
// Username
|
||||
OutlinedTextField(
|
||||
value = uiState.username,
|
||||
onValueChange = viewModel::onUsernameChange,
|
||||
label = { Text("用户名") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
enabled = !uiState.isLoading
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Password
|
||||
OutlinedTextField(
|
||||
value = uiState.password,
|
||||
onValueChange = viewModel::onPasswordChange,
|
||||
label = { Text("密码") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = if (passwordVisible) "隐藏密码" else "显示密码"
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
viewModel.login()
|
||||
}
|
||||
),
|
||||
enabled = !uiState.isLoading
|
||||
)
|
||||
|
||||
// Error
|
||||
uiState.error?.let { errorText ->
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = errorText,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Login button
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
viewModel.login()
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
enabled = !uiState.isLoading
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text("登录", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Register link
|
||||
TextButton(onClick = onNavigateToRegister) {
|
||||
Text("没有账户?立即注册")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Privacy policy & user agreement
|
||||
val context = LocalContext.current
|
||||
PrivacyPolicyRow(
|
||||
onPrivacyPolicy = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://tiangong.ai/privacy"))
|
||||
context.startActivity(intent)
|
||||
},
|
||||
onUserAgreement = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://tiangong.ai/terms"))
|
||||
context.startActivity(intent)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PrivacyPolicyRow(
|
||||
onPrivacyPolicy: () -> Unit,
|
||||
onUserAgreement: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
TextButton(onClick = onPrivacyPolicy, contentPadding = PaddingValues(horizontal = 4.dp)) {
|
||||
Text("隐私政策", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
Text(
|
||||
text = "|",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
)
|
||||
TextButton(onClick = onUserAgreement, contentPadding = PaddingValues(horizontal = 4.dp)) {
|
||||
Text("用户协议", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.tiangong.aiagent.ui.login
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tiangong.aiagent.data.repository.AuthRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class LoginUiState(
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val isLoggedIn: Boolean = false
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class LoginViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(LoginUiState())
|
||||
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun onUsernameChange(username: String) {
|
||||
_uiState.value = _uiState.value.copy(username = username, error = null)
|
||||
}
|
||||
|
||||
fun onPasswordChange(password: String) {
|
||||
_uiState.value = _uiState.value.copy(password = password, error = null)
|
||||
}
|
||||
|
||||
fun login() {
|
||||
val state = _uiState.value
|
||||
if (state.username.isBlank() || state.password.isBlank()) {
|
||||
_uiState.value = state.copy(error = "请输入用户名和密码")
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
val result = authRepository.login(state.username, state.password)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, isLoggedIn = true)
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "登录失败"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.tiangong.aiagent.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.tiangong.aiagent.domain.model.Agent
|
||||
import com.tiangong.aiagent.ui.agents.AgentListScreen
|
||||
import com.tiangong.aiagent.ui.chat.ChatScreen
|
||||
import com.tiangong.aiagent.ui.chat.ChatViewModel
|
||||
import com.tiangong.aiagent.ui.history.ConversationListScreen
|
||||
import com.tiangong.aiagent.ui.login.LoginScreen
|
||||
import com.tiangong.aiagent.ui.notifications.NotificationDetailScreen
|
||||
import com.tiangong.aiagent.ui.notifications.NotificationsScreen
|
||||
import com.tiangong.aiagent.ui.register.RegisterScreen
|
||||
import com.tiangong.aiagent.ui.settings.AboutScreen
|
||||
import com.tiangong.aiagent.ui.settings.SettingsScreen
|
||||
|
||||
object Routes {
|
||||
const val LOGIN = "login"
|
||||
const val REGISTER = "register"
|
||||
const val CHAT = "chat"
|
||||
const val AGENTS = "agents"
|
||||
const val SETTINGS = "settings"
|
||||
const val HISTORY = "history"
|
||||
const val ABOUT = "about"
|
||||
const val NOTIFICATIONS = "notifications"
|
||||
const val NOTIFICATION_DETAIL = "notifications/{notificationId}"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavGraph(
|
||||
token: String?,
|
||||
initialNotificationId: String? = null,
|
||||
navController: NavHostController = rememberNavController()
|
||||
) {
|
||||
val startDest = remember(token) {
|
||||
if (token != null) Routes.CHAT else Routes.LOGIN
|
||||
}
|
||||
|
||||
LaunchedEffect(initialNotificationId) {
|
||||
if (initialNotificationId != null) {
|
||||
navController.navigate("notifications/$initialNotificationId")
|
||||
}
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDest
|
||||
) {
|
||||
composable(Routes.LOGIN) {
|
||||
LoginScreen(
|
||||
onLoginSuccess = {
|
||||
navController.navigate(Routes.CHAT) {
|
||||
popUpTo(Routes.LOGIN) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onNavigateToRegister = {
|
||||
navController.navigate(Routes.REGISTER)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.REGISTER) {
|
||||
RegisterScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onRegisterSuccess = {
|
||||
navController.navigate(Routes.LOGIN) {
|
||||
popUpTo(Routes.REGISTER) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onNavigateToLogin = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.CHAT) {
|
||||
ChatScreen(
|
||||
onNavigateToAgents = { navController.navigate(Routes.AGENTS) },
|
||||
onNavigateToSettings = { navController.navigate(Routes.SETTINGS) },
|
||||
onNavigateToHistory = { navController.navigate(Routes.HISTORY) },
|
||||
onNavigateToNotifications = { navController.navigate(Routes.NOTIFICATIONS) },
|
||||
onLogout = {
|
||||
navController.navigate(Routes.LOGIN) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.AGENTS) {
|
||||
val chatViewModel: ChatViewModel = hiltViewModel(
|
||||
navController.getBackStackEntry(Routes.CHAT)
|
||||
)
|
||||
AgentListScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onAgentSelected = { agent: Agent ->
|
||||
chatViewModel.switchAgent(agent)
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.HISTORY) {
|
||||
val chatViewModel: ChatViewModel = hiltViewModel(
|
||||
navController.getBackStackEntry(Routes.CHAT)
|
||||
)
|
||||
val chatState by chatViewModel.uiState.collectAsState()
|
||||
ConversationListScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onConversationSelected = { sessionId ->
|
||||
chatViewModel.loadConversation(sessionId)
|
||||
navController.popBackStack()
|
||||
},
|
||||
currentAgentId = chatState.currentAgent?.id
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.SETTINGS) {
|
||||
SettingsScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onLogout = {
|
||||
navController.navigate(Routes.LOGIN) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onNavigateToAbout = { navController.navigate(Routes.ABOUT) }
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.ABOUT) {
|
||||
AboutScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable(Routes.NOTIFICATIONS) {
|
||||
NotificationsScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onNavigateToDetail = { notificationId ->
|
||||
navController.navigate("notifications/$notificationId")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.NOTIFICATION_DETAIL) { backStackEntry ->
|
||||
val notificationId = backStackEntry.arguments?.getString("notificationId") ?: return@composable
|
||||
NotificationDetailScreen(
|
||||
notificationId = notificationId,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.tiangong.aiagent.ui.notifications
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavHostController
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NotificationDetailScreen(
|
||||
notificationId: String,
|
||||
navController: NavHostController,
|
||||
viewModel: NotificationsViewModel = hiltViewModel(
|
||||
navController.getBackStackEntry("notifications")
|
||||
)
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val notification = uiState.notifications.find { it.id == notificationId }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("通知详情") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "返回"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
if (notification == null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||
) {
|
||||
Text("通知不存在", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
) {
|
||||
// Title with unread indicator
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
if (!notification.isRead) {
|
||||
Surface(
|
||||
modifier = Modifier.size(10.dp),
|
||||
shape = MaterialTheme.shapes.extraSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
) {}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(
|
||||
text = notification.title,
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = notification.createdAt,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
|
||||
|
||||
if (!notification.body.isNullOrBlank()) {
|
||||
Text(
|
||||
text = notification.body,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
if (!notification.url.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "相关链接",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = notification.url,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package com.tiangong.aiagent.ui.notifications
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tiangong.aiagent.data.remote.dto.NotificationResponse
|
||||
import com.tiangong.aiagent.data.repository.NotificationRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class NotificationsUiState(
|
||||
val notifications: List<NotificationResponse> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class NotificationsViewModel @Inject constructor(
|
||||
private val notificationRepository: NotificationRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(NotificationsUiState())
|
||||
val uiState: StateFlow<NotificationsUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadNotifications()
|
||||
}
|
||||
|
||||
fun loadNotifications() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
notificationRepository.getNotifications(limit = 50, offset = 0)
|
||||
.onSuccess { notifications ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
notifications = notifications,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
.onFailure { e ->
|
||||
Log.w("NotificationsVM", "Failed to load notifications", e)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "加载失败"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun markAsRead(notificationId: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
notificationRepository.markAsRead(notificationId)
|
||||
// Update local state
|
||||
val updated = _uiState.value.notifications.map {
|
||||
if (it.id == notificationId) it.copy(isRead = true) else it
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(notifications = updated)
|
||||
} catch (e: Exception) {
|
||||
Log.w("NotificationsVM", "Failed to mark notification read", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NotificationsScreen(
|
||||
onBack: () -> Unit,
|
||||
onNavigateToDetail: (String) -> Unit = {},
|
||||
viewModel: NotificationsViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("通知") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "返回"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else if (uiState.error != null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = uiState.error ?: "加载失败",
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextButton(onClick = { viewModel.loadNotifications() }) {
|
||||
Text("重试")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (uiState.notifications.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Notifications,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "暂无通知",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(vertical = 4.dp)
|
||||
) {
|
||||
items(uiState.notifications, key = { it.id }) { notification ->
|
||||
NotificationItem(
|
||||
notification = notification,
|
||||
onClick = {
|
||||
if (!notification.isRead) {
|
||||
viewModel.markAsRead(notification.id)
|
||||
}
|
||||
onNavigateToDetail(notification.id)
|
||||
}
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationItem(
|
||||
notification: NotificationResponse,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
color = if (notification.isRead)
|
||||
MaterialTheme.colorScheme.surface
|
||||
else
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
// Unread dot
|
||||
if (!notification.isRead) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.offset(y = 6.dp),
|
||||
shape = MaterialTheme.shapes.extraSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
) {}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = notification.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (notification.isRead) FontWeight.Normal else FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (!notification.body.isNullOrEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = notification.body,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = notification.createdAt,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package com.tiangong.aiagent.ui.register
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RegisterScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onRegisterSuccess: () -> Unit,
|
||||
onNavigateToLogin: () -> Unit,
|
||||
viewModel: RegisterViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val focusManager = LocalFocusManager.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
var confirmPasswordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
// Error snackbar
|
||||
LaunchedEffect(uiState.errorMessage) {
|
||||
uiState.errorMessage?.let { msg ->
|
||||
snackbarHostState.showSnackbar(message = msg, actionLabel = "确定", duration = SnackbarDuration.Long)
|
||||
viewModel.clearError()
|
||||
}
|
||||
}
|
||||
|
||||
// Success navigation
|
||||
LaunchedEffect(uiState.isSuccess) {
|
||||
if (uiState.isSuccess) {
|
||||
snackbarHostState.showSnackbar("注册成功!请登录")
|
||||
onRegisterSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("创建账户") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Username
|
||||
OutlinedTextField(
|
||||
value = uiState.username,
|
||||
onValueChange = viewModel::onUsernameChanged,
|
||||
label = { Text("用户名") },
|
||||
placeholder = { Text("至少3个字符") },
|
||||
isError = !uiState.isUsernameValid,
|
||||
supportingText = uiState.usernameError?.let { { Text(it) } },
|
||||
singleLine = true,
|
||||
enabled = !uiState.isLoading,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Display Name
|
||||
OutlinedTextField(
|
||||
value = uiState.displayName,
|
||||
onValueChange = viewModel::onDisplayNameChanged,
|
||||
label = { Text("显示名称 (可选)") },
|
||||
placeholder = { Text("其他人看到的名称") },
|
||||
singleLine = true,
|
||||
enabled = !uiState.isLoading,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Email
|
||||
OutlinedTextField(
|
||||
value = uiState.email,
|
||||
onValueChange = viewModel::onEmailChanged,
|
||||
label = { Text("邮箱 (可选)") },
|
||||
placeholder = { Text("example@mail.com") },
|
||||
isError = !uiState.isEmailValid,
|
||||
supportingText = uiState.emailError?.let { { Text(it) } },
|
||||
singleLine = true,
|
||||
enabled = !uiState.isLoading,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Password
|
||||
OutlinedTextField(
|
||||
value = uiState.password,
|
||||
onValueChange = viewModel::onPasswordChanged,
|
||||
label = { Text("密码") },
|
||||
placeholder = { Text("6-128个字符") },
|
||||
isError = uiState.password.length > 0 && !uiState.isPasswordValid,
|
||||
supportingText = {
|
||||
if (uiState.passwordError != null) {
|
||||
Text(uiState.passwordError!!)
|
||||
} else {
|
||||
Text("建议包含字母和数字,增强安全性", color = MaterialTheme.colorScheme.outline)
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
enabled = !uiState.isLoading,
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
|
||||
contentDescription = if (passwordVisible) "隐藏密码" else "显示密码"
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Confirm Password
|
||||
OutlinedTextField(
|
||||
value = uiState.confirmPassword,
|
||||
onValueChange = viewModel::onConfirmPasswordChanged,
|
||||
label = { Text("确认密码") },
|
||||
placeholder = { Text("再次输入密码") },
|
||||
isError = !uiState.isConfirmPasswordMatch,
|
||||
supportingText = uiState.confirmPasswordError?.let { { Text(it) } },
|
||||
singleLine = true,
|
||||
enabled = !uiState.isLoading,
|
||||
visualTransformation = if (confirmPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (confirmPasswordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
|
||||
contentDescription = if (confirmPasswordVisible) "隐藏密码" else "显示密码"
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { focusManager.clearFocus(); viewModel.register() }
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Register button
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
viewModel.register()
|
||||
},
|
||||
enabled = !uiState.isLoading,
|
||||
modifier = Modifier.fillMaxWidth().height(50.dp)
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text("注册")
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to Login
|
||||
TextButton(
|
||||
onClick = onNavigateToLogin,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
) {
|
||||
Text("已有账户?立即登录")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.tiangong.aiagent.ui.register
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tiangong.aiagent.data.remote.dto.RegisterRequest
|
||||
import com.tiangong.aiagent.data.repository.AuthRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
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 javax.inject.Inject
|
||||
|
||||
data class RegisterUiState(
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val confirmPassword: String = "",
|
||||
val email: String = "",
|
||||
val displayName: String = "",
|
||||
|
||||
val isUsernameValid: Boolean = true,
|
||||
val isPasswordValid: Boolean = true,
|
||||
val isConfirmPasswordMatch: Boolean = true,
|
||||
val isEmailValid: Boolean = true,
|
||||
|
||||
val usernameError: String? = null,
|
||||
val passwordError: String? = null,
|
||||
val confirmPasswordError: String? = null,
|
||||
val emailError: String? = null,
|
||||
|
||||
val isLoading: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val isSuccess: Boolean = false
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class RegisterViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(RegisterUiState())
|
||||
val uiState: StateFlow<RegisterUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun onUsernameChanged(value: String) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
username = value,
|
||||
isUsernameValid = validateUsername(value),
|
||||
usernameError = if (validateUsername(value)) null else "用户名至少3个字符"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onPasswordChanged(value: String) {
|
||||
_uiState.update { state ->
|
||||
val valid = validatePassword(value)
|
||||
val hint = if (!valid) "密码需要6-128个字符" else passwordStrengthHint(value)
|
||||
state.copy(
|
||||
password = value,
|
||||
isPasswordValid = valid,
|
||||
passwordError = hint,
|
||||
isConfirmPasswordMatch = value == state.confirmPassword || state.confirmPassword.isEmpty(),
|
||||
confirmPasswordError = if (value == state.confirmPassword || state.confirmPassword.isEmpty())
|
||||
null else "两次输入的密码不一致"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onConfirmPasswordChanged(value: String) {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
confirmPassword = value,
|
||||
isConfirmPasswordMatch = value == state.password || value.isEmpty(),
|
||||
confirmPasswordError = if (value == state.password || value.isEmpty()) null
|
||||
else "两次输入的密码不一致"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onEmailChanged(value: String) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
email = value,
|
||||
isEmailValid = value.isEmpty() || validateEmail(value),
|
||||
emailError = when {
|
||||
value.isEmpty() -> null
|
||||
!validateEmail(value) -> "邮箱格式不正确"
|
||||
else -> null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onDisplayNameChanged(value: String) {
|
||||
_uiState.update { it.copy(displayName = value) }
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(errorMessage = null) }
|
||||
}
|
||||
|
||||
fun register() {
|
||||
val state = _uiState.value
|
||||
|
||||
val usernameOk = validateUsername(state.username)
|
||||
val passwordOk = validatePassword(state.password)
|
||||
val confirmOk = state.password == state.confirmPassword
|
||||
val emailOk = state.email.isEmpty() || validateEmail(state.email)
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isUsernameValid = usernameOk,
|
||||
isPasswordValid = passwordOk,
|
||||
isConfirmPasswordMatch = confirmOk,
|
||||
isEmailValid = emailOk,
|
||||
usernameError = when {
|
||||
state.username.isBlank() -> "请输入用户名"
|
||||
!usernameOk -> "用户名至少3个字符"
|
||||
else -> null
|
||||
},
|
||||
passwordError = when {
|
||||
state.password.isBlank() -> "请输入密码"
|
||||
!passwordOk -> "密码需要6-128个字符"
|
||||
else -> passwordStrengthHint(state.password)
|
||||
},
|
||||
confirmPasswordError = when {
|
||||
state.confirmPassword.isBlank() -> "请确认密码"
|
||||
!confirmOk -> "两次输入的密码不一致"
|
||||
else -> null
|
||||
},
|
||||
emailError = when {
|
||||
!emailOk -> "邮箱格式不正确"
|
||||
else -> null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!usernameOk || !passwordOk || !confirmOk || !emailOk) return
|
||||
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
|
||||
viewModelScope.launch {
|
||||
val request = RegisterRequest(
|
||||
username = state.username.trim(),
|
||||
password = state.password,
|
||||
email = state.email.trim(),
|
||||
displayName = state.displayName.trim().ifBlank { null }
|
||||
)
|
||||
val result = authRepository.register(request)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
_uiState.update { it.copy(isLoading = false, isSuccess = true) }
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(isLoading = false, errorMessage = e.message) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetState() {
|
||||
_uiState.value = RegisterUiState()
|
||||
}
|
||||
|
||||
private fun validateUsername(username: String): Boolean = username.trim().length >= 3
|
||||
private fun validatePassword(password: String): Boolean {
|
||||
return password.length in 6..128
|
||||
}
|
||||
|
||||
private fun passwordStrengthHint(password: String): String? {
|
||||
if (password.isEmpty()) return null
|
||||
val issues = mutableListOf<String>()
|
||||
if (password.length < 6) issues.add("至少6个字符")
|
||||
if (password.length > 128) issues.add("最多128个字符")
|
||||
// Check for common weakness patterns
|
||||
if (password.none { it.isDigit() } && password.none { it.isLetter() }) {
|
||||
// only special chars, accept it but warn
|
||||
}
|
||||
if (password.length < 8) {
|
||||
issues.add("建议使用8位以上密码")
|
||||
}
|
||||
return if (issues.isNotEmpty()) issues.joinToString(";") else null
|
||||
}
|
||||
private fun validateEmail(email: String): Boolean {
|
||||
val regex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
|
||||
return regex.matches(email.trim())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.tiangong.aiagent.ui.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.SmartToy
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
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 com.tiangong.aiagent.ui.login.PrivacyPolicyRow
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AboutScreen(onBack: () -> Unit) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("关于") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "返回"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.SmartToy,
|
||||
contentDescription = "天工智能体",
|
||||
modifier = Modifier.size(72.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "天工智能体",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "TIANGONG AI Agent Platform",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "版本 1.0.0 (Build 1)",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 48.dp))
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = "AI Agent 搭建与工作流编排平台",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
FeatureRow(Icons.Default.Build, "Agent ReAct 运行时", "56+ 工具,多 Agent 编排")
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
FeatureRow(Icons.Default.Cloud, "工作流 DAG 引擎", "可视化设计 + 并行执行")
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
FeatureRow(Icons.Default.SmartToy, "知识库 RAG", "Embedding 语义检索 + 进化闭环")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Privacy policy & user agreement
|
||||
val context = LocalContext.current
|
||||
PrivacyPolicyRow(
|
||||
onPrivacyPolicy = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://tiangong.ai/privacy"))
|
||||
context.startActivity(intent)
|
||||
},
|
||||
onUserAgreement = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://tiangong.ai/terms"))
|
||||
context.startActivity(intent)
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Text(
|
||||
text = "Mobile Client for Android",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(bottom = 32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeatureRow(icon: androidx.compose.ui.graphics.vector.ImageVector, title: String, desc: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(text = title, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
text = desc,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
package com.tiangong.aiagent.ui.settings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.Logout
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tiangong.aiagent.BuildConfig
|
||||
import com.tiangong.aiagent.data.local.TokenDataStore
|
||||
import com.tiangong.aiagent.data.remote.ApiService
|
||||
import com.tiangong.aiagent.data.remote.DynamicUrlInterceptor
|
||||
import com.tiangong.aiagent.data.repository.AuthRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
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.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val tokenDataStore: TokenDataStore,
|
||||
private val dynamicUrlInterceptor: DynamicUrlInterceptor,
|
||||
private val apiService: ApiService
|
||||
) : ViewModel() {
|
||||
|
||||
private val _username = MutableStateFlow("admin")
|
||||
val username: StateFlow<String> = _username.asStateFlow()
|
||||
|
||||
val ttsEnabled: StateFlow<Boolean> = tokenDataStore.ttsEnabled
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
val ttsVoice: StateFlow<String> = tokenDataStore.ttsVoice
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "alloy")
|
||||
|
||||
val themeMode: StateFlow<String> = tokenDataStore.themeMode
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "system")
|
||||
|
||||
val serverUrl: StateFlow<String> = tokenDataStore.serverUrl
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, BuildConfig.BASE_URL)
|
||||
|
||||
init {
|
||||
loadUsername()
|
||||
}
|
||||
|
||||
private fun loadUsername() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val user = apiService.getCurrentUser()
|
||||
_username.value = user.username
|
||||
} catch (_: Exception) {
|
||||
// Keep default "admin" fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v1.2.0: Notification preferences
|
||||
val notificationEnabled: StateFlow<Boolean> = tokenDataStore.notificationEnabled
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||
|
||||
val notificationQuietStart: StateFlow<String> = tokenDataStore.notificationQuietStart
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "22:00")
|
||||
|
||||
val notificationQuietEnd: StateFlow<String> = tokenDataStore.notificationQuietEnd
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "07:00")
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
authRepository.logout()
|
||||
}
|
||||
}
|
||||
|
||||
fun setTtsEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
tokenDataStore.setTtsEnabled(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
fun setTtsVoice(voice: String) {
|
||||
viewModelScope.launch {
|
||||
tokenDataStore.setTtsVoice(voice)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveServerUrl(url: String) {
|
||||
viewModelScope.launch {
|
||||
tokenDataStore.saveServerUrl(url)
|
||||
dynamicUrlInterceptor.updateBaseUrl(url)
|
||||
}
|
||||
}
|
||||
|
||||
fun cycleThemeMode() {
|
||||
viewModelScope.launch {
|
||||
val next = when (themeMode.value) {
|
||||
"system" -> "light"
|
||||
"light" -> "dark"
|
||||
else -> "system"
|
||||
}
|
||||
tokenDataStore.setThemeMode(next)
|
||||
}
|
||||
}
|
||||
|
||||
fun themeModeLabel(mode: String): String = when (mode) {
|
||||
"light" -> "浅色模式"
|
||||
"dark" -> "深色模式"
|
||||
else -> "跟随系统"
|
||||
}
|
||||
|
||||
// v1.2.0: Notification preference setters
|
||||
fun setNotificationEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch { tokenDataStore.setNotificationEnabled(enabled) }
|
||||
}
|
||||
|
||||
fun setNotificationQuietHours(start: String, end: String) {
|
||||
viewModelScope.launch { tokenDataStore.setNotificationQuietHours(start, end) }
|
||||
}
|
||||
|
||||
fun voiceLabel(voice: String): String = when (voice) {
|
||||
"alloy" -> "Alloy (中性)"
|
||||
"echo" -> "Echo (男声)"
|
||||
"fable" -> "Fable (英式)"
|
||||
"onyx" -> "Onyx (深沉)"
|
||||
"nova" -> "Nova (女声)"
|
||||
"shimmer" -> "Shimmer (轻柔)"
|
||||
else -> voice
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onNavigateToAbout: (() -> Unit)? = null,
|
||||
viewModel: SettingsViewModel = hiltViewModel()
|
||||
) {
|
||||
var showLogoutDialog by remember { mutableStateOf(false) }
|
||||
var showVoiceDialog by remember { mutableStateOf(false) }
|
||||
var isEditingUrl by remember { mutableStateOf(false) }
|
||||
var editedUrl by remember { mutableStateOf("") }
|
||||
val ttsEnabled by viewModel.ttsEnabled.collectAsState()
|
||||
val ttsVoice by viewModel.ttsVoice.collectAsState()
|
||||
val themeMode by viewModel.themeMode.collectAsState()
|
||||
val serverUrl by viewModel.serverUrl.collectAsState()
|
||||
val username by viewModel.username.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("设置") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "返回"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
// Account section
|
||||
SettingsSectionHeader("账户")
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("当前用户") },
|
||||
supportingContent = { Text(username) },
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.Person, contentDescription = null)
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
// Preferences section
|
||||
SettingsSectionHeader("偏好设置")
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("语音播报") },
|
||||
supportingContent = { Text("收到回复时自动朗读") },
|
||||
leadingContent = {
|
||||
Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = null)
|
||||
},
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = ttsEnabled,
|
||||
onCheckedChange = { viewModel.setTtsEnabled(it) }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("TTS 音色") },
|
||||
supportingContent = { Text(viewModel.voiceLabel(ttsVoice)) },
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.RecordVoiceOver, contentDescription = null)
|
||||
},
|
||||
modifier = Modifier.clickable { showVoiceDialog = true }
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("主题模式") },
|
||||
supportingContent = { Text(viewModel.themeModeLabel(themeMode)) },
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.DarkMode, contentDescription = null)
|
||||
},
|
||||
modifier = Modifier.clickable { viewModel.cycleThemeMode() }
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
// v1.2.0: Notification preferences section
|
||||
SettingsSectionHeader("通知")
|
||||
|
||||
val notifEnabled by viewModel.notificationEnabled.collectAsState()
|
||||
ListItem(
|
||||
headlineContent = { Text("推送通知") },
|
||||
supportingContent = { Text(if (notifEnabled) "已开启" else "已关闭") },
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.Notifications, contentDescription = null)
|
||||
},
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = notifEnabled,
|
||||
onCheckedChange = { viewModel.setNotificationEnabled(it) }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
if (notifEnabled) {
|
||||
val quietStart by viewModel.notificationQuietStart.collectAsState()
|
||||
val quietEnd by viewModel.notificationQuietEnd.collectAsState()
|
||||
var showQuietDialog by remember { mutableStateOf(false) }
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("免打扰时段") },
|
||||
supportingContent = { Text("${quietStart} - ${quietEnd}") },
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.Block, contentDescription = null)
|
||||
},
|
||||
modifier = Modifier.clickable { showQuietDialog = true }
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
if (showQuietDialog) {
|
||||
var tempStart by remember { mutableStateOf(quietStart) }
|
||||
var tempEnd by remember { mutableStateOf(quietEnd) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showQuietDialog = false },
|
||||
title = { Text("免打扰时段") },
|
||||
text = {
|
||||
Column {
|
||||
Text("在此期间不接收推送通知", style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = tempStart,
|
||||
onValueChange = { tempStart = it },
|
||||
label = { Text("开始时间 (HH:MM)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = tempEnd,
|
||||
onValueChange = { tempEnd = it },
|
||||
label = { Text("结束时间 (HH:MM)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
viewModel.setNotificationQuietHours(tempStart, tempEnd)
|
||||
showQuietDialog = false
|
||||
}) { Text("确定") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showQuietDialog = false }) { Text("取消") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Server section
|
||||
SettingsSectionHeader("服务器")
|
||||
|
||||
if (isEditingUrl) {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
OutlinedTextField(
|
||||
value = editedUrl,
|
||||
onValueChange = { editedUrl = it },
|
||||
label = { Text("服务器地址") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = { isEditingUrl = false }) {
|
||||
Text("取消")
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(onClick = {
|
||||
viewModel.saveServerUrl(editedUrl)
|
||||
isEditingUrl = false
|
||||
}) {
|
||||
Text("保存")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "设置即时生效",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ListItem(
|
||||
headlineContent = { Text("服务器地址") },
|
||||
supportingContent = { Text(serverUrl) },
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.Cloud, contentDescription = null)
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
editedUrl = serverUrl
|
||||
isEditingUrl = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
// About
|
||||
SettingsSectionHeader("关于")
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("天工智能体") },
|
||||
supportingContent = { Text("版本 1.0.0") },
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.Info, contentDescription = null)
|
||||
},
|
||||
modifier = if (onNavigateToAbout != null) {
|
||||
Modifier.clickable { onNavigateToAbout() }
|
||||
} else Modifier
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Logout button
|
||||
Button(
|
||||
onClick = { showLogoutDialog = true },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Logout,
|
||||
contentDescription = "退出登录",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("退出登录")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// Voice selection dialog
|
||||
if (showVoiceDialog) {
|
||||
val voices = listOf("alloy", "echo", "fable", "onyx", "nova", "shimmer")
|
||||
AlertDialog(
|
||||
onDismissRequest = { showVoiceDialog = false },
|
||||
title = { Text("选择 TTS 音色") },
|
||||
text = {
|
||||
Column {
|
||||
voices.forEach { voice ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
viewModel.setTtsVoice(voice)
|
||||
showVoiceDialog = false
|
||||
}
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = ttsVoice == voice,
|
||||
onClick = {
|
||||
viewModel.setTtsVoice(voice)
|
||||
showVoiceDialog = false
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(viewModel.voiceLabel(voice))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showVoiceDialog = false }) {
|
||||
Text("关闭")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Logout confirmation dialog
|
||||
if (showLogoutDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showLogoutDialog = false },
|
||||
title = { Text("确认退出") },
|
||||
text = { Text("确定要退出登录吗?") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showLogoutDialog = false
|
||||
viewModel.logout()
|
||||
onLogout()
|
||||
}
|
||||
) {
|
||||
Text("确定", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showLogoutDialog = false }) {
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsSectionHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 8.dp),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.tiangong.aiagent.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF1565C0),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFD1E4FF),
|
||||
onPrimaryContainer = Color(0xFF001D36),
|
||||
secondary = Color(0xFF545F70),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFD8E3F8),
|
||||
onSecondaryContainer = Color(0xFF111C2B),
|
||||
tertiary = Color(0xFF6D5677),
|
||||
onTertiary = Color.White,
|
||||
error = Color(0xFFBA1A1A),
|
||||
onError = Color.White,
|
||||
background = Color(0xFFFDFCFF),
|
||||
onBackground = Color(0xFF1A1C1E),
|
||||
surface = Color(0xFFFDFCFF),
|
||||
onSurface = Color(0xFF1A1C1E),
|
||||
surfaceVariant = Color(0xFFDFE2EB),
|
||||
onSurfaceVariant = Color(0xFF43474E),
|
||||
outline = Color(0xFF73777F),
|
||||
)
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Color(0xFF9ECAFF),
|
||||
onPrimary = Color(0xFF003258),
|
||||
primaryContainer = Color(0xFF00497D),
|
||||
onPrimaryContainer = Color(0xFFD1E4FF),
|
||||
secondary = Color(0xFFBCC7DB),
|
||||
onSecondary = Color(0xFF263141),
|
||||
secondaryContainer = Color(0xFF3C4858),
|
||||
onSecondaryContainer = Color(0xFFD8E3F8),
|
||||
tertiary = Color(0xFFD7BDE4),
|
||||
onTertiary = Color(0xFF3B2948),
|
||||
background = Color(0xFF1A1C1E),
|
||||
onBackground = Color(0xFFE2E2E6),
|
||||
surface = Color(0xFF1A1C1E),
|
||||
onSurface = Color(0xFFE2E2E6),
|
||||
surfaceVariant = Color(0xFF43474E),
|
||||
onSurfaceVariant = Color(0xFFC3C7CF),
|
||||
outline = Color(0xFF8D9199),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun themeIsDark(mode: String): Boolean = when (mode) {
|
||||
"light" -> false
|
||||
"dark" -> true
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TiangongTheme(
|
||||
themeMode: String = "system",
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val darkTheme = themeIsDark(themeMode)
|
||||
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography(),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.tiangong.aiagent.util
|
||||
|
||||
import android.content.Context
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
enum class PlaybackState {
|
||||
IDLE, LOADING, PLAYING, PAUSED, ERROR
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class AudioPlayer @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private var player: ExoPlayer? = null
|
||||
|
||||
private val _playbackState = MutableStateFlow(PlaybackState.IDLE)
|
||||
val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
|
||||
|
||||
fun play(url: String) {
|
||||
release()
|
||||
|
||||
player = ExoPlayer.Builder(context).build().apply {
|
||||
setMediaItem(MediaItem.fromUri(url))
|
||||
prepare()
|
||||
play()
|
||||
|
||||
addListener(object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(state: Int) {
|
||||
_playbackState.value = when (state) {
|
||||
Player.STATE_IDLE -> PlaybackState.IDLE
|
||||
Player.STATE_BUFFERING -> PlaybackState.LOADING
|
||||
Player.STATE_READY -> {
|
||||
if (playWhenReady) PlaybackState.PLAYING else PlaybackState.PAUSED
|
||||
}
|
||||
Player.STATE_ENDED -> PlaybackState.IDLE
|
||||
else -> PlaybackState.IDLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
_playbackState.value = PlaybackState.ERROR
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
player?.pause()
|
||||
}
|
||||
|
||||
fun resume() {
|
||||
player?.play()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
player?.stop()
|
||||
_playbackState.value = PlaybackState.IDLE
|
||||
}
|
||||
|
||||
fun release() {
|
||||
player?.release()
|
||||
player = null
|
||||
_playbackState.value = PlaybackState.IDLE
|
||||
}
|
||||
|
||||
val isPlaying: Boolean
|
||||
get() = player?.isPlaying == true
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.tiangong.aiagent.util
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AudioRecorder @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private var mediaRecorder: MediaRecorder? = null
|
||||
private var outputFile: File? = null
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun startRecording(): File {
|
||||
outputFile = File(context.cacheDir, "voice_${System.currentTimeMillis()}.aac")
|
||||
|
||||
mediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(context)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
MediaRecorder()
|
||||
}.apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS)
|
||||
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
setAudioSamplingRate(16000)
|
||||
setAudioEncodingBitRate(64000)
|
||||
setOutputFile(outputFile!!.absolutePath)
|
||||
prepare()
|
||||
start()
|
||||
}
|
||||
|
||||
return outputFile!!
|
||||
}
|
||||
|
||||
fun stopRecording(): File? {
|
||||
return try {
|
||||
mediaRecorder?.apply {
|
||||
stop()
|
||||
release()
|
||||
}
|
||||
mediaRecorder = null
|
||||
outputFile
|
||||
} catch (e: Exception) {
|
||||
Log.e("AudioRecorder", "Failed to stop recording", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelRecording() {
|
||||
try {
|
||||
mediaRecorder?.apply {
|
||||
stop()
|
||||
release()
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
mediaRecorder = null
|
||||
outputFile?.delete()
|
||||
outputFile = null
|
||||
}
|
||||
|
||||
val isRecording: Boolean
|
||||
get() = mediaRecorder != null
|
||||
|
||||
fun getMaxAmplitude(): Int {
|
||||
return mediaRecorder?.maxAmplitude ?: 0
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
cancelRecording()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.tiangong.aiagent.util
|
||||
|
||||
import android.util.Log
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import com.tiangong.aiagent.data.local.TokenDataStore
|
||||
import com.tiangong.aiagent.data.remote.ApiService
|
||||
import com.tiangong.aiagent.data.remote.dto.FcmRegisterRequest
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* FCM Token registration manager.
|
||||
*
|
||||
* To enable push notifications:
|
||||
* 1. Add google-services.json to app/ directory
|
||||
* 2. Uncomment Firebase dependencies in build.gradle.kts
|
||||
* 3. Uncomment google-services plugin in both build.gradle.kts files
|
||||
* 4. Uncomment the service declaration in AndroidManifest.xml
|
||||
*/
|
||||
@Singleton
|
||||
class FcmTokenManager @Inject constructor(
|
||||
private val apiService: ApiService,
|
||||
private val tokenDataStore: TokenDataStore
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FcmTokenManager"
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var currentToken: String? = null
|
||||
|
||||
/**
|
||||
* Called when a new FCM token is obtained from Firebase.
|
||||
* Registers the token with the backend so the server can send push notifications.
|
||||
*/
|
||||
fun onNewToken(token: String) {
|
||||
if (token == currentToken) return
|
||||
currentToken = token
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val response = apiService.registerFcmToken(FcmRegisterRequest(token, "android"))
|
||||
Log.d(TAG, "FCM token registered: ${token.take(10)}... (status: ${response.status})")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to register FCM token with backend", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister the current device token from the backend.
|
||||
*/
|
||||
fun unregister() {
|
||||
currentToken?.let { token ->
|
||||
scope.launch {
|
||||
try {
|
||||
apiService.unregisterFcmToken(token)
|
||||
Log.d(TAG, "FCM token unregistered from backend")
|
||||
currentToken = null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to unregister FCM token", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all pending coroutines. Call when process is being destroyed.
|
||||
*/
|
||||
fun shutdown() {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Firebase Cloud Messaging and fetch the current token.
|
||||
*
|
||||
* Requires google-services.json in app/ directory.
|
||||
* The FirebaseMessagingService (declared in AndroidManifest.xml)
|
||||
* will call onNewToken() automatically on token refresh.
|
||||
*/
|
||||
fun initialize() {
|
||||
try {
|
||||
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
||||
if (task.isSuccessful) {
|
||||
task.result?.let { onNewToken(it) }
|
||||
} else {
|
||||
Log.w(TAG, "FCM token fetch failed", task.exception)
|
||||
}
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.w(TAG, "Firebase not initialized — missing google-services.json?")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package com.tiangong.aiagent.util
|
||||
|
||||
import android.content.Context
|
||||
import android.text.util.Linkify
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import io.noties.markwon.Markwon
|
||||
import io.noties.markwon.image.coil.CoilImagesPlugin
|
||||
import io.noties.markwon.linkify.LinkifyPlugin
|
||||
|
||||
object MarkdownRenderer {
|
||||
|
||||
private var markwonInstance: Markwon? = null
|
||||
|
||||
private fun getMarkwon(context: Context): Markwon {
|
||||
return markwonInstance ?: Markwon.builder(context)
|
||||
.usePlugin(CoilImagesPlugin.create(context))
|
||||
.usePlugin(LinkifyPlugin.create())
|
||||
.build()
|
||||
.also { markwonInstance = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* Full Markwon-based markdown renderer wrapped in AndroidView.
|
||||
* Supports: headers, bold, italic, code blocks, tables, links, images, nested formatting.
|
||||
*/
|
||||
@Composable
|
||||
fun MarkdownText(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// Use lightweight renderer for very short messages, Markwon for rich content
|
||||
if (text.length < 200 && !text.contains("```") && !text.contains("http")) {
|
||||
LightweightMarkdown(text, modifier)
|
||||
} else {
|
||||
MarkwonText(text, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MarkwonText(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val textColor = MaterialTheme.colorScheme.onSurface
|
||||
val linkColor = MaterialTheme.colorScheme.primary
|
||||
// Convert Compose Color to Android int for TextView
|
||||
val androidTextColor = android.graphics.Color.argb(
|
||||
(textColor.alpha * 255).toInt(),
|
||||
(textColor.red * 255).toInt(),
|
||||
(textColor.green * 255).toInt(),
|
||||
(textColor.blue * 255).toInt()
|
||||
)
|
||||
val androidLinkColor = android.graphics.Color.argb(
|
||||
(linkColor.alpha * 255).toInt(),
|
||||
(linkColor.red * 255).toInt(),
|
||||
(linkColor.green * 255).toInt(),
|
||||
(linkColor.blue * 255).toInt()
|
||||
)
|
||||
AndroidView(
|
||||
modifier = modifier.padding(0.dp),
|
||||
factory = { ctx ->
|
||||
TextView(ctx).apply {
|
||||
setTextColor(androidTextColor)
|
||||
setLinkTextColor(androidLinkColor)
|
||||
textSize = 15f
|
||||
setLineSpacing(4f, 1f)
|
||||
setPadding(12, 12, 12, 12)
|
||||
}
|
||||
},
|
||||
update = { textView ->
|
||||
val markwon = getMarkwon(context)
|
||||
markwon.setMarkdown(textView, text)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LightweightMarkdown(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
val lines = text.split("\n")
|
||||
var inCodeBlock = false
|
||||
val codeBlockLines = mutableListOf<String>()
|
||||
|
||||
for (line in lines) {
|
||||
if (line.trimStart().startsWith("```")) {
|
||||
if (inCodeBlock) {
|
||||
CodeBlock(codeBlockLines.joinToString("\n"))
|
||||
codeBlockLines.clear()
|
||||
inCodeBlock = false
|
||||
} else {
|
||||
inCodeBlock = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
codeBlockLines.add(line)
|
||||
continue
|
||||
}
|
||||
|
||||
when {
|
||||
line.trimStart().startsWith("### ") -> {
|
||||
HeaderText(line.trimStart().removePrefix("### "), level = 3)
|
||||
}
|
||||
line.trimStart().startsWith("## ") -> {
|
||||
HeaderText(line.trimStart().removePrefix("## "), level = 2)
|
||||
}
|
||||
line.trimStart().startsWith("# ") -> {
|
||||
HeaderText(line.trimStart().removePrefix("# "), level = 1)
|
||||
}
|
||||
line.trimStart().startsWith("- ") || line.trimStart().startsWith("* ") -> {
|
||||
val content = line.trimStart().removePrefix("- ").removePrefix("* ")
|
||||
ListItemText(content)
|
||||
}
|
||||
line.trimStart().matches(Regex("^\\d+\\.\\s.*")) -> {
|
||||
val content = line.trimStart().replaceFirst(Regex("^\\d+\\.\\s"), "")
|
||||
ListItemText(content)
|
||||
}
|
||||
line.trim().matches(Regex("^\\|[-:\\s|]+\\|$")) -> { /* skip */ }
|
||||
line.trimStart().startsWith("|") -> {
|
||||
TableRowText(line)
|
||||
}
|
||||
line.isBlank() -> {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
else -> {
|
||||
InlineFormattedText(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (codeBlockLines.isNotEmpty()) {
|
||||
CodeBlock(codeBlockLines.joinToString("\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderText(text: String, level: Int) {
|
||||
val fontSize = when (level) {
|
||||
1 -> 22.sp
|
||||
2 -> 18.sp
|
||||
3 -> 16.sp
|
||||
else -> 16.sp
|
||||
}
|
||||
val topPadding = when (level) {
|
||||
1 -> 16.dp
|
||||
else -> 12.dp
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.padding(top = topPadding, bottom = 4.dp),
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontSize = fontSize,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListItemText(content: String) {
|
||||
Row(modifier = Modifier.padding(start = 8.dp, top = 2.dp, bottom = 2.dp)) {
|
||||
Text(
|
||||
text = " \u2022 ",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
InlineFormattedText(content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CodeBlock(code: String) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f)
|
||||
) {
|
||||
Text(
|
||||
text = code,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TableRowText(line: String) {
|
||||
val cells = line.split("|")
|
||||
.filter { it.isNotBlank() }
|
||||
.map { it.trim() }
|
||||
|
||||
if (cells.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp)
|
||||
.horizontalScroll(rememberScrollState())
|
||||
) {
|
||||
cells.forEach { cell ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(2.dp),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
) {
|
||||
InlineFormattedText(
|
||||
text = cell,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InlineFormattedText(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val annotated = buildAnnotatedString {
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
when {
|
||||
// Bold: **text**
|
||||
text.startsWith("**", i) -> {
|
||||
val end = text.indexOf("**", i + 2)
|
||||
if (end > i) {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(text.substring(i + 2, end))
|
||||
}
|
||||
i = end + 2
|
||||
} else {
|
||||
append(text[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
// Italic: *text*
|
||||
text.startsWith("*", i) && !text.startsWith("**", i) -> {
|
||||
val end = text.indexOf("*", i + 1)
|
||||
if (end > i) {
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||
append(text.substring(i + 1, end))
|
||||
}
|
||||
i = end + 1
|
||||
} else {
|
||||
append(text[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
// Inline code: `text`
|
||||
text.startsWith("`", i) -> {
|
||||
val end = text.indexOf("`", i + 1)
|
||||
if (end > i) {
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
background = androidx.compose.ui.graphics.Color(0x20000000)
|
||||
)
|
||||
) {
|
||||
append(text.substring(i + 1, end))
|
||||
}
|
||||
i = end + 1
|
||||
} else {
|
||||
append(text[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
// Link: [text](url)
|
||||
text.startsWith("[", i) -> {
|
||||
val closeBracket = text.indexOf("](", i + 1)
|
||||
val closeParen = if (closeBracket > i) text.indexOf(")", closeBracket + 2) else -1
|
||||
if (closeBracket > i && closeParen > closeBracket) {
|
||||
val linkText = text.substring(i + 1, closeBracket)
|
||||
withStyle(SpanStyle(
|
||||
color = androidx.compose.ui.graphics.Color(0xFF3867D5),
|
||||
fontWeight = FontWeight.Medium
|
||||
)) {
|
||||
append(linkText)
|
||||
}
|
||||
i = closeParen + 1
|
||||
} else {
|
||||
append(text[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
append(text[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = annotated,
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.tiangong.aiagent.util
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Observes device network connectivity and exposes it as a Flow.
|
||||
* Used by ChatScreen to show offline banners (v1.2.0).
|
||||
*/
|
||||
@Singleton
|
||||
class NetworkMonitor @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
val isOnline: Flow<Boolean> = callbackFlow {
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
trySend(true)
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
trySend(false)
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
|
||||
val connected = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
trySend(connected)
|
||||
}
|
||||
}
|
||||
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
|
||||
connectivityManager.registerNetworkCallback(request, callback)
|
||||
|
||||
// Emit initial state
|
||||
val activeNetwork = connectivityManager.activeNetwork
|
||||
val caps = connectivityManager.getNetworkCapabilities(activeNetwork)
|
||||
trySend(caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true)
|
||||
|
||||
awaitClose {
|
||||
connectivityManager.unregisterNetworkCallback(callback)
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.tiangong.aiagent.util
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.tiangong.aiagent.MainActivity
|
||||
import com.tiangong.aiagent.R
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NotificationHelper @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
companion object {
|
||||
const val CHANNEL_ID_GENERAL = "tiangong_general"
|
||||
const val CHANNEL_NAME_GENERAL = "消息通知"
|
||||
const val EXTRA_NOTIFICATION_ID = "notification_id"
|
||||
}
|
||||
|
||||
init {
|
||||
createChannels()
|
||||
}
|
||||
|
||||
private fun createChannels() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val generalChannel = NotificationChannel(
|
||||
CHANNEL_ID_GENERAL,
|
||||
CHANNEL_NAME_GENERAL,
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
description = "智能体消息和通知"
|
||||
}
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(generalChannel)
|
||||
}
|
||||
}
|
||||
|
||||
fun showNotification(
|
||||
notificationId: String,
|
||||
title: String,
|
||||
body: String,
|
||||
url: String? = null
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) return
|
||||
}
|
||||
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||
url?.let { putExtra("notification_url", it) }
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
notificationId.hashCode(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID_GENERAL)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
|
||||
NotificationManagerCompat.from(context).notify(notificationId.hashCode(), builder.build())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.tiangong.aiagent.util
|
||||
|
||||
import android.util.Log
|
||||
import com.tiangong.aiagent.data.local.AppDatabase
|
||||
import com.tiangong.aiagent.data.local.PendingMessageEntity
|
||||
import com.tiangong.aiagent.data.repository.ChatRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class OfflineQueueManager @Inject constructor(
|
||||
private val database: AppDatabase,
|
||||
private val chatRepository: ChatRepository,
|
||||
networkMonitor: NetworkMonitor
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "OfflineQueue"
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private val _pendingCount = MutableStateFlow(0)
|
||||
val pendingCount: StateFlow<Int> = _pendingCount.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
// Restore count from DB on startup
|
||||
_pendingCount.value = database.pendingMessageDao().getAll().size
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
networkMonitor.isOnline.collect { online ->
|
||||
if (online) flushQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun enqueue(agentId: String?, message: String, sessionId: String?) {
|
||||
val entity = PendingMessageEntity(
|
||||
agentId = agentId,
|
||||
message = message,
|
||||
sessionId = sessionId,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
database.pendingMessageDao().insert(entity)
|
||||
_pendingCount.value = database.pendingMessageDao().getAll().size
|
||||
Log.d(TAG, "Queued message (total pending: ${_pendingCount.value})")
|
||||
}
|
||||
|
||||
private suspend fun flushQueue() {
|
||||
val pending = database.pendingMessageDao().getAll()
|
||||
if (pending.isEmpty()) return
|
||||
|
||||
Log.d(TAG, "Flushing ${pending.size} queued messages")
|
||||
for (entity in pending) {
|
||||
try {
|
||||
chatRepository.chat(entity.agentId, entity.message, entity.sessionId)
|
||||
database.pendingMessageDao().deleteById(entity.id)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send queued message ${entity.id}: ${e.message}")
|
||||
break
|
||||
}
|
||||
}
|
||||
_pendingCount.value = database.pendingMessageDao().getAll().size
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.tiangong.aiagent.util
|
||||
|
||||
import android.util.Log
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Firebase Cloud Messaging service for push notifications.
|
||||
*
|
||||
* Requires google-services.json in app/ directory to function.
|
||||
* Without it, Firebase initialization will fail but won't crash the app.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class TiangongFirebaseMessagingService : FirebaseMessagingService() {
|
||||
|
||||
@Inject
|
||||
lateinit var fcmTokenManager: FcmTokenManager
|
||||
|
||||
@Inject
|
||||
lateinit var notificationHelper: NotificationHelper
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FcmService"
|
||||
}
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
super.onNewToken(token)
|
||||
Log.d(TAG, "New FCM token: ${token.take(10)}...")
|
||||
fcmTokenManager.onNewToken(token)
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: RemoteMessage) {
|
||||
super.onMessageReceived(message)
|
||||
val notification = message.notification
|
||||
val title = notification?.title ?: message.data["title"] ?: "新通知"
|
||||
val body = notification?.body ?: message.data["body"] ?: ""
|
||||
val url = message.data["url"]
|
||||
val id = message.data["notification_id"] ?: message.messageId ?: System.currentTimeMillis().toString()
|
||||
|
||||
Log.d(TAG, "FCM message: $title")
|
||||
notificationHelper.showNotification(
|
||||
notificationId = id,
|
||||
title = title,
|
||||
body = body,
|
||||
url = url
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.tiangong.aiagent.util
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
fun formatTimestamp(millis: Long): String {
|
||||
val now = System.currentTimeMillis()
|
||||
val diff = now - millis
|
||||
return when {
|
||||
diff < 60_000 -> "刚刚"
|
||||
diff < 3600_000 -> "${diff / 60_000} 分钟前"
|
||||
diff < 86400_000 -> "${diff / 3600_000} 小时前"
|
||||
diff < 604800_000 -> "${diff / 86400_000} 天前"
|
||||
else -> {
|
||||
val sdf = SimpleDateFormat("MM/dd", Locale.getDefault())
|
||||
sdf.format(Date(millis))
|
||||
}
|
||||
}
|
||||
}
|
||||
38
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
38
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- Robot body rounded rectangle -->
|
||||
<path
|
||||
android:fillColor="#1565C0"
|
||||
android:pathData="M34,42 L74,42 Q82,42 82,50 L82,76 Q82,84 74,84 L34,84 Q26,84 26,76 L26,50 Q26,42 34,42 Z" />
|
||||
|
||||
<!-- Left eye -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M44,54 Q48,50 52,54 Q48,58 44,54 Z" />
|
||||
|
||||
<!-- Right eye -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M56,54 Q60,50 64,54 Q60,58 56,54 Z" />
|
||||
|
||||
<!-- Antenna line -->
|
||||
<path
|
||||
android:strokeColor="#1565C0"
|
||||
android:strokeWidth="3"
|
||||
android:pathData="M54,42 L54,28" />
|
||||
|
||||
<!-- Antenna dot -->
|
||||
<path
|
||||
android:fillColor="#42A5F5"
|
||||
android:pathData="M51,23 L57,23 Q60,23 60,26 L60,29 Q60,32 57,32 L51,32 Q48,32 48,29 L48,26 Q48,23 51,23 Z" />
|
||||
|
||||
<!-- Mouth smile -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M44,72 Q54,80 64,72" />
|
||||
</vector>
|
||||
10
android/app/src/main/res/drawable/ic_notification.xml
Normal file
10
android/app/src/main/res/drawable/ic_notification.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="@android:color/white">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,2A7,7 0 0,1 19,9C19,14.25 21,16 21,16H3C3,16 5,14.25 5,9A7,7 0 0,1 12,2M12,4A5,5 0 0,0 7,9C7,12.62 6.32,14.17 5.74,15H18.26C17.68,14.17 17,12.62 17,9A5,5 0 0,0 12,4M10,19A2,2 0 0,0 12,21A2,2 0 0,0 14,19H10Z"/>
|
||||
</vector>
|
||||
5
android/app/src/main/res/mipmap-hdpi/ic_launcher.xml
Normal file
5
android/app/src/main/res/mipmap-hdpi/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
5
android/app/src/main/res/mipmap-mdpi/ic_launcher.xml
Normal file
5
android/app/src/main/res/mipmap-mdpi/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
5
android/app/src/main/res/mipmap-xhdpi/ic_launcher.xml
Normal file
5
android/app/src/main/res/mipmap-xhdpi/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
5
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml
Normal file
5
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
5
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml
Normal file
5
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
4
android/app/src/main/res/values/colors.xml
Normal file
4
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
22
android/app/src/main/res/values/strings.xml
Normal file
22
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">天工智能体</string>
|
||||
<string name="login">登录</string>
|
||||
<string name="username">用户名</string>
|
||||
<string name="password">密码</string>
|
||||
<string name="server_address">服务器地址</string>
|
||||
<string name="send">发送</string>
|
||||
<string name="type_message">输入消息...</string>
|
||||
<string name="agents">智能体</string>
|
||||
<string name="settings">设置</string>
|
||||
<string name="logout">退出登录</string>
|
||||
<string name="voice_input">语音输入</string>
|
||||
<string name="thinking">思考中...</string>
|
||||
<string name="error_network">网络请求失败</string>
|
||||
<string name="error_login_failed">登录失败,请检查账号密码</string>
|
||||
<string name="no_agents">暂无可用智能体</string>
|
||||
<string name="switch_agent">切换智能体</string>
|
||||
<string name="new_conversation">新对话</string>
|
||||
<string name="confirm">确认</string>
|
||||
<string name="cancel">取消</string>
|
||||
</resources>
|
||||
11
android/app/src/main/res/values/themes.xml
Normal file
11
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.TiangongAgent" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
|
||||
<!-- Splash screen theme for Android 12+ -->
|
||||
<style name="Theme.TiangongAgent.Splash" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">#121212</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.TiangongAgent</item>
|
||||
</style>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user