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:
2026-06-29 01:17:21 +08:00
parent 86b98865e3
commit beff3fac8d
1084 changed files with 117315 additions and 1281 deletions

View 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)
}

View 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
View 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.** { *; }

View 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>

View File

@@ -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
)
}
}
}
}
}

View File

@@ -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
)
}
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
)

View File

@@ -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()
}
}

View File

@@ -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()
}

View File

@@ -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
)

View File

@@ -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()
}

View File

@@ -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
)

View File

@@ -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() }
}
}

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}

View File

@@ -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}")
}
}
}

View File

@@ -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
)

View File

@@ -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
)
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View 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()
}
}

View File

@@ -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 = ""
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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()
}

View File

@@ -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
)
}
}
}
}
}

View File

@@ -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 ?: "删除失败")
}
)
}
}
}

View File

@@ -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
)
}

View File

@@ -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
)

View File

@@ -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
)
}
}
}
}

View File

@@ -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))
}
}

View File

@@ -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)
)
}
}
}

View File

@@ -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))
}
}
}

View File

@@ -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
)
}
}
}
}
}
}

View File

@@ -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
)
}
}
}
}

View File

@@ -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))
}
}
}

View File

@@ -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("取消")
}
}
)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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 ?: "登录失败"
)
}
)
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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
)
}
}
}
}
}

View File

@@ -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)
)
}
}
}
}

View File

@@ -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))
}
}
}

View File

@@ -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())
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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
)
}

View File

@@ -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
)
}

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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?")
}
}
}

View File

@@ -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
)
}
}

View File

@@ -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()
}

View File

@@ -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())
}
}

View File

@@ -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()
}
}

View File

@@ -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
)
}
}

View File

@@ -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))
}
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View 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>

View 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>