diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index f4c2918..da1a05e 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -126,4 +126,10 @@ dependencies {
// Bugly (Tencent crash reporting)
implementation(libs.bugly.crashreport)
implementation(libs.bugly.native)
+
+ // Umeng Analytics (from zhini_im)
+ implementation(libs.umeng.common)
+ implementation(libs.umeng.asms)
+ implementation(libs.umeng.uyumao)
+ implementation(libs.umeng.abtest)
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index bc54b15..27f1e89 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -36,6 +36,17 @@
+
+
+
+
+
>
- @Query("SELECT * FROM conversations ORDER BY lastMessageAt DESC")
+ @Query("SELECT * FROM conversations ORDER BY isPinned DESC, lastMessageAt DESC")
suspend fun getAllConversationsSync(): List
- @Query("SELECT * FROM conversations WHERE agentId = :agentId ORDER BY lastMessageAt DESC")
+ @Query("SELECT * FROM conversations WHERE agentId = :agentId ORDER BY isPinned DESC, lastMessageAt DESC")
suspend fun getByAgentId(agentId: String): List
@Query("SELECT * FROM conversations WHERE sessionId = :sessionId LIMIT 1")
@@ -26,4 +26,7 @@ interface ConversationDao {
@Query("DELETE FROM conversations WHERE sessionId = :sessionId")
suspend fun deleteById(sessionId: String)
+
+ @Query("UPDATE conversations SET isPinned = :isPinned WHERE sessionId = :sessionId")
+ suspend fun updatePinStatus(sessionId: String, isPinned: Boolean)
}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/local/ConversationEntity.kt b/android/app/src/main/java/com/tiangong/aiagent/data/local/ConversationEntity.kt
index 7d67f9b..5592b99 100644
--- a/android/app/src/main/java/com/tiangong/aiagent/data/local/ConversationEntity.kt
+++ b/android/app/src/main/java/com/tiangong/aiagent/data/local/ConversationEntity.kt
@@ -11,5 +11,6 @@ data class ConversationEntity(
val title: String?,
val lastMessage: String?,
val lastMessageAt: Long,
- val messageCount: Int
+ val messageCount: Int,
+ val isPinned: Boolean = false
)
diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/local/CredentialStore.kt b/android/app/src/main/java/com/tiangong/aiagent/data/local/CredentialStore.kt
index 0594525..5812f86 100644
--- a/android/app/src/main/java/com/tiangong/aiagent/data/local/CredentialStore.kt
+++ b/android/app/src/main/java/com/tiangong/aiagent/data/local/CredentialStore.kt
@@ -41,4 +41,29 @@ class CredentialStore @Inject constructor(
.remove("password")
.apply()
}
+
+ // ─── Encrypted tokens ───
+
+ fun saveToken(token: String) {
+ prefs.edit().putString("access_token", token).apply()
+ }
+
+ fun getToken(): String? {
+ return prefs.getString("access_token", null)
+ }
+
+ fun saveRefreshToken(refreshToken: String) {
+ prefs.edit().putString("refresh_token", refreshToken).apply()
+ }
+
+ fun getRefreshToken(): String? {
+ return prefs.getString("refresh_token", null)
+ }
+
+ fun clearTokens() {
+ prefs.edit()
+ .remove("access_token")
+ .remove("refresh_token")
+ .apply()
+ }
}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/local/TokenDataStore.kt b/android/app/src/main/java/com/tiangong/aiagent/data/local/TokenDataStore.kt
index 131a4b2..bb80b03 100644
--- a/android/app/src/main/java/com/tiangong/aiagent/data/local/TokenDataStore.kt
+++ b/android/app/src/main/java/com/tiangong/aiagent/data/local/TokenDataStore.kt
@@ -30,6 +30,7 @@ class TokenDataStore @Inject constructor(
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")
+ private val KEY_ONBOARDING_COMPLETED = booleanPreferencesKey("onboarding_completed")
}
// ─── Token ───
@@ -137,6 +138,13 @@ class TokenDataStore @Inject constructor(
context.dataStore.edit { it[KEY_THEME_MODE] = mode }
}
+ // ─── Onboarding (v1.1.0) ───
+ val onboardingCompleted: Flow = context.dataStore.data.map { it[KEY_ONBOARDING_COMPLETED] ?: false }
+
+ suspend fun setOnboardingCompleted() {
+ context.dataStore.edit { it[KEY_ONBOARDING_COMPLETED] = true }
+ }
+
// ─── Clear All ───
suspend fun clearAll() {
context.dataStore.edit { prefs ->
diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt b/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt
index c082bbf..a614fee 100644
--- a/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt
+++ b/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt
@@ -125,9 +125,32 @@ interface ApiService {
@GET("api/v1/agent-chat/{agentId}/sessions")
suspend fun getAgentSessions(
@Path("agentId") agentId: String,
- @Query("limit") limit: Int = 50
+ @Query("limit") limit: Int = 50,
+ @Query("search") search: String? = null
): SessionListResponse
+ @PATCH("api/v1/agent-chat/{agentId}/sessions/{sessionId}")
+ suspend fun updateSession(
+ @Path("agentId") agentId: String,
+ @Path("sessionId") sessionId: String,
+ @Body request: UpdateSessionRequest
+ ): UpdateSessionResponse
+
+ @DELETE("api/v1/agent-chat/{agentId}/sessions/{sessionId}")
+ suspend fun deleteSession(
+ @Path("agentId") agentId: String,
+ @Path("sessionId") sessionId: String
+ ): DeleteSessionResponse
+
+ // ─── Message Search ───
+ @GET("api/v1/agent-chat/{agentId}/search-messages")
+ suspend fun searchMessages(
+ @Path("agentId") agentId: String,
+ @Query("q") query: String,
+ @Query("skip") skip: Int = 0,
+ @Query("limit") limit: Int = 50
+ ): SearchMessagesResponse
+
// ─── Agent Memory ───
@GET("api/v1/agents/{agentId}/memory/global-knowledge")
suspend fun getGlobalKnowledge(
@@ -228,4 +251,92 @@ interface ApiService {
@Path("agentId") agentId: String,
@Path("memoryId") memoryId: String
): Response
+
+ // ─── Analytics ───
+ @POST("api/v1/analytics/events")
+ suspend fun sendAnalyticsEvents(@Body request: AnalyticsEventsRequest): Response
+
+ // ─── Phone Login ───
+ @POST("api/v1/auth/phone/send-code")
+ suspend fun sendPhoneCode(@Body request: PhoneSendCodeRequest): PhoneSendCodeResponse
+
+ @POST("api/v1/auth/login/phone")
+ suspend fun phoneLogin(@Body request: PhoneLoginRequest): PhoneLoginResponse
+
+ // ─── Agent Market ───
+ @GET("api/v1/agent-market")
+ suspend fun browseMarket(
+ @Query("skip") skip: Int = 0,
+ @Query("limit") limit: Int = 20,
+ @Query("search") search: String? = null,
+ @Query("category") category: String? = null,
+ @Query("sort_by") sortBy: String = "created_at",
+ @Query("sort_order") sortOrder: String = "desc",
+ @Query("featured_only") featuredOnly: Boolean = false
+ ): List
+
+ @GET("api/v1/agent-market/{agentId}")
+ suspend fun getMarketAgentDetail(@Path("agentId") agentId: String): AgentMarketDetailDto
+
+ @POST("api/v1/agent-market/{agentId}/install")
+ suspend fun installMarketAgent(@Path("agentId") agentId: String): InstallAgentResponse
+
+ @POST("api/v1/agent-market/{agentId}/rate")
+ suspend fun rateMarketAgent(
+ @Path("agentId") agentId: String,
+ @Body request: RateAgentRequest
+ ): Response
+
+ @POST("api/v1/agent-market/{agentId}/favorite")
+ suspend fun favoriteMarketAgent(@Path("agentId") agentId: String): Response
+
+ @DELETE("api/v1/agent-market/{agentId}/favorite")
+ suspend fun unfavoriteMarketAgent(@Path("agentId") agentId: String): Response
+
+ @GET("api/v1/agent-market/my/favorites")
+ suspend fun getMyFavorites(
+ @Query("skip") skip: Int = 0,
+ @Query("limit") limit: Int = 20
+ ): List
+
+ @GET("api/v1/agent-market/categories/list")
+ suspend fun getMarketCategories(): List
+
+ // ─── Template Market ───
+ @GET("api/v1/template-market")
+ suspend fun browseTemplates(
+ @Query("skip") skip: Int = 0,
+ @Query("limit") limit: Int = 20,
+ @Query("search") search: String? = null,
+ @Query("category") category: String? = null
+ ): List
+
+ @POST("api/v1/template-market/{templateId}/use")
+ suspend fun useTemplate(@Path("templateId") templateId: String): AgentResponse
+
+ // ─── Scene Templates ───
+ @GET("api/v1/platform/scene-templates")
+ suspend fun getSceneTemplates(): List
+
+ @POST("api/v1/platform/agents/from-template")
+ suspend fun createFromSceneTemplate(@Body request: CreateFromTemplateRequest): AgentResponse
+
+ // ─── Billing (v1.2) ───
+ @GET("api/v1/billing/plans")
+ suspend fun getBillingPlans(): List
+
+ @GET("api/v1/billing/user/subscription")
+ suspend fun getUserSubscription(): UserSubscriptionDto
+
+ @GET("api/v1/billing/user/usage")
+ suspend fun getUserUsage(): UserUsageDto
+
+ @POST("api/v1/billing/orders")
+ suspend fun createOrder(@Body request: CreateOrderRequest): OrderResponseDto
+
+ @GET("api/v1/billing/orders/{orderId}")
+ suspend fun getOrder(@Path("orderId") orderId: String): OrderResponseDto
+
+ @POST("api/v1/billing/orders/{orderId}/pay")
+ suspend fun payOrder(@Path("orderId") orderId: String): PayOrderResponse
}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt b/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt
index d2a5ffe..d275f0c 100644
--- a/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt
+++ b/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt
@@ -336,6 +336,7 @@ data class SessionItemDto(
val title: String? = null,
@SerializedName("last_message") val lastMessage: String? = null,
@SerializedName("message_count") val messageCount: Int = 0,
+ @SerializedName("is_pinned") val isPinned: Boolean = false,
@SerializedName("created_at") val createdAt: String? = null,
@SerializedName("updated_at") val updatedAt: String? = null
)
@@ -344,6 +345,64 @@ data class SessionListResponse(
val sessions: List
)
+// ─────────── Session Management ───────────
+
+data class UpdateSessionRequest(
+ val title: String? = null,
+ @SerializedName("is_pinned") val isPinned: Boolean? = null
+)
+
+data class UpdateSessionResponse(
+ val id: String,
+ val title: String? = null,
+ @SerializedName("is_pinned") val isPinned: Boolean = false,
+ @SerializedName("created_at") val createdAt: String? = null,
+ @SerializedName("updated_at") val updatedAt: String? = null
+)
+
+data class DeleteSessionResponse(
+ val deleted: Boolean,
+ @SerializedName("messages_deleted") val messagesDeleted: Int = 0
+)
+
+// ─────────── Analytics ───────────
+
+data class AnalyticsEventItem(
+ @SerializedName("event_type") val eventType: String,
+ @SerializedName("screen_name") val screenName: String? = null,
+ val category: String? = null,
+ val action: String? = null,
+ val label: String? = null,
+ @SerializedName("agent_id") val agentId: String? = null,
+ val timestamp: String? = null
+)
+
+data class AnalyticsEventsRequest(
+ val events: List
+)
+
+// ─────────── Phone Login ───────────
+
+data class PhoneSendCodeRequest(
+ val phone: String
+)
+
+data class PhoneSendCodeResponse(
+ val message: String? = null,
+ @SerializedName("dev_code") val devCode: String? = null
+)
+
+data class PhoneLoginRequest(
+ val phone: String,
+ val code: String
+)
+
+data class PhoneLoginResponse(
+ @SerializedName("access_token") val accessToken: String,
+ @SerializedName("token_type") val tokenType: String = "bearer",
+ @SerializedName("refresh_token") val refreshToken: String? = null
+)
+
// ─────────── Feedback ───────────
@@ -477,3 +536,184 @@ data class VectorMemoryListResponse(
val items: List,
val total: Int
)
+
+// ─────────── Agent Market ───────────
+
+data class AgentMarketItemDto(
+ val id: String,
+ val name: String,
+ val description: String? = null,
+ val category: String? = null,
+ val tags: List? = null,
+ val thumbnail: String? = null,
+ @SerializedName("is_featured") val isFeatured: Boolean = false,
+ @SerializedName("rating_avg") val ratingAvg: Double = 0.0,
+ @SerializedName("rating_count") val ratingCount: Int = 0,
+ @SerializedName("use_count") val useCount: Int = 0,
+ @SerializedName("view_count") val viewCount: Int = 0,
+ val version: Int = 1,
+ @SerializedName("user_id") val userId: String? = null,
+ @SerializedName("creator_username") val creatorUsername: String? = null,
+ @SerializedName("is_favorited") val isFavorited: Boolean? = null,
+ @SerializedName("user_rating") val userRating: Int? = null,
+ @SerializedName("created_at") val createdAt: String? = null,
+ @SerializedName("updated_at") val updatedAt: String? = null
+)
+
+data class AgentMarketDetailDto(
+ val id: String,
+ val name: String,
+ val description: String? = null,
+ val category: String? = null,
+ val tags: List? = null,
+ val thumbnail: String? = null,
+ @SerializedName("is_featured") val isFeatured: Boolean = false,
+ @SerializedName("rating_avg") val ratingAvg: Double = 0.0,
+ @SerializedName("rating_count") val ratingCount: Int = 0,
+ @SerializedName("use_count") val useCount: Int = 0,
+ @SerializedName("view_count") val viewCount: Int = 0,
+ val version: Int = 1,
+ @SerializedName("user_id") val userId: String? = null,
+ @SerializedName("creator_username") val creatorUsername: String? = null,
+ @SerializedName("is_favorited") val isFavorited: Boolean? = null,
+ @SerializedName("user_rating") val userRating: Int? = null,
+ @SerializedName("workflow_config") val workflowConfig: Map? = null,
+ @SerializedName("budget_config") val budgetConfig: Map? = null,
+ @SerializedName("created_at") val createdAt: String? = null,
+ @SerializedName("updated_at") val updatedAt: String? = null
+)
+
+data class InstallAgentRequest(
+ @SerializedName("agent_id") val agentId: String? = null
+)
+
+data class InstallAgentResponse(
+ val message: String,
+ @SerializedName("agent_id") val agentId: String,
+ @SerializedName("agent_name") val agentName: String,
+ @SerializedName("forked_from_id") val forkedFromId: String,
+ @SerializedName("upstream_version") val upstreamVersion: Int
+)
+
+data class RateAgentRequest(
+ val rating: Int,
+ val comment: String? = null
+)
+
+data class MarketCategoryDto(
+ val category: String,
+ val count: Int
+)
+
+// ─────────── Template Market ───────────
+
+data class TemplateItemDto(
+ val id: String,
+ val name: String,
+ val description: String? = null,
+ val category: String? = null,
+ val tags: List? = null,
+ val thumbnail: String? = null,
+ @SerializedName("use_count") val useCount: Int = 0,
+ @SerializedName("rating_avg") val ratingAvg: Double = 0.0,
+ @SerializedName("user_id") val userId: String? = null,
+ @SerializedName("creator_username") val creatorUsername: String? = null,
+ @SerializedName("created_at") val createdAt: String? = null
+)
+
+data class SceneTemplateItemDto(
+ val id: String,
+ val name: String,
+ val description: String? = null,
+ val category: String? = null,
+ val icon: String? = null,
+ @SerializedName("workflow_config") val workflowConfig: Map? = null
+)
+
+data class CreateFromTemplateRequest(
+ val name: String,
+ val description: String? = null,
+ @SerializedName("scene_template_id") val sceneTemplateId: String? = null
+)
+
+// ─────────── Search ───────────
+
+data class SearchMessageItemDto(
+ val id: String,
+ @SerializedName("session_id") val sessionId: String,
+ @SerializedName("agent_id") val agentId: String? = null,
+ val role: String,
+ val content: String? = null,
+ @SerializedName("session_title") val sessionTitle: String? = null,
+ @SerializedName("created_at") val createdAt: String? = null
+)
+
+data class SearchMessagesResponse(
+ val messages: List,
+ val total: Int
+)
+
+// ─────────── Billing (v1.2) ───────────
+
+data class BillingPlanDto(
+ val id: String,
+ val name: String,
+ val tier: String,
+ @SerializedName("price_monthly") val priceMonthly: Double,
+ @SerializedName("price_yearly") val priceYearly: Double,
+ @SerializedName("daily_quota") val dailyQuota: Int,
+ @SerializedName("agent_limit") val agentLimit: Int,
+ @SerializedName("knowledge_limit") val knowledgeLimit: Int,
+ @SerializedName("file_upload_mb") val fileUploadMb: Int = 5,
+ val models: List? = null,
+ val features: List? = null,
+ @SerializedName("is_active") val isActive: Boolean = true,
+ @SerializedName("sort_order") val sortOrder: Int = 0
+)
+
+data class UserSubscriptionDto(
+ val tier: String = "free",
+ val status: String = "active",
+ @SerializedName("plan_name") val planName: String? = null,
+ @SerializedName("started_at") val startedAt: String? = null,
+ @SerializedName("expires_at") val expiresAt: String? = null,
+ @SerializedName("auto_renew") val autoRenew: Boolean = false,
+ @SerializedName("daily_quota") val dailyQuota: Int = 50,
+ @SerializedName("agent_limit") val agentLimit: Int = 3,
+ @SerializedName("knowledge_limit") val knowledgeLimit: Int = 10,
+ @SerializedName("file_upload_mb") val fileUploadMb: Int = 5,
+ val models: List? = null,
+ val features: List? = null
+)
+
+data class UserUsageDto(
+ @SerializedName("daily_used") val dailyUsed: Int = 0,
+ @SerializedName("daily_limit") val dailyLimit: Int = 50,
+ @SerializedName("daily_remaining") val dailyRemaining: Int = 50,
+ @SerializedName("is_exhausted") val isExhausted: Boolean = false
+)
+
+data class CreateOrderRequest(
+ @SerializedName("plan_id") val planId: String,
+ val period: String = "monthly",
+ @SerializedName("payment_method") val paymentMethod: String = "mock"
+)
+
+data class OrderResponseDto(
+ val id: String,
+ @SerializedName("plan_id") val planId: String? = null,
+ val amount: Double,
+ val period: String,
+ val status: String,
+ @SerializedName("payment_method") val paymentMethod: String? = null,
+ @SerializedName("payment_ref") val paymentRef: String? = null,
+ @SerializedName("created_at") val createdAt: String? = null,
+ @SerializedName("paid_at") val paidAt: String? = null
+)
+
+data class PayOrderResponse(
+ val success: Boolean = false,
+ val tier: String = "",
+ @SerializedName("expires_at") val expiresAt: String? = null,
+ val message: String = ""
+)
diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/repository/AgentMarketRepository.kt b/android/app/src/main/java/com/tiangong/aiagent/data/repository/AgentMarketRepository.kt
new file mode 100644
index 0000000..9710d0d
--- /dev/null
+++ b/android/app/src/main/java/com/tiangong/aiagent/data/repository/AgentMarketRepository.kt
@@ -0,0 +1,95 @@
+package com.tiangong.aiagent.data.repository
+
+import com.tiangong.aiagent.data.remote.ApiService
+import com.tiangong.aiagent.data.remote.dto.*
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AgentMarketRepository @Inject constructor(
+ private val apiService: ApiService
+) {
+ // ─── Market Browse ───
+
+ suspend fun browseAgents(
+ skip: Int = 0,
+ limit: Int = 20,
+ search: String? = null,
+ category: String? = null,
+ sortBy: String = "created_at",
+ featuredOnly: Boolean = false
+ ): Result> = runCatching {
+ apiService.browseMarket(skip, limit, search, category, sortBy, "desc", featuredOnly)
+ }
+
+ suspend fun getFeaturedAgents(limit: Int = 10): Result> = runCatching {
+ apiService.browseMarket(0, limit, featuredOnly = true, sortBy = "rating_avg")
+ }
+
+ suspend fun getAgentDetail(agentId: String): Result = runCatching {
+ apiService.getMarketAgentDetail(agentId)
+ }
+
+ // ─── Install ───
+
+ suspend fun installAgent(agentId: String): Result = runCatching {
+ apiService.installMarketAgent(agentId)
+ }
+
+ // ─── Rate ───
+
+ suspend fun rateAgent(agentId: String, rating: Int, comment: String? = null): Result = runCatching {
+ apiService.rateMarketAgent(agentId, RateAgentRequest(rating, comment))
+ }
+
+ // ─── Favorites ───
+
+ suspend fun favoriteAgent(agentId: String): Result = runCatching {
+ apiService.favoriteMarketAgent(agentId)
+ }
+
+ suspend fun unfavoriteAgent(agentId: String): Result = runCatching {
+ apiService.unfavoriteMarketAgent(agentId)
+ }
+
+ suspend fun getMyFavorites(skip: Int = 0, limit: Int = 20): Result> = runCatching {
+ apiService.getMyFavorites(skip, limit)
+ }
+
+ // ─── Categories ───
+
+ suspend fun getCategories(): Result> = runCatching {
+ apiService.getMarketCategories()
+ }
+
+ // ─── Templates ───
+
+ suspend fun browseTemplates(
+ skip: Int = 0,
+ limit: Int = 20,
+ search: String? = null,
+ category: String? = null
+ ): Result> = runCatching {
+ apiService.browseTemplates(skip, limit, search, category)
+ }
+
+ suspend fun useTemplate(templateId: String): Result = runCatching {
+ apiService.useTemplate(templateId)
+ }
+
+ // ─── Scene Templates ───
+
+ suspend fun getSceneTemplates(): Result> = runCatching {
+ apiService.getSceneTemplates()
+ }
+
+ suspend fun createFromSceneTemplate(
+ name: String,
+ description: String? = null,
+ sceneTemplateId: String? = null
+ ): Result = runCatching {
+ apiService.createFromSceneTemplate(
+ CreateFromTemplateRequest(name, description, sceneTemplateId)
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/repository/AuthRepository.kt b/android/app/src/main/java/com/tiangong/aiagent/data/repository/AuthRepository.kt
index 9e45a91..5866702 100644
--- a/android/app/src/main/java/com/tiangong/aiagent/data/repository/AuthRepository.kt
+++ b/android/app/src/main/java/com/tiangong/aiagent/data/repository/AuthRepository.kt
@@ -1,12 +1,15 @@
package com.tiangong.aiagent.data.repository
+import android.content.Context
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.tiangong.aiagent.util.UmengHelper
import com.google.gson.JsonParser
+import dagger.hilt.android.qualifiers.ApplicationContext
import retrofit2.HttpException
import java.io.IOException
import javax.inject.Inject
@@ -17,7 +20,8 @@ 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
+ private val authInterceptor: com.tiangong.aiagent.data.remote.AuthInterceptor,
+ @ApplicationContext private val context: Context
) {
suspend fun login(username: String, password: String): Result {
@@ -30,6 +34,8 @@ class AuthRepository @Inject constructor(
credentialStore.saveCredentials(username, password)
// Update AuthInterceptor memory cache so subsequent requests carry the token
authInterceptor.updateToken(response.accessToken)
+ // Umeng: track user sign-in
+ UmengHelper.onProfileSignIn(username)
Result.success(response)
} catch (e: Exception) {
Result.failure(e)
@@ -99,6 +105,8 @@ class AuthRepository @Inject constructor(
tokenDataStore.clearAll()
credentialStore.clearCredentials()
authInterceptor.updateToken(null)
+ // Umeng: track user sign-out
+ UmengHelper.onProfileSignOff()
}
suspend fun getToken(): String? {
diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/repository/BillingRepository.kt b/android/app/src/main/java/com/tiangong/aiagent/data/repository/BillingRepository.kt
new file mode 100644
index 0000000..f7fd8b3
--- /dev/null
+++ b/android/app/src/main/java/com/tiangong/aiagent/data/repository/BillingRepository.kt
@@ -0,0 +1,35 @@
+package com.tiangong.aiagent.data.repository
+
+import com.tiangong.aiagent.data.remote.ApiService
+import com.tiangong.aiagent.data.remote.dto.*
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class BillingRepository @Inject constructor(
+ private val apiService: ApiService
+) {
+ suspend fun getPlans(): Result> = runCatching {
+ apiService.getBillingPlans()
+ }
+
+ suspend fun getUserSubscription(): Result = runCatching {
+ apiService.getUserSubscription()
+ }
+
+ suspend fun getUserUsage(): Result = runCatching {
+ apiService.getUserUsage()
+ }
+
+ suspend fun createOrder(request: CreateOrderRequest): Result = runCatching {
+ apiService.createOrder(request)
+ }
+
+ suspend fun getOrder(orderId: String): Result = runCatching {
+ apiService.getOrder(orderId)
+ }
+
+ suspend fun payOrder(orderId: String): Result = runCatching {
+ apiService.payOrder(orderId)
+ }
+}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/repository/ChatRepository.kt b/android/app/src/main/java/com/tiangong/aiagent/data/repository/ChatRepository.kt
index d00df80..8f5be4e 100644
--- a/android/app/src/main/java/com/tiangong/aiagent/data/repository/ChatRepository.kt
+++ b/android/app/src/main/java/com/tiangong/aiagent/data/repository/ChatRepository.kt
@@ -12,6 +12,8 @@ import com.tiangong.aiagent.data.remote.dto.MessageHistoryResponse
import com.tiangong.aiagent.data.remote.dto.MessageItemDto
import com.tiangong.aiagent.data.remote.dto.SessionListResponse
import com.tiangong.aiagent.data.remote.dto.SessionItemDto
+import com.tiangong.aiagent.data.remote.dto.SearchMessagesResponse
+import com.tiangong.aiagent.data.remote.dto.UpdateSessionRequest
import com.tiangong.aiagent.data.remote.dto.TtsRequest
import com.tiangong.aiagent.domain.model.Conversation
import com.tiangong.aiagent.domain.model.Message
@@ -101,7 +103,8 @@ class ChatRepository @Inject constructor(
title = title,
lastMessage = content.take(100),
lastMessageAt = now,
- messageCount = count
+ messageCount = count,
+ isPinned = existing?.isPinned ?: false
)
)
}
@@ -121,7 +124,8 @@ class ChatRepository @Inject constructor(
title = entity.title,
lastMessage = entity.lastMessage,
lastMessageAt = entity.lastMessageAt,
- messageCount = entity.messageCount
+ messageCount = entity.messageCount,
+ isPinned = entity.isPinned
)
}
}
@@ -129,18 +133,20 @@ class ChatRepository @Inject constructor(
private suspend fun saveMessagesFromResponse(response: ChatResponse, agentId: String?) {
val conversationId = response.sessionId ?: return
val now = System.currentTimeMillis()
+ val existing = database.conversationDao().getById(conversationId)
- // Save conversation
- val title = response.steps.firstOrNull()?.content?.take(50) ?: "New Chat"
+ // Save conversation (preserve existing pinned state and title if present)
+ val title = existing?.title ?: (response.steps.firstOrNull()?.content?.take(50) ?: "New Chat")
database.conversationDao().insert(
ConversationEntity(
sessionId = conversationId,
- agentId = agentId,
- agentName = null,
+ agentId = agentId ?: existing?.agentId,
+ agentName = existing?.agentName,
title = title,
lastMessage = response.content.take(100),
lastMessageAt = now,
- messageCount = response.steps.size
+ messageCount = response.steps.size,
+ isPinned = existing?.isPinned ?: false
)
)
@@ -176,7 +182,8 @@ class ChatRepository @Inject constructor(
title = entity.title,
lastMessage = entity.lastMessage,
lastMessageAt = entity.lastMessageAt,
- messageCount = entity.messageCount
+ messageCount = entity.messageCount,
+ isPinned = entity.isPinned
)
}
}
@@ -204,15 +211,72 @@ class ChatRepository @Inject constructor(
}
}
- // v1.2.0: Update conversation title
+ // v1.2.0: Update conversation title (with server sync)
suspend fun updateConversationTitle(sessionId: String, newTitle: String) {
val entity = database.conversationDao().getById(sessionId) ?: return
database.conversationDao().update(entity.copy(title = newTitle))
+ // Sync to server
+ val agentId = entity.agentId
+ if (agentId != null) {
+ try {
+ apiService.updateSession(agentId, sessionId, UpdateSessionRequest(title = newTitle))
+ } catch (e: Exception) {
+ // Server sync is best-effort
+ }
+ }
}
suspend fun deleteConversation(sessionId: String) {
+ val entity = database.conversationDao().getById(sessionId)
database.messageDao().deleteByConversation(sessionId)
database.conversationDao().deleteById(sessionId)
+ // Sync to server
+ val agentId = entity?.agentId
+ if (agentId != null) {
+ try {
+ apiService.deleteSession(agentId, sessionId)
+ } catch (e: Exception) {
+ // Server sync is best-effort
+ }
+ }
+ }
+
+ // v1.1.0: Pin/unpin conversation (with server sync)
+ suspend fun pinConversation(sessionId: String, isPinned: Boolean) {
+ database.conversationDao().updatePinStatus(sessionId, isPinned)
+ val entity = database.conversationDao().getById(sessionId) ?: return
+ val agentId = entity.agentId
+ if (agentId != null) {
+ try {
+ apiService.updateSession(agentId, sessionId, UpdateSessionRequest(isPinned = isPinned))
+ } catch (e: Exception) {
+ // Server sync is best-effort
+ }
+ }
+ }
+
+ // Sync pin/rename state from server sessions list to local Room
+ suspend fun syncSessionStateFromServer(agentId: String) {
+ try {
+ val result = apiService.getAgentSessions(agentId, limit = 200)
+ for (dto in result.sessions) {
+ val local = database.conversationDao().getById(dto.sessionId)
+ if (local != null) {
+ val needsUpdate = (dto.isPinned != local.isPinned) ||
+ (dto.title != null && dto.title != local.title)
+ if (needsUpdate) {
+ database.conversationDao().insert(
+ local.copy(
+ title = dto.title ?: local.title,
+ isPinned = dto.isPinned
+ )
+ )
+ }
+ }
+ }
+ } catch (e: Exception) {
+ // Best-effort sync
+ }
}
// ─── Media operations (v1.2.0: moved from ChatViewModel to repository layer) ───
@@ -386,6 +450,15 @@ class ChatRepository @Inject constructor(
)
}
+ suspend fun searchMessages(agentId: String, query: String): Result {
+ return try {
+ val response = apiService.searchMessages(agentId, query)
+ Result.success(response)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
companion object {
fun parseIso8601ToEpoch(isoString: String): Long {
return try {
diff --git a/android/app/src/main/java/com/tiangong/aiagent/di/AppModule.kt b/android/app/src/main/java/com/tiangong/aiagent/di/AppModule.kt
index f3ef5b7..e4145a3 100644
--- a/android/app/src/main/java/com/tiangong/aiagent/di/AppModule.kt
+++ b/android/app/src/main/java/com/tiangong/aiagent/di/AppModule.kt
@@ -133,6 +133,12 @@ object AppModule {
}
}
+ private val MIGRATION_3_4 = object : Migration(3, 4) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE conversations ADD COLUMN isPinned INTEGER NOT NULL DEFAULT 0")
+ }
+ }
+
@Provides
@Singleton
fun provideRoomDatabase(@ApplicationContext context: Context): AppDatabase {
@@ -141,7 +147,7 @@ object AppModule {
AppDatabase::class.java,
"tiangong_db"
)
- .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
+ .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
.build()
}
}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/domain/model/Conversation.kt b/android/app/src/main/java/com/tiangong/aiagent/domain/model/Conversation.kt
index a34e978..2795f13 100644
--- a/android/app/src/main/java/com/tiangong/aiagent/domain/model/Conversation.kt
+++ b/android/app/src/main/java/com/tiangong/aiagent/domain/model/Conversation.kt
@@ -7,5 +7,6 @@ data class Conversation(
val title: String? = null,
val lastMessage: String? = null,
val lastMessageAt: Long = System.currentTimeMillis(),
- val messageCount: Int = 0
+ val messageCount: Int = 0,
+ val isPinned: Boolean = false
)
diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/agents/AgentListScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/agents/AgentListScreen.kt
index f749a3e..541a989 100644
--- a/android/app/src/main/java/com/tiangong/aiagent/ui/agents/AgentListScreen.kt
+++ b/android/app/src/main/java/com/tiangong/aiagent/ui/agents/AgentListScreen.kt
@@ -9,6 +9,7 @@ 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.material.icons.filled.Store
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -17,6 +18,7 @@ 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
+import com.tiangong.aiagent.util.AnalyticsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -24,10 +26,12 @@ fun AgentListScreen(
onBack: () -> Unit,
onAgentSelected: (com.tiangong.aiagent.domain.model.Agent) -> Unit,
onManageMemory: (agentId: String, agentName: String) -> Unit = { _, _ -> },
+ onNavigateToMarket: () -> Unit = {},
viewModel: AgentListViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
var showCreateDialog by remember { mutableStateOf(false) }
+ val analyticsVM: AnalyticsViewModel = hiltViewModel()
Scaffold(
topBar = {
@@ -60,6 +64,18 @@ fun AgentListScreen(
.fillMaxSize()
.padding(paddingValues)
) {
+ // Market entry
+ OutlinedButton(
+ onClick = onNavigateToMarket,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp)
+ ) {
+ Icon(Icons.Default.Store, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("探索 Agent 市场")
+ }
+
// Search bar
OutlinedTextField(
value = uiState.searchQuery,
@@ -114,7 +130,10 @@ fun AgentListScreen(
Box {
AgentListItem(
agent = agent,
- onClick = { onAgentSelected(agent) },
+ onClick = {
+ analyticsVM.tracker.trackEvent("agent", "agent_switch", agentId = agent.id)
+ onAgentSelected(agent)
+ },
onLongClick = { showMenu = true }
)
DropdownMenu(
diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt
index 1c5cda1..560e5b1 100644
--- a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt
+++ b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt
@@ -3,6 +3,7 @@ package com.tiangong.aiagent.ui.chat
import android.net.Uri
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -34,6 +35,7 @@ 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
+import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -52,6 +54,19 @@ fun ChatScreen(
var firstLoaded by remember { mutableStateOf(false) }
var isOffline by remember { mutableStateOf(false) }
+ // Draft save per agent
+ val drafts = remember { mutableStateMapOf() }
+ var previousAgentId by remember { mutableStateOf(null) }
+ val currentAgentId = uiState.currentAgent?.id
+ // On agent switch: save previous draft, restore new
+ if (previousAgentId != currentAgentId) {
+ if (previousAgentId != null) {
+ drafts[previousAgentId!!] = inputText
+ }
+ inputText = drafts[currentAgentId] ?: ""
+ previousAgentId = currentAgentId
+ }
+
// Drawer state
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
@@ -112,29 +127,36 @@ fun ChatScreen(
val context = androidx.compose.ui.platform.LocalContext.current
- // Image picker
+ // Image picker (multi-select, max 9)
val imagePickerLauncher = rememberLauncherForActivityResult(
- contract = ActivityResultContracts.GetContent()
- ) { uri: Uri? ->
- uri?.let { selectedUri ->
+ contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 9)
+ ) { uris: List ->
+ if (uris.isNotEmpty()) {
scope.launch {
+ val files = mutableListOf()
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"
+ for ((index, uri) in uris.withIndex()) {
+ val inputStream = context.contentResolver.openInputStream(uri)
+ val ext = context.contentResolver.getType(uri)?.let { mime ->
+ when {
+ mime.contains("png") -> ".png"
+ mime.contains("gif") -> ".gif"
+ mime.contains("webp") -> ".webp"
+ else -> ".jpg"
+ }
+ } ?: ".jpg"
+ val tempFile = File(context.cacheDir, "upload_${System.currentTimeMillis()}_$index$ext")
+ inputStream?.use { input ->
+ tempFile.outputStream().use { output -> input.copyTo(output) }
}
- } ?: ".jpg"
- val tempFile = java.io.File(context.cacheDir, "upload_${System.currentTimeMillis()}$ext")
- inputStream?.use { input ->
- tempFile.outputStream().use { output -> input.copyTo(output) }
+ files.add(tempFile)
+ }
+ if (files.isNotEmpty()) {
+ viewModel.uploadImages(files)
}
- viewModel.uploadImage(tempFile)
} catch (e: Exception) {
Log.e("ChatScreen", "Image selection failed", e)
+ files.forEach { it.delete() }
}
}
}
@@ -257,7 +279,7 @@ fun ChatScreen(
onRecordingStopped = { viewModel.onRecordingStopped() }
)
IconButton(
- onClick = { imagePickerLauncher.launch("image/*") },
+ onClick = { imagePickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
modifier = Modifier.size(40.dp)
) {
Icon(Icons.Default.Add, contentDescription = "添加附件",
@@ -266,18 +288,21 @@ fun ChatScreen(
}
OutlinedTextField(
value = inputText,
- onValueChange = { if (it.length <= 200) inputText = it },
+ onValueChange = { if (it.length <= 2000) inputText = it },
placeholder = {
- Text(if (uiState.editingMessageId != null) "编辑消息..." else "输入消息...")
+ Text(
+ if (uiState.editingMessageId != null) "编辑消息..."
+ else "给 ${uiState.currentAgent?.name ?: "AI"} 发送消息..."
+ )
},
modifier = Modifier.weight(1f),
maxLines = 8,
enabled = !uiState.isLoading && !uiState.isStreaming,
supportingText = {
Text(
- "${inputText.length}/200",
+ "${inputText.length}/2000",
style = MaterialTheme.typography.bodySmall,
- color = if (inputText.length >= 200)
+ color = if (inputText.length >= 1800)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant
@@ -533,7 +558,16 @@ fun ChatScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
- val suggestions = listOf("介绍一下你自己", "你能帮我做什么?", "给我讲个笑话")
+ val agent = uiState.currentAgent
+ val suggestions = if (agent != null) {
+ listOf(
+ "介绍一下你自己",
+ "你能帮我做什么?",
+ "用你的能力帮我完成一个任务"
+ )
+ } else {
+ listOf("介绍一下你自己", "你能帮我做什么?", "给我讲个笑话")
+ }
suggestions.forEach { suggestion ->
OutlinedButton(
onClick = { viewModel.sendMessage(suggestion) },
@@ -602,6 +636,27 @@ fun ChatScreen(
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
+ // Share
+ val shareContext = androidx.compose.ui.platform.LocalContext.current
+ IconButton(
+ onClick = {
+ val shareIntent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(android.content.Intent.EXTRA_TEXT, message.content)
+ }
+ shareContext.startActivity(
+ android.content.Intent.createChooser(shareIntent, "分享消息")
+ )
+ },
+ modifier = Modifier.size(28.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Share,
+ contentDescription = "分享",
+ modifier = Modifier.size(15.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
// Read aloud
IconButton(
onClick = { viewModel.speakText(message.content) },
@@ -673,7 +728,50 @@ fun ChatScreen(
}
}
} else {
- MessageBubbleFromUi(message = message)
+ // User message: show bubble with copy & share actions
+ val clipboardManager = LocalClipboardManager.current
+ Column {
+ MessageBubbleFromUi(message = message)
+ Row(
+ modifier = Modifier.padding(end = 16.dp, bottom = 4.dp),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // 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
+ )
+ }
+ // Share
+ val shareContext = androidx.compose.ui.platform.LocalContext.current
+ IconButton(
+ onClick = {
+ val shareIntent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(android.content.Intent.EXTRA_TEXT, message.content)
+ }
+ shareContext.startActivity(
+ android.content.Intent.createChooser(shareIntent, "分享消息")
+ )
+ },
+ modifier = Modifier.size(28.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Share,
+ contentDescription = "分享",
+ modifier = Modifier.size(15.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
}
}
}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt
index 768abad..eade082 100644
--- a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt
+++ b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt
@@ -20,6 +20,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
+import com.tiangong.aiagent.util.AnalyticsTracker
import java.io.File
import java.util.UUID
import javax.inject.Inject
@@ -114,6 +115,7 @@ class ChatViewModel @Inject constructor(
private val audioPlayer: AudioPlayer,
private val tokenDataStore: TokenDataStore,
private val savedStateHandle: SavedStateHandle,
+ private val analyticsTracker: AnalyticsTracker,
private val gson: Gson,
private val networkMonitor: com.tiangong.aiagent.util.NetworkMonitor,
private val offlineQueueManager: OfflineQueueManager
@@ -429,6 +431,9 @@ class ChatViewModel @Inject constructor(
val agentId = _uiState.value.currentAgent?.id
val sessionId = _uiState.value.sessionId ?: UUID.randomUUID().toString()
+ // Analytics: track message send
+ analyticsTracker.trackEvent("chat", "message_send", agentId = agentId)
+
// v1.2.0: Queue message when offline
if (_uiState.value.isOffline) {
val userMsg = UiMessage(
@@ -768,6 +773,27 @@ class ChatViewModel @Inject constructor(
}
}
+ fun uploadImages(files: List) {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isTranscribing = true)
+ val results = mutableListOf()
+ for (file in files) {
+ chatRepository.uploadImage(file)
+ .onSuccess { markdownUrl -> results.add(markdownUrl) }
+ .onFailure { e ->
+ Log.e("ChatViewModel", "Image upload failed: ${file.name}", e)
+ }
+ file.delete()
+ }
+ if (results.isNotEmpty()) {
+ _uiState.value = _uiState.value.copy(transcribedText = results.joinToString(" "))
+ } else {
+ _uiState.value = _uiState.value.copy(error = "图片上传失败")
+ }
+ _uiState.value = _uiState.value.copy(isTranscribing = false)
+ }
+ }
+
fun clearTranscribedText() {
_uiState.value = _uiState.value.copy(transcribedText = null)
}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/common/ZoomableImage.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/common/ZoomableImage.kt
new file mode 100644
index 0000000..66f0b8b
--- /dev/null
+++ b/android/app/src/main/java/com/tiangong/aiagent/ui/common/ZoomableImage.kt
@@ -0,0 +1,126 @@
+package com.tiangong.aiagent.ui.common
+
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.gestures.detectTransformGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.ContentScale
+import coil.compose.AsyncImage
+import coil.request.ImageRequest
+import androidx.compose.ui.platform.LocalContext
+
+/**
+ * A zoomable, pannable image viewer — Compose equivalent of the PhotoView
+ * pattern used in zhini_im (Chris Banes' PhotoView).
+ *
+ * Supports:
+ * - Pinch-to-zoom (1x ~ 5x)
+ * - Pan when zoomed in
+ * - Double-tap to toggle between 1x and 3x
+ * - Tap to dismiss callback
+ */
+@Composable
+fun ZoomableImage(
+ imageUrl: String,
+ modifier: Modifier = Modifier,
+ contentDescription: String? = null,
+ onTap: (() -> Unit)? = null
+) {
+ var scale by remember { mutableFloatStateOf(1f) }
+ var offsetX by remember { mutableFloatStateOf(0f) }
+ var offsetY by remember { mutableFloatStateOf(0f) }
+
+ fun resetZoom() {
+ scale = 1f
+ offsetX = 0f
+ offsetY = 0f
+ }
+
+ Box(
+ modifier = modifier,
+ contentAlignment = Alignment.Center
+ ) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(imageUrl)
+ .crossfade(true)
+ .build(),
+ contentDescription = contentDescription,
+ contentScale = ContentScale.Fit,
+ modifier = Modifier
+ .matchParentSize()
+ .graphicsLayer(
+ scaleX = scale.coerceIn(0.5f, 5f),
+ scaleY = scale.coerceIn(0.5f, 5f),
+ translationX = offsetX,
+ translationY = offsetY
+ )
+ .pointerInput(Unit) {
+ detectTransformGestures { centroid, pan, zoom, _ ->
+ val newScale = (scale * zoom).coerceIn(0.5f, 5f)
+ scale = newScale
+
+ if (newScale > 1f) {
+ // Pan when zoomed in
+ offsetX += pan.x
+ offsetY += pan.y
+ } else {
+ offsetX = 0f
+ offsetY = 0f
+ }
+ }
+ }
+ .pointerInput(Unit) {
+ detectTapGestures(
+ onDoubleTap = {
+ if (scale > 1.5f) {
+ resetZoom()
+ } else {
+ scale = 3f
+ }
+ },
+ onTap = {
+ if (scale <= 1.1f) {
+ onTap?.invoke()
+ } else {
+ resetZoom()
+ }
+ }
+ )
+ }
+ )
+ }
+}
+
+/**
+ * Full-screen zoomable image viewer with dark background and tap-to-dismiss.
+ * Equivalent to zhini_im's ImagePreviewActivity.
+ */
+@Composable
+fun FullScreenImageViewer(
+ imageUrl: String,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier
+ .graphicsLayer { alpha = 0.95f }
+ .pointerInput(Unit) {
+ // Simple tap on background to dismiss
+ detectTapGestures(
+ onTap = { onDismiss() }
+ )
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ ZoomableImage(
+ imageUrl = imageUrl,
+ onTap = onDismiss
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/history/ConversationListScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/history/ConversationListScreen.kt
index 6a6c58a..8fad36a 100644
--- a/android/app/src/main/java/com/tiangong/aiagent/ui/history/ConversationListScreen.kt
+++ b/android/app/src/main/java/com/tiangong/aiagent/ui/history/ConversationListScreen.kt
@@ -72,6 +72,10 @@ class ConversationListViewModel @Inject constructor(
loadJob?.cancel()
loadJob = viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
+ // Sync session state from server (pin/rename)
+ if (agentId != null) {
+ chatRepository.syncSessionStateFromServer(agentId)
+ }
chatRepository.getConversations().collect { conversations ->
val filtered = if (agentId != null) {
conversations.filter { it.agentId == agentId }
@@ -121,6 +125,13 @@ class ConversationListViewModel @Inject constructor(
chatRepository.updateConversationTitle(sessionId, newTitle)
}
}
+
+ // v1.1.0: Pin/unpin conversation
+ fun pinConversation(sessionId: String, isPinned: Boolean) {
+ viewModelScope.launch {
+ chatRepository.pinConversation(sessionId, isPinned)
+ }
+ }
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -232,6 +243,9 @@ fun ConversationListScreen(
onDelete = { viewModel.deleteConversation(conversation.sessionId) },
onRename = { newTitle ->
viewModel.renameConversation(conversation.sessionId, newTitle)
+ },
+ onPin = {
+ viewModel.pinConversation(conversation.sessionId, !conversation.isPinned)
}
)
}
@@ -247,7 +261,8 @@ fun ConversationListItem(
conversation: Conversation,
onClick: () -> Unit,
onDelete: () -> Unit,
- onRename: (String) -> Unit = {}
+ onRename: (String) -> Unit = {},
+ onPin: () -> Unit = {}
) {
var showDeleteDialog by remember { mutableStateOf(false) }
var showRenameDialog by remember { mutableStateOf(false) }
@@ -286,13 +301,24 @@ fun ConversationListItem(
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
- )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (conversation.isPinned) {
+ Icon(
+ imageVector = Icons.Default.PushPin,
+ contentDescription = "已置顶",
+ modifier = Modifier.size(14.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ }
+ 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(
@@ -342,6 +368,20 @@ fun ConversationListItem(
Icon(Icons.Default.Create, contentDescription = null, modifier = Modifier.size(18.dp))
}
)
+ DropdownMenuItem(
+ text = { Text(if (conversation.isPinned) "取消置顶" else "置顶") },
+ onClick = {
+ showMenu = false
+ onPin()
+ },
+ leadingIcon = {
+ Icon(
+ Icons.Default.PushPin,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ )
DropdownMenuItem(
text = { Text("删除", color = MaterialTheme.colorScheme.error) },
onClick = {
diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/login/LoginScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/login/LoginScreen.kt
index 1efb9af..44199bc 100644
--- a/android/app/src/main/java/com/tiangong/aiagent/ui/login/LoginScreen.kt
+++ b/android/app/src/main/java/com/tiangong/aiagent/ui/login/LoginScreen.kt
@@ -1,8 +1,12 @@
package com.tiangong.aiagent.ui.login
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
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.filled.Settings
import androidx.compose.material.icons.filled.Visibility
@@ -11,6 +15,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
@@ -20,15 +25,23 @@ 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
+import com.tiangong.aiagent.util.AnalyticsViewModel
+
+// ── zhini_im inspired colors ──────────────────────────────────────────────
+private val ZhiNiBg = Color(0xFFF5F7FA)
+private val ZhiNiBlue = Color(0xFF3B62E0)
+private val ZhiNiTextPrimary = Color(0xFF1A193E)
+private val ZhiNiTextHint = Color(0xFFABB1C1)
+private val ZhiNiDivider = Color(0xFFE8E8E8)
@Composable
fun LoginScreen(
onLoginSuccess: () -> Unit,
onNavigateToRegister: () -> Unit = {},
+ onNavigateToPhoneLogin: () -> Unit = {},
viewModel: LoginViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
@@ -36,44 +49,39 @@ fun LoginScreen(
var passwordVisible by remember { mutableStateOf(false) }
var isEditingUrl by remember { mutableStateOf(false) }
var editedUrl by remember { mutableStateOf("") }
+ val analyticsVM: AnalyticsViewModel = hiltViewModel()
LaunchedEffect(uiState.isLoggedIn) {
if (uiState.isLoggedIn) {
+ analyticsVM.tracker.trackEvent("auth", "login_success")
onLoginSuccess()
}
}
Surface(
modifier = Modifier.fillMaxSize(),
- color = MaterialTheme.colorScheme.background
+ color = ZhiNiBg
) {
Column(
modifier = Modifier
.fillMaxSize()
- .padding(horizontal = 32.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 24.dp),
+ horizontalAlignment = Alignment.Start
) {
+ Spacer(modifier = Modifier.height(100.dp))
+
+ // ── Title ─────────────────────────────────────────────────────
Text(
- text = "天工智能体",
- style = MaterialTheme.typography.headlineLarge.copy(
- fontWeight = FontWeight.Bold,
- fontSize = 28.sp
- ),
- color = MaterialTheme.colorScheme.primary
+ text = "密码登录",
+ fontSize = 26.sp,
+ fontWeight = FontWeight.Bold,
+ color = ZhiNiTextPrimary
)
- Spacer(modifier = Modifier.height(8.dp))
+ Spacer(modifier = Modifier.height(32.dp))
- Text(
- text = "AI Agent Platform",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
-
- Spacer(modifier = Modifier.height(36.dp))
-
- // Server URL
+ // ── Server URL (compact, tap to edit) ─────────────────────────
if (isEditingUrl) {
// Preset quick-select buttons
Row(
@@ -82,42 +90,29 @@ fun LoginScreen(
) {
val isLocalSelected = editedUrl == "http://192.168.31.135:8037"
val isCloudSelected = editedUrl == "http://101.43.95.130:8037"
- OutlinedButton(
+ FilterChip(
+ selected = isLocalSelected,
onClick = { editedUrl = "http://192.168.31.135:8037" },
- colors = if (isLocalSelected) {
- ButtonDefaults.outlinedButtonColors(
- containerColor = MaterialTheme.colorScheme.primaryContainer
- )
- } else {
- ButtonDefaults.outlinedButtonColors()
- }
- ) {
- Text("本地")
- }
- OutlinedButton(
+ label = { Text("本地") },
+ enabled = !uiState.isLoading
+ )
+ FilterChip(
+ selected = isCloudSelected,
onClick = { editedUrl = "http://101.43.95.130:8037" },
- colors = if (isCloudSelected) {
- ButtonDefaults.outlinedButtonColors(
- containerColor = MaterialTheme.colorScheme.primaryContainer
- )
- } else {
- ButtonDefaults.outlinedButtonColors()
- }
- ) {
- Text("云服务")
- }
+ label = { Text("云服务") },
+ enabled = !uiState.isLoading
+ )
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = editedUrl,
onValueChange = { editedUrl = it },
- label = { Text("服务器地址") },
placeholder = { Text("http://101.43.95.130:8037") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
enabled = !uiState.isLoading,
- supportingText = { Text("点击上方快捷选择,或手动输入后保存") }
+ textStyle = MaterialTheme.typography.bodySmall
)
Row(
modifier = Modifier.fillMaxWidth(),
@@ -126,9 +121,7 @@ fun LoginScreen(
TextButton(
onClick = { isEditingUrl = false },
enabled = !uiState.isLoading
- ) {
- Text("取消")
- }
+ ) { Text("取消") }
Spacer(modifier = Modifier.width(8.dp))
TextButton(
onClick = {
@@ -136,78 +129,112 @@ fun LoginScreen(
isEditingUrl = false
},
enabled = !uiState.isLoading
- ) {
- Text("保存")
- }
+ ) { Text("保存") }
}
} else {
- TextButton(
- onClick = {
- editedUrl = uiState.serverUrl
- isEditingUrl = true
- },
- enabled = !uiState.isLoading,
- modifier = Modifier.fillMaxWidth()
- ) {
- Icon(
- imageVector = Icons.Default.Settings,
- contentDescription = null,
- modifier = Modifier.size(16.dp)
- )
- Spacer(modifier = Modifier.width(4.dp))
- Text(
- text = if (uiState.serverUrl.isNotBlank()) uiState.serverUrl else "点击配置服务器地址",
- style = MaterialTheme.typography.bodySmall,
- maxLines = 1
+ Text(
+ text = if (uiState.serverUrl.isNotBlank()) uiState.serverUrl else "点击配置服务器地址",
+ style = MaterialTheme.typography.bodySmall,
+ color = ZhiNiTextHint,
+ modifier = Modifier
+ .clickable(enabled = !uiState.isLoading) {
+ editedUrl = uiState.serverUrl
+ isEditingUrl = true
+ }
+ .padding(vertical = 4.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // ── Username field ────────────────────────────────────────────
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "用户名",
+ fontSize = 16.sp,
+ color = ZhiNiTextPrimary
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Box(modifier = Modifier.weight(1f)) {
+ if (uiState.username.isEmpty()) {
+ Text(
+ text = "请输入用户名",
+ fontSize = 15.sp,
+ color = ZhiNiTextHint,
+ modifier = Modifier.align(Alignment.CenterStart)
+ )
+ }
+ TextField(
+ value = uiState.username,
+ onValueChange = viewModel::onUsernameChange,
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ colors = zhiniTextFieldColors(),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
+ enabled = !uiState.isLoading
)
}
}
+ HorizontalDivider(color = if (uiState.username.isNotEmpty()) ZhiNiBlue else ZhiNiDivider, thickness = 1.dp)
- Spacer(modifier = Modifier.height(8.dp))
+ Spacer(modifier = Modifier.height(24.dp))
- // Username
- OutlinedTextField(
- value = uiState.username,
- onValueChange = viewModel::onUsernameChange,
- label = { Text("用户名") },
- singleLine = true,
+ // ── Password field ────────────────────────────────────────────
+ Row(
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 "显示密码"
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "密 码",
+ fontSize = 16.sp,
+ color = ZhiNiTextPrimary
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Box(modifier = Modifier.weight(1f)) {
+ if (uiState.password.isEmpty()) {
+ Text(
+ text = "请输入密码",
+ fontSize = 15.sp,
+ color = ZhiNiTextHint,
+ modifier = Modifier.align(Alignment.CenterStart)
)
}
- },
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Password,
- imeAction = ImeAction.Done
- ),
- keyboardActions = KeyboardActions(
- onDone = {
- focusManager.clearFocus()
- viewModel.login()
- }
- ),
- enabled = !uiState.isLoading
- )
+ TextField(
+ value = uiState.password,
+ onValueChange = viewModel::onPasswordChange,
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ colors = zhiniTextFieldColors(),
+ 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 "显示密码",
+ tint = ZhiNiTextHint
+ )
+ }
+ },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Done
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ focusManager.clearFocus()
+ if (uiState.agreedTerms) viewModel.login()
+ }
+ ),
+ enabled = !uiState.isLoading
+ )
+ }
+ }
+ HorizontalDivider(color = if (uiState.password.isNotEmpty()) ZhiNiBlue else ZhiNiDivider, thickness = 1.dp)
- // Error
+ // ── Error message ─────────────────────────────────────────────
uiState.error?.let { errorText ->
Spacer(modifier = Modifier.height(12.dp))
Text(
@@ -217,9 +244,30 @@ fun LoginScreen(
)
}
- Spacer(modifier = Modifier.height(16.dp))
+ Spacer(modifier = Modifier.height(20.dp))
- // Agreement checkbox
+ // ── Links row: phone login + register ─────────────────────────
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = "使用验证码登录",
+ color = ZhiNiBlue,
+ fontSize = 14.sp,
+ modifier = Modifier.clickable(enabled = !uiState.isLoading) { onNavigateToPhoneLogin() }
+ )
+ Text(
+ text = "注册",
+ color = ZhiNiBlue,
+ fontSize = 14.sp,
+ modifier = Modifier.clickable(enabled = !uiState.isLoading) { onNavigateToRegister() }
+ )
+ }
+
+ Spacer(modifier = Modifier.height(40.dp))
+
+ // ── Agreement checkbox ────────────────────────────────────────
val context = LocalContext.current
Row(
modifier = Modifier.fillMaxWidth(),
@@ -228,33 +276,40 @@ fun LoginScreen(
Checkbox(
checked = uiState.agreedTerms,
onCheckedChange = viewModel::onAgreedTermsChanged,
- enabled = !uiState.isLoading
+ enabled = !uiState.isLoading,
+ colors = CheckboxDefaults.colors(checkedColor = ZhiNiBlue)
+ )
+ Text(
+ text = "我已阅读并同意",
+ style = MaterialTheme.typography.bodySmall,
+ color = ZhiNiTextHint
+ )
+ Text(
+ text = "《隐私政策》",
+ style = MaterialTheme.typography.bodySmall,
+ color = ZhiNiBlue,
+ modifier = Modifier.clickable {
+ context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://tiangong.ai/privacy")))
+ }
+ )
+ Text(
+ text = "和",
+ style = MaterialTheme.typography.bodySmall,
+ color = ZhiNiTextHint
+ )
+ Text(
+ text = "《用户协议》",
+ style = MaterialTheme.typography.bodySmall,
+ color = ZhiNiBlue,
+ modifier = Modifier.clickable {
+ context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://tiangong.ai/terms")))
+ }
)
- Text("我已阅读并同意", style = MaterialTheme.typography.bodySmall)
- TextButton(
- onClick = {
- val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://tiangong.ai/privacy"))
- context.startActivity(intent)
- },
- contentPadding = PaddingValues(horizontal = 2.dp)
- ) {
- Text("《隐私政策》", style = MaterialTheme.typography.bodySmall)
- }
- Text("和", style = MaterialTheme.typography.bodySmall)
- TextButton(
- onClick = {
- val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://tiangong.ai/terms"))
- context.startActivity(intent)
- },
- contentPadding = PaddingValues(horizontal = 2.dp)
- ) {
- Text("《用户协议》", style = MaterialTheme.typography.bodySmall)
- }
}
Spacer(modifier = Modifier.height(16.dp))
- // Login button
+ // ── Login button ──────────────────────────────────────────────
Button(
onClick = {
focusManager.clearFocus()
@@ -263,63 +318,70 @@ fun LoginScreen(
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
- enabled = !uiState.isLoading && uiState.agreedTerms
+ enabled = !uiState.isLoading && uiState.agreedTerms,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = ZhiNiBlue,
+ disabledContainerColor = ZhiNiBlue.copy(alpha = 0.4f),
+ disabledContentColor = Color.White.copy(alpha = 0.6f)
+ ),
+ shape = MaterialTheme.shapes.medium
) {
if (uiState.isLoading) {
CircularProgressIndicator(
- modifier = Modifier.size(24.dp),
- color = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.size(22.dp),
+ color = Color.White,
strokeWidth = 2.dp
)
} else {
- Text("登录", style = MaterialTheme.typography.titleMedium)
+ Text("登录", fontSize = 16.sp, color = Color.White)
}
}
- Spacer(modifier = Modifier.height(16.dp))
+ Spacer(modifier = Modifier.height(32.dp))
- // Register link
- TextButton(onClick = onNavigateToRegister) {
- Text("没有账户?立即注册")
+ // ── Bottom privacy policy ─────────────────────────────────────
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 32.dp),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "隐私政策",
+ style = MaterialTheme.typography.labelSmall,
+ color = ZhiNiTextHint,
+ modifier = Modifier.clickable {
+ context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://tiangong.ai/privacy")))
+ }
+ )
+ Text(
+ text = " | ",
+ style = MaterialTheme.typography.labelSmall,
+ color = ZhiNiTextHint
+ )
+ Text(
+ text = "用户协议",
+ style = MaterialTheme.typography.labelSmall,
+ color = ZhiNiTextHint,
+ modifier = Modifier.clickable {
+ context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://tiangong.ai/terms")))
+ }
+ )
}
-
- Spacer(modifier = Modifier.height(24.dp))
-
- // Privacy policy & user agreement
- 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)
- }
- )
}
}
}
+/** zhini_im-style TextField: transparent background, no border, compact. */
@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)
- }
- }
-}
+private fun zhiniTextFieldColors() = TextFieldDefaults.colors(
+ focusedContainerColor = Color.Transparent,
+ unfocusedContainerColor = Color.Transparent,
+ disabledContainerColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent,
+ focusedTextColor = ZhiNiTextPrimary,
+ unfocusedTextColor = ZhiNiTextPrimary,
+ cursorColor = ZhiNiBlue
+)
diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/login/PhoneLoginScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/login/PhoneLoginScreen.kt
new file mode 100644
index 0000000..1c3fbcd
--- /dev/null
+++ b/android/app/src/main/java/com/tiangong/aiagent/ui/login/PhoneLoginScreen.kt
@@ -0,0 +1,294 @@
+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.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Phone
+import androidx.compose.material3.*
+import androidx.compose.material3.ExperimentalMaterial3Api
+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.input.KeyboardType
+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.ApiService
+import com.tiangong.aiagent.data.remote.dto.PhoneLoginRequest
+import com.tiangong.aiagent.data.remote.dto.PhoneSendCodeRequest
+import com.tiangong.aiagent.data.remote.dto.PhoneLoginResponse
+import com.tiangong.aiagent.data.local.TokenDataStore
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.delay
+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 PhoneLoginState(
+ val phone: String = "",
+ val code: String = "",
+ val countdown: Int = 0, // 0 = ready, >0 = counting
+ val isSending: Boolean = false,
+ val isLoading: Boolean = false,
+ val error: String? = null,
+ val token: String? = null,
+ val refreshToken: String? = null
+)
+
+@HiltViewModel
+class PhoneLoginViewModel @Inject constructor(
+ private val apiService: ApiService,
+ private val tokenDataStore: TokenDataStore
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(PhoneLoginState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ fun updatePhone(phone: String) {
+ val digits = phone.filter { it.isDigit() }.take(11)
+ _uiState.value = _uiState.value.copy(phone = digits, error = null)
+ }
+
+ fun updateCode(code: String) {
+ val digits = code.filter { it.isDigit() }.take(6)
+ _uiState.value = _uiState.value.copy(code = digits, error = null)
+ }
+
+ fun sendCode() {
+ val phone = _uiState.value.phone
+ if (phone.length != 11) {
+ _uiState.value = _uiState.value.copy(error = "请输入正确的手机号")
+ return
+ }
+ if (_uiState.value.countdown > 0) return
+
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isSending = true)
+ try {
+ apiService.sendPhoneCode(PhoneSendCodeRequest(phone = phone))
+ _uiState.value = _uiState.value.copy(isSending = false, countdown = 60)
+ // Start countdown
+ launch {
+ for (i in 60 downTo 1) {
+ delay(1000)
+ _uiState.value = _uiState.value.copy(countdown = i)
+ }
+ }
+ } catch (e: Exception) {
+ val msg = if (e is retrofit2.HttpException) {
+ when (e.code()) {
+ 429 -> "操作过于频繁,请稍后再试"
+ else -> "发送失败,请重试"
+ }
+ } else {
+ "网络错误,请检查连接"
+ }
+ _uiState.value = _uiState.value.copy(isSending = false, error = msg)
+ }
+ }
+ }
+
+ fun login() {
+ val state = _uiState.value
+ if (state.phone.length != 11) {
+ _uiState.value = _uiState.value.copy(error = "请输入正确的手机号")
+ return
+ }
+ if (state.code.length < 4) {
+ _uiState.value = _uiState.value.copy(error = "请输入验证码")
+ return
+ }
+
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isLoading = true, error = null)
+ try {
+ val response = apiService.phoneLogin(
+ PhoneLoginRequest(phone = state.phone, code = state.code)
+ )
+ // Save tokens
+ tokenDataStore.saveToken(response.accessToken)
+ response.refreshToken?.let { tokenDataStore.saveRefreshToken(it) }
+ _uiState.value = _uiState.value.copy(
+ isLoading = false,
+ token = response.accessToken,
+ refreshToken = response.refreshToken
+ )
+ } catch (e: Exception) {
+ val msg = if (e is retrofit2.HttpException) {
+ when (e.code()) {
+ 400 -> "验证码错误或已过期"
+ 403 -> "该账号已注销"
+ else -> "登录失败,请重试"
+ }
+ } else {
+ "网络错误,请检查连接"
+ }
+ _uiState.value = _uiState.value.copy(isLoading = false, error = msg)
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PhoneLoginScreen(
+ onNavigateBack: () -> Unit,
+ onLoginSuccess: () -> Unit,
+ viewModel: PhoneLoginViewModel = hiltViewModel()
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ val focusManager = LocalFocusManager.current
+
+ LaunchedEffect(uiState.token) {
+ if (uiState.token != null) {
+ onLoginSuccess()
+ }
+ }
+
+ Scaffold(
+ 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),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(48.dp))
+
+ // Header
+ Icon(
+ imageVector = Icons.Default.Phone,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ "验证码登录",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ "未注册的手机号将自动创建账号",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Phone input
+ OutlinedTextField(
+ value = uiState.phone,
+ onValueChange = viewModel::updatePhone,
+ label = { Text("手机号") },
+ placeholder = { Text("请输入手机号") },
+ leadingIcon = { Icon(Icons.Default.Phone, contentDescription = null) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Phone,
+ imeAction = ImeAction.Next
+ ),
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Code input + send button
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.Top
+ ) {
+ OutlinedTextField(
+ value = uiState.code,
+ onValueChange = viewModel::updateCode,
+ label = { Text("验证码") },
+ placeholder = { Text("6位验证码") },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ imeAction = ImeAction.Done
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ focusManager.clearFocus()
+ viewModel.login()
+ }
+ ),
+ modifier = Modifier.weight(1f)
+ )
+
+ OutlinedButton(
+ onClick = { viewModel.sendCode() },
+ enabled = uiState.phone.length == 11 && uiState.countdown == 0 && !uiState.isSending,
+ modifier = Modifier
+ .height(56.dp)
+ .width(120.dp)
+ ) {
+ if (uiState.isSending) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(18.dp),
+ strokeWidth = 2.dp
+ )
+ } else {
+ Text(
+ if (uiState.countdown > 0) "${uiState.countdown}s" else "发送验证码",
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+ }
+
+ // Error
+ if (uiState.error != null) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ uiState.error!!,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Login button
+ Button(
+ onClick = { viewModel.login() },
+ enabled = !uiState.isLoading && uiState.phone.length == 11 && uiState.code.length >= 4,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(52.dp),
+ shape = MaterialTheme.shapes.large
+ ) {
+ if (uiState.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("登录", style = MaterialTheme.typography.titleSmall)
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/login/PrivacyPolicyRow.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/login/PrivacyPolicyRow.kt
new file mode 100644
index 0000000..4025aba
--- /dev/null
+++ b/android/app/src/main/java/com/tiangong/aiagent/ui/login/PrivacyPolicyRow.kt
@@ -0,0 +1,38 @@
+package com.tiangong.aiagent.ui.login
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun PrivacyPolicyRow(
+ onPrivacyPolicy: () -> Unit,
+ onUserAgreement: () -> Unit
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ TextButton(onClick = onPrivacyPolicy) {
+ Text("隐私政策", fontSize = 12.sp)
+ }
+ Text(" | ", fontSize = 12.sp, color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant)
+ TextButton(onClick = onUserAgreement) {
+ Text("用户协议", fontSize = 12.sp)
+ }
+ }
+ Text(
+ text = "继续使用即表示您同意以上协议",
+ fontSize = 10.sp,
+ color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/market/MarketScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/market/MarketScreen.kt
new file mode 100644
index 0000000..72ee542
--- /dev/null
+++ b/android/app/src/main/java/com/tiangong/aiagent/ui/market/MarketScreen.kt
@@ -0,0 +1,555 @@
+package com.tiangong.aiagent.ui.market
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+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.ArrowBack
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Star
+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.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.tiangong.aiagent.data.remote.dto.AgentMarketItemDto
+import com.tiangong.aiagent.data.remote.dto.SceneTemplateItemDto
+import com.tiangong.aiagent.data.remote.dto.TemplateItemDto
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MarketScreen(
+ onNavigateBack: () -> Unit,
+ viewModel: MarketViewModel = hiltViewModel()
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ var searchText by remember { mutableStateOf("") }
+
+ // Snackbar for success/error
+ val snackbarHostState = remember { SnackbarHostState() }
+ LaunchedEffect(uiState.installSuccessMessage) {
+ uiState.installSuccessMessage?.let {
+ snackbarHostState.showSnackbar(it)
+ viewModel.clearError()
+ }
+ }
+ LaunchedEffect(uiState.error) {
+ uiState.error?.let {
+ snackbarHostState.showSnackbar(it)
+ viewModel.clearError()
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Agent 市场") },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
+ }
+ }
+ )
+ },
+ snackbarHost = { SnackbarHost(snackbarHostState) }
+ ) { padding ->
+ Column(modifier = Modifier.padding(padding)) {
+ // Tab Row
+ TabRow(selectedTabIndex = uiState.selectedTab) {
+ Tab(
+ selected = uiState.selectedTab == 0,
+ onClick = { viewModel.selectTab(0) },
+ text = { Text("推荐") }
+ )
+ Tab(
+ selected = uiState.selectedTab == 1,
+ onClick = { viewModel.selectTab(1) },
+ text = { Text("市场") }
+ )
+ Tab(
+ selected = uiState.selectedTab == 2,
+ onClick = { viewModel.selectTab(2) },
+ text = { Text("模板") }
+ )
+ }
+
+ when (uiState.selectedTab) {
+ 0 -> FeaturedTab(uiState, viewModel)
+ 1 -> AllAgentsTab(uiState, viewModel, searchText) { searchText = it }
+ 2 -> TemplatesTab(uiState, viewModel)
+ }
+ }
+ }
+}
+
+// ─── Featured Tab ─────────────────────────────────────────────────────
+
+@Composable
+private fun FeaturedTab(uiState: MarketUiState, viewModel: MarketViewModel) {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // Recommended section
+ item {
+ Text(
+ "精选推荐",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ if (uiState.isLoadingFeatured) {
+ item { LoadingIndicator() }
+ } else {
+ items(uiState.featuredAgents.chunked(2)) { row ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ row.forEach { agent ->
+ AgentCard(
+ agent = agent,
+ isInstalling = agent.id in uiState.isInstalling,
+ onInstall = { viewModel.installAgent(agent) },
+ modifier = Modifier.weight(1f)
+ )
+ }
+ // Fill empty slot if odd count
+ if (row.size == 1) {
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ }
+ }
+ }
+
+ // Scene templates section
+ if (uiState.sceneTemplates.isNotEmpty()) {
+ item {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ "场景模板",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ items(uiState.sceneTemplates) { template ->
+ SceneTemplateCard(template, viewModel)
+ }
+ }
+ }
+}
+
+// ─── All Agents Tab ────────────────────────────────────────────────────
+
+@Composable
+private fun AllAgentsTab(
+ uiState: MarketUiState,
+ viewModel: MarketViewModel,
+ searchText: String,
+ onSearchTextChange: (String) -> Unit
+) {
+ Column(modifier = Modifier.fillMaxSize()) {
+ // Search bar
+ OutlinedTextField(
+ value = searchText,
+ onValueChange = onSearchTextChange,
+ placeholder = { Text("搜索智能体...") },
+ leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+
+ // Trigger search
+ LaunchedEffect(searchText) {
+ viewModel.setSearchQuery(searchText)
+ viewModel.search()
+ }
+
+ // Category chips
+ if (uiState.categories.isNotEmpty()) {
+ LazyRow(
+ modifier = Modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ item {
+ FilterChip(
+ selected = uiState.selectedCategory == null,
+ onClick = { viewModel.selectCategory(null) },
+ label = { Text("全部") }
+ )
+ }
+ items(uiState.categories) { cat ->
+ FilterChip(
+ selected = uiState.selectedCategory == cat.category,
+ onClick = { viewModel.selectCategory(cat.category) },
+ label = { Text("${cat.category} (${cat.count})") }
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ // Agent list
+ if (uiState.isLoadingAll) {
+ LoadingIndicator()
+ } else {
+ LazyColumn(
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ items(uiState.allAgents) { agent ->
+ AgentListItem(agent, agent.id in uiState.isInstalling) {
+ viewModel.installAgent(agent)
+ }
+ }
+ }
+ }
+ }
+}
+
+// ─── Templates Tab ─────────────────────────────────────────────────────
+
+@Composable
+private fun TemplatesTab(uiState: MarketUiState, viewModel: MarketViewModel) {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ if (uiState.isLoadingTemplates) {
+ item { LoadingIndicator() }
+ } else {
+ item {
+ Text(
+ "工作流模板",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ items(uiState.templates) { template ->
+ TemplateCard(template, template.id in uiState.isInstalling) {
+ viewModel.useTemplate(template)
+ }
+ }
+ }
+ }
+}
+
+// ─── Components ────────────────────────────────────────────────────────
+
+@Composable
+private fun AgentCard(
+ agent: AgentMarketItemDto,
+ isInstalling: Boolean,
+ onInstall: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ modifier = modifier,
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Column(modifier = Modifier.padding(12.dp)) {
+ Text(
+ agent.name,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ agent.description ?: "暂无描述",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ lineHeight = 16.sp
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Default.Star,
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ tint = Color(0xFFFFB800)
+ )
+ Spacer(modifier = Modifier.width(2.dp))
+ Text(
+ "${agent.ratingAvg}",
+ style = MaterialTheme.typography.labelSmall
+ )
+ Text(
+ " (${agent.useCount}次)",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ FilledTonalButton(
+ onClick = onInstall,
+ enabled = !isInstalling,
+ contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
+ ) {
+ if (isInstalling) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(14.dp),
+ strokeWidth = 2.dp
+ )
+ } else {
+ Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(14.dp))
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("安装", style = MaterialTheme.typography.labelSmall)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun AgentListItem(
+ agent: AgentMarketItemDto,
+ isInstalling: Boolean,
+ onInstall: () -> Unit
+) {
+ Card(
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ agent.name,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ agent.description ?: "暂无描述",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Default.Star, contentDescription = null,
+ modifier = Modifier.size(14.dp), tint = Color(0xFFFFB800))
+ Spacer(modifier = Modifier.width(2.dp))
+ Text("${agent.ratingAvg} (${agent.ratingCount}评)", style = MaterialTheme.typography.labelSmall)
+ Spacer(modifier = Modifier.width(12.dp))
+ Text("${agent.useCount}次使用", style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant)
+ if (agent.creatorUsername != null) {
+ Spacer(modifier = Modifier.width(12.dp))
+ Text("@${agent.creatorUsername}", style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary)
+ }
+ }
+ }
+ Spacer(modifier = Modifier.width(12.dp))
+ Button(
+ onClick = onInstall,
+ enabled = !isInstalling,
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
+ ) {
+ if (isInstalling) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("安装")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TemplateCard(
+ template: TemplateItemDto,
+ isInstalling: Boolean,
+ onUse: () -> Unit
+) {
+ Card(
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ template.name,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold
+ )
+ if (!template.description.isNullOrBlank()) {
+ Text(
+ template.description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("${template.useCount}次使用", style = MaterialTheme.typography.labelSmall)
+ if (template.creatorUsername != null) {
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("@${template.creatorUsername}", style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary)
+ }
+ }
+ }
+ Spacer(modifier = Modifier.width(12.dp))
+ FilledTonalButton(
+ onClick = onUse,
+ enabled = !isInstalling
+ ) {
+ if (isInstalling) {
+ CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
+ } else {
+ Text("使用")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SceneTemplateCard(
+ template: SceneTemplateItemDto,
+ viewModel: MarketViewModel
+) {
+ var showNameDialog by remember { mutableStateOf(false) }
+ var agentName by remember { mutableStateOf(template.name) }
+
+ Card(
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ template.name,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold
+ )
+ if (!template.description.isNullOrBlank()) {
+ Text(
+ template.description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ if (template.category != null) {
+ Text(
+ template.category,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ Spacer(modifier = Modifier.width(12.dp))
+ FilledTonalButton(onClick = { showNameDialog = true }) {
+ Text("创建")
+ }
+ }
+ }
+
+ if (showNameDialog) {
+ AlertDialog(
+ onDismissRequest = { showNameDialog = false },
+ title = { Text("从模板创建智能体") },
+ text = {
+ OutlinedTextField(
+ value = agentName,
+ onValueChange = { agentName = it },
+ label = { Text("智能体名称") },
+ singleLine = true
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ viewModel.createFromSceneTemplate(template, agentName)
+ showNameDialog = false
+ },
+ enabled = agentName.isNotBlank()
+ ) {
+ Text("创建")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showNameDialog = false }) {
+ Text("取消")
+ }
+ }
+ )
+ }
+}
+
+@Composable
+private fun LoadingIndicator() {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(32.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/market/MarketViewModel.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/market/MarketViewModel.kt
new file mode 100644
index 0000000..fab30cb
--- /dev/null
+++ b/android/app/src/main/java/com/tiangong/aiagent/ui/market/MarketViewModel.kt
@@ -0,0 +1,233 @@
+package com.tiangong.aiagent.ui.market
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.tiangong.aiagent.data.remote.dto.AgentMarketItemDto
+import com.tiangong.aiagent.data.remote.dto.MarketCategoryDto
+import com.tiangong.aiagent.data.remote.dto.SceneTemplateItemDto
+import com.tiangong.aiagent.data.remote.dto.TemplateItemDto
+import com.tiangong.aiagent.data.repository.AgentMarketRepository
+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 MarketUiState(
+ val selectedTab: Int = 0, // 0=Featured, 1=All, 2=Templates
+ val featuredAgents: List = emptyList(),
+ val allAgents: List = emptyList(),
+ val templates: List = emptyList(),
+ val sceneTemplates: List = emptyList(),
+ val categories: List = emptyList(),
+ val selectedCategory: String? = null,
+ val searchQuery: String = "",
+ val isLoadingFeatured: Boolean = false,
+ val isLoadingAll: Boolean = false,
+ val isLoadingTemplates: Boolean = false,
+ val isInstalling: Set = emptySet(), // agent IDs being installed
+ val installSuccessMessage: String? = null,
+ val error: String? = null
+)
+
+@HiltViewModel
+class MarketViewModel @Inject constructor(
+ private val marketRepository: AgentMarketRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(MarketUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ loadFeatured()
+ loadAllAgents()
+ loadTemplates()
+ loadCategories()
+ loadSceneTemplates()
+ }
+
+ fun selectTab(tab: Int) {
+ _uiState.value = _uiState.value.copy(selectedTab = tab)
+ }
+
+ fun selectCategory(category: String?) {
+ _uiState.value = _uiState.value.copy(selectedCategory = category)
+ loadAllAgents()
+ }
+
+ fun setSearchQuery(query: String) {
+ _uiState.value = _uiState.value.copy(searchQuery = query)
+ }
+
+ fun clearError() {
+ _uiState.value = _uiState.value.copy(error = null, installSuccessMessage = null)
+ }
+
+ // ─── Load ───
+
+ fun loadFeatured() {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isLoadingFeatured = true)
+ marketRepository.getFeaturedAgents(10).fold(
+ onSuccess = { agents ->
+ _uiState.value = _uiState.value.copy(
+ featuredAgents = agents,
+ isLoadingFeatured = false
+ )
+ },
+ onFailure = { e ->
+ _uiState.value = _uiState.value.copy(
+ isLoadingFeatured = false,
+ error = "加载推荐失败: ${e.message}"
+ )
+ }
+ )
+ }
+ }
+
+ fun loadAllAgents() {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isLoadingAll = true)
+ val state = _uiState.value
+ marketRepository.browseAgents(
+ search = state.searchQuery.ifBlank { null },
+ category = state.selectedCategory,
+ sortBy = "use_count"
+ ).fold(
+ onSuccess = { agents ->
+ _uiState.value = _uiState.value.copy(
+ allAgents = agents,
+ isLoadingAll = false
+ )
+ },
+ onFailure = { e ->
+ _uiState.value = _uiState.value.copy(
+ isLoadingAll = false,
+ error = "加载市场失败: ${e.message}"
+ )
+ }
+ )
+ }
+ }
+
+ fun loadTemplates() {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isLoadingTemplates = true)
+ marketRepository.browseTemplates().fold(
+ onSuccess = { tmpl ->
+ _uiState.value = _uiState.value.copy(
+ templates = tmpl,
+ isLoadingTemplates = false
+ )
+ },
+ onFailure = { e ->
+ _uiState.value = _uiState.value.copy(
+ isLoadingTemplates = false,
+ error = "加载模板失败: ${e.message}"
+ )
+ }
+ )
+ }
+ }
+
+ private fun loadCategories() {
+ viewModelScope.launch {
+ marketRepository.getCategories().fold(
+ onSuccess = { cats ->
+ _uiState.value = _uiState.value.copy(categories = cats)
+ },
+ onFailure = { /* silently ignore */ }
+ )
+ }
+ }
+
+ private fun loadSceneTemplates() {
+ viewModelScope.launch {
+ marketRepository.getSceneTemplates().fold(
+ onSuccess = { st ->
+ _uiState.value = _uiState.value.copy(sceneTemplates = st)
+ },
+ onFailure = { /* silently ignore */ }
+ )
+ }
+ }
+
+ // ─── Actions ───
+
+ fun installAgent(agent: AgentMarketItemDto) {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(
+ isInstalling = _uiState.value.isInstalling + agent.id,
+ error = null
+ )
+ marketRepository.installAgent(agent.id).fold(
+ onSuccess = { resp ->
+ _uiState.value = _uiState.value.copy(
+ isInstalling = _uiState.value.isInstalling - agent.id,
+ installSuccessMessage = "已安装: ${resp.agentName}"
+ )
+ },
+ onFailure = { e ->
+ _uiState.value = _uiState.value.copy(
+ isInstalling = _uiState.value.isInstalling - agent.id,
+ error = "安装失败: ${e.message}"
+ )
+ }
+ )
+ }
+ }
+
+ fun useTemplate(template: TemplateItemDto) {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(
+ isInstalling = _uiState.value.isInstalling + template.id,
+ error = null
+ )
+ marketRepository.useTemplate(template.id).fold(
+ onSuccess = { resp ->
+ _uiState.value = _uiState.value.copy(
+ isInstalling = _uiState.value.isInstalling - template.id,
+ installSuccessMessage = "已从模板创建: ${resp.name}"
+ )
+ },
+ onFailure = { e ->
+ _uiState.value = _uiState.value.copy(
+ isInstalling = _uiState.value.isInstalling - template.id,
+ error = "使用模板失败: ${e.message}"
+ )
+ }
+ )
+ }
+ }
+
+ fun createFromSceneTemplate(template: SceneTemplateItemDto, agentName: String) {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(
+ isInstalling = _uiState.value.isInstalling + template.id,
+ error = null
+ )
+ marketRepository.createFromSceneTemplate(
+ name = agentName,
+ sceneTemplateId = template.id
+ ).fold(
+ onSuccess = { resp ->
+ _uiState.value = _uiState.value.copy(
+ isInstalling = _uiState.value.isInstalling - template.id,
+ installSuccessMessage = "已创建: ${resp.name}"
+ )
+ },
+ onFailure = { e ->
+ _uiState.value = _uiState.value.copy(
+ isInstalling = _uiState.value.isInstalling - template.id,
+ error = "创建失败: ${e.message}"
+ )
+ }
+ )
+ }
+ }
+
+ fun search() {
+ loadAllAgents()
+ }
+}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/navigation/NavGraph.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/navigation/NavGraph.kt
index 2c11833..f10de44 100644
--- a/android/app/src/main/java/com/tiangong/aiagent/ui/navigation/NavGraph.kt
+++ b/android/app/src/main/java/com/tiangong/aiagent/ui/navigation/NavGraph.kt
@@ -16,18 +16,26 @@ 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.login.PhoneLoginScreen
+import com.tiangong.aiagent.ui.market.MarketScreen
+import com.tiangong.aiagent.ui.subscription.SubscriptionScreen
import com.tiangong.aiagent.ui.memory.MemoryManageScreen
import com.tiangong.aiagent.ui.notifications.NotificationDetailScreen
import com.tiangong.aiagent.ui.notifications.NotificationsScreen
+import com.tiangong.aiagent.ui.onboarding.OnboardingScreen
import com.tiangong.aiagent.ui.register.RegisterScreen
import com.tiangong.aiagent.ui.settings.AboutScreen
import com.tiangong.aiagent.ui.settings.AccountSecurityScreen
+import com.tiangong.aiagent.ui.settings.NetworkDiagnoseScreen
import com.tiangong.aiagent.ui.settings.SettingsScreen
+import com.tiangong.aiagent.ui.splash.SplashScreen
+import com.tiangong.aiagent.util.AnalyticsViewModel
private const val CS_AGENT_ID = "3490efa8-330d-458d-a26e-0b6c1d72e6f4"
private const val CS_AGENT_NAME = "天工客服"
object Routes {
+ const val SPLASH = "splash"
const val LOGIN = "login"
const val REGISTER = "register"
const val CHAT = "chat"
@@ -39,16 +47,29 @@ object Routes {
const val NOTIFICATIONS = "notifications"
const val NOTIFICATION_DETAIL = "notifications/{notificationId}"
const val MEMORY_MANAGE = "memory_manage/{agentId}/{agentName}"
+ const val ONBOARDING = "onboarding"
+ const val PHONE_LOGIN = "phone_login"
+ const val MARKET = "market"
+ const val SUBSCRIPTION = "subscription"
+ const val NETWORK_DIAGNOSE = "network_diagnose"
}
@Composable
fun NavGraph(
token: String?,
+ onboardingCompleted: Boolean = false,
+ onOnboardingComplete: () -> Unit = {},
initialNotificationId: String? = null,
navController: NavHostController = rememberNavController()
) {
- val startDest = remember(token) {
- if (token != null) Routes.CHAT else Routes.LOGIN
+ val startDest = Routes.SPLASH
+
+ // Analytics: app open
+ val analyticsVM: AnalyticsViewModel = hiltViewModel()
+ LaunchedEffect(token) {
+ if (token != null) {
+ analyticsVM.tracker.trackEvent("app", "app_open")
+ }
}
LaunchedEffect(initialNotificationId) {
@@ -61,6 +82,28 @@ fun NavGraph(
navController = navController,
startDestination = startDest
) {
+ composable(Routes.SPLASH) {
+ SplashScreen(
+ token = token,
+ onboardingCompleted = onboardingCompleted,
+ onNavigateToLogin = {
+ navController.navigate(Routes.LOGIN) {
+ popUpTo(Routes.SPLASH) { inclusive = true }
+ }
+ },
+ onNavigateToOnboarding = {
+ navController.navigate(Routes.ONBOARDING) {
+ popUpTo(Routes.SPLASH) { inclusive = true }
+ }
+ },
+ onNavigateToChat = {
+ navController.navigate(Routes.CHAT) {
+ popUpTo(Routes.SPLASH) { inclusive = true }
+ }
+ }
+ )
+ }
+
composable(Routes.LOGIN) {
LoginScreen(
onLoginSuccess = {
@@ -70,6 +113,9 @@ fun NavGraph(
},
onNavigateToRegister = {
navController.navigate(Routes.REGISTER)
+ },
+ onNavigateToPhoneLogin = {
+ navController.navigate(Routes.PHONE_LOGIN)
}
)
}
@@ -115,6 +161,9 @@ fun NavGraph(
onManageMemory = { agentId, agentName ->
val encoded = java.net.URLEncoder.encode(agentName, "UTF-8")
navController.navigate("memory_manage/$agentId/$encoded")
+ },
+ onNavigateToMarket = {
+ navController.navigate(Routes.MARKET)
}
)
}
@@ -155,7 +204,9 @@ fun NavGraph(
)
chatViewModel.switchAgent(csAgent)
navController.popBackStack()
- }
+ },
+ onNavigateToSubscription = { navController.navigate(Routes.SUBSCRIPTION) },
+ onNavigateToNetworkDiagnose = { navController.navigate(Routes.NETWORK_DIAGNOSE) }
)
}
@@ -202,5 +253,45 @@ fun NavGraph(
onBack = { navController.popBackStack() }
)
}
+
+ composable(Routes.ONBOARDING) {
+ OnboardingScreen(
+ onComplete = {
+ onOnboardingComplete()
+ navController.navigate(Routes.CHAT) {
+ popUpTo(Routes.ONBOARDING) { inclusive = true }
+ }
+ }
+ )
+ }
+
+ composable(Routes.PHONE_LOGIN) {
+ PhoneLoginScreen(
+ onNavigateBack = { navController.popBackStack() },
+ onLoginSuccess = {
+ navController.navigate(Routes.CHAT) {
+ popUpTo(0) { inclusive = true }
+ }
+ }
+ )
+ }
+
+ composable(Routes.MARKET) {
+ MarketScreen(
+ onNavigateBack = { navController.popBackStack() }
+ )
+ }
+
+ composable(Routes.SUBSCRIPTION) {
+ SubscriptionScreen(
+ onBack = { navController.popBackStack() }
+ )
+ }
+
+ composable(Routes.NETWORK_DIAGNOSE) {
+ NetworkDiagnoseScreen(
+ onBack = { navController.popBackStack() }
+ )
+ }
}
}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/onboarding/OnboardingScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/onboarding/OnboardingScreen.kt
new file mode 100644
index 0000000..8d37899
--- /dev/null
+++ b/android/app/src/main/java/com/tiangong/aiagent/ui/onboarding/OnboardingScreen.kt
@@ -0,0 +1,183 @@
+package com.tiangong.aiagent.ui.onboarding
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Chat
+import androidx.compose.material.icons.filled.DoneAll
+import androidx.compose.material.icons.filled.History
+import androidx.compose.material.icons.filled.SmartToy
+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.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+
+private data class OnboardingPage(
+ val icon: ImageVector,
+ val title: String,
+ val description: String
+)
+
+private val pages = listOf(
+ OnboardingPage(
+ icon = Icons.AutoMirrored.Filled.Chat,
+ title = "与智能体对话",
+ description = "选择你感兴趣的智能体,开始自然流畅的AI对话。每个智能体都有独特的能力和知识领域,随时为你提供帮助。"
+ ),
+ OnboardingPage(
+ icon = Icons.Default.SmartToy,
+ title = "切换不同智能体",
+ description = "从智能体市场选择适合你的AI助手。每个智能体擅长不同的任务——编程、写作、客服、数据分析,总有一个适合你。"
+ ),
+ OnboardingPage(
+ icon = Icons.Default.History,
+ title = "管理你的对话",
+ description = "所有对话自动保存,随时查看历史记录。支持重命名、搜索和置顶对话,让你的每一次交流都有迹可循。"
+ )
+)
+
+@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
+@Composable
+fun OnboardingScreen(
+ onComplete: () -> Unit
+) {
+ val pagerState = rememberPagerState(pageCount = { pages.size })
+ val currentPage = pagerState.currentPage
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .systemBarsPadding()
+ ) {
+ // Skip button
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.End
+ ) {
+ TextButton(onClick = onComplete) {
+ Text("跳过")
+ }
+ }
+
+ // Pager content
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ ) { page ->
+ OnboardingPageContent(pages[page])
+ }
+
+ // Page indicator + button
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Page indicators
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.padding(bottom = 32.dp)
+ ) {
+ repeat(pages.size) { index ->
+ val color by animateColorAsState(
+ targetValue = if (index == currentPage) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
+ },
+ label = "indicatorColor"
+ )
+ Box(
+ modifier = Modifier
+ .size(if (index == currentPage) 10.dp else 8.dp)
+ .clip(CircleShape)
+ .background(color)
+ )
+ }
+ }
+
+ // Start button
+ Button(
+ onClick = onComplete,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(52.dp),
+ shape = MaterialTheme.shapes.large
+ ) {
+ Icon(
+ imageVector = Icons.Default.DoneAll,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = if (currentPage == pages.size - 1) "开始使用" else "下一步",
+ style = MaterialTheme.typography.titleSmall
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun OnboardingPageContent(page: OnboardingPage) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ // Icon
+ Surface(
+ modifier = Modifier.size(120.dp),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.primaryContainer
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ imageVector = page.icon,
+ contentDescription = null,
+ modifier = Modifier.size(64.dp),
+ tint = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(48.dp))
+
+ // Title
+ Text(
+ text = page.title,
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Description
+ Text(
+ text = page.description,
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/settings/AppUpdateDialogs.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/settings/AppUpdateDialogs.kt
new file mode 100644
index 0000000..87306e5
--- /dev/null
+++ b/android/app/src/main/java/com/tiangong/aiagent/ui/settings/AppUpdateDialogs.kt
@@ -0,0 +1,176 @@
+package com.tiangong.aiagent.ui.settings
+
+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.filled.CloudDownload
+import androidx.compose.material.icons.filled.Error
+import androidx.compose.material.icons.filled.SystemUpdate
+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 com.tiangong.aiagent.util.AppUpdateManager
+import com.tiangong.aiagent.util.UpdateState
+
+/**
+ * Compose dialogs for app update flow: notification, download progress, error.
+ */
+@Composable
+fun AppUpdateDialogs(
+ updateManager: AppUpdateManager,
+ onDismiss: () -> Unit = {}
+) {
+ val state by updateManager.updateState.collectAsState()
+
+ // ── Update available notification dialog ──
+ if (state.updateAvailable && state.response != null && !state.downloading && !state.downloadComplete) {
+ val response = state.response!!
+ AlertDialog(
+ onDismissRequest = {
+ if (!state.forceUpdate) {
+ updateManager.reset()
+ onDismiss()
+ }
+ },
+ icon = {
+ Icon(Icons.Default.SystemUpdate, contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary)
+ },
+ title = {
+ Text("发现新版本 ${response.latestVersion}")
+ },
+ text = {
+ Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
+ Text("当前版本: ${response.currentVersion}")
+ if (response.releaseNotes.isNotBlank()) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "更新内容:",
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = response.releaseNotes,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ if (response.forceUpdate) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "此版本为强制更新,请立即升级。",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ },
+ confirmButton = {
+ Button(onClick = { updateManager.startDownload() }) {
+ Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(modifier = Modifier.width(6.dp))
+ Text("立即更新")
+ }
+ },
+ dismissButton = {
+ if (!state.forceUpdate) {
+ TextButton(onClick = {
+ updateManager.reset()
+ onDismiss()
+ }) {
+ Text("稍后")
+ }
+ }
+ }
+ )
+ }
+
+ // ── Download progress dialog ──
+ if (state.downloading) {
+ AlertDialog(
+ onDismissRequest = { /* block dismiss during download */ },
+ title = { Text("正在下载更新") },
+ text = {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ LinearProgressIndicator(
+ progress = { state.downloadProgress / 100f },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "${state.downloadProgress}%",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ },
+ confirmButton = {
+ // No confirm - show progress only
+ },
+ dismissButton = {
+ TextButton(onClick = {
+ updateManager.reset()
+ }) {
+ Text("取消")
+ }
+ }
+ )
+ }
+
+ // ── Download complete → install dialog ──
+ if (state.downloadComplete && !state.downloading && state.apkFilePath != null) {
+ AlertDialog(
+ onDismissRequest = {},
+ title = { Text("下载完成") },
+ text = { Text("更新包已下载完成,是否立即安装?") },
+ confirmButton = {
+ Button(onClick = { updateManager.installApk() }) {
+ Text("立即安装")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = {
+ updateManager.reset()
+ onDismiss()
+ }) {
+ Text("稍后安装")
+ }
+ }
+ )
+ }
+
+ // ── Download error dialog ──
+ state.downloadError?.let { error ->
+ AlertDialog(
+ onDismissRequest = { updateManager.reset() },
+ icon = {
+ Icon(Icons.Default.Error, contentDescription = null,
+ tint = MaterialTheme.colorScheme.error)
+ },
+ title = { Text("更新失败") },
+ text = { Text(error) },
+ confirmButton = {
+ Button(onClick = {
+ updateManager.reset()
+ // Fallback: open in browser
+ updateManager.openInBrowser()
+ }) {
+ Text("浏览器下载")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { updateManager.reset() }) {
+ Text("取消")
+ }
+ }
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/settings/NetworkDiagnoseScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/settings/NetworkDiagnoseScreen.kt
new file mode 100644
index 0000000..df05cc6
--- /dev/null
+++ b/android/app/src/main/java/com/tiangong/aiagent/ui/settings/NetworkDiagnoseScreen.kt
@@ -0,0 +1,281 @@
+package com.tiangong.aiagent.ui.settings
+
+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.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.InetSocketAddress
+import java.net.Socket
+import java.net.URL
+
+data class DiagnoseItem(
+ val label: String,
+ val description: String,
+ val status: DiagnoseStatus = DiagnoseStatus.IDLE
+)
+
+enum class DiagnoseStatus {
+ IDLE, RUNNING, SUCCESS, FAIL
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun NetworkDiagnoseScreen(
+ onBack: () -> Unit,
+ serverUrl: String = com.tiangong.aiagent.BuildConfig.BASE_URL
+) {
+ val scope = rememberCoroutineScope()
+
+ val items = remember {
+ mutableStateListOf(
+ DiagnoseItem("应用服务器", "HTTP 连接到 $serverUrl"),
+ DiagnoseItem("API 服务", "检查 /api/v1/health 端点"),
+ DiagnoseItem("网络连通性", "TCP 连接测试 baidu.com:80"),
+ )
+ }
+
+ var isRunning by remember { mutableStateOf(false) }
+ var allDone by remember { mutableStateOf(false) }
+
+ fun runDiagnose() {
+ if (isRunning) return
+ isRunning = true
+ allDone = false
+ // Reset statuses
+ items.indices.forEach { i -> items[i] = items[i].copy(status = DiagnoseStatus.IDLE) }
+
+ scope.launch {
+ // Step 1: TCP connectivity
+ updateItem(items, 2, DiagnoseStatus.RUNNING)
+ val tcpOk = withContext(Dispatchers.IO) {
+ try {
+ val socket = Socket()
+ socket.connect(InetSocketAddress("baidu.com", 80), 5000)
+ socket.close()
+ true
+ } catch (_: IOException) { false }
+ }
+ updateItem(items, 2, if (tcpOk) DiagnoseStatus.SUCCESS else DiagnoseStatus.FAIL)
+
+ if (!tcpOk) {
+ // No network — skip HTTP tests
+ updateItem(items, 0, DiagnoseStatus.FAIL)
+ updateItem(items, 1, DiagnoseStatus.FAIL)
+ isRunning = false
+ allDone = true
+ return@launch
+ }
+
+ // Step 2: App server HTTP
+ updateItem(items, 0, DiagnoseStatus.RUNNING)
+ val serverOk = withContext(Dispatchers.IO) {
+ try {
+ val conn = URL(serverUrl).openConnection() as HttpURLConnection
+ conn.connectTimeout = 5000
+ conn.readTimeout = 5000
+ conn.requestMethod = "GET"
+ conn.responseCode in 200..499 // any response (not a timeout) is OK
+ } catch (_: Exception) { false }
+ }
+ updateItem(items, 0, if (serverOk) DiagnoseStatus.SUCCESS else DiagnoseStatus.FAIL)
+
+ // Step 3: API health endpoint
+ updateItem(items, 1, DiagnoseStatus.RUNNING)
+ val apiOk = withContext(Dispatchers.IO) {
+ try {
+ val url = serverUrl.trimEnd('/') + "/api/v1/health"
+ val conn = URL(url).openConnection() as HttpURLConnection
+ conn.connectTimeout = 5000
+ conn.readTimeout = 5000
+ conn.requestMethod = "GET"
+ conn.responseCode == 200
+ } catch (_: Exception) { false }
+ }
+ updateItem(items, 1, if (apiOk) DiagnoseStatus.SUCCESS else DiagnoseStatus.FAIL)
+
+ isRunning = false
+ allDone = true
+ }
+ }
+
+ LaunchedEffect(Unit) { runDiagnose() }
+
+ 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())
+ .padding(16.dp)
+ ) {
+ Text(
+ "正在检查网络连接状态...",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(Modifier.height(24.dp))
+
+ items.forEachIndexed { index, item ->
+ DiagnoseCard(item)
+ if (index < items.size - 1) {
+ Spacer(Modifier.height(12.dp))
+ }
+ }
+
+ Spacer(Modifier.height(24.dp))
+
+ // Re-test button
+ Button(
+ onClick = { runDiagnose() },
+ enabled = !isRunning,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ if (isRunning) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(18.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ Spacer(Modifier.width(8.dp))
+ Text("诊断中...")
+ } else {
+ Icon(Icons.Default.Refresh, contentDescription = null)
+ Spacer(Modifier.width(8.dp))
+ Text("重新检测")
+ }
+ }
+
+ if (allDone) {
+ Spacer(Modifier.height(16.dp))
+ val allOk = items.all { it.status == DiagnoseStatus.SUCCESS }
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = if (allOk) Color(0xFFE8F5E9) else Color(0xFFFFF3E0)
+ )
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = if (allOk) Icons.Default.CheckCircle else Icons.Default.Warning,
+ contentDescription = null,
+ tint = if (allOk) Color(0xFF4CAF50) else Color(0xFFFF9800)
+ )
+ Spacer(Modifier.width(12.dp))
+ Text(
+ if (allOk) "网络连接正常,所有服务均可访问"
+ else "部分服务连接异常,请检查网络或服务器状态",
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun DiagnoseCard(item: DiagnoseItem) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
+ )
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Status icon
+ when (item.status) {
+ DiagnoseStatus.IDLE -> Icon(
+ Icons.Default.FiberManualRecord,
+ contentDescription = null,
+ tint = Color.Gray,
+ modifier = Modifier.size(24.dp)
+ )
+ DiagnoseStatus.RUNNING -> CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ strokeWidth = 2.dp
+ )
+ DiagnoseStatus.SUCCESS -> Icon(
+ Icons.Default.CheckCircle,
+ contentDescription = null,
+ tint = Color(0xFF4CAF50),
+ modifier = Modifier.size(24.dp)
+ )
+ DiagnoseStatus.FAIL -> Icon(
+ Icons.Default.Cancel,
+ contentDescription = null,
+ tint = Color(0xFFE53935),
+ modifier = Modifier.size(24.dp)
+ )
+ }
+
+ Spacer(Modifier.width(16.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = item.label,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Medium
+ )
+ Text(
+ text = item.description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = when (item.status) {
+ DiagnoseStatus.IDLE -> "等待检测"
+ DiagnoseStatus.RUNNING -> "检测中..."
+ DiagnoseStatus.SUCCESS -> "连接正常"
+ DiagnoseStatus.FAIL -> "连接失败"
+ },
+ fontSize = 12.sp,
+ color = when (item.status) {
+ DiagnoseStatus.SUCCESS -> Color(0xFF4CAF50)
+ DiagnoseStatus.FAIL -> Color(0xFFE53935)
+ else -> Color.Gray
+ }
+ )
+ }
+ }
+ }
+}
+
+private fun updateItem(items: MutableList, index: Int, status: DiagnoseStatus) {
+ items[index] = items[index].copy(status = status)
+}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/settings/SettingsScreen.kt
index add4489..cfbd802 100644
--- a/android/app/src/main/java/com/tiangong/aiagent/ui/settings/SettingsScreen.kt
+++ b/android/app/src/main/java/com/tiangong/aiagent/ui/settings/SettingsScreen.kt
@@ -39,7 +39,7 @@ class SettingsViewModel @Inject constructor(
private val tokenDataStore: TokenDataStore,
private val dynamicUrlInterceptor: DynamicUrlInterceptor,
private val apiService: ApiService,
- private val appUpdateManager: AppUpdateManager
+ val appUpdateManager: AppUpdateManager
) : ViewModel() {
private val _username = MutableStateFlow("admin")
@@ -87,13 +87,12 @@ class SettingsViewModel @Inject constructor(
fun checkUpdate() {
_updateMessage.value = "正在检查..."
- appUpdateManager.checkForUpdate { response ->
- _updateMessage.value = if (response.hasUpdate) {
- "发现新版本 ${response.latestVersion}"
- } else if (response.releaseNotes.isNotBlank()) {
- response.releaseNotes // error message
- } else {
- "已是最新版本 (${response.currentVersion})"
+ appUpdateManager.checkForUpdate(showNoUpdateToast = false)
+ viewModelScope.launch {
+ appUpdateManager.updateState.collect { state ->
+ if (!state.checking && !state.updateAvailable && state.downloadError == null) {
+ _updateMessage.value = "已是最新版本"
+ }
}
}
}
@@ -172,6 +171,8 @@ fun SettingsScreen(
onNavigateToAbout: (() -> Unit)? = null,
onNavigateToAccountSecurity: (() -> Unit)? = null,
onContactSupport: (() -> Unit)? = null,
+ onNavigateToSubscription: (() -> Unit)? = null,
+ onNavigateToNetworkDiagnose: (() -> Unit)? = null,
viewModel: SettingsViewModel = hiltViewModel()
) {
var showLogoutDialog by remember { mutableStateOf(false) }
@@ -410,6 +411,34 @@ fun SettingsScreen(
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ // Network diagnostics
+ if (onNavigateToNetworkDiagnose != null) {
+ ListItem(
+ headlineContent = { Text("网络诊断") },
+ supportingContent = { Text("检测网络连接和服务状态") },
+ leadingContent = {
+ Icon(Icons.Default.NetworkCheck, contentDescription = null)
+ },
+ modifier = Modifier.clickable { onNavigateToNetworkDiagnose() }
+ )
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ }
+
+ // Subscription (v1.2)
+ SettingsSectionHeader("订阅")
+ ListItem(
+ headlineContent = { Text("会员订阅") },
+ supportingContent = { Text("管理您的订阅方案") },
+ leadingContent = {
+ Icon(Icons.Default.WorkspacePremium, contentDescription = null)
+ },
+ modifier = if (onNavigateToSubscription != null) {
+ Modifier.clickable { onNavigateToSubscription() }
+ } else Modifier
+ )
+
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+
// About
SettingsSectionHeader("关于")
@@ -525,6 +554,9 @@ fun SettingsScreen(
}
)
}
+
+ // App update dialogs (download progress, etc.)
+ AppUpdateDialogs(updateManager = viewModel.appUpdateManager)
}
@Composable
diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/splash/SplashScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/splash/SplashScreen.kt
new file mode 100644
index 0000000..3efac55
--- /dev/null
+++ b/android/app/src/main/java/com/tiangong/aiagent/ui/splash/SplashScreen.kt
@@ -0,0 +1,150 @@
+package com.tiangong.aiagent.ui.splash
+
+import androidx.compose.animation.core.*
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Android
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+/**
+ * Splash screen with professional branding, token validation, and routing.
+ * Pattern adapted from zhni_im SplashActivity.
+ *
+ * Flow:
+ * 1. Show animated branding (logo + title + subtitle) for min 1.5s
+ * 2. Validate token in background (check presence from data store)
+ * 3. Route: no token → Login, token + not onboarded → Onboarding, token + onboarded → Chat
+ */
+@Composable
+fun SplashScreen(
+ token: String?,
+ onboardingCompleted: Boolean,
+ onNavigateToLogin: () -> Unit,
+ onNavigateToOnboarding: () -> Unit,
+ onNavigateToChat: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ // Animations
+ val logoAlpha = remember { Animatable(0f) }
+ val titleAlpha = remember { Animatable(0f) }
+ val subtitleAlpha = remember { Animatable(0f) }
+ var isRouting by remember { mutableStateOf(false) }
+
+ // Gradient background colors
+ val gradient = Brush.verticalGradient(
+ colors = listOf(
+ Color(0xFF1A237E), // Deep indigo
+ Color(0xFF283593),
+ Color(0xFF3949AB)
+ )
+ )
+
+ LaunchedEffect(Unit) {
+ // Fade in sequences (parallel launches within coroutine scope)
+ coroutineScope {
+ launch { logoAlpha.animateTo(1f, animationSpec = tween(600, easing = FastOutSlowInEasing)) }
+ launch {
+ delay(300)
+ titleAlpha.animateTo(1f, animationSpec = tween(500, easing = FastOutSlowInEasing))
+ }
+ launch {
+ delay(600)
+ subtitleAlpha.animateTo(1f, animationSpec = tween(500, easing = FastOutSlowInEasing))
+ }
+ }
+ }
+
+ // Token validation + routing after minimum splash duration
+ LaunchedEffect(token, onboardingCompleted) {
+ if (isRouting) return@LaunchedEffect
+
+ // Minimum splash display time for professional feel
+ delay(1800)
+ isRouting = true
+
+ // Determine destination
+ when {
+ token == null -> onNavigateToLogin()
+ !onboardingCompleted -> onNavigateToOnboarding()
+ else -> onNavigateToChat()
+ }
+ }
+
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(brush = gradient),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ // App logo
+ Icon(
+ imageVector = Icons.Default.Android,
+ contentDescription = "天工智能体",
+ modifier = Modifier
+ .size(80.dp)
+ .alpha(logoAlpha.value),
+ tint = Color.White
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // App title
+ Text(
+ text = "天工智能体",
+ modifier = Modifier.alpha(titleAlpha.value),
+ fontSize = 28.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color.White
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Subtitle
+ Text(
+ text = "AI Agent Platform",
+ modifier = Modifier.alpha(subtitleAlpha.value),
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Normal,
+ color = Color.White.copy(alpha = 0.7f)
+ )
+
+ Spacer(modifier = Modifier.height(48.dp))
+
+ // Loading indicator
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(24.dp)
+ .alpha(subtitleAlpha.value),
+ color = Color.White.copy(alpha = 0.6f),
+ strokeWidth = 2.dp
+ )
+ }
+
+ // Bottom version text
+ Text(
+ text = "v${com.tiangong.aiagent.BuildConfig.VERSION_NAME}",
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 32.dp),
+ fontSize = 12.sp,
+ color = Color.White.copy(alpha = 0.4f)
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/SubscriptionScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/SubscriptionScreen.kt
new file mode 100644
index 0000000..c82e48c
--- /dev/null
+++ b/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/SubscriptionScreen.kt
@@ -0,0 +1,329 @@
+package com.tiangong.aiagent.ui.subscription
+
+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.filled.Check
+import androidx.compose.material.icons.filled.WorkspacePremium
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.tiangong.aiagent.data.remote.dto.BillingPlanDto
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SubscriptionScreen(
+ onBack: () -> Unit,
+ viewModel: SubscriptionViewModel = hiltViewModel()
+) {
+ val uiState by viewModel.uiState.collectAsState()
+
+ // Handle purchase success
+ LaunchedEffect(uiState.purchaseSuccess) {
+ if (uiState.purchaseSuccess != null) {
+ viewModel.clearPurchaseSuccess()
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("会员订阅") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
+ }
+ }
+ )
+ },
+ snackbarHost = {
+ if (uiState.error != null) {
+ Snackbar(
+ action = {
+ TextButton(onClick = { viewModel.clearError() }) { Text("关闭") }
+ }
+ ) { Text(uiState.error!!) }
+ }
+ }
+ ) { padding ->
+ if (uiState.isLoading) {
+ Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ } else {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Current tier banner
+ val currentTier = uiState.subscription?.tier ?: "free"
+ CurrentTierBanner(currentTier)
+
+ Spacer(Modifier.height(16.dp))
+
+ // Period toggle
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("月付", fontSize = 14.sp)
+ Switch(
+ checked = uiState.selectedPeriod == "yearly",
+ onCheckedChange = {
+ viewModel.selectPeriod(if (it) "yearly" else "monthly")
+ }
+ )
+ Text("年付", fontSize = 14.sp)
+ Spacer(Modifier.weight(1f))
+ if (uiState.selectedPeriod == "yearly") {
+ Text(
+ "省 43%",
+ color = Color(0xFF4CAF50),
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+
+ Spacer(Modifier.height(16.dp))
+
+ // Plan cards
+ uiState.plans.forEachIndexed { index, plan ->
+ PlanCard(
+ plan = plan,
+ isCurrentPlan = plan.tier == currentTier,
+ isRecommended = plan.tier == "pro",
+ selectedPeriod = uiState.selectedPeriod,
+ isPurchasing = uiState.isPurchasing,
+ onPurchase = { viewModel.purchase(plan.id) }
+ )
+ if (index < uiState.plans.size - 1) {
+ Spacer(Modifier.height(12.dp))
+ }
+ }
+
+ Spacer(Modifier.height(24.dp))
+
+ // Features comparison
+ FeaturesComparison(uiState.plans)
+
+ Spacer(Modifier.height(32.dp))
+ }
+ }
+ }
+}
+
+@Composable
+private fun CurrentTierBanner(currentTier: String) {
+ val (label, color) = when (currentTier) {
+ "pro" -> "专业版" to Color(0xFF2196F3)
+ "enterprise" -> "企业版" to Color(0xFF9C27B0)
+ else -> "免费版" to Color(0xFF757575)
+ }
+ Surface(
+ modifier = Modifier.fillMaxWidth().padding(16.dp),
+ shape = MaterialTheme.shapes.large,
+ color = color.copy(alpha = 0.1f)
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.WorkspacePremium,
+ contentDescription = null,
+ tint = color,
+ modifier = Modifier.size(32.dp)
+ )
+ Spacer(Modifier.width(12.dp))
+ Column {
+ Text("当前套餐", fontSize = 12.sp, color = Color.Gray)
+ Text(label, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = color)
+ }
+ }
+ }
+}
+
+@Composable
+private fun PlanCard(
+ plan: BillingPlanDto,
+ isCurrentPlan: Boolean,
+ isRecommended: Boolean,
+ selectedPeriod: String,
+ isPurchasing: Boolean,
+ onPurchase: () -> Unit
+) {
+ val price = if (selectedPeriod == "yearly") plan.priceYearly else plan.priceMonthly
+ val priceLabel = if (selectedPeriod == "yearly") "/年" else "/月"
+ val isFree = plan.tier == "free"
+
+ Card(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
+ shape = MaterialTheme.shapes.large,
+ colors = CardDefaults.cardColors(
+ containerColor = if (isRecommended) MaterialTheme.colorScheme.primaryContainer
+ else MaterialTheme.colorScheme.surfaceVariant
+ ),
+ border = if (isRecommended) androidx.compose.foundation.BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
+ else null
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ if (isRecommended) {
+ Surface(
+ shape = MaterialTheme.shapes.small,
+ color = MaterialTheme.colorScheme.primary
+ ) {
+ Text(
+ "推荐",
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ Spacer(Modifier.height(8.dp))
+ }
+
+ Text(plan.name, fontSize = 20.sp, fontWeight = FontWeight.Bold)
+ Spacer(Modifier.height(8.dp))
+
+ Row(verticalAlignment = Alignment.Bottom) {
+ Text("¥", fontSize = 16.sp, fontWeight = FontWeight.Bold)
+ Text("${price.toInt()}", fontSize = 36.sp, fontWeight = FontWeight.Bold)
+ Text(priceLabel, fontSize = 14.sp, color = Color.Gray)
+ }
+
+ Spacer(Modifier.height(16.dp))
+
+ // Features
+ plan.features?.forEach { feature ->
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Check,
+ contentDescription = null,
+ tint = Color(0xFF4CAF50),
+ modifier = Modifier.size(16.dp)
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(feature, fontSize = 13.sp)
+ }
+ }
+
+ Spacer(Modifier.height(16.dp))
+
+ // Action button
+ if (isCurrentPlan) {
+ OutlinedButton(
+ onClick = {},
+ modifier = Modifier.fillMaxWidth(),
+ enabled = false
+ ) {
+ Text("当前方案")
+ }
+ } else if (isFree) {
+ // Free plan with non-free current — should not happen for upgrade
+ OutlinedButton(
+ onClick = onPurchase,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isPurchasing
+ ) {
+ Text("升级到 Pro")
+ }
+ } else {
+ Button(
+ onClick = onPurchase,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isPurchasing
+ ) {
+ if (isPurchasing) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp
+ )
+ } else {
+ Text(if (isCurrentPlan) "当前方案" else "立即订阅")
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun FeaturesComparison(plans: List) {
+ Text(
+ "权益对比",
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+ Spacer(Modifier.height(12.dp))
+
+ val compareItems = listOf(
+ "每日对话" to { p: BillingPlanDto -> if (p.dailyQuota == 0) "无限" else "${p.dailyQuota}次" },
+ "智能体数量" to { p: BillingPlanDto -> if (p.agentLimit == 0) "无限" else "${p.agentLimit}个" },
+ "知识条目" to { p: BillingPlanDto -> if (p.knowledgeLimit == 0) "无限" else "${p.knowledgeLimit}条" },
+ "文件上传" to { p: BillingPlanDto -> "${p.fileUploadMb} MB" },
+ "高级模型" to { p: BillingPlanDto ->
+ (p.models?.size?.minus(1)?.let { "+${it}" } ?: "0")
+ }
+ )
+
+ Card(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
+ shape = MaterialTheme.shapes.large
+ ) {
+ Column(Modifier.padding(16.dp)) {
+ // Header
+ Row(Modifier.fillMaxWidth()) {
+ Spacer(Modifier.weight(1.2f))
+ plans.forEach { plan ->
+ Text(
+ plan.name,
+ modifier = Modifier.weight(1f),
+ textAlign = TextAlign.Center,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+ HorizontalDivider(Modifier.padding(vertical = 8.dp))
+
+ compareItems.forEach { (label, extractor) ->
+ Row(
+ Modifier.fillMaxWidth().padding(vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(label, modifier = Modifier.weight(1.2f), fontSize = 13.sp)
+ plans.forEach { plan ->
+ Text(
+ extractor(plan),
+ modifier = Modifier.weight(1f),
+ textAlign = TextAlign.Center,
+ fontSize = 13.sp,
+ fontWeight = if (plan.tier == "pro") FontWeight.Bold else FontWeight.Normal
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/SubscriptionViewModel.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/SubscriptionViewModel.kt
new file mode 100644
index 0000000..4d9f548
--- /dev/null
+++ b/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/SubscriptionViewModel.kt
@@ -0,0 +1,124 @@
+package com.tiangong.aiagent.ui.subscription
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.tiangong.aiagent.data.remote.dto.BillingPlanDto
+import com.tiangong.aiagent.data.remote.dto.CreateOrderRequest
+import com.tiangong.aiagent.data.remote.dto.OrderResponseDto
+import com.tiangong.aiagent.data.remote.dto.UserSubscriptionDto
+import com.tiangong.aiagent.data.remote.dto.UserUsageDto
+import com.tiangong.aiagent.data.repository.BillingRepository
+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 SubscriptionUiState(
+ val plans: List = emptyList(),
+ val subscription: UserSubscriptionDto? = null,
+ val usage: UserUsageDto? = null,
+ val isLoading: Boolean = false,
+ val isPurchasing: Boolean = false,
+ val selectedPeriod: String = "monthly",
+ val error: String? = null,
+ val purchaseSuccess: String? = null,
+ val showUpgradeDialog: Boolean = false
+)
+
+@HiltViewModel
+class SubscriptionViewModel @Inject constructor(
+ private val billingRepository: BillingRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(SubscriptionUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ loadData()
+ }
+
+ fun loadData() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, error = null) }
+ // Load plans (public, no auth needed)
+ billingRepository.getPlans().fold(
+ onSuccess = { plans -> _uiState.update { it.copy(plans = plans) } },
+ onFailure = { e -> Log.e("SubscriptionVM", "Failed to load plans", e) }
+ )
+ // Load subscription
+ billingRepository.getUserSubscription().fold(
+ onSuccess = { sub -> _uiState.update { it.copy(subscription = sub) } },
+ onFailure = { e -> Log.e("SubscriptionVM", "Failed to load subscription", e) }
+ )
+ // Load usage
+ billingRepository.getUserUsage().fold(
+ onSuccess = { usage -> _uiState.update { it.copy(usage = usage) } },
+ onFailure = { e -> Log.e("SubscriptionVM", "Failed to load usage", e) }
+ )
+ _uiState.update { it.copy(isLoading = false) }
+ }
+ }
+
+ fun loadUsage() {
+ viewModelScope.launch {
+ billingRepository.getUserUsage().fold(
+ onSuccess = { usage -> _uiState.update { it.copy(usage = usage) } },
+ onFailure = { e -> Log.e("SubscriptionVM", "Failed to load usage", e) }
+ )
+ }
+ }
+
+ fun selectPeriod(period: String) {
+ _uiState.update { it.copy(selectedPeriod = period) }
+ }
+
+ fun purchase(planId: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isPurchasing = true, error = null) }
+ val period = _uiState.value.selectedPeriod
+ billingRepository.createOrder(CreateOrderRequest(planId, period, "mock"))
+ .fold(
+ onSuccess = { order ->
+ // Pay the order
+ billingRepository.payOrder(order.id).fold(
+ onSuccess = { result ->
+ _uiState.update {
+ it.copy(
+ isPurchasing = false,
+ purchaseSuccess = result.message
+ )
+ }
+ loadData()
+ },
+ onFailure = { e ->
+ _uiState.update {
+ it.copy(isPurchasing = false, error = e.message ?: "支付失败")
+ }
+ }
+ )
+ },
+ onFailure = { e ->
+ _uiState.update {
+ it.copy(isPurchasing = false, error = e.message ?: "创建订单失败")
+ }
+ }
+ )
+ }
+ }
+
+ fun clearError() {
+ _uiState.update { it.copy(error = null) }
+ }
+
+ fun clearPurchaseSuccess() {
+ _uiState.update { it.copy(purchaseSuccess = null) }
+ }
+
+ fun dismissUpgradeDialog() {
+ _uiState.update { it.copy(showUpgradeDialog = false) }
+ }
+}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/UpgradeDialog.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/UpgradeDialog.kt
new file mode 100644
index 0000000..9f83a76
--- /dev/null
+++ b/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/UpgradeDialog.kt
@@ -0,0 +1,34 @@
+package com.tiangong.aiagent.ui.subscription
+
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun UpgradeDialog(
+ title: String = "升级会员",
+ message: String = "今日免费对话次数已用完,升级专业版享受每日 500 次对话和更多高级功能。",
+ onDismiss: () -> Unit,
+ onUpgrade: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = {
+ Text(title, fontWeight = FontWeight.Bold)
+ },
+ text = {
+ Text(message, fontSize = 14.sp)
+ },
+ confirmButton = {
+ Button(onClick = onUpgrade) {
+ Text("立即升级")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("稍后再说")
+ }
+ }
+ )
+}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/UsageIndicator.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/UsageIndicator.kt
new file mode 100644
index 0000000..6f1c0ae
--- /dev/null
+++ b/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/UsageIndicator.kt
@@ -0,0 +1,100 @@
+package com.tiangong.aiagent.ui.subscription
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.WorkspacePremium
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+
+@Composable
+fun UsageIndicator(
+ onClick: () -> Unit,
+ viewModel: SubscriptionViewModel = hiltViewModel()
+) {
+ val uiState = viewModel.uiState.collectAsState()
+ val usage = uiState.value.usage
+ val dailyUsed = usage?.dailyUsed ?: 0
+ val dailyLimit = usage?.dailyLimit ?: 50
+ val dailyRemaining = usage?.dailyRemaining ?: 50
+ val isExhausted = usage?.isExhausted ?: false
+
+ // Refresh usage periodically
+ LaunchedEffect(Unit) {
+ viewModel.loadUsage()
+ }
+
+ val fraction = if (dailyLimit > 0) (dailyUsed.toFloat() / dailyLimit).coerceIn(0f, 1f) else 0f
+ val progressColor = when {
+ isExhausted -> Color(0xFFE53935)
+ fraction > 0.8f -> Color(0xFFFFA726)
+ else -> MaterialTheme.colorScheme.primary
+ }
+
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick),
+ color = if (isExhausted) Color(0xFFFFEBEE) else MaterialTheme.colorScheme.surface,
+ tonalElevation = 1.dp
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.WorkspacePremium,
+ contentDescription = null,
+ tint = if (isExhausted) Color(0xFFE53935) else MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(Modifier.width(8.dp))
+
+ Column(Modifier.weight(1f)) {
+ Text(
+ if (isExhausted) "今日次数已用完" else "今日剩余 $dailyRemaining 次",
+ fontSize = 12.sp,
+ color = if (isExhausted) Color(0xFFE53935) else MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(Modifier.height(2.dp))
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(4.dp)
+ .clip(RoundedCornerShape(2.dp))
+ .background(progressColor.copy(alpha = 0.2f))
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(fraction)
+ .height(4.dp)
+ .clip(RoundedCornerShape(2.dp))
+ .background(progressColor)
+ )
+ }
+ }
+
+ if (isExhausted) {
+ Text(
+ "升级",
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/util/AnalyticsTracker.kt b/android/app/src/main/java/com/tiangong/aiagent/util/AnalyticsTracker.kt
new file mode 100644
index 0000000..67754d2
--- /dev/null
+++ b/android/app/src/main/java/com/tiangong/aiagent/util/AnalyticsTracker.kt
@@ -0,0 +1,99 @@
+package com.tiangong.aiagent.util
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import com.tiangong.aiagent.data.remote.ApiService
+import com.tiangong.aiagent.data.remote.dto.AnalyticsEventItem
+import com.tiangong.aiagent.data.remote.dto.AnalyticsEventsRequest
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.*
+import java.time.Instant
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@HiltViewModel
+class AnalyticsViewModel @Inject constructor(
+ val tracker: AnalyticsTracker
+) : ViewModel()
+
+@Singleton
+class AnalyticsTracker @Inject constructor(
+ private val apiService: ApiService
+) {
+ companion object {
+ private const val TAG = "AnalyticsTracker"
+ private const val BATCH_SIZE = 10
+ private const val FLUSH_INTERVAL_MS = 30_000L
+ }
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val eventQueue = mutableListOf()
+ private var flushJob: Job? = null
+
+ fun trackScreen(screenName: String) {
+ enqueue(AnalyticsEventItem(
+ eventType = "screen_view",
+ screenName = screenName,
+ timestamp = Instant.now().toString()
+ ))
+ }
+
+ fun trackEvent(category: String, action: String, label: String? = null, agentId: String? = null) {
+ enqueue(AnalyticsEventItem(
+ eventType = "user_action",
+ category = category,
+ action = action,
+ label = label,
+ agentId = agentId,
+ timestamp = Instant.now().toString()
+ ))
+ }
+
+ private fun enqueue(event: AnalyticsEventItem) {
+ synchronized(eventQueue) {
+ eventQueue.add(event)
+ if (eventQueue.size >= BATCH_SIZE) {
+ flushInternal()
+ } else {
+ scheduleFlush()
+ }
+ }
+ }
+
+ private fun scheduleFlush() {
+ flushJob?.cancel()
+ flushJob = scope.launch {
+ delay(FLUSH_INTERVAL_MS)
+ synchronized(eventQueue) {
+ if (eventQueue.isNotEmpty()) {
+ flushInternal()
+ }
+ }
+ }
+ }
+
+ private fun flushInternal() {
+ if (eventQueue.isEmpty()) return
+ val batch = eventQueue.toList()
+ eventQueue.clear()
+ scope.launch {
+ try {
+ apiService.sendAnalyticsEvents(AnalyticsEventsRequest(events = batch))
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to flush analytics events: ${e.message}")
+ // Re-queue on failure (up to max)
+ synchronized(eventQueue) {
+ if (eventQueue.size < 50) {
+ eventQueue.addAll(0, batch.take(10))
+ }
+ }
+ }
+ }
+ }
+
+ fun flush() {
+ synchronized(eventQueue) {
+ flushInternal()
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/util/AppUpdateManager.kt b/android/app/src/main/java/com/tiangong/aiagent/util/AppUpdateManager.kt
index 825c16d..579c36a 100644
--- a/android/app/src/main/java/com/tiangong/aiagent/util/AppUpdateManager.kt
+++ b/android/app/src/main/java/com/tiangong/aiagent/util/AppUpdateManager.kt
@@ -1,107 +1,247 @@
package com.tiangong.aiagent.util
+import android.app.DownloadManager
+import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
+import android.content.IntentFilter
import android.net.Uri
+import android.os.Build
+import android.os.Environment
+import android.util.Log
+import androidx.core.content.FileProvider
import com.tiangong.aiagent.BuildConfig
import com.tiangong.aiagent.data.remote.ApiService
import com.tiangong.aiagent.data.remote.dto.AppVersionResponse
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
+import java.net.HttpURLConnection
+import java.net.URL
import javax.inject.Inject
import javax.inject.Singleton
+/**
+ * App update state for Compose UI observation.
+ */
+data class UpdateState(
+ val checking: Boolean = false,
+ val updateAvailable: Boolean = false,
+ val downloading: Boolean = false,
+ val downloadProgress: Int = 0, // 0-100
+ val downloadError: String? = null,
+ val downloadComplete: Boolean = false,
+ val response: AppVersionResponse? = null,
+ val forceUpdate: Boolean = false,
+ val apkFilePath: String? = null
+)
+
@Singleton
class AppUpdateManager @Inject constructor(
@ApplicationContext private val context: Context,
private val apiService: ApiService
) {
- /**
- * Check for app update asynchronously. If update is found, shows a dialog.
- * Called from MainActivity.onCreate().
- */
- fun checkForUpdate(onResult: ((AppVersionResponse) -> Unit)? = null) {
- CoroutineScope(Dispatchers.IO).launch {
+ companion object {
+ private const val TAG = "AppUpdateManager"
+ private const val APK_FILE_NAME = "tiangong_update.apk"
+ }
+
+ private val scope = CoroutineScope(Dispatchers.IO)
+ private val _updateState = MutableStateFlow(UpdateState())
+ val updateState: StateFlow = _updateState.asStateFlow()
+
+ /** Check for update from server. */
+ fun checkForUpdate(showNoUpdateToast: Boolean = false) {
+ scope.launch {
+ _updateState.value = _updateState.value.copy(checking = true)
try {
val response = apiService.checkUpdate(
platform = "android",
version = BuildConfig.VERSION_NAME
)
- withContext(Dispatchers.Main) {
- if (response.hasUpdate) {
- showUpdateDialog(response)
- }
- onResult?.invoke(response)
- }
+ val hasUpdate = response.hasUpdate &&
+ VersionUtils.compareVersion(BuildConfig.VERSION_NAME, response.latestVersion) < 0
+
+ _updateState.value = _updateState.value.copy(
+ checking = false,
+ updateAvailable = hasUpdate,
+ forceUpdate = response.forceUpdate,
+ response = response
+ )
} catch (e: Exception) {
- withContext(Dispatchers.Main) {
- onResult?.invoke(
- AppVersionResponse(
- latestVersion = "", currentVersion = BuildConfig.VERSION_NAME,
- hasUpdate = false, forceUpdate = false,
- releaseNotes = "检查失败: ${e.message}"
- )
+ Log.w(TAG, "Version check failed: ${e.message}")
+ _updateState.value = _updateState.value.copy(
+ checking = false,
+ updateAvailable = false,
+ downloadError = if (showNoUpdateToast) "版本检查失败" else null
+ )
+ }
+ }
+ }
+
+ /** Start downloading the APK in-app with progress tracking. */
+ fun startDownload() {
+ val response = _updateState.value.response ?: return
+ val url = response.updateUrl
+ if (url.isBlank()) {
+ _updateState.value = _updateState.value.copy(
+ downloadError = "下载地址无效"
+ )
+ return
+ }
+
+ _updateState.value = _updateState.value.copy(
+ downloading = true,
+ downloadProgress = 0,
+ downloadError = null,
+ downloadComplete = false
+ )
+
+ scope.launch {
+ downloadApk(url)
+ }
+ }
+
+ private suspend fun downloadApk(urlStr: String) {
+ withContext(Dispatchers.IO) {
+ var conn: HttpURLConnection? = null
+ var fos: FileOutputStream? = null
+ try {
+ val apkFile = getApkFile()
+ // Ensure parent directory exists
+ apkFile.parentFile?.mkdirs()
+
+ val url = URL(urlStr)
+ conn = (url.openConnection() as HttpURLConnection).apply {
+ connectTimeout = 15_000
+ readTimeout = 60_000
+ setRequestProperty("Accept-Encoding", "identity")
+ connect()
+ }
+
+ if (conn.responseCode != HttpURLConnection.HTTP_OK) {
+ _updateState.value = _updateState.value.copy(
+ downloading = false,
+ downloadError = "服务器错误: ${conn.responseCode}"
)
+ return@withContext
+ }
+
+ val totalSize = conn.contentLength.toLong()
+ val input = conn.inputStream
+ fos = FileOutputStream(apkFile)
+
+ val buffer = ByteArray(8192)
+ var downloaded = 0L
+ var lastProgress = -1
+
+ while (true) {
+ val read = input.read(buffer)
+ if (read == -1) break
+ fos.write(buffer, 0, read)
+ downloaded += read
+
+ if (totalSize > 0) {
+ val progress = ((downloaded * 100) / totalSize).toInt()
+ if (progress != lastProgress) {
+ lastProgress = progress
+ _updateState.value = _updateState.value.copy(downloadProgress = progress)
+ }
+ }
+ }
+
+ _updateState.value = _updateState.value.copy(
+ downloading = false,
+ downloadProgress = 100,
+ downloadComplete = true,
+ apkFilePath = apkFile.absolutePath
+ )
+
+ Log.d(TAG, "APK download complete: ${apkFile.absolutePath} (${apkFile.length()} bytes)")
+ } catch (e: Exception) {
+ Log.e(TAG, "APK download failed", e)
+ _updateState.value = _updateState.value.copy(
+ downloading = false,
+ downloadError = when {
+ e.message?.contains("timeout", ignoreCase = true) == true -> "下载超时,请检查网络"
+ e.message?.contains("Network", ignoreCase = true) == true -> "网络连接失败"
+ else -> "下载失败: ${e.message}"
+ }
+ )
+ } finally {
+ try { fos?.close() } catch (_: Exception) {}
+ try { conn?.disconnect() } catch (_: Exception) {}
+ }
+ }
+ }
+
+ /** Install the downloaded APK via FileProvider. */
+ fun installApk() {
+ val path = _updateState.value.apkFilePath
+ if (path == null) {
+ _updateState.value = _updateState.value.copy(downloadError = "安装包不存在")
+ return
+ }
+ val apkFile = File(path)
+ if (!apkFile.exists()) {
+ _updateState.value = _updateState.value.copy(downloadError = "安装包文件不存在")
+ return
+ }
+
+ try {
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ val authority = "${context.packageName}.fileprovider"
+ val apkUri = FileProvider.getUriForFile(context, authority, apkFile)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ setDataAndType(apkUri, "application/vnd.android.package-archive")
+ } else {
+ @Suppress("DEPRECATION")
+ setDataAndType(Uri.fromFile(apkFile), "application/vnd.android.package-archive")
}
}
- }
- }
-
- private fun showUpdateDialog(response: AppVersionResponse) {
- try {
- val builder = if (response.forceUpdate) {
- android.app.AlertDialog.Builder(context)
- .setTitle("发现新版本 ${response.latestVersion}")
- .setMessage(buildDialogMessage(response))
- .setCancelable(false)
- .setPositiveButton("立即更新") { _, _ ->
- openDownloadUrl(response.updateUrl)
- }
- } else {
- android.app.AlertDialog.Builder(context)
- .setTitle("发现新版本 ${response.latestVersion}")
- .setMessage(buildDialogMessage(response))
- .setCancelable(true)
- .setPositiveButton("立即更新") { _, _ ->
- openDownloadUrl(response.updateUrl)
- }
- .setNegativeButton("稍后", null)
- }
-
- // Show dialog from a new activity context is safer but we use application context
- // wrapped in a theme dialog
- val dialog = builder.create()
- // Ensure dialog is shown in an activity context if possible
- dialog.show()
- } catch (_: Exception) {
- // Fallback: open browser
- try {
- openDownloadUrl(response.updateUrl)
- } catch (_: Exception) { }
- }
- }
-
- private fun buildDialogMessage(response: AppVersionResponse): String {
- val sb = StringBuilder()
- sb.append("当前版本: ${BuildConfig.VERSION_NAME}\n")
- sb.append("最新版本: ${response.latestVersion}\n")
- if (response.releaseNotes.isNotBlank()) {
- sb.append("\n更新内容:\n${response.releaseNotes}")
- }
- if (response.forceUpdate) {
- sb.append("\n\n此版本为强制更新,请立即升级。")
- }
- return sb.toString()
- }
-
- private fun openDownloadUrl(url: String) {
- try {
- val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
- intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
- } catch (_: Exception) { }
+ } catch (e: Exception) {
+ Log.e(TAG, "Install failed", e)
+ _updateState.value = _updateState.value.copy(
+ downloadError = "安装失败: ${e.message}"
+ )
+ }
+ }
+
+ /** Open download URL in browser (fallback). */
+ fun openInBrowser() {
+ val url = _updateState.value.response?.updateUrl ?: return
+ try {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ } catch (_: Exception) {}
+ }
+
+ /** Reset update state. */
+ fun reset() {
+ _updateState.value = UpdateState()
+ }
+
+ /** Get the target APK file path. */
+ fun getApkFile(): File {
+ val dir = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
+ ?: context.filesDir
+ } else {
+ @Suppress("DEPRECATION")
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
+ }
+ return File(dir, APK_FILE_NAME)
}
}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/util/CrashHandler.kt b/android/app/src/main/java/com/tiangong/aiagent/util/CrashHandler.kt
new file mode 100644
index 0000000..88a7dfd
--- /dev/null
+++ b/android/app/src/main/java/com/tiangong/aiagent/util/CrashHandler.kt
@@ -0,0 +1,90 @@
+package com.tiangong.aiagent.util
+
+import android.os.Environment
+import android.util.Log
+import java.io.File
+import java.io.PrintWriter
+import java.io.StringWriter
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+/**
+ * Crash log handler that writes uncaught exceptions to file before delegating
+ * to the previous handler (e.g. Bugly).
+ *
+ * Usage: CrashHandler.init() after Bugly.initCrashReport() in TiangongApp.onCreate()
+ */
+object CrashHandler : Thread.UncaughtExceptionHandler {
+
+ private const val TAG = "CrashHandler"
+ private const val MAX_LOG_FILES = 5
+
+ private var previousHandler: Thread.UncaughtExceptionHandler? = null
+ private var crashDir: File? = null
+
+ fun init() {
+ if (previousHandler != null) return // already initialized
+
+ previousHandler = Thread.getDefaultUncaughtExceptionHandler()
+
+ // Don't chain ourselves if we're already set (unlikely but defensive)
+ if (previousHandler is CrashHandler) return
+
+ Thread.setDefaultUncaughtExceptionHandler(this)
+ }
+
+ fun setCrashDir(dir: File) {
+ crashDir = dir
+ }
+
+ override fun uncaughtException(thread: Thread, throwable: Throwable) {
+ try {
+ writeCrashLog(throwable)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to write crash log", e)
+ }
+
+ // Delegate to previous handler (Bugly or system default)
+ previousHandler?.uncaughtException(thread, throwable)
+ }
+
+ private fun writeCrashLog(throwable: Throwable) {
+ val dir = crashDir ?: run {
+ val extDir = Environment.getExternalStorageDirectory()
+ ?: return
+ File(extDir, "Tiangong/crashes").also {
+ it.mkdirs()
+ }
+ }
+
+ if (!dir.exists()) dir.mkdirs()
+
+ // Rotate log files (keep MAX_LOG_FILES)
+ val logFiles = dir.listFiles { f -> f.extension == "log" }
+ ?.sortedByDescending { it.lastModified() } ?: emptyList()
+ logFiles.drop(MAX_LOG_FILES - 1).forEach { it.delete() }
+
+ val dateStr = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
+ val crashFile = File(dir, "crash_$dateStr.log")
+
+ val sw = StringWriter()
+ val pw = PrintWriter(sw)
+ pw.println("Crash Time: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(Date())}")
+ pw.println()
+ throwable.printStackTrace(pw)
+
+ // Also include cause chain
+ var cause = throwable.cause
+ while (cause != null) {
+ pw.println()
+ pw.println("Caused by:")
+ cause.printStackTrace(pw)
+ cause = cause.cause
+ }
+
+ pw.flush()
+ crashFile.writeText(sw.toString())
+ Log.i(TAG, "Crash log written to ${crashFile.absolutePath}")
+ }
+}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/util/UmengHelper.kt b/android/app/src/main/java/com/tiangong/aiagent/util/UmengHelper.kt
new file mode 100644
index 0000000..09c87f7
--- /dev/null
+++ b/android/app/src/main/java/com/tiangong/aiagent/util/UmengHelper.kt
@@ -0,0 +1,179 @@
+package com.tiangong.aiagent.util
+
+import android.content.Context
+import android.util.Log
+import com.umeng.analytics.MobclickAgent
+import com.umeng.commonsdk.UMConfigure
+import com.tiangong.aiagent.BuildConfig
+
+/**
+ * Umeng (友盟) statistics helper.
+ * Ported from zhini_im project's UmengAnalytics.java + UmengDebugHelper.java.
+ *
+ * Provides page tracking, custom event tracking, user profile tracking,
+ * and debug utilities for verifying SDK integration.
+ */
+object UmengHelper {
+
+ private const val TAG = "UmengHelper"
+ private const val UMENG_APP_KEY = "69009244644c9e2c2067d39b"
+ private const val UMENG_CHANNEL = "default"
+
+ @Volatile
+ private var initialized = false
+
+ /** Initialize Umeng SDK. Call from Application.onCreate() BEFORE UI loads. */
+ fun init(context: Context) {
+ if (initialized) return
+
+ try {
+ // Pre-init: set log level, session timeout, etc.
+ UMConfigure.setLogEnabled(BuildConfig.DEBUG)
+ UMConfigure.preInit(context, UMENG_APP_KEY, UMENG_CHANNEL)
+
+ // Main init: encrypt enabled, analytics + push configs
+ UMConfigure.init(
+ context,
+ UMENG_APP_KEY,
+ UMENG_CHANNEL,
+ UMConfigure.DEVICE_TYPE_PHONE,
+ null // push secret (not used for analytics-only integration)
+ )
+
+ // Enable auto page tracking (Activity lifecycle callbacks)
+ MobclickAgent.setPageCollectionMode(MobclickAgent.PageMode.AUTO)
+
+ // Session timeout: 30 seconds (default)
+ MobclickAgent.setSessionContinueMillis(30_000)
+
+ initialized = true
+ Log.d(TAG, "Umeng SDK initialized successfully")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to initialize Umeng SDK", e)
+ }
+ }
+
+ fun isInitialized(): Boolean = initialized
+
+ // ─── Page Tracking ──────────────────────────────────────────────
+
+ fun onPageStart(pageName: String) {
+ try {
+ if (initialized) {
+ MobclickAgent.onPageStart(pageName)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "onPageStart failed: $pageName", e)
+ }
+ }
+
+ fun onPageEnd(pageName: String) {
+ try {
+ if (initialized) {
+ MobclickAgent.onPageEnd(pageName)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "onPageEnd failed: $pageName", e)
+ }
+ }
+
+ // ─── Event Tracking ─────────────────────────────────────────────
+
+ /** Simple event without parameters. */
+ fun onEvent(context: Context, eventId: String) {
+ try {
+ if (initialized) {
+ MobclickAgent.onEvent(context, eventId)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "onEvent failed: $eventId", e)
+ }
+ }
+
+ /** Event with a label. */
+ fun onEvent(context: Context, eventId: String, label: String) {
+ try {
+ if (initialized) {
+ MobclickAgent.onEvent(context, eventId, label)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "onEvent failed: $eventId", e)
+ }
+ }
+
+ /** Event with custom key-value parameters. */
+ fun onEvent(context: Context, eventId: String, params: Map) {
+ try {
+ if (initialized) {
+ MobclickAgent.onEvent(context, eventId, params)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "onEvent failed: $eventId", e)
+ }
+ }
+
+ // ─── User Profile ───────────────────────────────────────────────
+
+ /** Sign in user for analytics (call after login). */
+ fun onProfileSignIn(userId: String) {
+ try {
+ if (initialized) {
+ MobclickAgent.onProfileSignIn(userId)
+ Log.d(TAG, "User signed in: $userId")
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "onProfileSignIn failed", e)
+ }
+ }
+
+ /** Sign out user (call on logout). */
+ fun onProfileSignOff() {
+ try {
+ if (initialized) {
+ MobclickAgent.onProfileSignOff()
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "onProfileSignOff failed", e)
+ }
+ }
+
+ // ─── Debug Utilities ────────────────────────────────────────────
+
+ /** Send test events to verify Umeng integration. */
+ fun sendTestEvents(context: Context) {
+ if (!initialized) {
+ Log.w(TAG, "SDK not initialized, skipping test events")
+ return
+ }
+ Log.d(TAG, "Sending Umeng test events...")
+ onEvent(context, "umeng_debug_test", "environment_check")
+ onEvent(context, "umeng_custom_debug", mapOf(
+ "test_type" to "debug",
+ "timestamp" to System.currentTimeMillis().toString(),
+ "device_model" to android.os.Build.MODEL,
+ "android_version" to android.os.Build.VERSION.RELEASE
+ ))
+ Log.d(TAG, "Test events sent")
+ }
+
+ /** Force flush cached analytics data to server. */
+ fun forceFlush(context: Context) {
+ try {
+ if (initialized) {
+ MobclickAgent.onKillProcess(context)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "forceFlush failed", e)
+ }
+ }
+
+ fun getDebugInfo(): String {
+ return buildString {
+ appendLine("=== Umeng Analytics Debug ===")
+ appendLine("SDK initialized: $initialized")
+ appendLine("Device: ${android.os.Build.MODEL}")
+ appendLine("Android: ${android.os.Build.VERSION.RELEASE}")
+ appendLine("SDK version: 9.8.8")
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/tiangong/aiagent/util/VersionUtils.kt b/android/app/src/main/java/com/tiangong/aiagent/util/VersionUtils.kt
new file mode 100644
index 0000000..91c29af
--- /dev/null
+++ b/android/app/src/main/java/com/tiangong/aiagent/util/VersionUtils.kt
@@ -0,0 +1,67 @@
+package com.tiangong.aiagent.util
+
+import android.content.Context
+import android.content.pm.PackageManager
+
+/**
+ * Version comparison and info utilities.
+ * Ported from zhini_im project's VersionUtils.java.
+ */
+object VersionUtils {
+
+ /**
+ * Compare two semantic version strings.
+ * @return -1 if v1 < v2, 0 if equal, 1 if v1 > v2
+ */
+ fun compareVersion(v1: String, v2: String): Int {
+ if (v1 == v2) return 0
+ val parts1 = v1.split(".").map { it.toIntOrNull() ?: 0 }
+ val parts2 = v2.split(".").map { it.toIntOrNull() ?: 0 }
+ val minLen = minOf(parts1.size, parts2.size)
+ for (i in 0 until minLen) {
+ val diff = parts1[i] - parts2[i]
+ if (diff != 0) return if (diff > 0) 1 else -1
+ }
+ // If common prefix matches, longer version with non-zero parts is newer
+ for (i in minLen until parts1.size) {
+ if (parts1[i] > 0) return 1
+ }
+ for (i in minLen until parts2.size) {
+ if (parts2[i] > 0) return -1
+ }
+ return 0
+ }
+
+ fun getVersionName(context: Context): String {
+ return try {
+ val info = context.packageManager.getPackageInfo(context.packageName, 0)
+ info.versionName ?: "1.0.0"
+ } catch (_: PackageManager.NameNotFoundException) {
+ "1.0.0"
+ }
+ }
+
+ fun getVersionCode(context: Context): Long {
+ return try {
+ val info = context.packageManager.getPackageInfo(context.packageName, 0)
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
+ info.longVersionCode
+ } else {
+ @Suppress("DEPRECATION")
+ info.versionCode.toLong()
+ }
+ } catch (_: PackageManager.NameNotFoundException) {
+ 1L
+ }
+ }
+
+ fun getAppName(context: Context): String {
+ return try {
+ val pm = context.packageManager
+ val info = pm.getApplicationInfo(context.packageName, 0)
+ pm.getApplicationLabel(info).toString()
+ } catch (_: PackageManager.NameNotFoundException) {
+ "天工智能体"
+ }
+ }
+}
diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..32e28fc
--- /dev/null
+++ b/android/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index a527516..04416d4 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -87,6 +87,12 @@ markwon-core = { group = "io.noties.markwon", name = "core", version.ref = "mark
markwon-image = { group = "io.noties.markwon", name = "image-coil", version.ref = "markwon" }
markwon-linkify = { group = "io.noties.markwon", name = "linkify", version.ref = "markwon" }
+# Umeng Analytics (from zhini_im)
+umeng-common = { group = "com.umeng.umsdk", name = "common", version = "9.8.8" }
+umeng-asms = { group = "com.umeng.umsdk", name = "asms", version = "1.8.7" }
+umeng-uyumao = { group = "com.umeng.umsdk", name = "uyumao", version = "1.1.4" }
+umeng-abtest = { group = "com.umeng.umsdk", name = "abtest", version = "1.0.1" }
+
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
diff --git a/backend/alembic/versions/025_billing.py b/backend/alembic/versions/025_billing.py
new file mode 100644
index 0000000..990e808
--- /dev/null
+++ b/backend/alembic/versions/025_billing.py
@@ -0,0 +1,98 @@
+"""Add billing tables and user subscription fields
+
+Revision ID: 025_billing
+Revises: 104e05fc9cf2
+Create Date: 2026-07-03
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects.mysql import CHAR
+
+revision = '025_billing'
+down_revision = '104e05fc9cf2'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # billing_plans
+ op.create_table(
+ 'billing_plans',
+ sa.Column('id', CHAR(36), primary_key=True, comment='套餐ID'),
+ sa.Column('name', sa.String(100), nullable=False, comment='套餐名称'),
+ sa.Column('tier', sa.String(20), nullable=False, comment='套餐等级: free/pro/enterprise'),
+ sa.Column('price_monthly', sa.Float, nullable=False, server_default='0', comment='月付价格(CNY)'),
+ sa.Column('price_yearly', sa.Float, nullable=False, server_default='0', comment='年付价格(CNY)'),
+ sa.Column('daily_quota', sa.Integer, nullable=False, server_default='0', comment='每日对话次数限制'),
+ sa.Column('agent_limit', sa.Integer, nullable=False, server_default='0', comment='智能体数量限制'),
+ sa.Column('knowledge_limit', sa.Integer, nullable=False, server_default='0', comment='知识条目限制'),
+ sa.Column('file_upload_mb', sa.Integer, nullable=False, server_default='5', comment='文件上传大小限制(MB)'),
+ sa.Column('models', sa.JSON, nullable=True, comment='可用模型列表'),
+ sa.Column('features', sa.JSON, nullable=True, comment='权益特性列表'),
+ sa.Column('is_active', sa.Boolean, server_default='1', comment='是否启用'),
+ sa.Column('sort_order', sa.Integer, server_default='0', comment='排序'),
+ sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), comment='创建时间'),
+ sa.Column('updated_at', sa.DateTime, server_default=sa.func.now(), comment='更新时间'),
+ )
+
+ # billing_orders
+ op.create_table(
+ 'billing_orders',
+ sa.Column('id', CHAR(36), primary_key=True, comment='订单ID'),
+ sa.Column('user_id', CHAR(36), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, comment='用户ID'),
+ sa.Column('plan_id', CHAR(36), sa.ForeignKey('billing_plans.id', ondelete='SET NULL'), nullable=True, comment='套餐ID'),
+ sa.Column('amount', sa.Float, nullable=False, comment='支付金额(CNY)'),
+ sa.Column('period', sa.String(10), nullable=False, server_default="'monthly'", comment='周期: monthly/yearly'),
+ sa.Column('status', sa.String(20), nullable=False, server_default="'pending'", comment='状态: pending/paid/cancelled/expired'),
+ sa.Column('payment_method', sa.String(20), nullable=True, comment='支付方式: mock/wechat/alipay'),
+ sa.Column('payment_ref', sa.String(255), nullable=True, comment='第三方支付流水号'),
+ sa.Column('paid_at', sa.DateTime, nullable=True, comment='支付时间'),
+ sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), comment='创建时间'),
+ sa.Column('updated_at', sa.DateTime, server_default=sa.func.now(), comment='更新时间'),
+ )
+ op.create_index('ix_billing_orders_user_id', 'billing_orders', ['user_id'])
+
+ # user_subscriptions
+ op.create_table(
+ 'user_subscriptions',
+ sa.Column('id', CHAR(36), primary_key=True, comment='订阅ID'),
+ sa.Column('user_id', CHAR(36), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, comment='用户ID'),
+ sa.Column('plan_id', CHAR(36), sa.ForeignKey('billing_plans.id', ondelete='SET NULL'), nullable=True, comment='当前套餐ID'),
+ sa.Column('tier', sa.String(20), nullable=False, server_default="'free'", comment='当前等级'),
+ sa.Column('status', sa.String(20), nullable=False, server_default="'active'", comment='状态: active/cancelled/expired'),
+ sa.Column('started_at', sa.DateTime, nullable=True, comment='订阅开始时间'),
+ sa.Column('expires_at', sa.DateTime, nullable=True, comment='订阅到期时间'),
+ sa.Column('auto_renew', sa.Boolean, server_default='0', comment='是否自动续费'),
+ sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), comment='创建时间'),
+ sa.Column('updated_at', sa.DateTime, server_default=sa.func.now(), comment='更新时间'),
+ )
+ op.create_index('ix_user_subscriptions_user_id', 'user_subscriptions', ['user_id'], unique=True)
+
+ # usage_records
+ op.create_table(
+ 'usage_records',
+ sa.Column('id', CHAR(36), primary_key=True, comment='记录ID'),
+ sa.Column('user_id', CHAR(36), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, comment='用户ID'),
+ sa.Column('date', sa.Date, nullable=False, comment='日期'),
+ sa.Column('count', sa.Integer, nullable=False, server_default='0', comment='当日对话次数'),
+ sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), comment='创建时间'),
+ )
+ op.create_index('ix_usage_user_date', 'usage_records', ['user_id', 'date'], unique=True)
+
+ # Add columns to users
+ op.add_column('users', sa.Column('subscription_tier', sa.String(20), server_default="'free'", comment='订阅等级'))
+ op.add_column('users', sa.Column('subscription_expires_at', sa.DateTime, nullable=True, comment='订阅到期时间'))
+ op.add_column('users', sa.Column('daily_usage_count', sa.Integer, server_default='0', comment='今日对话次数'))
+ op.add_column('users', sa.Column('daily_usage_date', sa.Date, nullable=True, comment='用量统计日期'))
+
+
+def downgrade() -> None:
+ op.drop_column('users', 'daily_usage_date')
+ op.drop_column('users', 'daily_usage_count')
+ op.drop_column('users', 'subscription_expires_at')
+ op.drop_column('users', 'subscription_tier')
+ op.drop_table('usage_records')
+ op.drop_table('user_subscriptions')
+ op.drop_index('ix_billing_orders_user_id', table_name='billing_orders')
+ op.drop_table('billing_orders')
+ op.drop_table('billing_plans')
diff --git a/backend/app/api/agent_chat.py b/backend/app/api/agent_chat.py
index 3c576e0..747fc12 100644
--- a/backend/app/api/agent_chat.py
+++ b/backend/app/api/agent_chat.py
@@ -10,13 +10,14 @@ from __future__ import annotations
import logging
import json
from typing import Any, AsyncGenerator, Dict, List, Optional
-from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi import APIRouter, Depends, HTTPException, Request, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.core.database import get_db
from sqlalchemy.orm import Session
from app.api.auth import get_current_user
+from app.api.deps import get_current_workspace_id, get_workspace_membership, WorkspaceContext
from app.models.user import User
from app.models.agent import Agent
from app.models.agent_llm_log import AgentLLMLog
@@ -43,6 +44,7 @@ def _make_llm_logger(
db: Session,
agent_id: Optional[str] = None,
user_id: Optional[str] = None,
+ workspace_id: Optional[str] = None,
):
"""创建 LLM 调用日志回调,写入 AgentLLMLog 表。"""
def _log(metrics: dict):
@@ -51,6 +53,7 @@ def _make_llm_logger(
agent_id=agent_id,
session_id=metrics.get("session_id"),
user_id=user_id,
+ workspace_id=workspace_id,
model=metrics.get("model", ""),
provider=metrics.get("provider"),
prompt_tokens=metrics.get("prompt_tokens", 0),
@@ -74,6 +77,7 @@ def _make_message_saver(
db: Session,
agent_id: Optional[str] = None,
user_id: Optional[str] = None,
+ workspace_id: Optional[str] = None,
):
"""创建消息持久化回调,将每条消息写入 chat_messages 表。"""
def _save(msg: dict):
@@ -82,6 +86,7 @@ def _make_message_saver(
session_id=msg.get("session_id"),
agent_id=agent_id,
user_id=user_id,
+ workspace_id=workspace_id,
role=msg.get("role", "user"),
content=msg.get("content"),
tool_name=msg.get("tool_name"),
@@ -90,6 +95,29 @@ def _make_message_saver(
iteration=msg.get("iteration", 0),
)
db.add(record)
+
+ # 自动创建 / 更新 AgentSession 记录
+ session_id = msg.get("session_id")
+ if session_id and agent_id:
+ from app.models.agent_session import AgentSession
+ existing = db.query(AgentSession).filter(
+ AgentSession.id == session_id
+ ).first()
+ if not existing:
+ title = None
+ if msg.get("role") == "user" and msg.get("content"):
+ title = msg["content"][:100]
+ session = AgentSession(
+ id=session_id,
+ user_id=user_id,
+ agent_id=agent_id,
+ workspace_id=workspace_id,
+ title=title,
+ )
+ db.add(session)
+ elif existing.title is None and msg.get("role") == "user" and msg.get("content"):
+ existing.title = msg["content"][:100]
+
db.commit()
except Exception as e:
logger.warning("写入 ChatMessage 失败: %s", e)
@@ -155,6 +183,7 @@ class SessionItem(BaseModel):
title: Optional[str] = None
last_message: Optional[str] = None
message_count: int = 0
+ is_pinned: bool = False
created_at: Optional[str] = None
updated_at: Optional[str] = None
@@ -164,6 +193,23 @@ class SessionListResponse(BaseModel):
sessions: List[SessionItem]
+class SearchMessageItem(BaseModel):
+ """跨会话搜索消息条目"""
+ id: str
+ session_id: str
+ agent_id: Optional[str] = None
+ role: str
+ content: Optional[str] = None
+ session_title: Optional[str] = None
+ created_at: Optional[str] = None
+
+
+class SearchMessagesResponse(BaseModel):
+ """消息搜索响应"""
+ messages: List[SearchMessageItem]
+ total: int
+
+
class OrchestrateAgentItem(BaseModel):
"""编排中单个 Agent 的定义"""
id: str
@@ -208,6 +254,7 @@ class OrchestrateResponse(BaseModel):
async def orchestrate_agents(
req: OrchestrateRequest,
current_user: User = Depends(get_current_user),
+ workspace_id: str = Depends(get_current_workspace_id),
db: Session = Depends(get_db),
):
"""多 Agent 编排:支持 route / sequential / debate 三种模式。"""
@@ -225,7 +272,7 @@ async def orchestrate_agents(
for a in req.agents
]
- on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id)
+ on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id)
orchestrator = AgentOrchestrator(
default_llm_config=AgentLLMConfig(
model=req.model or "deepseek-v4-flash",
@@ -265,10 +312,11 @@ class GraphOrchestrateRequest(BaseModel):
async def orchestrate_graph(
req: GraphOrchestrateRequest,
current_user: User = Depends(get_current_user),
+ workspace_id: str = Depends(get_current_workspace_id),
db: Session = Depends(get_db),
):
"""图编排模式:按 DAG 拓扑顺序执行 Agent 和条件节点。"""
- on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id)
+ on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id)
orchestrator = AgentOrchestrator(
default_llm_config=AgentLLMConfig(
model=req.model or "deepseek-v4-flash",
@@ -301,6 +349,7 @@ async def orchestrate_graph(
async def chat_bare(
req: ChatRequest,
current_user: User = Depends(get_current_user),
+ workspace_id: str = Depends(get_current_workspace_id),
db: Session = Depends(get_db),
):
"""无需 Agent 配置,使用默认设置直接对话。"""
@@ -334,8 +383,8 @@ async def chat_bare(
config.prompt_sections.enabled = False
if req.system_prompt_override:
config.system_prompt = req.system_prompt_override
- on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id)
- on_message = _make_message_saver(db, agent_id=None, user_id=current_user.id)
+ on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id)
+ on_message = _make_message_saver(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id)
context = AgentContext(session_id=req.session_id)
runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
result = await runtime.run(req.message)
@@ -366,6 +415,7 @@ async def chat_bare(
async def chat_bare_stream(
req: ChatRequest,
current_user: User = Depends(get_current_user),
+ workspace_id: str = Depends(get_current_workspace_id),
db: Session = Depends(get_db),
):
"""无需 Agent 配置,使用默认设置直接对话(流式 SSE)。"""
@@ -399,8 +449,8 @@ async def chat_bare_stream(
config.prompt_sections.enabled = False
if req.system_prompt_override:
config.system_prompt = req.system_prompt_override
- on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id)
- on_message = _make_message_saver(db, agent_id=None, user_id=current_user.id)
+ on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id)
+ on_message = _make_message_saver(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id)
context = AgentContext(session_id=req.session_id)
runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
return StreamingResponse(
@@ -419,6 +469,7 @@ async def chat_with_agent(
agent_id: str,
req: ChatRequest,
current_user: User = Depends(get_current_user),
+ workspace_id: str = Depends(get_current_workspace_id),
db: Session = Depends(get_db),
):
"""与指定的 Agent 对话。Agent 的工作流配置会用于构建 Runtime。"""
@@ -485,8 +536,8 @@ async def chat_with_agent(
if req.system_prompt_override:
config.system_prompt = req.system_prompt_override
- on_llm_call = _make_llm_logger(db, agent_id=agent_id, user_id=current_user.id)
- on_message = _make_message_saver(db, agent_id=agent_id, user_id=current_user.id)
+ on_llm_call = _make_llm_logger(db, agent_id=agent_id, user_id=current_user.id, workspace_id=workspace_id)
+ on_message = _make_message_saver(db, agent_id=agent_id, user_id=current_user.id, workspace_id=workspace_id)
context = AgentContext(session_id=req.session_id)
runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
result = await runtime.run(req.message)
@@ -519,6 +570,7 @@ async def chat_with_agent_stream(
agent_id: str,
req: ChatRequest,
current_user: User = Depends(get_current_user),
+ workspace_id: str = Depends(get_current_workspace_id),
db: Session = Depends(get_db),
):
"""与指定的 Agent 对话(流式 SSE)。"""
@@ -581,8 +633,8 @@ async def chat_with_agent_stream(
if req.system_prompt_override:
config.system_prompt = req.system_prompt_override
- on_llm_call = _make_llm_logger(db, agent_id=agent_id, user_id=current_user.id)
- on_message = _make_message_saver(db, agent_id=agent_id, user_id=current_user.id)
+ on_llm_call = _make_llm_logger(db, agent_id=agent_id, user_id=current_user.id, workspace_id=workspace_id)
+ on_message = _make_message_saver(db, agent_id=agent_id, user_id=current_user.id, workspace_id=workspace_id)
context = AgentContext(session_id=req.session_id)
runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
return StreamingResponse(
@@ -600,18 +652,20 @@ async def chat_with_agent_stream(
async def list_agent_sessions(
agent_id: str,
limit: int = 50,
+ search: Optional[str] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
- """获取 Agent 的会话列表,按最近活跃时间排序。"""
+ """获取 Agent 的会话列表,置顶优先,再按最近活跃时间排序。支持 search 参数按标题/内容搜索。"""
from sqlalchemy import func as sa_func, desc, or_
+ from app.models.agent_session import AgentSession
# 验证 agent 存在或有权限
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
- rows = (
+ rows_query = (
db.query(
ChatMessage.session_id,
sa_func.min(ChatMessage.created_at).label("created_at"),
@@ -620,23 +674,65 @@ async def list_agent_sessions(
)
.filter(ChatMessage.agent_id == agent_id)
.group_by(ChatMessage.session_id)
- .order_by(desc("updated_at"))
- .limit(limit)
- .all()
)
+ # 搜索过滤:按会话标题(AgentSession.title)或消息内容
+ if search and search.strip():
+ pattern = f"%{search.strip()}%"
+ # 子查询:在标题或消息内容中匹配的 session_id
+ matching_session_ids = set()
+
+ # 匹配 AgentSession.title
+ title_matches = db.query(AgentSession.id).filter(
+ AgentSession.title.like(pattern)
+ ).all()
+ matching_session_ids.update(r[0] for r in title_matches)
+
+ # 匹配消息内容
+ content_matches = db.query(ChatMessage.session_id).filter(
+ ChatMessage.agent_id == agent_id,
+ ChatMessage.content.like(pattern),
+ ).distinct().all()
+ matching_session_ids.update(r[0] for r in content_matches)
+
+ if matching_session_ids:
+ rows_query = rows_query.filter(
+ ChatMessage.session_id.in_(list(matching_session_ids))
+ )
+ else:
+ # 无匹配,返回空
+ return SessionListResponse(sessions=[])
+
+ rows = rows_query.order_by(desc("updated_at")).limit(limit).all()
+
+ # 批量查询 AgentSession 记录
+ session_ids = [row.session_id for row in rows]
+ agent_sessions_map = {}
+ if session_ids:
+ records = db.query(AgentSession).filter(
+ AgentSession.id.in_(session_ids)
+ ).all()
+ agent_sessions_map = {s.id: s for s in records}
+
sessions = []
for row in rows:
- # 取第一条 user 消息作为标题
- first_user_msg = (
- db.query(ChatMessage)
- .filter(
- ChatMessage.session_id == row.session_id,
- ChatMessage.role == "user",
+ asession = agent_sessions_map.get(row.session_id)
+
+ # 标题优先级: AgentSession.title > 第一条 user 消息
+ title = asession.title if asession and asession.title else None
+ if not title:
+ first_user_msg = (
+ db.query(ChatMessage)
+ .filter(
+ ChatMessage.session_id == row.session_id,
+ ChatMessage.role == "user",
+ )
+ .order_by(ChatMessage.created_at.asc())
+ .first()
)
- .order_by(ChatMessage.created_at.asc())
- .first()
- )
+ if first_user_msg and first_user_msg.content:
+ title = first_user_msg.content[:100]
+
# 取最后一条消息作为预览
last_msg = (
db.query(ChatMessage)
@@ -646,13 +742,21 @@ async def list_agent_sessions(
)
sessions.append(SessionItem(
session_id=row.session_id,
- title=first_user_msg.content[:100] if first_user_msg and first_user_msg.content else None,
+ title=title,
last_message=last_msg.content[:200] if last_msg and last_msg.content else None,
message_count=row.message_count,
+ is_pinned=asession.is_pinned if asession else False,
created_at=row.created_at.isoformat() if row.created_at else None,
updated_at=row.updated_at.isoformat() if row.updated_at else None,
))
+ # 排序:置顶优先,再按更新时间降序
+ pinned = [s for s in sessions if s.is_pinned]
+ unpinned = [s for s in sessions if not s.is_pinned]
+ pinned.sort(key=lambda s: s.updated_at or "", reverse=True)
+ unpinned.sort(key=lambda s: s.updated_at or "", reverse=True)
+ sessions = pinned + unpinned
+
return SessionListResponse(sessions=sessions)
@@ -662,10 +766,11 @@ async def get_session_messages(
session_id: str,
before_id: Optional[str] = None,
limit: int = 50,
+ search: Optional[str] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
- """获取会话的消息历史(分页),从旧到新排序。"""
+ """获取会话的消息历史(分页),从旧到新排序。支持 search 参数按内容搜索。"""
from sqlalchemy import or_
# limit 限制
@@ -676,6 +781,11 @@ async def get_session_messages(
ChatMessage.session_id == session_id,
)
+ # 消息内容搜索
+ if search and search.strip():
+ pattern = f"%{search.strip()}%"
+ base_q = base_q.filter(ChatMessage.content.like(pattern))
+
# 游标分页:before_id 之前的老消息(使用复合键避免 created_at 相同导致遗漏)
if before_id:
cursor_msg = db.query(ChatMessage).filter(ChatMessage.id == before_id).first()
@@ -725,6 +835,72 @@ async def get_session_messages(
return MessageHistoryResponse(messages=messages, has_more=has_more, total=total)
+@router.get("/{agent_id}/search-messages", response_model=SearchMessagesResponse)
+async def search_messages(
+ agent_id: str,
+ q: str = Query(..., min_length=1, description="搜索关键词"),
+ limit: int = Query(50, ge=1, le=100),
+ skip: int = Query(0, ge=0),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ """跨会话搜索消息内容,返回匹配的消息及所属会话信息。"""
+ from app.models.agent_session import AgentSession
+
+ # 验证 agent 存在
+ agent = db.query(Agent).filter(Agent.id == agent_id).first()
+ if not agent:
+ raise HTTPException(status_code=404, detail="Agent 不存在")
+
+ pattern = f"%{q.strip()}%"
+
+ # 搜索匹配的消息
+ total = (
+ db.query(ChatMessage)
+ .filter(
+ ChatMessage.agent_id == agent_id,
+ ChatMessage.content.like(pattern),
+ )
+ .count()
+ )
+
+ messages = (
+ db.query(ChatMessage)
+ .filter(
+ ChatMessage.agent_id == agent_id,
+ ChatMessage.content.like(pattern),
+ )
+ .order_by(ChatMessage.created_at.desc())
+ .offset(skip)
+ .limit(limit)
+ .all()
+ )
+
+ # 批量获取会话标题
+ session_ids = list(set(m.session_id for m in messages))
+ session_titles = {}
+ if session_ids:
+ records = db.query(AgentSession).filter(
+ AgentSession.id.in_(session_ids)
+ ).all()
+ session_titles = {s.id: s.title for s in records if s.title}
+
+ result = [
+ SearchMessageItem(
+ id=m.id,
+ session_id=m.session_id,
+ agent_id=m.agent_id,
+ role=m.role,
+ content=m.content,
+ session_title=session_titles.get(m.session_id),
+ created_at=m.created_at.isoformat() if m.created_at else None,
+ )
+ for m in messages
+ ]
+
+ return SearchMessagesResponse(messages=result, total=total)
+
+
def _find_agent_node_config(nodes: list) -> Dict[str, Any]:
"""从工作流节点列表中查找第一个 agent 类型或 llm 类型的节点配置。"""
if not nodes:
diff --git a/backend/app/api/agent_schedules.py b/backend/app/api/agent_schedules.py
index 228931f..74e055e 100644
--- a/backend/app/api/agent_schedules.py
+++ b/backend/app/api/agent_schedules.py
@@ -10,6 +10,7 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.auth import get_current_user
+from app.api.deps import require_workspace_admin, WorkspaceContext
from app.core.database import get_db
from app.models.agent import Agent
from app.models.agent_schedule import AgentSchedule
@@ -162,8 +163,9 @@ async def delete_schedule(
schedule_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
+ _ws_admin: WorkspaceContext = Depends(require_workspace_admin),
):
- """删除定时任务。"""
+ """删除定时任务(仅工作区管理员和平台管理员可操作)。"""
schedule = db.query(AgentSchedule).filter(AgentSchedule.id == schedule_id).first()
if not schedule:
raise HTTPException(status_code=404, detail="定时任务不存在")
@@ -180,8 +182,9 @@ async def trigger_schedule(
schedule_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
+ _ws_admin: WorkspaceContext = Depends(require_workspace_admin),
):
- """手动触发一次定时任务。"""
+ """手动触发一次定时任务(仅工作区管理员和平台管理员可操作)。"""
schedule = db.query(AgentSchedule).filter(AgentSchedule.id == schedule_id).first()
if not schedule:
raise HTTPException(status_code=404, detail="定时任务不存在")
diff --git a/backend/app/api/agent_sessions.py b/backend/app/api/agent_sessions.py
new file mode 100644
index 0000000..e6aa89d
--- /dev/null
+++ b/backend/app/api/agent_sessions.py
@@ -0,0 +1,180 @@
+"""
+Agent 会话管理 API
+提供会话重命名、置顶、删除、导出功能
+"""
+from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import PlainTextResponse
+from pydantic import BaseModel, Field
+from typing import Optional
+from sqlalchemy.orm import Session
+import json
+
+from app.core.database import get_db
+from app.api.auth import get_current_user
+from app.api.deps import get_current_workspace_id
+from app.models.user import User
+from app.models.agent import Agent
+from app.models.agent_session import AgentSession
+from app.models.chat_message import ChatMessage
+
+router = APIRouter(prefix="/api/v1/agent-chat", tags=["agent-sessions"])
+
+
+class UpdateSessionRequest(BaseModel):
+ title: Optional[str] = Field(default=None, description="会话标题")
+ is_pinned: Optional[bool] = Field(default=None, description="是否置顶")
+
+
+class SessionResponse(BaseModel):
+ id: str
+ title: Optional[str] = None
+ is_pinned: bool = False
+ created_at: Optional[str] = None
+ updated_at: Optional[str] = None
+
+
+@router.patch("/{agent_id}/sessions/{session_id}", response_model=SessionResponse)
+async def update_session(
+ agent_id: str,
+ session_id: str,
+ req: UpdateSessionRequest,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+ workspace_id: str = Depends(get_current_workspace_id),
+):
+ """更新会话信息(重命名 / 置顶)"""
+ agent = db.query(Agent).filter(Agent.id == agent_id).first()
+ if not agent:
+ raise HTTPException(status_code=404, detail="Agent 不存在")
+
+ session = db.query(AgentSession).filter(
+ AgentSession.id == session_id,
+ AgentSession.agent_id == agent_id,
+ ).first()
+
+ if not session:
+ session = AgentSession(
+ id=session_id,
+ user_id=current_user.id,
+ agent_id=agent_id,
+ workspace_id=workspace_id or None,
+ )
+ db.add(session)
+
+ if req.title is not None:
+ session.title = req.title
+ if req.is_pinned is not None:
+ session.is_pinned = req.is_pinned
+
+ db.commit()
+ db.refresh(session)
+
+ return SessionResponse(
+ id=session.id,
+ title=session.title,
+ is_pinned=session.is_pinned,
+ created_at=session.created_at.isoformat() if session.created_at else None,
+ updated_at=session.updated_at.isoformat() if session.updated_at else None,
+ )
+
+
+@router.delete("/{agent_id}/sessions/{session_id}")
+async def delete_session(
+ agent_id: str,
+ session_id: str,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+ workspace_id: str = Depends(get_current_workspace_id),
+):
+ """删除会话及所有关联消息"""
+ agent = db.query(Agent).filter(Agent.id == agent_id).first()
+ if not agent:
+ raise HTTPException(status_code=404, detail="Agent 不存在")
+
+ session_filter = [
+ AgentSession.id == session_id,
+ AgentSession.agent_id == agent_id,
+ ]
+ if workspace_id:
+ session_filter.append(AgentSession.workspace_id == workspace_id)
+
+ db.query(AgentSession).filter(*session_filter).delete()
+
+ msg_filter = [
+ ChatMessage.agent_id == agent_id,
+ ChatMessage.session_id == session_id,
+ ]
+ if workspace_id:
+ msg_filter.append(ChatMessage.workspace_id == workspace_id)
+
+ deleted_count = db.query(ChatMessage).filter(*msg_filter).delete()
+
+ db.commit()
+
+ return {"deleted": True, "messages_deleted": deleted_count}
+
+
+@router.get("/{agent_id}/sessions/{session_id}/export")
+async def export_session(
+ agent_id: str,
+ session_id: str,
+ format: str = Query("json", pattern="^(json|markdown)$"),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+ workspace_id: str = Depends(get_current_workspace_id),
+):
+ """导出会话为 JSON 或 Markdown 格式"""
+ agent = db.query(Agent).filter(Agent.id == agent_id).first()
+ if not agent:
+ raise HTTPException(status_code=404, detail="Agent 不存在")
+
+ msg_query = db.query(ChatMessage).filter(
+ ChatMessage.agent_id == agent_id,
+ ChatMessage.session_id == session_id,
+ )
+ if workspace_id:
+ msg_query = msg_query.filter(ChatMessage.workspace_id == workspace_id)
+
+ messages = msg_query.order_by(ChatMessage.created_at.asc()).all()
+
+ if not messages:
+ raise HTTPException(status_code=404, detail="会话无消息")
+
+ # 获取会话标题
+ session = db.query(AgentSession).filter(AgentSession.id == session_id).first()
+ title = session.title if session and session.title else "对话"
+
+ if format == "markdown":
+ lines = [f"# {title}", "", f"**Agent**: {agent.name}", f"**时间**: {messages[0].created_at.isoformat() if messages[0].created_at else 'N/A'}", "", "---", ""]
+ for m in messages:
+ role_label = "用户" if m.role == "user" else "AI 助手" if m.role == "assistant" else m.role
+ lines.append(f"### {role_label}")
+ if m.content:
+ lines.append(m.content)
+ if m.tool_name:
+ lines.append(f"\n> 工具调用: `{m.tool_name}`")
+ if m.tool_output:
+ lines.append(f"\n工具输出
\n\n```\n{m.tool_output[:500]}\n```\n ")
+ lines.append("")
+ return PlainTextResponse("\n".join(lines), media_type="text/markdown")
+ else:
+ # JSON
+ export_data = {
+ "title": title,
+ "agent_name": agent.name,
+ "agent_id": agent_id,
+ "session_id": session_id,
+ "exported_at": messages[0].created_at.isoformat() if messages[0].created_at else None,
+ "messages": [
+ {
+ "role": m.role,
+ "content": m.content,
+ "tool_name": m.tool_name,
+ "tool_input": m.tool_input,
+ "tool_output": m.tool_output,
+ "created_at": m.created_at.isoformat() if m.created_at else None,
+ }
+ for m in messages
+ ],
+ }
+ return export_data
diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py
index 3c8e114..aa2a87f 100644
--- a/backend/app/api/agents.py
+++ b/backend/app/api/agents.py
@@ -11,6 +11,7 @@ import logging
from app.core.database import get_db
from app.models.agent import Agent
from app.api.auth import get_current_user
+from app.api.deps import require_workspace_admin, WorkspaceContext
from app.models.user import User
from app.core.exceptions import NotFoundError, ValidationError, ConflictError
from app.services.permission_service import check_agent_permission
@@ -385,10 +386,11 @@ async def update_agent(
async def delete_agent(
agent_id: str,
db: Session = Depends(get_db),
- current_user: User = Depends(get_current_user)
+ current_user: User = Depends(get_current_user),
+ _ws_admin: WorkspaceContext = Depends(require_workspace_admin),
):
"""
- 删除Agent(只有所有者可以删除)
+ 删除Agent(仅工作区管理员和平台管理员可操作)
"""
agent = db.query(Agent).filter(Agent.id == agent_id).first()
@@ -435,11 +437,12 @@ async def delete_agent(
async def deploy_agent(
agent_id: str,
db: Session = Depends(get_db),
- current_user: User = Depends(get_current_user)
+ current_user: User = Depends(get_current_user),
+ _ws_admin: WorkspaceContext = Depends(require_workspace_admin),
):
"""
- 部署Agent
-
+ 部署Agent(仅工作区管理员和平台管理员可操作)
+
将Agent状态设置为published
"""
agent = db.query(Agent).filter(Agent.id == agent_id).first()
@@ -463,11 +466,12 @@ async def deploy_agent(
async def stop_agent(
agent_id: str,
db: Session = Depends(get_db),
- current_user: User = Depends(get_current_user)
+ current_user: User = Depends(get_current_user),
+ _ws_admin: WorkspaceContext = Depends(require_workspace_admin),
):
"""
- 停止Agent
-
+ 停止Agent(仅工作区管理员和平台管理员可操作)
+
将Agent状态设置为stopped
"""
agent = db.query(Agent).filter(Agent.id == agent_id).first()
@@ -601,8 +605,9 @@ async def create_main_agent(
agent_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
+ _ws_admin: WorkspaceContext = Depends(require_workspace_admin),
):
- """一键将当前 Agent 升级为 Main Agent(预置专用工具 + 系统提示词)"""
+ """一键将当前 Agent 升级为 Main Agent(仅工作区管理员和平台管理员可操作)"""
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise NotFoundError(f"Agent不存在: {agent_id}")
diff --git a/backend/app/api/alert_rules.py b/backend/app/api/alert_rules.py
index 61e80ee..1faa6f4 100644
--- a/backend/app/api/alert_rules.py
+++ b/backend/app/api/alert_rules.py
@@ -10,6 +10,7 @@ import logging
from app.core.database import get_db
from app.models.alert_rule import AlertRule, AlertLog
from app.api.auth import get_current_user
+from app.api.deps import get_current_workspace_id
from app.models.user import User
from app.core.exceptions import NotFoundError, ValidationError, ConflictError
from app.services.alert_service import AlertService
@@ -102,14 +103,17 @@ async def get_alert_rules(
alert_type: Optional[str] = Query(None, description="告警类型筛选"),
enabled: Optional[bool] = Query(None, description="是否启用筛选"),
db: Session = Depends(get_db),
- current_user: User = Depends(get_current_user)
+ current_user: User = Depends(get_current_user),
+ workspace_id: str = Depends(get_current_workspace_id),
):
"""
获取告警规则列表
-
+
支持分页和筛选
"""
query = db.query(AlertRule).filter(AlertRule.user_id == current_user.id)
+ if workspace_id:
+ query = query.filter(AlertRule.workspace_id == workspace_id)
# 筛选
if alert_type:
@@ -126,7 +130,8 @@ async def get_alert_rules(
async def create_alert_rule(
rule_data: AlertRuleCreate,
db: Session = Depends(get_db),
- current_user: User = Depends(get_current_user)
+ current_user: User = Depends(get_current_user),
+ workspace_id: str = Depends(get_current_workspace_id),
):
"""
创建告警规则
@@ -165,7 +170,8 @@ async def create_alert_rule(
notification_type=rule_data.notification_type,
notification_config=rule_data.notification_config,
enabled=rule_data.enabled,
- user_id=current_user.id
+ user_id=current_user.id,
+ workspace_id=workspace_id or None,
)
db.add(alert_rule)
db.commit()
diff --git a/backend/app/api/analytics.py b/backend/app/api/analytics.py
new file mode 100644
index 0000000..34fc7a1
--- /dev/null
+++ b/backend/app/api/analytics.py
@@ -0,0 +1,229 @@
+"""
+数据分析 API — 从 user_behavior_logs 表聚合
+提供概览、日活趋势、Agent 排行榜
+"""
+from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import BaseModel, Field
+from typing import Optional, List, Any, Dict
+from sqlalchemy.orm import Session
+from sqlalchemy import func as sa_func, text, desc
+from datetime import datetime, timedelta
+
+from app.core.database import get_db
+from app.api.auth import get_current_user
+from app.models.user import User
+from app.models.user_behavior import UserBehaviorLog
+from app.models.chat_message import ChatMessage
+
+router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"])
+
+
+# ─────────── Response Models ───────────
+
+class OverviewResponse(BaseModel):
+ total_users: int = 0
+ active_users_today: int = 0
+ messages_today: int = 0
+ sessions_today: int = 0
+
+
+class DailyStatItem(BaseModel):
+ date: str
+ active_users: int = 0
+ messages: int = 0
+ new_registrations: int = 0
+
+
+class DailyStatsResponse(BaseModel):
+ stats: List[DailyStatItem]
+
+
+class TopAgentItem(BaseModel):
+ agent_id: str
+ message_count: int = 0
+ session_count: int = 0
+
+
+class TopAgentsResponse(BaseModel):
+ agents: List[TopAgentItem]
+
+
+# ─────────── Endpoints ───────────
+
+@router.get("/overview", response_model=OverviewResponse)
+async def get_overview(
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """运营概览:总用户数、今日活跃、今日消息数/会话数"""
+ today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+
+ total_users = db.query(sa_func.count(User.id)).scalar() or 0
+
+ active_users_today = db.query(sa_func.count(sa_func.distinct(UserBehaviorLog.user_id))).filter(
+ UserBehaviorLog.created_at >= today
+ ).scalar() or 0
+
+ messages_today = db.query(sa_func.count(ChatMessage.id)).filter(
+ ChatMessage.created_at >= today
+ ).scalar() or 0
+
+ sessions_today = db.query(sa_func.count(sa_func.distinct(ChatMessage.session_id))).filter(
+ ChatMessage.created_at >= today
+ ).scalar() or 0
+
+ return OverviewResponse(
+ total_users=total_users,
+ active_users_today=active_users_today,
+ messages_today=messages_today,
+ sessions_today=sessions_today,
+ )
+
+
+@router.get("/daily-stats", response_model=DailyStatsResponse)
+async def get_daily_stats(
+ days: int = Query(default=30, ge=1, le=365),
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """每日趋势:UV、消息数、新注册数"""
+ since = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=days - 1)
+
+ # 每日活跃用户
+ uv_rows = (
+ db.query(
+ sa_func.date(UserBehaviorLog.created_at).label("date"),
+ sa_func.count(sa_func.distinct(UserBehaviorLog.user_id)).label("uv"),
+ )
+ .filter(UserBehaviorLog.created_at >= since)
+ .group_by(text("date"))
+ .order_by("date")
+ .all()
+ )
+ uv_map = {str(row.date): row.uv for row in uv_rows}
+
+ # 每日消息
+ msg_rows = (
+ db.query(
+ sa_func.date(ChatMessage.created_at).label("date"),
+ sa_func.count(ChatMessage.id).label("cnt"),
+ )
+ .filter(ChatMessage.created_at >= since)
+ .group_by(text("date"))
+ .order_by("date")
+ .all()
+ )
+ msg_map = {str(row.date): row.cnt for row in msg_rows}
+
+ # 每日新注册
+ reg_rows = (
+ db.query(
+ sa_func.date(User.created_at).label("date"),
+ sa_func.count(User.id).label("cnt"),
+ )
+ .filter(User.created_at >= since)
+ .group_by(text("date"))
+ .order_by("date")
+ .all()
+ )
+ reg_map = {str(row.date): row.cnt for row in reg_rows}
+
+ # 构建日期序列
+ stats = []
+ for i in range(days):
+ d = (since + timedelta(days=i)).strftime("%Y-%m-%d")
+ stats.append(DailyStatItem(
+ date=d,
+ active_users=uv_map.get(d, 0),
+ messages=msg_map.get(d, 0),
+ new_registrations=reg_map.get(d, 0),
+ ))
+
+ return DailyStatsResponse(stats=stats)
+
+
+@router.get("/top-agents", response_model=TopAgentsResponse)
+async def get_top_agents(
+ days: int = Query(default=30, ge=1, le=365),
+ limit: int = Query(default=10, ge=1, le=100),
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """最活跃 Agent 排行"""
+ since = datetime.now() - timedelta(days=days)
+
+ rows = (
+ db.query(
+ ChatMessage.agent_id,
+ sa_func.count(ChatMessage.id).label("message_count"),
+ sa_func.count(sa_func.distinct(ChatMessage.session_id)).label("session_count"),
+ )
+ .filter(
+ ChatMessage.created_at >= since,
+ ChatMessage.agent_id.isnot(None),
+ )
+ .group_by(ChatMessage.agent_id)
+ .order_by(desc("message_count"))
+ .limit(limit)
+ .all()
+ )
+
+ agents = [
+ TopAgentItem(
+ agent_id=row.agent_id,
+ message_count=row.message_count,
+ session_count=row.session_count,
+ )
+ for row in rows
+ ]
+
+ return TopAgentsResponse(agents=agents)
+
+
+# ─────────── Mobile Events (v1.1.0) ───────────
+
+class AnalyticsEventItem(BaseModel):
+ event_type: str = Field(..., description="事件类型: screen_view / user_action")
+ screen_name: Optional[str] = None
+ category: Optional[str] = None
+ action: Optional[str] = None
+ label: Optional[str] = None
+ agent_id: Optional[str] = None
+ timestamp: Optional[str] = None
+
+
+class AnalyticsEventsRequest(BaseModel):
+ events: List[AnalyticsEventItem]
+
+
+@router.post("/events")
+async def receive_events(
+ req: AnalyticsEventsRequest,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """接收移动端批量埋点事件"""
+ count = 0
+ for evt in req.events:
+ try:
+ t = datetime.fromisoformat(evt.timestamp.replace("Z", "+00:00")) if evt.timestamp else datetime.now()
+ except Exception:
+ t = datetime.now()
+
+ log = UserBehaviorLog(
+ user_id=current_user.id,
+ category=evt.category or "mobile",
+ action=evt.action or evt.event_type,
+ context={
+ "screen": evt.screen_name,
+ "label": evt.label,
+ "agent_id": evt.agent_id,
+ },
+ source="android",
+ created_at=t,
+ )
+ db.add(log)
+ count += 1
+
+ db.commit()
+ return {"received": count}
diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
index 35eefe7..c8c5f75 100644
--- a/backend/app/api/auth.py
+++ b/backend/app/api/auth.py
@@ -585,19 +585,256 @@ async def bind_phone(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
- """绑定或修改手机号。"""
+ """绑定或修改手机号(需验证码校验)。"""
+ redis = get_redis_client()
+
+ # 必须提供验证码
+ if not body.code:
+ raise HTTPException(status_code=400, detail="请提供短信验证码")
+
+ # 校验验证码
+ if not _verify_sms_code(redis, body.phone, body.code):
+ raise HTTPException(status_code=400, detail="验证码错误或已过期")
+
# 检查手机号是否已被其他用户绑定
existing = db.query(User).filter(User.phone == body.phone, User.id != current_user.id).first()
if existing:
raise ConflictError("该手机号已被其他用户绑定")
current_user.phone = body.phone
+ current_user.phone_verified = True
+ current_user.phone_verified_at = datetime.utcnow()
db.commit()
logger.info("用户 %s 绑定手机号成功", current_user.username)
return {"message": "手机号绑定成功", "phone": body.phone}
+# ─── 手机验证码(v1.1.0)────────────────────────────────────
+
+SMS_CODE_TTL_SEC = 600 # 验证码 10 分钟有效
+SMS_RATE_LIMIT_SEC = 60 # 同一手机号 60 秒内只能发一次
+
+
+class SendSmsCodeRequest(BaseModel):
+ phone: str
+
+ @field_validator("phone")
+ @classmethod
+ def phone_format(cls, v: str) -> str:
+ if not re.match(r"^1[3-9]\d{9}$", v):
+ raise ValueError("手机号格式无效")
+ return v
+
+
+class VerifyPhoneRequest(BaseModel):
+ phone: str
+ code: str
+
+ @field_validator("phone")
+ @classmethod
+ def phone_format(cls, v: str) -> str:
+ if not re.match(r"^1[3-9]\d{9}$", v):
+ raise ValueError("手机号格式无效")
+ return v
+
+
+class PhoneLoginRequest(BaseModel):
+ phone: str
+ code: str
+
+ @field_validator("phone")
+ @classmethod
+ def phone_format(cls, v: str) -> str:
+ if not re.match(r"^1[3-9]\d{9}$", v):
+ raise ValueError("手机号格式无效")
+ return v
+
+
+def _gen_sms_code() -> str:
+ """生成 6 位数字验证码"""
+ return str(secrets.randbelow(900000) + 100000)
+
+
+async def _send_sms_code(phone: str, code: str) -> bool:
+ """发送短信验证码。返回 True 表示发送成功。"""
+ from app.core.sms_service import get_sms_provider
+ provider = get_sms_provider()
+ try:
+ return await provider.send(phone, code)
+ except Exception as e:
+ logger.warning("SMS 发送异常: %s", e)
+ return False
+
+
+@router.post("/phone/send-code")
+async def send_phone_code(body: SendSmsCodeRequest):
+ """发送手机验证码。"""
+ redis = get_redis_client()
+
+ # 频率限制
+ rate_key = f"sms_rate:{body.phone}"
+ if redis:
+ if redis.exists(rate_key):
+ ttl = redis.ttl(rate_key)
+ raise HTTPException(
+ status_code=429,
+ detail=f"操作过于频繁,请 {ttl} 秒后重试"
+ )
+
+ code = _gen_sms_code()
+
+ # 存储到 Redis
+ code_key = f"sms_code:{body.phone}"
+ if redis:
+ redis.setex(code_key, SMS_CODE_TTL_SEC, code)
+ redis.setex(rate_key, SMS_RATE_LIMIT_SEC, "1")
+ else:
+ # 无 Redis 时回退内存存储
+ if not hasattr(send_phone_code, "_memory_store"):
+ send_phone_code._memory_store = {}
+ send_phone_code._memory_rate = {}
+ send_phone_code._memory_store[body.phone] = {
+ "code": code,
+ "expires_at": datetime.utcnow() + timedelta(seconds=SMS_CODE_TTL_SEC),
+ }
+ send_phone_code._memory_rate[body.phone] = \
+ datetime.utcnow() + timedelta(seconds=SMS_RATE_LIMIT_SEC)
+
+ # 发送验证码
+ sent = await _send_sms_code(body.phone, code)
+
+ if not sent:
+ # Mock 模式或发送失败时返回验证码(仅开发环境)
+ logger.info("开发模式:%s 的短信验证码为 %s", body.phone, code)
+ return {
+ "message": "验证码已生成(Mock 模式)",
+ "dev_code": code,
+ }
+
+ return {"message": "验证码已发送"}
+
+
+def _verify_sms_code(redis, phone: str, code: str) -> bool:
+ """校验短信验证码(从 Redis 或内存存储中读取)。"""
+ code_key = f"sms_code:{phone}"
+
+ stored_code = None
+ if redis:
+ stored_code_raw = redis.get(code_key)
+ stored_code = stored_code_raw.decode() if isinstance(stored_code_raw, bytes) else stored_code_raw
+ elif hasattr(send_phone_code, "_memory_store"):
+ entry = send_phone_code._memory_store.get(phone, {})
+ if entry and entry.get("expires_at", datetime.min) > datetime.utcnow():
+ stored_code = entry.get("code")
+
+ if not stored_code:
+ return False
+
+ if stored_code != code.strip():
+ return False
+
+ # 验证通过后清除
+ if redis:
+ redis.delete(code_key)
+ elif hasattr(send_phone_code, "_memory_store"):
+ send_phone_code._memory_store.pop(phone, None)
+
+ return True
+
+
+@router.post("/phone/verify")
+async def verify_phone(
+ body: VerifyPhoneRequest,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ """验证手机号(绑定后验证)。"""
+ redis = get_redis_client()
+
+ if not _verify_sms_code(redis, body.phone, body.code):
+ raise HTTPException(status_code=400, detail="验证码错误或已过期")
+
+ # 检查手机号是否已被其他用户绑定
+ existing = db.query(User).filter(
+ User.phone == body.phone, User.id != current_user.id
+ ).first()
+ if existing:
+ raise HTTPException(status_code=400, detail="该手机号已被其他用户绑定")
+
+ current_user.phone = body.phone
+ current_user.phone_verified = True
+ current_user.phone_verified_at = datetime.utcnow()
+ db.commit()
+
+ logger.info("用户 %s 手机号 %s 验证成功", current_user.username, body.phone)
+ return {"message": "手机号验证成功", "phone": body.phone}
+
+
+@router.post("/login/phone", response_model=Token)
+async def phone_login(
+ body: PhoneLoginRequest,
+ db: Session = Depends(get_db),
+):
+ """手机号+验证码登录。用户不存在则自动注册。"""
+ redis = get_redis_client()
+
+ if not _verify_sms_code(redis, body.phone, body.code):
+ raise HTTPException(status_code=400, detail="验证码错误或已过期")
+
+ user = db.query(User).filter(User.phone == body.phone).first()
+
+ if not user:
+ # 自动注册:用手机号生成用户名
+ username_base = f"user_{body.phone[-6:]}"
+ username = username_base
+ counter = 1
+ while db.query(User).filter(User.username == username).first():
+ username = f"{username_base}_{counter}"
+ counter += 1
+
+ user = User(
+ username=username,
+ email=f"{body.phone}@phone.local",
+ password_hash=get_password_hash(secrets.token_urlsafe(16)),
+ phone=body.phone,
+ phone_verified=True,
+ phone_verified_at=datetime.utcnow(),
+ )
+ db.add(user)
+ db.commit()
+ db.refresh(user)
+ logger.info("手机号自动注册: %s → %s", body.phone, username)
+ else:
+ # 如果手机号还没标记为已验证,更新
+ if not user.phone_verified:
+ user.phone_verified = True
+ user.phone_verified_at = datetime.utcnow()
+ db.commit()
+
+ if user.status == "deleted":
+ raise HTTPException(status_code=403, detail="该账号已注销")
+
+ ws_id = _get_user_default_workspace_id(db, user)
+
+ access_token = create_access_token(
+ data={"sub": user.id, "username": user.username, "ws": ws_id or ""},
+ expires_delta=timedelta(minutes=settings.JWT_MOBILE_TOKEN_EXPIRE_MINUTES),
+ )
+
+ refresh_token = create_refresh_token(
+ user_id=user.id,
+ username=user.username,
+ workspace_id=ws_id or "",
+ )
+
+ return {
+ "access_token": access_token,
+ "token_type": "bearer",
+ "refresh_token": refresh_token,
+ }
+
+
async def get_optional_user(
token: str | None = Depends(oauth2_scheme_optional),
db: Session = Depends(get_db)
diff --git a/backend/app/api/billing.py b/backend/app/api/billing.py
new file mode 100644
index 0000000..57854b6
--- /dev/null
+++ b/backend/app/api/billing.py
@@ -0,0 +1,303 @@
+"""
+付费体系 API:套餐、订阅、订单、用量
+"""
+from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import BaseModel, Field
+from typing import Optional, List
+from sqlalchemy.orm import Session
+from datetime import date, datetime, timedelta
+
+from app.core.database import get_db
+from app.api.auth import get_current_user
+from app.models.user import User
+from app.models.billing import BillingPlan, BillingOrder, UserSubscription, UsageRecord
+
+router = APIRouter(prefix="/api/v1/billing", tags=["billing"])
+
+
+# ─── Pydantic Models ───
+
+class PlanResponse(BaseModel):
+ id: str
+ name: str
+ tier: str
+ price_monthly: float
+ price_yearly: float
+ daily_quota: int
+ agent_limit: int
+ knowledge_limit: int
+ file_upload_mb: int = 5
+ models: Optional[list] = None
+ features: Optional[list] = None
+ is_active: bool = True
+ sort_order: int = 0
+
+ class Config:
+ from_attributes = True
+
+
+class SubscriptionResponse(BaseModel):
+ tier: str = "free"
+ status: str = "active"
+ plan_name: Optional[str] = None
+ started_at: Optional[str] = None
+ expires_at: Optional[str] = None
+ auto_renew: bool = False
+ daily_quota: int = 50
+ agent_limit: int = 3
+ knowledge_limit: int = 10
+ file_upload_mb: int = 5
+ models: Optional[list] = None
+ features: Optional[list] = None
+
+
+class UsageResponse(BaseModel):
+ daily_used: int = 0
+ daily_limit: int = 50
+ daily_remaining: int = 50
+ is_exhausted: bool = False
+
+
+class CreateOrderRequest(BaseModel):
+ plan_id: str = Field(..., description="套餐ID")
+ period: str = Field(default="monthly", description="周期: monthly/yearly")
+ payment_method: str = Field(default="mock", description="支付方式: mock/wechat/alipay")
+
+
+class OrderResponse(BaseModel):
+ id: str
+ plan_id: Optional[str] = None
+ amount: float
+ period: str
+ status: str
+ payment_method: Optional[str] = None
+ payment_ref: Optional[str] = None
+ created_at: Optional[str] = None
+ paid_at: Optional[str] = None
+
+ class Config:
+ from_attributes = True
+
+
+# ─── Helpers ───
+
+QUOTA_DEFAULTS = {
+ "free": {"daily_quota": 50, "agent_limit": 3, "knowledge_limit": 10, "file_upload_mb": 5},
+ "pro": {"daily_quota": 500, "agent_limit": 20, "knowledge_limit": 500, "file_upload_mb": 50},
+ "enterprise": {"daily_quota": 0, "agent_limit": 0, "knowledge_limit": 0, "file_upload_mb": 200},
+}
+
+
+def _get_user_subscription_info(db: Session, user: User) -> SubscriptionResponse:
+ """获取用户当前订阅信息(套餐+权益)"""
+ sub = db.query(UserSubscription).filter(UserSubscription.user_id == user.id).first()
+ tier = sub.tier if sub else (user.subscription_tier or "free")
+ plan = db.query(BillingPlan).filter(BillingPlan.id == sub.plan_id).first() if (sub and sub.plan_id) else None
+
+ quota = QUOTA_DEFAULTS.get(tier, QUOTA_DEFAULTS["free"])
+ return SubscriptionResponse(
+ tier=tier,
+ status=sub.status if sub else "active",
+ plan_name=plan.name if plan else (sub.tier if sub else "免费版"),
+ started_at=sub.started_at.isoformat() if (sub and sub.started_at) else None,
+ expires_at=sub.expires_at.isoformat() if (sub and sub.expires_at) else None,
+ auto_renew=sub.auto_renew if sub else False,
+ daily_quota=plan.daily_quota if plan else quota["daily_quota"],
+ agent_limit=plan.agent_limit if plan else quota["agent_limit"],
+ knowledge_limit=plan.knowledge_limit if plan else quota["knowledge_limit"],
+ file_upload_mb=plan.file_upload_mb if plan else quota["file_upload_mb"],
+ models=plan.models if plan else None,
+ features=plan.features if plan else None,
+ )
+
+
+def _get_daily_limit(tier: str) -> int:
+ plan = QUOTA_DEFAULTS.get(tier)
+ return plan["daily_quota"] if plan else 50
+
+
+# ─── Endpoints ───
+
+@router.get("/plans", response_model=List[PlanResponse])
+async def list_plans(
+ db: Session = Depends(get_db),
+):
+ """获取所有可用套餐"""
+ return db.query(BillingPlan).filter(BillingPlan.is_active == True).order_by(BillingPlan.sort_order.asc()).all()
+
+
+@router.get("/user/subscription", response_model=SubscriptionResponse)
+async def get_user_subscription(
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ """获取当前用户订阅状态与权益"""
+ return _get_user_subscription_info(db, current_user)
+
+
+@router.get("/user/usage", response_model=UsageResponse)
+async def get_user_usage(
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ """获取当前用户今日用量"""
+ today = date.today()
+ sub = db.query(UserSubscription).filter(UserSubscription.user_id == current_user.id).first()
+ tier = sub.tier if sub else (current_user.subscription_tier or "free")
+ daily_limit = _get_daily_limit(tier)
+
+ # 如果日期变了,重置计数
+ if current_user.daily_usage_date and current_user.daily_usage_date < today:
+ current_user.daily_usage_count = 0
+ current_user.daily_usage_date = today
+ db.commit()
+
+ used = current_user.daily_usage_count or 0
+ remaining = max(0, daily_limit - used) if daily_limit > 0 else 999999
+
+ return UsageResponse(
+ daily_used=used,
+ daily_limit=daily_limit,
+ daily_remaining=remaining,
+ is_exhausted=daily_limit > 0 and used >= daily_limit,
+ )
+
+
+@router.post("/orders", response_model=OrderResponse)
+async def create_order(
+ req: CreateOrderRequest,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ """创建订单"""
+ plan = db.query(BillingPlan).filter(BillingPlan.id == req.plan_id, BillingPlan.is_active == True).first()
+ if not plan:
+ raise HTTPException(status_code=404, detail="套餐不存在")
+
+ if plan.tier == "free":
+ raise HTTPException(status_code=400, detail="免费套餐无需购买")
+
+ amount = plan.price_yearly if req.period == "yearly" else plan.price_monthly
+ if amount <= 0:
+ raise HTTPException(status_code=400, detail="套餐价格无效")
+
+ order = BillingOrder(
+ user_id=current_user.id,
+ plan_id=plan.id,
+ amount=amount,
+ period=req.period,
+ status="pending",
+ payment_method=req.payment_method,
+ )
+ db.add(order)
+ db.commit()
+ db.refresh(order)
+
+ return OrderResponse(
+ id=order.id,
+ plan_id=order.plan_id,
+ amount=order.amount,
+ period=order.period,
+ status=order.status,
+ payment_method=order.payment_method,
+ payment_ref=order.payment_ref,
+ created_at=order.created_at.isoformat() if order.created_at else None,
+ paid_at=order.paid_at.isoformat() if order.paid_at else None,
+ )
+
+
+@router.get("/orders/{order_id}", response_model=OrderResponse)
+async def get_order(
+ order_id: str,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ """查询订单状态"""
+ order = db.query(BillingOrder).filter(
+ BillingOrder.id == order_id,
+ BillingOrder.user_id == current_user.id,
+ ).first()
+ if not order:
+ raise HTTPException(status_code=404, detail="订单不存在")
+
+ return OrderResponse(
+ id=order.id,
+ plan_id=order.plan_id,
+ amount=order.amount,
+ period=order.period,
+ status=order.status,
+ payment_method=order.payment_method,
+ payment_ref=order.payment_ref,
+ created_at=order.created_at.isoformat() if order.created_at else None,
+ paid_at=order.paid_at.isoformat() if order.paid_at else None,
+ )
+
+
+@router.post("/orders/{order_id}/pay")
+async def pay_order(
+ order_id: str,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ """模拟支付(Phase 4 替换为真实支付)"""
+ order = db.query(BillingOrder).filter(
+ BillingOrder.id == order_id,
+ BillingOrder.user_id == current_user.id,
+ ).first()
+ if not order:
+ raise HTTPException(status_code=404, detail="订单不存在")
+ if order.status != "pending":
+ raise HTTPException(status_code=400, detail="订单状态不允许支付")
+
+ plan = db.query(BillingPlan).filter(BillingPlan.id == order.plan_id).first()
+ if not plan:
+ raise HTTPException(status_code=404, detail="套餐不存在")
+
+ # 计算到期时间
+ days = 365 if order.period == "yearly" else 30
+ now = datetime.utcnow()
+ expires_at = now + timedelta(days=days)
+
+ # 更新订单状态
+ order.status = "paid"
+ order.paid_at = now
+ order.payment_ref = f"mock_{order.id}"
+
+ # 创建或更新订阅
+ sub = db.query(UserSubscription).filter(UserSubscription.user_id == current_user.id).first()
+ if not sub:
+ sub = UserSubscription(user_id=current_user.id)
+ db.add(sub)
+
+ sub.plan_id = plan.id
+ sub.tier = plan.tier
+ sub.status = "active"
+ sub.started_at = now
+ sub.expires_at = expires_at
+
+ # 更新用户模型
+ current_user.subscription_tier = plan.tier
+ current_user.subscription_expires_at = expires_at
+ current_user.daily_usage_count = 0
+ current_user.daily_usage_date = date.today()
+
+ db.commit()
+
+ return {
+ "success": True,
+ "tier": plan.tier,
+ "expires_at": expires_at.isoformat(),
+ "message": f"已升级到 {plan.name},有效期至 {expires_at.strftime('%Y-%m-%d')}",
+ }
+
+
+@router.post("/callback/wechat")
+async def wechat_callback():
+ """微信支付回调(Phase 4 实现)"""
+ return {"code": "SUCCESS", "message": "not implemented yet"}
+
+
+@router.post("/callback/alipay")
+async def alipay_callback():
+ """支付宝回调(Phase 4 实现)"""
+ return {"code": "SUCCESS", "message": "not implemented yet"}
diff --git a/backend/app/api/collaboration.py b/backend/app/api/collaboration.py
index f3112f9..2d5b6d0 100644
--- a/backend/app/api/collaboration.py
+++ b/backend/app/api/collaboration.py
@@ -6,6 +6,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, HTTPExce
from sqlalchemy.orm import Session
from app.core.database import get_db, SessionLocal
from app.models.workflow import Workflow
+from app.models.workspace import WorkspaceMembership
from app.api.auth import get_current_user
from app.models.user import User
from app.websocket.collaboration_manager import collaboration_manager
@@ -79,6 +80,25 @@ async def websocket_collaboration(
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="工作流不存在")
return
+ # 检查 workspace 成员资格
+ ws_id = payload.get("ws", "")
+ if ws_id and user.role != "admin":
+ membership = (
+ db.query(WorkspaceMembership)
+ .filter(
+ WorkspaceMembership.workspace_id == ws_id,
+ WorkspaceMembership.user_id == user.id,
+ )
+ .first()
+ )
+ if not membership:
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="无权访问此工作区")
+ return
+ # 验证 workflow 属于该 workspace
+ if workflow.workspace_id and workflow.workspace_id != ws_id:
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="无权编辑此工作流")
+ return
+
# 检查权限(只有工作流所有者可以协作编辑,或者未来可以扩展权限)
# 暂时只允许所有者编辑
if workflow.user_id != user.id:
diff --git a/backend/app/api/data_sources.py b/backend/app/api/data_sources.py
index 7cfdf8c..778b374 100644
--- a/backend/app/api/data_sources.py
+++ b/backend/app/api/data_sources.py
@@ -10,6 +10,7 @@ import logging
from app.core.database import get_db
from app.models.data_source import DataSource
from app.api.auth import get_current_user
+from app.api.deps import require_workspace_admin, WorkspaceContext
from app.models.user import User
from app.core.exceptions import NotFoundError, ValidationError
from app.services.data_source_connector import DataSourceConnector
@@ -163,9 +164,10 @@ async def update_data_source(
async def delete_data_source(
data_source_id: str,
db: Session = Depends(get_db),
- current_user: User = Depends(get_current_user)
+ current_user: User = Depends(get_current_user),
+ _ws_admin: WorkspaceContext = Depends(require_workspace_admin),
):
- """删除数据源"""
+ """删除数据源(仅工作区管理员和平台管理员可操作)"""
data_source = db.query(DataSource).filter(
DataSource.id == data_source_id,
DataSource.user_id == current_user.id
diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py
index 6d8e681..49988a2 100644
--- a/backend/app/api/deps.py
+++ b/backend/app/api/deps.py
@@ -1,7 +1,8 @@
"""
-FastAPI 依赖注入 — Workspace 上下文、权限检查
+FastAPI 依赖注入 — Workspace 上下文、RBAC 权限检查
"""
from dataclasses import dataclass
+import logging
from typing import Optional
from fastapi import Depends, HTTPException, Request
@@ -10,6 +11,9 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import decode_access_token
from app.models.user import User
+from app.models.workspace import WorkspaceMembership
+
+logger = logging.getLogger(__name__)
@dataclass
@@ -41,7 +45,12 @@ def get_workspace_context(
request: Request,
db: Session = Depends(get_db),
) -> WorkspaceContext:
- """获取完整的 Workspace 上下文(用户 + 工作区),用于需要 workspace 过滤的接口。"""
+ """获取完整的 Workspace 上下文(用户 + 工作区)。
+
+ 注意:此函数仅认证用户并提取 workspace_id,不验证 workspace 成员资格。
+ 用于跨 workspace 或 admin 端点。需要强制成员资格验证的端点请使用
+ get_workspace_membership()。
+ """
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未提供有效的认证令牌")
@@ -61,4 +70,128 @@ def get_workspace_context(
if not user:
raise HTTPException(status_code=401, detail="用户不存在")
+ if user.status == "deleted":
+ raise HTTPException(status_code=403, detail="账号已注销")
+
return WorkspaceContext(user=user, workspace_id=ws_id or "")
+
+
+# ─── 多租户 RBAC 依赖 (v1.3) ───
+
+def get_workspace_membership(
+ request: Request,
+ db: Session = Depends(get_db),
+) -> WorkspaceContext:
+ """认证用户 + 验证 workspace 成员资格。
+
+ 平台管理员(user.role == 'admin')绕过 workspace 成员资格检查。
+ 普通用户必须属于 JWT 中 `ws` 指定的 workspace。
+ """
+ ctx = get_workspace_context(request, db)
+
+ # 平台管理员可访问所有 workspace
+ if ctx.user.role == "admin":
+ return ctx
+
+ if not ctx.workspace_id:
+ raise HTTPException(status_code=400, detail="未选择工作区")
+
+ membership = (
+ db.query(WorkspaceMembership)
+ .filter(
+ WorkspaceMembership.workspace_id == ctx.workspace_id,
+ WorkspaceMembership.user_id == ctx.user.id,
+ )
+ .first()
+ )
+ if not membership:
+ logger.warning("用户 %s 尝试访问未加入的工作区 %s", ctx.user.id, ctx.workspace_id)
+ raise HTTPException(status_code=403, detail="无权访问此工作区")
+
+ return ctx
+
+
+async def require_workspace_admin(
+ ctx: WorkspaceContext = Depends(get_workspace_membership),
+ db: Session = Depends(get_db),
+) -> WorkspaceContext:
+ """要求 workspace 管理员或平台管理员权限。
+
+ 用于删除/发布/修改工作区设置等敏感操作。
+ """
+ # 平台管理员
+ if ctx.user.role == "admin":
+ return ctx
+
+ # workspace 管理员
+ membership = (
+ db.query(WorkspaceMembership)
+ .filter(
+ WorkspaceMembership.workspace_id == ctx.workspace_id,
+ WorkspaceMembership.user_id == ctx.user.id,
+ )
+ .first()
+ )
+ if not membership or membership.role != "admin":
+ raise HTTPException(status_code=403, detail="需要工作区管理员权限")
+
+ return ctx
+
+
+class WorkspacePermission:
+ """细粒度权限检查依赖 — 桥接系统 RBAC 与 workspace 上下文。
+
+ 用法:
+ @router.delete("/agents/{id}")
+ async def delete_agent(
+ ...,
+ ctx: WorkspaceContext = Depends(WorkspacePermission("agent:delete")),
+ ):
+ """
+
+ def __init__(self, permission_code: str):
+ self.permission_code = permission_code
+
+ async def __call__(
+ self,
+ ctx: WorkspaceContext = Depends(get_workspace_membership),
+ db: Session = Depends(get_db),
+ ) -> WorkspaceContext:
+ # 平台管理员总是通过
+ if ctx.user.role == "admin":
+ return ctx
+
+ # workspace 管理员拥有该 workspace 内所有权限
+ membership = (
+ db.query(WorkspaceMembership)
+ .filter(
+ WorkspaceMembership.workspace_id == ctx.workspace_id,
+ WorkspaceMembership.user_id == ctx.user.id,
+ )
+ .first()
+ )
+ if membership and membership.role == "admin":
+ return ctx
+
+ # 检查系统级 RBAC 权限
+ if _user_has_permission(db, ctx.user.id, self.permission_code):
+ return ctx
+
+ raise HTTPException(status_code=403, detail=f"缺少权限: {self.permission_code}")
+
+
+def _user_has_permission(db: Session, user_id: str, permission_code: str) -> bool:
+ """检查用户是否拥有指定权限(通过系统级 RBAC)。"""
+ from app.models.permission import Role, Permission
+ from app.models.user import User
+ result = (
+ db.query(Permission)
+ .join(Permission.roles)
+ .join(Role.users)
+ .filter(
+ Permission.code == permission_code,
+ User.id == user_id,
+ )
+ .first()
+ )
+ return result is not None
diff --git a/backend/app/api/knowledge_dashboard.py b/backend/app/api/knowledge_dashboard.py
index 52bf772..a164c03 100644
--- a/backend/app/api/knowledge_dashboard.py
+++ b/backend/app/api/knowledge_dashboard.py
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.api.auth import get_current_user
+from app.api.deps import get_current_workspace_id
from app.models.user import User
from app.models.knowledge_entry import KnowledgeEntry
from app.services.bottleneck_detector import bottleneck_detector
@@ -53,19 +54,17 @@ def get_knowledge_entries(
limit: int = Query(50, ge=1, le=200, description="返回条数"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
+ workspace_id: str = Depends(get_current_workspace_id),
):
- """获取知识条目列表,按创建时间倒序。"""
+ """获取当前工作区知识条目列表,按创建时间倒序。"""
since = datetime.now() - timedelta(days=days)
- entries = (
- db.query(KnowledgeEntry)
- .filter(
- KnowledgeEntry.created_at >= since,
- KnowledgeEntry.is_active == True,
- )
- .order_by(KnowledgeEntry.created_at.desc())
- .limit(limit)
- .all()
+ q = db.query(KnowledgeEntry).filter(
+ KnowledgeEntry.created_at >= since,
+ KnowledgeEntry.is_active == True,
)
+ if workspace_id:
+ q = q.filter(KnowledgeEntry.workspace_id == workspace_id)
+ entries = q.order_by(KnowledgeEntry.created_at.desc()).limit(limit).all()
return [e.to_dict() for e in entries]
@@ -74,24 +73,24 @@ def get_knowledge_trend(
days: int = Query(7, ge=1, le=365, description="统计天数"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
+ workspace_id: str = Depends(get_current_workspace_id),
):
- """知识条目增长趋势 — 按天统计新增数量。"""
+ """知识条目增长趋势(当前工作区)— 按天统计新增数量。"""
since = datetime.now() - timedelta(days=days)
- # GROUP BY date(created_at)
- rows = (
- db.query(
- func.date(KnowledgeEntry.created_at).label("date"),
- func.count(KnowledgeEntry.id).label("count"),
- )
- .filter(
- KnowledgeEntry.created_at >= since,
- KnowledgeEntry.is_active == True,
- )
- .group_by(func.date(KnowledgeEntry.created_at))
- .order_by(func.date(KnowledgeEntry.created_at).asc())
- .all()
+ q = db.query(
+ func.date(KnowledgeEntry.created_at).label("date"),
+ func.count(KnowledgeEntry.id).label("count"),
+ ).filter(
+ KnowledgeEntry.created_at >= since,
+ KnowledgeEntry.is_active == True,
)
+ if workspace_id:
+ q = q.filter(KnowledgeEntry.workspace_id == workspace_id)
+
+ rows = q.group_by(func.date(KnowledgeEntry.created_at)).order_by(
+ func.date(KnowledgeEntry.created_at).asc()
+ ).all()
# Fill in missing dates with 0 count
trend = []
diff --git a/backend/app/api/model_configs.py b/backend/app/api/model_configs.py
index 23090a9..8d01450 100644
--- a/backend/app/api/model_configs.py
+++ b/backend/app/api/model_configs.py
@@ -10,6 +10,7 @@ import logging
from app.core.database import get_db
from app.models.model_config import ModelConfig
from app.api.auth import get_current_user
+from app.api.deps import require_workspace_admin, WorkspaceContext
from app.models.user import User
from app.core.exceptions import NotFoundError, ValidationError, ConflictError
from app.services.encryption_service import EncryptionService
@@ -207,10 +208,11 @@ async def update_model_config(
async def delete_model_config(
config_id: str,
db: Session = Depends(get_db),
- current_user: User = Depends(get_current_user)
+ current_user: User = Depends(get_current_user),
+ _ws_admin: WorkspaceContext = Depends(require_workspace_admin),
):
"""
- 删除模型配置
+ 删除模型配置(仅工作区管理员和平台管理员可操作)
"""
config = db.query(ModelConfig).filter(
ModelConfig.id == config_id,
diff --git a/backend/app/api/plugins.py b/backend/app/api/plugins.py
index c5a7529..a171951 100644
--- a/backend/app/api/plugins.py
+++ b/backend/app/api/plugins.py
@@ -11,6 +11,7 @@ import logging
from app.core.database import get_db
from app.api.auth import get_current_user
+from app.api.deps import get_current_workspace_id
from app.models.user import User
from app.models.plugin import NodePlugin
from app.services.plugin_loader import (
@@ -202,11 +203,14 @@ async def create_plugin(
async def get_plugin(
plugin_id: str,
db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
):
- """获取插件详情。"""
+ """获取插件详情(仅所有者或公开插件可查看)。"""
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
if not plugin:
raise HTTPException(status_code=404, detail="插件不存在")
+ if not plugin.is_public and plugin.user_id and plugin.user_id != current_user.id:
+ raise HTTPException(status_code=403, detail="无权查看此插件")
return plugin
@@ -222,7 +226,7 @@ async def update_plugin(
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
if not plugin:
raise HTTPException(status_code=404, detail="插件不存在")
- if plugin.user_id and plugin.user_id != current_user.id:
+ if plugin.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权修改此插件")
for field, value in body.dict(exclude_unset=True).items():
@@ -251,7 +255,7 @@ async def delete_plugin(
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
if not plugin:
raise HTTPException(status_code=404, detail="插件不存在")
- if plugin.user_id and plugin.user_id != current_user.id:
+ if plugin.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权删除此插件")
unload_plugin_code(plugin.id)
@@ -271,6 +275,8 @@ async def toggle_plugin(
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
if not plugin:
raise HTTPException(status_code=404, detail="插件不存在")
+ if plugin.user_id != current_user.id:
+ raise HTTPException(status_code=403, detail="无权操作此插件")
plugin.enabled = not plugin.enabled
if plugin.enabled:
@@ -310,11 +316,14 @@ async def test_plugin(
plugin_id: str,
body: PluginExecuteRequest,
db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
):
- """在沙箱中测试执行插件。"""
+ """在沙箱中测试执行插件(仅所有者可测试)。"""
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
if not plugin:
raise HTTPException(status_code=404, detail="插件不存在")
+ if plugin.user_id != current_user.id:
+ raise HTTPException(status_code=403, detail="无权测试此插件")
if not plugin.code:
raise HTTPException(status_code=400, detail="插件无代码")
diff --git a/backend/app/api/system_logs.py b/backend/app/api/system_logs.py
index 3e537e3..904e8d0 100644
--- a/backend/app/api/system_logs.py
+++ b/backend/app/api/system_logs.py
@@ -110,26 +110,25 @@ def _build_union_query(
AgentExecutionLog.id.label("id"),
text("'agent'").label("source"),
case(
- (AgentExecutionLog.status == "failed", "ERROR"),
- (AgentExecutionLog.status == "completed", "INFO"),
+ (AgentExecutionLog.success == False, "ERROR"),
else_="INFO"
).label("level"),
- AgentExecutionLog.user_message.label("message"),
+ AgentExecutionLog.input_text.label("message"),
AgentExecutionLog.created_at.label("timestamp"),
text("'agent_chat'").label("resource_type"),
AgentExecutionLog.agent_id.label("resource_id"),
- AgentExecutionLog.total_latency_ms.label("duration_ms"),
+ AgentExecutionLog.latency_ms.label("duration_ms"),
text("NULL").label("username"),
)
if level:
if level.upper() == "ERROR":
- q = q.filter(AgentExecutionLog.status == "failed")
+ q = q.filter(AgentExecutionLog.success == False)
elif level.upper() == "WARN":
- q = q.filter(AgentExecutionLog.status == "failed")
+ q = q.filter(AgentExecutionLog.success == False)
else:
- q = q.filter(AgentExecutionLog.status != "failed")
+ q = q.filter(AgentExecutionLog.success == True)
if keyword:
- q = q.filter(AgentExecutionLog.user_message.contains(keyword))
+ q = q.filter(AgentExecutionLog.input_text.contains(keyword))
if start_date:
q = q.filter(AgentExecutionLog.created_at >= start_date)
if end_date:
@@ -195,7 +194,6 @@ async def get_system_logs(
):
"""
统一日志查询:跨 execution_logs / agent_execution_logs / agent_llm_logs 联合查询。
-
管理员可查全部,普通用户只能查看自己相关的 Agent 执行日志和 LLM 日志。
"""
_check_admin(current_user)
@@ -209,22 +207,91 @@ async def get_system_logs(
if getattr(current_user, "role", None) != "admin":
user_id = current_user.id
- source_val = source if source else "all"
- queries = _build_union_query(db, source_val, level, keyword, sd, ed, user_id)
+ results: list[UnifiedLogItem] = []
- if not queries:
- return []
+ # 1) 工作流执行日志
+ if source in (None, "all", "execution"):
+ q = db.query(ExecutionLog)
+ if level:
+ q = q.filter(ExecutionLog.level == level.upper())
+ if keyword:
+ q = q.filter(ExecutionLog.message.contains(keyword))
+ if sd:
+ q = q.filter(ExecutionLog.timestamp >= sd)
+ if ed:
+ q = q.filter(ExecutionLog.timestamp <= ed)
+ for row in q.order_by(ExecutionLog.timestamp.desc()).offset(skip).limit(limit).all():
+ results.append(UnifiedLogItem(
+ id=row.id,
+ source="execution",
+ level=row.level,
+ message=row.message or "",
+ timestamp=row.timestamp,
+ resource_type=row.node_type,
+ resource_id=row.execution_id,
+ duration_ms=row.duration,
+ ))
- # UNION ALL
- union = queries[0]
- for q in queries[1:]:
- union = union.union_all(q)
+ # 2) Agent 执行日志
+ if source in (None, "all", "agent"):
+ q = db.query(AgentExecutionLog)
+ if keyword:
+ q = q.filter(AgentExecutionLog.input_text.contains(keyword))
+ if sd:
+ q = q.filter(AgentExecutionLog.created_at >= sd)
+ if ed:
+ q = q.filter(AgentExecutionLog.created_at <= ed)
+ if user_id:
+ q = q.filter(AgentExecutionLog.user_id == user_id)
+ if level:
+ if level.upper() == "ERROR":
+ q = q.filter(AgentExecutionLog.success == False)
+ elif level.upper() != "WARN":
+ q = q.filter(AgentExecutionLog.success == True)
+ for row in q.order_by(AgentExecutionLog.created_at.desc()).offset(skip).limit(limit).all():
+ results.append(UnifiedLogItem(
+ id=row.id,
+ source="agent",
+ level="ERROR" if not row.success else "INFO",
+ message=row.input_text or "",
+ timestamp=row.created_at,
+ resource_type="agent_chat",
+ resource_id=row.agent_id,
+ duration_ms=row.latency_ms,
+ ))
- # 排序 + 分页
- total = union.count() if hasattr(union, 'count') else 0
- rows = union.order_by(text("timestamp DESC")).offset(skip).limit(limit).all()
+ # 3) LLM 调用日志
+ if source in (None, "all", "llm"):
+ q = db.query(AgentLLMLog)
+ if keyword:
+ q = q.filter(AgentLLMLog.error_message.contains(keyword))
+ if sd:
+ q = q.filter(AgentLLMLog.created_at >= sd)
+ if ed:
+ q = q.filter(AgentLLMLog.created_at <= ed)
+ if level:
+ if level.upper() == "ERROR":
+ q = q.filter(AgentLLMLog.status == "error")
+ elif level.upper() == "WARN":
+ q = q.filter(AgentLLMLog.status == "rate_limited")
+ else:
+ q = q.filter(AgentLLMLog.status == "success")
+ for row in q.order_by(AgentLLMLog.created_at.desc()).offset(skip).limit(limit).all():
+ msg = row.error_message or f"LLM call: {row.model or 'unknown'} ({row.step_type or '?'})"
+ results.append(UnifiedLogItem(
+ id=row.id,
+ source="llm",
+ level="ERROR" if row.status == "error" else ("WARN" if row.status == "rate_limited" else "INFO"),
+ message=msg,
+ timestamp=row.created_at,
+ resource_type="llm_call",
+ resource_id=row.agent_id,
+ duration_ms=row.latency_ms,
+ ))
- return rows
+ # 按时间排序 + 分页
+ results.sort(key=lambda x: x.timestamp or datetime.min, reverse=True)
+ return results[skip:skip + limit]
@router.get("/stats", response_model=LogStatsResponse)
@@ -258,7 +325,7 @@ async def get_system_logs_stats(
).scalar() or 0
agent_errors = db.query(func.count(AgentExecutionLog.id)).filter(
AgentExecutionLog.created_at >= today_start,
- AgentExecutionLog.status == "failed"
+ AgentExecutionLog.success == False
).scalar() or 0
# LLM 调用日志统计
diff --git a/backend/app/api/tools.py b/backend/app/api/tools.py
index 219e852..ec6e4c4 100644
--- a/backend/app/api/tools.py
+++ b/backend/app/api/tools.py
@@ -13,6 +13,7 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.auth import get_current_user
+from app.api.deps import require_workspace_admin, WorkspaceContext
from app.core.database import get_db
from app.models.tool import Tool
from app.models.user import User
@@ -355,8 +356,12 @@ async def delete_tool(
@router.post("/reload")
-async def reload_tools(db: Session = Depends(get_db)):
- """从数据库重新加载所有自定义工具到注册表(热更新)。"""
+async def reload_tools(
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+ _ws_admin: WorkspaceContext = Depends(require_workspace_admin),
+):
+ """从数据库重新加载所有自定义工具到注册表(热更新,仅工作区管理员可操作)。"""
# 清除旧的自定义工具
for name in list(tool_registry._custom_tool_configs.keys()):
if name not in tool_registry._builtin_tools:
diff --git a/backend/app/api/users.py b/backend/app/api/users.py
new file mode 100644
index 0000000..8eba819
--- /dev/null
+++ b/backend/app/api/users.py
@@ -0,0 +1,264 @@
+"""
+Admin 用户管理 API(仅平台管理员可访问)
+"""
+from __future__ import annotations
+
+import logging
+import uuid
+from datetime import datetime
+from typing import List, Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import BaseModel, Field, EmailStr
+from sqlalchemy.orm import Session
+from sqlalchemy import func
+
+from app.api.auth import get_current_user
+from app.core.database import get_db
+from app.core.security import get_password_hash
+from app.models.user import User
+from app.models.workspace import WorkspaceMembership
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/v1/admin/users", tags=["admin-users"])
+
+
+def _require_admin(current_user: User = Depends(get_current_user)) -> User:
+ if current_user.role != "admin":
+ raise HTTPException(status_code=403, detail="仅平台管理员可访问")
+ return current_user
+
+
+# ── Request / Response schemas ──
+
+class UserCreateRequest(BaseModel):
+ username: str = Field(..., min_length=2, max_length=50)
+ email: EmailStr
+ password: str = Field(..., min_length=6, max_length=128)
+ role: str = Field(default="user")
+ phone: Optional[str] = None
+ status: str = Field(default="active")
+
+
+class UserUpdateRequest(BaseModel):
+ username: Optional[str] = Field(None, min_length=2, max_length=50)
+ email: Optional[EmailStr] = None
+ role: Optional[str] = None
+ phone: Optional[str] = None
+ status: Optional[str] = None
+ subscription_tier: Optional[str] = None
+
+
+class ResetPasswordRequest(BaseModel):
+ new_password: str = Field(..., min_length=6, max_length=128)
+
+
+class UserItem(BaseModel):
+ id: str
+ username: str
+ email: str
+ role: str
+ phone: Optional[str] = None
+ phone_verified: bool = False
+ status: str
+ is_email_verified: bool = False
+ subscription_tier: str = "free"
+ daily_usage_count: int = 0
+ workspace_count: int = 0
+ created_at: Optional[datetime] = None
+ updated_at: Optional[datetime] = None
+
+ class Config:
+ from_attributes = True
+
+
+class UserListResponse(BaseModel):
+ items: List[UserItem]
+ total: int
+ page: int
+ page_size: int
+
+
+# ── Endpoints ──
+
+@router.get("", response_model=UserListResponse)
+def list_users(
+ page: int = Query(1, ge=1),
+ page_size: int = Query(20, ge=1, le=100),
+ search: Optional[str] = Query(None, description="搜索用户名或邮箱"),
+ status: Optional[str] = Query(None, description="过滤状态: active/disabled/deleted"),
+ role: Optional[str] = Query(None, description="过滤角色"),
+ db: Session = Depends(get_db),
+ _admin: User = Depends(_require_admin),
+):
+ """获取用户列表(分页、搜索、过滤)"""
+ q = db.query(User)
+
+ if search:
+ like = f"%{search}%"
+ q = q.filter((User.username.ilike(like)) | (User.email.ilike(like)))
+ if status:
+ q = q.filter(User.status == status)
+ if role:
+ q = q.filter(User.role == role)
+
+ total = q.count()
+ items = q.order_by(User.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
+
+ # 获取每个用户的 workspace 数量
+ user_ids = [u.id for u in items]
+ ws_counts = {}
+ if user_ids:
+ rows = (
+ db.query(
+ WorkspaceMembership.user_id,
+ func.count(WorkspaceMembership.workspace_id).label("cnt"),
+ )
+ .filter(WorkspaceMembership.user_id.in_(user_ids))
+ .group_by(WorkspaceMembership.user_id)
+ .all()
+ )
+ ws_counts = {r.user_id: r.cnt for r in rows}
+
+ return {
+ "items": [
+ {**{c.name: getattr(u, c.name) for c in User.__table__.columns},
+ "workspace_count": ws_counts.get(u.id, 0)}
+ for u in items
+ ],
+ "total": total,
+ "page": page,
+ "page_size": page_size,
+ }
+
+
+@router.post("", response_model=UserItem)
+def create_user(
+ data: UserCreateRequest,
+ db: Session = Depends(get_db),
+ _admin: User = Depends(_require_admin),
+):
+ """管理员创建新用户"""
+ if db.query(User).filter(User.username == data.username).first():
+ raise HTTPException(status_code=409, detail="用户名已存在")
+ if db.query(User).filter(User.email == data.email).first():
+ raise HTTPException(status_code=409, detail="邮箱已存在")
+
+ user = User(
+ id=str(uuid.uuid4()),
+ username=data.username,
+ email=data.email,
+ password_hash=get_password_hash(data.password),
+ role=data.role,
+ phone=data.phone,
+ status=data.status,
+ )
+ db.add(user)
+ db.commit()
+ db.refresh(user)
+
+ logger.info("管理员创建了用户: %s (%s)", user.username, user.id)
+ return {**{c.name: getattr(user, c.name) for c in User.__table__.columns}, "workspace_count": 0}
+
+
+@router.get("/{user_id}", response_model=UserItem)
+def get_user(
+ user_id: str,
+ db: Session = Depends(get_db),
+ _admin: User = Depends(_require_admin),
+):
+ """获取用户详情"""
+ user = db.query(User).filter(User.id == user_id).first()
+ if not user:
+ raise HTTPException(status_code=404, detail="用户不存在")
+
+ ws_count = (
+ db.query(func.count(WorkspaceMembership.workspace_id))
+ .filter(WorkspaceMembership.user_id == user_id)
+ .scalar()
+ ) or 0
+
+ return {**{c.name: getattr(user, c.name) for c in User.__table__.columns}, "workspace_count": ws_count}
+
+
+@router.put("/{user_id}", response_model=UserItem)
+def update_user(
+ user_id: str,
+ data: UserUpdateRequest,
+ db: Session = Depends(get_db),
+ _admin: User = Depends(_require_admin),
+):
+ """编辑用户"""
+ user = db.query(User).filter(User.id == user_id).first()
+ if not user:
+ raise HTTPException(status_code=404, detail="用户不存在")
+
+ if data.username is not None and data.username != user.username:
+ if db.query(User).filter(User.username == data.username, User.id != user_id).first():
+ raise HTTPException(status_code=409, detail="用户名已存在")
+ user.username = data.username
+ if data.email is not None and data.email != user.email:
+ if db.query(User).filter(User.email == data.email, User.id != user_id).first():
+ raise HTTPException(status_code=409, detail="邮箱已存在")
+ user.email = data.email
+ if data.role is not None:
+ user.role = data.role
+ if data.phone is not None:
+ user.phone = data.phone
+ if data.status is not None:
+ if data.status not in ("active", "disabled", "deleted"):
+ raise HTTPException(status_code=400, detail="无效的状态值")
+ user.status = data.status
+ if data.subscription_tier is not None:
+ user.subscription_tier = data.subscription_tier
+
+ db.commit()
+ db.refresh(user)
+
+ logger.info("管理员更新了用户: %s (%s)", user.username, user.id)
+ ws_count = (
+ db.query(func.count(WorkspaceMembership.workspace_id))
+ .filter(WorkspaceMembership.user_id == user_id)
+ .scalar()
+ ) or 0
+ return {**{c.name: getattr(user, c.name) for c in User.__table__.columns}, "workspace_count": ws_count}
+
+
+@router.delete("/{user_id}")
+def delete_user(
+ user_id: str,
+ db: Session = Depends(get_db),
+ _admin: User = Depends(_require_admin),
+):
+ """软删除用户(标记为 deleted)"""
+ user = db.query(User).filter(User.id == user_id).first()
+ if not user:
+ raise HTTPException(status_code=404, detail="用户不存在")
+ if user.role == "admin":
+ raise HTTPException(status_code=400, detail="不能删除平台管理员")
+
+ user.status = "deleted"
+ db.commit()
+
+ logger.info("管理员软删除了用户: %s (%s)", user.username, user.id)
+ return {"message": "用户已删除"}
+
+
+@router.post("/{user_id}/reset-password")
+def reset_password(
+ user_id: str,
+ data: ResetPasswordRequest,
+ db: Session = Depends(get_db),
+ _admin: User = Depends(_require_admin),
+):
+ """重置用户密码"""
+ user = db.query(User).filter(User.id == user_id).first()
+ if not user:
+ raise HTTPException(status_code=404, detail="用户不存在")
+
+ user.password_hash = get_password_hash(data.new_password)
+ db.commit()
+
+ logger.info("管理员重置了用户 %s 的密码", user.username)
+ return {"message": "密码已重置"}
diff --git a/backend/app/api/websocket.py b/backend/app/api/websocket.py
index 71debfd..0bc689c 100644
--- a/backend/app/api/websocket.py
+++ b/backend/app/api/websocket.py
@@ -1,14 +1,19 @@
"""
-WebSocket API
+WebSocket API — 实时推送执行状态(workspace 隔离)
"""
-from fastapi import APIRouter, WebSocket, WebSocketDisconnect
+from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from app.websocket.manager import websocket_manager
from app.core.database import SessionLocal
+from app.core.security import decode_access_token
from app.models.execution import Execution
+from app.models.user import User
+from app.models.workspace import WorkspaceMembership
from typing import Optional
import json
import asyncio
+import logging
+logger = logging.getLogger(__name__)
router = APIRouter()
@@ -27,21 +32,83 @@ def _get_progress_from_redis(execution_id: str) -> Optional[dict]:
return None
+def _validate_ws_token(token: Optional[str], execution_id: str) -> dict:
+ """验证 WebSocket JWT token,返回 {user_id, workspace_id}。
+
+ 失败时返回包含 error/code 的字典,调用方应据此拒绝连接。
+ """
+ if not token:
+ return {"error": "缺少认证令牌", "code": 4001}
+
+ payload = decode_access_token(token)
+ if payload is None:
+ return {"error": "无效的访问令牌", "code": 4001}
+
+ user_id = payload.get("sub")
+ ws_id = payload.get("ws", "")
+ if not user_id:
+ return {"error": "无效的令牌载荷", "code": 4001}
+
+ # 验证用户存在且未被删除
+ db = SessionLocal()
+ try:
+ user = db.query(User).filter(User.id == user_id).first()
+ if not user:
+ return {"error": "用户不存在", "code": 4001}
+ if user.status == "deleted":
+ return {"error": "账号已注销", "code": 4003}
+
+ # 平台管理员绕过 workspace 检查
+ if user.role == "admin":
+ return {"user_id": user_id, "workspace_id": ws_id, "is_admin": True}
+
+ # 验证 workspace 成员资格
+ if ws_id:
+ membership = (
+ db.query(WorkspaceMembership)
+ .filter(
+ WorkspaceMembership.workspace_id == ws_id,
+ WorkspaceMembership.user_id == user_id,
+ )
+ .first()
+ )
+ if not membership:
+ return {"error": "无权访问此工作区", "code": 4003}
+
+ # 验证 execution 属于该 workspace
+ execution = db.query(Execution).filter(Execution.id == execution_id).first()
+ if execution and ws_id and execution.workspace_id and execution.workspace_id != ws_id:
+ return {"error": "无权查看此执行记录", "code": 4003}
+
+ return {"user_id": user_id, "workspace_id": ws_id}
+ finally:
+ db.close()
+
+
@router.websocket("/api/v1/ws/executions/{execution_id}")
async def websocket_execution_status(
websocket: WebSocket,
execution_id: str,
- token: Optional[str] = None
+ token: Optional[str] = Query(None),
):
"""
- WebSocket实时推送执行状态
+ WebSocket 实时推送执行状态(workspace 隔离)。
- Args:
- websocket: WebSocket连接
- execution_id: 执行记录ID
- token: JWT Token(可选,通过query参数传递)
+ 需要 JWT token 通过 query 参数传递: ?token=xxx
+ 连接建立前验证:JWT 有效性 → workspace 成员资格 → execution workspace 归属。
"""
- await websocket_manager.connect(websocket, execution_id)
+ # ── 认证 + 鉴权 ──
+ auth = _validate_ws_token(token, execution_id)
+ if "error" in auth:
+ await websocket.accept()
+ await websocket.send_json({"type": "error", "message": auth["error"], "code": auth["code"]})
+ await websocket.close(code=auth["code"])
+ return
+
+ user_id = auth["user_id"]
+ ws_id = auth.get("workspace_id", "")
+
+ await websocket_manager.connect(websocket, execution_id, workspace_id=ws_id)
db = SessionLocal()
@@ -139,7 +206,7 @@ async def websocket_execution_status(
except WebSocketDisconnect:
pass
except Exception as e:
- print(f"WebSocket错误: {e}")
+ logger.error("WebSocket 错误: %s", e)
try:
await websocket_manager.send_personal_message({
"type": "error",
diff --git a/backend/app/api/workflows.py b/backend/app/api/workflows.py
index b097fd3..89ae749 100644
--- a/backend/app/api/workflows.py
+++ b/backend/app/api/workflows.py
@@ -11,6 +11,7 @@ from app.core.database import get_db
from app.models.workflow import Workflow
from app.models.workflow_version import WorkflowVersion
from app.api.auth import get_current_user, UserResponse
+from app.api.deps import require_workspace_admin, WorkspaceContext
from app.models.user import User
from app.core.exceptions import NotFoundError, ValidationError, ConflictError
from app.services.workflow_validator import validate_workflow
@@ -340,9 +341,10 @@ async def update_workflow(
async def delete_workflow(
workflow_id: str,
db: Session = Depends(get_db),
- current_user: User = Depends(get_current_user)
+ current_user: User = Depends(get_current_user),
+ _ws_admin: WorkspaceContext = Depends(require_workspace_admin),
):
- """删除工作流(只有所有者可以删除)"""
+ """删除工作流(仅工作区管理员和平台管理员可操作)"""
workflow = db.query(Workflow).filter(Workflow.id == workflow_id).first()
if not workflow:
@@ -610,9 +612,10 @@ async def rollback_workflow_version(
version: int,
rollback_data: Optional[WorkflowVersionRollback] = None,
db: Session = Depends(get_db),
- current_user: User = Depends(get_current_user)
+ current_user: User = Depends(get_current_user),
+ _ws_admin: WorkspaceContext = Depends(require_workspace_admin),
):
- """回滚工作流到指定版本"""
+ """回滚工作流到指定版本(仅工作区管理员和平台管理员可操作)"""
# 验证工作流是否存在且属于当前用户
workflow = db.query(Workflow).filter(
Workflow.id == workflow_id,
diff --git a/backend/app/api/workspaces.py b/backend/app/api/workspaces.py
index 755371c..9dd3407 100644
--- a/backend/app/api/workspaces.py
+++ b/backend/app/api/workspaces.py
@@ -11,6 +11,7 @@ import logging
from app.core.database import get_db
from app.api.auth import get_current_user
+from app.api.users import _require_admin
from app.models.user import User
from app.models.workspace import Workspace, WorkspaceMembership
from app.services.workspace_service import check_workspace_access, get_user_workspaces
@@ -391,3 +392,165 @@ def remove_member(
db.commit()
return {"message": "成员已移除"}
+
+
+# ── 平台管理员端点 ──
+
+admin_router = APIRouter(
+ prefix="/api/v1/admin/workspaces",
+ tags=["admin-workspaces"],
+ responses={
+ 401: {"description": "未授权"},
+ 403: {"description": "仅平台管理员可访问"},
+ 404: {"description": "资源不存在"},
+ }
+)
+
+
+class AdminWorkspaceResponse(BaseModel):
+ id: str
+ name: str
+ description: Optional[str]
+ is_default: bool
+ owner_id: str
+ owner_name: str = ""
+ max_members: int
+ member_count: int = 0
+ status: str
+ created_at: Optional[str]
+ updated_at: Optional[str]
+
+
+class AdminWorkspaceListResponse(BaseModel):
+ items: List[AdminWorkspaceResponse]
+ total: int
+ page: int
+ page_size: int
+
+
+@admin_router.get("", response_model=AdminWorkspaceListResponse)
+def admin_list_workspaces(
+ page: int = Query(1, ge=1),
+ page_size: int = Query(20, ge=1, le=100),
+ search: Optional[str] = Query(None, description="搜索工作区名称"),
+ status: Optional[str] = Query(None, description="过滤状态: active/disabled/deleted"),
+ db: Session = Depends(get_db),
+ _admin: User = Depends(_require_admin),
+):
+ """平台管理员查看所有工作区(分页)。"""
+ q = db.query(Workspace)
+ if search:
+ q = q.filter(Workspace.name.ilike(f"%{search}%"))
+ if status:
+ q = q.filter(Workspace.status == status)
+
+ total = q.count()
+ workspaces = q.order_by(Workspace.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
+
+ # 批量查 owner 和 member_count
+ owner_ids = [w.owner_id for w in workspaces]
+ owners = {}
+ if owner_ids:
+ for u in db.query(User).filter(User.id.in_(owner_ids)).all():
+ owners[u.id] = u.username
+
+ items = []
+ for w in workspaces:
+ mc = (
+ db.query(WorkspaceMembership)
+ .filter(WorkspaceMembership.workspace_id == w.id)
+ .count()
+ )
+ items.append(AdminWorkspaceResponse(
+ id=w.id,
+ name=w.name,
+ description=w.description,
+ is_default=bool(w.is_default),
+ owner_id=w.owner_id,
+ owner_name=owners.get(w.owner_id, ""),
+ max_members=w.max_members,
+ member_count=mc,
+ status=w.status,
+ created_at=w.created_at.isoformat() if w.created_at else None,
+ updated_at=w.updated_at.isoformat() if w.updated_at else None,
+ ))
+
+ return AdminWorkspaceListResponse(items=items, total=total, page=page, page_size=page_size)
+
+
+@admin_router.get("/{workspace_id}", response_model=AdminWorkspaceResponse)
+def admin_get_workspace(
+ workspace_id: str,
+ db: Session = Depends(get_db),
+ _admin: User = Depends(_require_admin),
+):
+ """平台管理员查看任意工作区详情。"""
+ ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
+ if not ws:
+ raise NotFoundError("工作区", workspace_id)
+
+ owner = db.query(User).filter(User.id == ws.owner_id).first()
+ mc = (
+ db.query(WorkspaceMembership)
+ .filter(WorkspaceMembership.workspace_id == workspace_id)
+ .count()
+ )
+
+ return AdminWorkspaceResponse(
+ id=ws.id,
+ name=ws.name,
+ description=ws.description,
+ is_default=bool(ws.is_default),
+ owner_id=ws.owner_id,
+ owner_name=owner.username if owner else "",
+ max_members=ws.max_members,
+ member_count=mc,
+ status=ws.status,
+ created_at=ws.created_at.isoformat() if ws.created_at else None,
+ updated_at=ws.updated_at.isoformat() if ws.updated_at else None,
+ )
+
+
+@admin_router.put("/{workspace_id}")
+def admin_update_workspace(
+ workspace_id: str,
+ data: WorkspaceUpdate,
+ db: Session = Depends(get_db),
+ _admin: User = Depends(_require_admin),
+):
+ """平台管理员编辑任意工作区。"""
+ ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
+ if not ws:
+ raise NotFoundError("工作区", workspace_id)
+
+ if data.name is not None:
+ ws.name = data.name
+ if data.description is not None:
+ ws.description = data.description
+ if data.max_members is not None:
+ ws.max_members = data.max_members
+ if data.status is not None:
+ ws.status = data.status
+ ws.updated_at = datetime.utcnow()
+
+ db.commit()
+ return {"message": "工作区已更新"}
+
+
+@admin_router.delete("/{workspace_id}")
+def admin_delete_workspace(
+ workspace_id: str,
+ db: Session = Depends(get_db),
+ _admin: User = Depends(_require_admin),
+):
+ """平台管理员强制删除工作区(软删除)。"""
+ ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
+ if not ws:
+ raise NotFoundError("工作区", workspace_id)
+
+ ws.status = "deleted"
+ ws.updated_at = datetime.utcnow()
+ db.commit()
+
+ logger.info("平台管理员删除了工作区: %s (%s)", ws.name, ws.id)
+ return {"message": "工作区已删除"}
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index ed6397e..900a431 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -105,6 +105,9 @@ class Settings(BaseSettings):
# 外部访问地址(用于飞书通知中的详情链接等)
EXTERNAL_URL: str = ""
+ # SMS 短信服务配置(mock / tencent / aliyun)
+ SMS_PROVIDER: str = "mock"
+
# 飞书应用配置(用于发送消息通知到用户飞书)
FEISHU_APP_ID: str = ""
FEISHU_APP_SECRET: str = ""
diff --git a/backend/app/core/database.py b/backend/app/core/database.py
index 3ee95bd..17d3f72 100644
--- a/backend/app/core/database.py
+++ b/backend/app/core/database.py
@@ -77,4 +77,245 @@ def init_db():
import app.models.scene_contract
import app.models.team
import app.models.chat_message
+ import app.models.agent_session
+ import app.models.billing
Base.metadata.create_all(bind=engine)
+
+ # v1.1.0: 手机验证字段迁移(safe ALTER)
+ _run_safe_migrations()
+
+ # v1.3: Auto-seed RBAC roles & permissions
+ _seed_rbac()
+
+
+def _run_safe_migrations():
+ """安全迁移:对可能缺失的列执行 ALTER TABLE(幂等,失败不中断)。"""
+ import logging
+ from sqlalchemy import text
+ logger = logging.getLogger(__name__)
+ migrations = [
+ # v1.0: 手机验证
+ "ALTER TABLE users ADD COLUMN phone_verified TINYINT(1) DEFAULT 0",
+ "ALTER TABLE users ADD COLUMN phone_verified_at DATETIME NULL",
+ # v1.2: 订阅与用量
+ "ALTER TABLE users ADD COLUMN subscription_tier VARCHAR(20) DEFAULT 'free'",
+ "ALTER TABLE users ADD COLUMN subscription_expires_at DATETIME NULL",
+ "ALTER TABLE users ADD COLUMN daily_usage_count INT DEFAULT 0",
+ "ALTER TABLE users ADD COLUMN daily_usage_date DATE NULL",
+ # v1.3: 多租户 workspace_id
+ "ALTER TABLE agent_sessions ADD COLUMN workspace_id CHAR(36) NULL",
+ "ALTER TABLE agent_sessions ADD INDEX idx_session_workspace (workspace_id)",
+ "ALTER TABLE chat_messages ADD COLUMN workspace_id CHAR(36) NULL",
+ "ALTER TABLE chat_messages ADD INDEX idx_msg_workspace (workspace_id)",
+ "ALTER TABLE agent_execution_logs ADD COLUMN workspace_id CHAR(36) NULL",
+ "ALTER TABLE agent_execution_logs ADD INDEX idx_exec_workspace (workspace_id)",
+ "ALTER TABLE agent_llm_logs ADD COLUMN workspace_id CHAR(36) NULL",
+ "ALTER TABLE agent_llm_logs ADD INDEX idx_llm_workspace (workspace_id)",
+ "ALTER TABLE conversation_branches ADD COLUMN workspace_id CHAR(36) NULL",
+ "ALTER TABLE conversation_branches ADD INDEX idx_branch_workspace (workspace_id)",
+ "ALTER TABLE node_templates ADD COLUMN workspace_id CHAR(36) NULL",
+ "ALTER TABLE node_templates ADD INDEX idx_nt_workspace (workspace_id)",
+ "ALTER TABLE node_plugins ADD COLUMN workspace_id CHAR(36) NULL",
+ "ALTER TABLE node_plugins ADD INDEX idx_plugin_workspace (workspace_id)",
+ "ALTER TABLE knowledge_entries ADD COLUMN workspace_id CHAR(36) NULL",
+ "ALTER TABLE knowledge_entries ADD INDEX idx_ke_workspace (workspace_id)",
+ "ALTER TABLE alert_rules ADD COLUMN workspace_id CHAR(36) NULL",
+ "ALTER TABLE alert_rules ADD INDEX idx_ar_workspace (workspace_id)",
+ "ALTER TABLE alert_logs ADD COLUMN workspace_id CHAR(36) NULL",
+ "ALTER TABLE alert_logs ADD INDEX idx_al_workspace (workspace_id)",
+ "ALTER TABLE agent_ratings ADD COLUMN workspace_id CHAR(36) NULL",
+ "ALTER TABLE agent_ratings ADD INDEX idx_arating_workspace (workspace_id)",
+ "ALTER TABLE agent_favorites ADD COLUMN workspace_id CHAR(36) NULL",
+ "ALTER TABLE agent_favorites ADD INDEX idx_afav_workspace (workspace_id)",
+ "ALTER TABLE template_ratings ADD COLUMN workspace_id CHAR(36) NULL",
+ "ALTER TABLE template_ratings ADD INDEX idx_trating_workspace (workspace_id)",
+ "ALTER TABLE template_favorites ADD COLUMN workspace_id CHAR(36) NULL",
+ "ALTER TABLE template_favorites ADD INDEX idx_tfav_workspace (workspace_id)",
+ ]
+ for raw_sql in migrations:
+ try:
+ with engine.begin() as conn:
+ conn.execute(text(raw_sql))
+ logger.info("迁移执行: %s", raw_sql)
+ except Exception:
+ # 列已存在或其他错误,跳过
+ pass
+
+ # 回填已有数据的 workspace_id
+ _backfill_workspace_ids(logger)
+
+
+def _backfill_workspace_ids(logger):
+ """回填已有数据的 workspace_id(通过父表 FK 链推导)。"""
+ from sqlalchemy import text
+
+ backfills = [
+ # (table, fk_column, parent_table) — 通过父表的 workspace_id 回填
+ ("agent_sessions", "agent_id", "agents"),
+ ("agent_execution_logs", "agent_id", "agents"),
+ ("agent_llm_logs", "agent_id", "agents"),
+ ("agent_ratings", "agent_id", "agents"),
+ ("agent_favorites", "agent_id", "agents"),
+ ("template_ratings", "template_id", "workflow_templates"),
+ ("template_favorites", "template_id", "workflow_templates"),
+ ]
+
+ for table, fk_col, parent_table in backfills:
+ try:
+ with engine.begin() as conn:
+ result = conn.execute(text(
+ f"UPDATE {table} t "
+ f"JOIN {parent_table} p ON t.{fk_col} = p.id "
+ f"SET t.workspace_id = p.workspace_id "
+ f"WHERE t.workspace_id IS NULL AND p.workspace_id IS NOT NULL"
+ ))
+ if result.rowcount:
+ logger.info("回填 %s.workspace_id: %s 行", table, result.rowcount)
+ except Exception:
+ pass # 父表可能没有 workspace_id 列
+
+ # chat_messages: 通过 agent_sessions 回填
+ try:
+ with engine.begin() as conn:
+ result = conn.execute(text(
+ "UPDATE chat_messages cm "
+ "JOIN agent_sessions s ON cm.session_id = s.id "
+ "SET cm.workspace_id = s.workspace_id "
+ "WHERE cm.workspace_id IS NULL AND s.workspace_id IS NOT NULL"
+ ))
+ if result.rowcount:
+ logger.info("回填 chat_messages.workspace_id: %s 行", result.rowcount)
+ except Exception:
+ pass
+
+ # conversation_branches: 通过 agent_sessions 回填
+ try:
+ with engine.begin() as conn:
+ result = conn.execute(text(
+ "UPDATE conversation_branches cb "
+ "JOIN agent_sessions s ON cb.parent_session_id = s.id "
+ "SET cb.workspace_id = s.workspace_id "
+ "WHERE cb.workspace_id IS NULL AND s.workspace_id IS NOT NULL"
+ ))
+ if result.rowcount:
+ logger.info("回填 conversation_branches.workspace_id: %s 行", result.rowcount)
+ except Exception:
+ pass
+
+
+def _seed_rbac():
+ """Auto-seed system roles & permissions on startup (idempotent)."""
+ import logging
+ import uuid
+ from sqlalchemy import text
+
+ logger = logging.getLogger(__name__)
+
+ # Check if RBAC tables exist (may fail on fresh DB before create_all runs)
+ try:
+ from app.models.permission import Role, Permission
+ except Exception:
+ logger.warning("RBAC model not available, skip seeding")
+ return
+
+ db = SessionLocal()
+ try:
+ # ── Permissions ──
+ PERMISSIONS = [
+ {"name": "工作流-创建", "code": "workflow:create", "resource": "workflow", "action": "create"},
+ {"name": "工作流-查看", "code": "workflow:read", "resource": "workflow", "action": "read"},
+ {"name": "工作流-更新", "code": "workflow:update", "resource": "workflow", "action": "update"},
+ {"name": "工作流-删除", "code": "workflow:delete", "resource": "workflow", "action": "delete"},
+ {"name": "工作流-执行", "code": "workflow:execute", "resource": "workflow", "action": "execute"},
+ {"name": "工作流-分享", "code": "workflow:share", "resource": "workflow", "action": "share"},
+ {"name": "Agent-创建", "code": "agent:create", "resource": "agent", "action": "create"},
+ {"name": "Agent-查看", "code": "agent:read", "resource": "agent", "action": "read"},
+ {"name": "Agent-更新", "code": "agent:update", "resource": "agent", "action": "update"},
+ {"name": "Agent-删除", "code": "agent:delete", "resource": "agent", "action": "delete"},
+ {"name": "Agent-执行", "code": "agent:execute", "resource": "agent", "action": "execute"},
+ {"name": "Agent-部署", "code": "agent:deploy", "resource": "agent", "action": "deploy"},
+ {"name": "执行-查看", "code": "execution:read", "resource": "execution", "action": "read"},
+ {"name": "执行-取消", "code": "execution:cancel", "resource": "execution", "action": "cancel"},
+ {"name": "数据源-创建", "code": "data_source:create", "resource": "data_source", "action": "create"},
+ {"name": "数据源-查看", "code": "data_source:read", "resource": "data_source", "action": "read"},
+ {"name": "数据源-更新", "code": "data_source:update", "resource": "data_source", "action": "update"},
+ {"name": "数据源-删除", "code": "data_source:delete", "resource": "data_source", "action": "delete"},
+ {"name": "模型配置-创建", "code": "model_config:create", "resource": "model_config", "action": "create"},
+ {"name": "模型配置-查看", "code": "model_config:read", "resource": "model_config", "action": "read"},
+ {"name": "模型配置-更新", "code": "model_config:update", "resource": "model_config", "action": "update"},
+ {"name": "模型配置-删除", "code": "model_config:delete", "resource": "model_config", "action": "delete"},
+ {"name": "权限-管理", "code": "permission:manage", "resource": "permission", "action": "manage"},
+ ]
+
+ permission_map = {}
+ for perm_data in PERMISSIONS:
+ existing = db.query(Permission).filter(Permission.code == perm_data["code"]).first()
+ if existing:
+ permission_map[perm_data["code"]] = existing
+ else:
+ perm = Permission(
+ id=str(uuid.uuid4()),
+ name=perm_data["name"],
+ code=perm_data["code"],
+ resource=perm_data["resource"],
+ action=perm_data["action"],
+ )
+ db.add(perm)
+ permission_map[perm_data["code"]] = perm
+ db.commit()
+
+ # ── Roles ──
+ SYSTEM_ROLES = [
+ {"name": "admin", "description": "系统管理员,拥有所有权限", "is_system": True},
+ {"name": "developer", "description": "开发者,可以创建和管理工作流、Agent", "is_system": True},
+ {"name": "viewer", "description": "查看者,只能查看工作流和执行记录", "is_system": True},
+ {"name": "operator", "description": "操作员,可以执行工作流,但不能修改", "is_system": True},
+ ]
+
+ role_map = {}
+ for role_data in SYSTEM_ROLES:
+ existing = db.query(Role).filter(Role.name == role_data["name"]).first()
+ if existing:
+ role_map[role_data["name"]] = existing
+ else:
+ role = Role(
+ id=str(uuid.uuid4()),
+ name=role_data["name"],
+ description=role_data["description"],
+ is_system=role_data["is_system"],
+ )
+ db.add(role)
+ role_map[role_data["name"]] = role
+ db.commit()
+
+ # ── Role-Permission mappings ──
+ ROLE_PERMISSIONS = {
+ "admin": ["*"],
+ "developer": [
+ "workflow:create", "workflow:read", "workflow:update", "workflow:delete", "workflow:execute", "workflow:share",
+ "agent:create", "agent:read", "agent:update", "agent:delete", "agent:execute", "agent:deploy",
+ "execution:read", "execution:cancel",
+ "data_source:create", "data_source:read", "data_source:update", "data_source:delete",
+ "model_config:create", "model_config:read", "model_config:update", "model_config:delete",
+ ],
+ "viewer": ["workflow:read", "agent:read", "execution:read", "data_source:read", "model_config:read"],
+ "operator": ["workflow:read", "workflow:execute", "agent:read", "agent:execute", "execution:read", "execution:cancel"],
+ }
+
+ for role_name, perm_codes in ROLE_PERMISSIONS.items():
+ role = role_map.get(role_name)
+ if not role:
+ continue
+ if perm_codes == ["*"]:
+ role.permissions = list(permission_map.values())
+ else:
+ role.permissions = [permission_map[c] for c in perm_codes if c in permission_map]
+ db.commit()
+
+ logger.info("RBAC seeded: %d roles, %d permissions", len(role_map), len(permission_map))
+
+ except Exception:
+ db.rollback()
+ logger.warning("RBAC seed skipped (tables may not exist yet)", exc_info=True)
+ finally:
+ db.close()
diff --git a/backend/app/core/sms_service.py b/backend/app/core/sms_service.py
new file mode 100644
index 0000000..1b56819
--- /dev/null
+++ b/backend/app/core/sms_service.py
@@ -0,0 +1,83 @@
+"""
+SMS 服务抽象层
+Mock 模式:验证码打印到后端日志
+后续可接入阿里云短信 / 腾讯云 SMS,只需替换 Provider 实现
+"""
+import logging
+from abc import ABC, abstractmethod
+from app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+class SmsProvider(ABC):
+ """SMS 发送提供者基类"""
+
+ @abstractmethod
+ async def send(self, phone: str, code: str) -> bool:
+ """发送验证码短信,成功返回 True"""
+ ...
+
+
+class MockSmsProvider(SmsProvider):
+ """Mock 提供者 —— 验证码打印到后端日志(开发/测试环境用)"""
+
+ async def send(self, phone: str, code: str) -> bool:
+ logger.info("=" * 50)
+ logger.info("📱 [MockSMS] 验证码: %s → %s", code, phone)
+ logger.info("=" * 50)
+ return True
+
+
+class TencentSmsProvider(SmsProvider):
+ """腾讯云 SMS 提供者(待接入)"""
+
+ def __init__(self, secret_id: str, secret_key: str, app_id: str, sign_name: str, template_id: str):
+ self.secret_id = secret_id
+ self.secret_key = secret_key
+ self.app_id = app_id
+ self.sign_name = sign_name
+ self.template_id = template_id
+
+ async def send(self, phone: str, code: str) -> bool:
+ # TODO: 接入腾讯云 SMS SDK
+ logger.warning("TencentSmsProvider 尚未实现,验证码: %s → %s", code, phone)
+ return False
+
+
+class AliyunSmsProvider(SmsProvider):
+ """阿里云短信提供者(待接入)"""
+
+ def __init__(self, access_key_id: str, access_key_secret: str, sign_name: str, template_code: str):
+ self.access_key_id = access_key_id
+ self.access_key_secret = access_key_secret
+ self.sign_name = sign_name
+ self.template_code = template_code
+
+ async def send(self, phone: str, code: str) -> bool:
+ # TODO: 接入阿里云短信 SDK
+ logger.warning("AliyunSmsProvider 尚未实现,验证码: %s → %s", code, phone)
+ return False
+
+
+def get_sms_provider() -> SmsProvider:
+ """根据配置返回 SMS 提供者实例"""
+ provider_name = getattr(settings, "SMS_PROVIDER", "mock") or "mock"
+
+ if provider_name == "tencent":
+ return TencentSmsProvider(
+ secret_id=getattr(settings, "TENCENT_SMS_SECRET_ID", ""),
+ secret_key=getattr(settings, "TENCENT_SMS_SECRET_KEY", ""),
+ app_id=getattr(settings, "TENCENT_SMS_APP_ID", ""),
+ sign_name=getattr(settings, "TENCENT_SMS_SIGN_NAME", ""),
+ template_id=getattr(settings, "TENCENT_SMS_TEMPLATE_ID", ""),
+ )
+ elif provider_name == "aliyun":
+ return AliyunSmsProvider(
+ access_key_id=getattr(settings, "ALIYUN_SMS_ACCESS_KEY_ID", ""),
+ access_key_secret=getattr(settings, "ALIYUN_SMS_ACCESS_KEY_SECRET", ""),
+ sign_name=getattr(settings, "ALIYUN_SMS_SIGN_NAME", ""),
+ template_code=getattr(settings, "ALIYUN_SMS_TEMPLATE_CODE", ""),
+ )
+ else:
+ return MockSmsProvider()
diff --git a/backend/app/core/usage_middleware.py b/backend/app/core/usage_middleware.py
new file mode 100644
index 0000000..cdc16fc
--- /dev/null
+++ b/backend/app/core/usage_middleware.py
@@ -0,0 +1,119 @@
+"""
+用量追踪中间件:每日对话次数限制与强制执行
+"""
+import logging
+from datetime import date
+from fastapi import Request
+from starlette.middleware.base import BaseHTTPMiddleware
+from starlette.responses import JSONResponse
+from sqlalchemy.orm import Session
+
+from app.core.database import SessionLocal
+from app.core.security import decode_access_token
+from app.models.user import User
+
+logger = logging.getLogger(__name__)
+
+# 套餐每日限额
+TIER_DAILY_LIMITS = {
+ "free": 50,
+ "pro": 500,
+ "enterprise": 0, # 0 = 无限
+}
+
+# 需要计数的路径前缀
+COUNTED_PATH_PREFIXES = [
+ "/api/v1/agent-chat/",
+]
+
+# 不计数路径(导出、会话管理等非对话操作)
+EXCLUDED_PATH_PATTERNS = [
+ "/sessions/",
+ "/search-messages",
+ "/export",
+]
+
+
+class UsageMiddleware(BaseHTTPMiddleware):
+ """用量追踪中间件:检查每日限额 → 放行 → 递增计数"""
+
+ async def dispatch(self, request: Request, call_next):
+ path = request.url.path
+ should_count = (
+ request.method == "POST"
+ and any(path.startswith(p) for p in COUNTED_PATH_PREFIXES)
+ and not any(p in path for p in EXCLUDED_PATH_PATTERNS)
+ )
+
+ if not should_count:
+ return await call_next(request)
+
+ # 从 Authorization header 解码 user_id
+ user_id = None
+ auth_header = request.headers.get("Authorization", "")
+ if auth_header.startswith("Bearer "):
+ token = auth_header[7:]
+ try:
+ payload = decode_access_token(token)
+ user_id = payload.get("sub")
+ except Exception:
+ pass
+
+ if not user_id:
+ return await call_next(request)
+
+ db: Session = SessionLocal()
+ try:
+ user = db.query(User).filter(User.id == user_id).first()
+ if not user:
+ return await call_next(request)
+
+ today = date.today()
+
+ # 日期重置
+ if user.daily_usage_date and user.daily_usage_date < today:
+ user.daily_usage_count = 0
+ user.daily_usage_date = today
+ if not user.daily_usage_date:
+ user.daily_usage_date = today
+
+ tier = user.subscription_tier or "free"
+ limit = TIER_DAILY_LIMITS.get(tier, 50)
+ current = user.daily_usage_count or 0
+
+ # 检查是否已达限额
+ if limit > 0 and current >= limit:
+ db.close()
+ logger.warning("用户 %s 已达每日限额 %s/%s,拒绝请求", user_id, current, limit)
+ return JSONResponse(
+ status_code=429,
+ content={
+ "detail": f"今日对话次数已用完({current}/{limit})。升级 Pro 版享受每日 {TIER_DAILY_LIMITS.get('pro', 500)} 次对话。",
+ "daily_used": current,
+ "daily_limit": limit,
+ "upgrade_required": True,
+ },
+ headers={"X-Upgrade-Required": "true"},
+ )
+
+ # 未达限额:先放行,再递增
+ response = await call_next(request)
+
+ if response.status_code < 400:
+ # 递增计数(需要重新查询,因为 request 可能耗时较长)
+ db_user = db.query(User).filter(User.id == user_id).first()
+ if db_user:
+ db_user.daily_usage_count = (db_user.daily_usage_count or 0) + 1
+ db.commit()
+
+ new_count = db_user.daily_usage_count
+ if limit > 0 and new_count >= limit:
+ logger.info("用户 %s 已达每日限额 %s/%s", user_id, new_count, limit)
+
+ return response
+ except Exception as e:
+ db.rollback()
+ logger.error("用量追踪失败: %s", e)
+ return await call_next(request)
+ finally:
+ db.close()
diff --git a/backend/app/main.py b/backend/app/main.py
index 413d1ee..ea0cdcb 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -22,6 +22,7 @@ from app.core.database import init_db
from app.core.rate_limiter import RateLimiterMiddleware
from app.core.behavior_middleware import BehaviorCollectionMiddleware
from app.core.security_headers import SecurityHeadersMiddleware
+from app.core.usage_middleware import UsageMiddleware
from app.core.metrics import setup_metrics
_SECURITY_WARNED = False
@@ -198,6 +199,9 @@ app.add_middleware(SecurityHeadersMiddleware)
# 用户行为自动采集中间件
app.add_middleware(BehaviorCollectionMiddleware)
+# v1.2: 用量追踪中间件
+app.add_middleware(UsageMiddleware)
+
# 注册全局异常处理器
app.add_exception_handler(RequestValidationError, validation_exception_handler)
app.add_exception_handler(BaseAPIException, api_exception_handler)
@@ -510,10 +514,11 @@ async def startup_event():
logger.error(f"定时任务调度器启动失败: {e}")
# 注册路由
-from app.api import auth, workspaces, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_branches, agent_monitoring, knowledge_base, knowledge_dashboard, agent_schedules, notifications, feishu_bind, approval, orchestration_templates, plugins, agent_market, goals, tasks, system_logs, audit_logs, feedback, agent_swarm, push, voice, fcm, scene_contracts, teams, agent_memory, legal, app_update
+from app.api import auth, workspaces, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_sessions, agent_branches, agent_monitoring, knowledge_base, knowledge_dashboard, agent_schedules, notifications, feishu_bind, approval, orchestration_templates, plugins, agent_market, goals, tasks, system_logs, audit_logs, feedback, agent_swarm, push, voice, fcm, scene_contracts, teams, agent_memory, legal, app_update, analytics, billing, users
app.include_router(auth.router)
app.include_router(workspaces.router)
+app.include_router(workspaces.admin_router)
app.include_router(uploads.router)
app.include_router(workflows.router)
app.include_router(executions.router)
@@ -535,6 +540,7 @@ app.include_router(node_templates.router)
app.include_router(tools.router)
app.include_router(agent_branches.router)
app.include_router(agent_chat.router)
+app.include_router(agent_sessions.router)
app.include_router(agent_monitoring.router)
app.include_router(knowledge_base.router)
app.include_router(agent_schedules.router)
@@ -559,6 +565,9 @@ app.include_router(teams.router)
app.include_router(agent_memory.router)
app.include_router(legal.router)
app.include_router(app_update.router)
+app.include_router(analytics.router)
+app.include_router(billing.router)
+app.include_router(users.router)
if __name__ == "__main__":
import uvicorn
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 7dbae02..861b84d 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -34,5 +34,7 @@ from app.models.workspace import Workspace, WorkspaceMembership
from app.models.scene_contract import SceneContract
from app.models.team import Team, TeamMember
from app.models.chat_message import ChatMessage
+from app.models.agent_session import AgentSession
+from app.models.billing import BillingPlan, BillingOrder, UserSubscription, UsageRecord
-__all__ = ["User", "Workflow", "WorkflowVersion", "Agent", "GlobalKnowledge", "AgentRating", "AgentFavorite", "Execution", "ExecutionLog", "ModelConfig", "DataSource", "WorkflowTemplate", "TemplateRating", "TemplateFavorite", "NodeTemplate", "Role", "Permission", "WorkflowPermission", "AgentPermission", "AlertRule", "AlertLog", "PersistentUserMemory", "AgentLLMLog", "AgentVectorMemory", "AgentLearningPattern", "AgentSchedule", "KnowledgeBase", "Document", "DocumentChunk", "Notification", "UserFeishuOpenId", "NodePlugin", "OrchestrationTemplate", "Goal", "Task", "AgentExecutionLog", "UserBehaviorLog", "KnowledgeEntry", "UserFingerprint", "ShadowComparison", "FeedbackRecord", "AuditLog", "Workspace", "WorkspaceMembership", "SceneContract", "Team", "TeamMember", "ChatMessage"]
\ No newline at end of file
+__all__ = ["User", "Workflow", "WorkflowVersion", "Agent", "GlobalKnowledge", "AgentRating", "AgentFavorite", "Execution", "ExecutionLog", "ModelConfig", "DataSource", "WorkflowTemplate", "TemplateRating", "TemplateFavorite", "NodeTemplate", "Role", "Permission", "WorkflowPermission", "AgentPermission", "AlertRule", "AlertLog", "PersistentUserMemory", "AgentLLMLog", "AgentVectorMemory", "AgentLearningPattern", "AgentSchedule", "KnowledgeBase", "Document", "DocumentChunk", "Notification", "UserFeishuOpenId", "NodePlugin", "OrchestrationTemplate", "Goal", "Task", "AgentExecutionLog", "UserBehaviorLog", "KnowledgeEntry", "UserFingerprint", "ShadowComparison", "FeedbackRecord", "AuditLog", "Workspace", "WorkspaceMembership", "SceneContract", "Team", "TeamMember", "ChatMessage", "AgentSession", "BillingPlan", "BillingOrder", "UserSubscription", "UsageRecord"]
\ No newline at end of file
diff --git a/backend/app/models/agent.py b/backend/app/models/agent.py
index bb4db8c..d653f70 100644
--- a/backend/app/models/agent.py
+++ b/backend/app/models/agent.py
@@ -137,6 +137,7 @@ class AgentRating(Base):
id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="评分ID")
agent_id = Column(CHAR(36), nullable=False, index=True, comment="Agent ID")
user_id = Column(CHAR(36), ForeignKey("users.id"), nullable=False, comment="评分用户ID")
+ workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID")
rating = Column(Integer, nullable=False, comment="评分 1-5")
comment = Column(Text, nullable=True, comment="评论内容")
created_at = Column(DateTime, default=func.now(), comment="评分时间")
@@ -155,6 +156,7 @@ class AgentFavorite(Base):
id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="收藏ID")
agent_id = Column(CHAR(36), nullable=False, index=True, comment="Agent ID")
user_id = Column(CHAR(36), ForeignKey("users.id"), nullable=False, comment="收藏用户ID")
+ workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID")
created_at = Column(DateTime, default=func.now(), comment="收藏时间")
# 关系
diff --git a/backend/app/models/agent_execution_log.py b/backend/app/models/agent_execution_log.py
index 97a923e..24f6224 100644
--- a/backend/app/models/agent_execution_log.py
+++ b/backend/app/models/agent_execution_log.py
@@ -2,7 +2,7 @@
Agent 执行日志模型 — 结构化记录每次 Agent 执行的完整信息
用于知识自进化系统的数据基础
"""
-from sqlalchemy import Column, String, Text, Integer, DateTime, JSON, Float, Boolean
+from sqlalchemy import Column, String, Text, Integer, DateTime, JSON, Float, Boolean, ForeignKey
from sqlalchemy.dialects.mysql import CHAR
from app.core.database import Base
import uuid
@@ -20,6 +20,7 @@ class AgentExecutionLog(Base):
task_id = Column(String(36), nullable=True, index=True, comment="关联 Task ID")
user_id = Column(String(36), nullable=True, index=True, comment="用户 ID")
session_id = Column(String(100), nullable=True, comment="会话标识")
+ workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID")
# 输入/输出
input_text = Column(Text, nullable=True, comment="用户输入文本")
diff --git a/backend/app/models/agent_llm_log.py b/backend/app/models/agent_llm_log.py
index 161b3e4..d0f5185 100644
--- a/backend/app/models/agent_llm_log.py
+++ b/backend/app/models/agent_llm_log.py
@@ -15,6 +15,7 @@ class AgentLLMLog(Base):
agent_id = Column(CHAR(36), ForeignKey("agents.id"), nullable=True, comment="Agent ID")
session_id = Column(String(100), nullable=True, comment="会话ID")
user_id = Column(CHAR(36), ForeignKey("users.id"), nullable=True, comment="用户ID")
+ workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID")
model = Column(String(100), nullable=False, comment="模型名称")
provider = Column(String(50), nullable=True, comment="提供商")
prompt_tokens = Column(Integer, default=0, comment="提示 tokens")
diff --git a/backend/app/models/agent_session.py b/backend/app/models/agent_session.py
new file mode 100644
index 0000000..bdf56f2
--- /dev/null
+++ b/backend/app/models/agent_session.py
@@ -0,0 +1,24 @@
+"""
+会话表 — 独立于 chat_messages 管理会话元数据
+"""
+from sqlalchemy import Column, String, Text, Integer, DateTime, Boolean, ForeignKey, func
+from sqlalchemy.dialects.mysql import CHAR
+from app.core.database import Base
+import uuid
+
+
+class AgentSession(Base):
+ """Agent 会话表"""
+ __tablename__ = "agent_sessions"
+
+ id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="会话ID")
+ user_id = Column(CHAR(36), nullable=True, index=True, comment="用户ID")
+ workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID")
+ agent_id = Column(CHAR(36), nullable=True, index=True, comment="Agent ID")
+ title = Column(String(200), nullable=True, comment="会话标题")
+ is_pinned = Column(Boolean, default=False, comment="是否置顶")
+ created_at = Column(DateTime, default=func.now(), comment="创建时间")
+ updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间")
+
+ def __repr__(self):
+ return f""
diff --git a/backend/app/models/alert_rule.py b/backend/app/models/alert_rule.py
index a70104b..bbb7289 100644
--- a/backend/app/models/alert_rule.py
+++ b/backend/app/models/alert_rule.py
@@ -40,7 +40,8 @@ class AlertRule(Base):
# 用户关联
user_id = Column(CHAR(36), ForeignKey("users.id"), nullable=False, comment="创建者ID")
-
+ workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID")
+
# 时间戳
created_at = Column(DateTime, default=func.now(), comment="创建时间")
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间")
@@ -82,6 +83,7 @@ class AlertLog(Base):
triggered_at = Column(DateTime, default=func.now(), comment="触发时间")
acknowledged_at = Column(DateTime, comment="确认时间")
acknowledged_by = Column(CHAR(36), ForeignKey("users.id"), nullable=True, comment="确认人ID")
+ workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID")
__table_args__ = (
Index("idx_al_rule_id", "rule_id"),
diff --git a/backend/app/models/billing.py b/backend/app/models/billing.py
new file mode 100644
index 0000000..a52e929
--- /dev/null
+++ b/backend/app/models/billing.py
@@ -0,0 +1,93 @@
+"""
+付费体系模型:套餐、订单、订阅、用量记录
+"""
+from sqlalchemy import Boolean, Column, String, Integer, Float, DateTime, Date, Text, JSON, ForeignKey, func, Index
+from sqlalchemy.dialects.mysql import CHAR
+from sqlalchemy.orm import relationship
+from app.core.database import Base
+import uuid
+
+
+class BillingPlan(Base):
+ """套餐定义表"""
+ __tablename__ = "billing_plans"
+
+ id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="套餐ID")
+ name = Column(String(100), nullable=False, comment="套餐名称")
+ tier = Column(String(20), nullable=False, comment="套餐等级: free/pro/enterprise")
+ price_monthly = Column(Float, nullable=False, default=0, comment="月付价格(CNY)")
+ price_yearly = Column(Float, nullable=False, default=0, comment="年付价格(CNY)")
+ daily_quota = Column(Integer, nullable=False, default=0, comment="每日对话次数限制, 0=无限")
+ agent_limit = Column(Integer, nullable=False, default=0, comment="智能体数量限制, 0=无限")
+ knowledge_limit = Column(Integer, nullable=False, default=0, comment="知识条目限制, 0=无限")
+ file_upload_mb = Column(Integer, nullable=False, default=5, comment="文件上传大小限制(MB)")
+ models = Column(JSON, nullable=True, comment="可用模型列表")
+ features = Column(JSON, nullable=True, comment="权益特性列表")
+ is_active = Column(Boolean, default=True, comment="是否启用")
+ sort_order = Column(Integer, default=0, comment="排序")
+ created_at = Column(DateTime, default=func.now(), comment="创建时间")
+ updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间")
+
+ def __repr__(self):
+ return f""
+
+
+class BillingOrder(Base):
+ """订单记录表"""
+ __tablename__ = "billing_orders"
+
+ id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="订单ID")
+ user_id = Column(CHAR(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, comment="用户ID")
+ plan_id = Column(CHAR(36), ForeignKey("billing_plans.id", ondelete="SET NULL"), nullable=True, comment="套餐ID")
+ amount = Column(Float, nullable=False, comment="支付金额(CNY)")
+ period = Column(String(10), nullable=False, default="monthly", comment="周期: monthly/yearly")
+ status = Column(String(20), nullable=False, default="pending", comment="状态: pending/paid/cancelled/expired")
+ payment_method = Column(String(20), nullable=True, comment="支付方式: mock/wechat/alipay")
+ payment_ref = Column(String(255), nullable=True, comment="第三方支付流水号")
+ paid_at = Column(DateTime, nullable=True, comment="支付时间")
+ created_at = Column(DateTime, default=func.now(), comment="创建时间")
+ updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间")
+
+ user = relationship("User", backref="orders")
+
+ def __repr__(self):
+ return f""
+
+
+class UserSubscription(Base):
+ """用户订阅状态表"""
+ __tablename__ = "user_subscriptions"
+
+ id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="订阅ID")
+ user_id = Column(CHAR(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, comment="用户ID")
+ plan_id = Column(CHAR(36), ForeignKey("billing_plans.id", ondelete="SET NULL"), nullable=True, comment="当前套餐ID")
+ tier = Column(String(20), nullable=False, default="free", comment="当前等级")
+ status = Column(String(20), nullable=False, default="active", comment="状态: active/cancelled/expired")
+ started_at = Column(DateTime, nullable=True, comment="订阅开始时间")
+ expires_at = Column(DateTime, nullable=True, comment="订阅到期时间")
+ auto_renew = Column(Boolean, default=False, comment="是否自动续费")
+ created_at = Column(DateTime, default=func.now(), comment="创建时间")
+ updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间")
+
+ user = relationship("User", backref="subscription")
+ plan = relationship("BillingPlan")
+
+ def __repr__(self):
+ return f""
+
+
+class UsageRecord(Base):
+ """每日用量记录表"""
+ __tablename__ = "usage_records"
+ __table_args__ = (
+ Index("ix_usage_user_date", "user_id", "date", unique=True),
+ )
+
+ id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="记录ID")
+ user_id = Column(CHAR(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, comment="用户ID")
+ date = Column(Date, nullable=False, comment="日期")
+ count = Column(Integer, nullable=False, default=0, comment="当日对话次数")
+ created_at = Column(DateTime, default=func.now(), comment="创建时间")
+
+ def __repr__(self):
+ return f""
diff --git a/backend/app/models/chat_message.py b/backend/app/models/chat_message.py
index d04f5d4..260a069 100644
--- a/backend/app/models/chat_message.py
+++ b/backend/app/models/chat_message.py
@@ -15,6 +15,7 @@ class ChatMessage(Base):
session_id = Column(CHAR(36), nullable=False, comment="会话ID")
agent_id = Column(CHAR(36), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True, comment="智能体ID")
user_id = Column(CHAR(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, comment="用户ID")
+ workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID")
role = Column(String(20), nullable=False, comment="角色: user/assistant/tool/system")
content = Column(Text, comment="消息内容")
tool_name = Column(String(100), nullable=True, comment="工具名称(仅tool消息)")
@@ -33,6 +34,7 @@ class ChatMessage(Base):
"session_id": self.session_id,
"agent_id": self.agent_id,
"user_id": self.user_id,
+ "workspace_id": self.workspace_id,
"role": self.role,
"content": self.content,
"tool_name": self.tool_name,
diff --git a/backend/app/models/conversation_branch.py b/backend/app/models/conversation_branch.py
index f30c0bd..33d0ce2 100644
--- a/backend/app/models/conversation_branch.py
+++ b/backend/app/models/conversation_branch.py
@@ -7,7 +7,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
-from sqlalchemy import Column, String, Text, Integer, DateTime, JSON, Boolean
+from sqlalchemy import Column, String, Text, Integer, DateTime, JSON, Boolean, ForeignKey
from sqlalchemy.dialects.mysql import CHAR
from app.core.database import Base
@@ -19,6 +19,7 @@ class ConversationBranch(Base):
id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="分支ID")
user_id = Column(String(36), nullable=False, index=True, comment="用户ID")
+ workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID")
agent_id = Column(String(36), nullable=True, comment="关联Agent ID")
agent_name = Column(String(200), nullable=True, comment="Agent 名称(冗余便于展示)")
diff --git a/backend/app/models/knowledge_entry.py b/backend/app/models/knowledge_entry.py
index ba96bd6..fe382b1 100644
--- a/backend/app/models/knowledge_entry.py
+++ b/backend/app/models/knowledge_entry.py
@@ -3,7 +3,8 @@
"""
import uuid
from datetime import datetime
-from sqlalchemy import Column, String, Text, Integer, DateTime, Boolean, JSON, Float, Index
+from sqlalchemy import Column, String, Text, Integer, DateTime, Boolean, JSON, Float, Index, ForeignKey
+from sqlalchemy.dialects.mysql import CHAR
from app.core.database import Base
@@ -13,6 +14,7 @@ class KnowledgeEntry(Base):
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
agent_id = Column(String(36), nullable=True, index=True, comment="所属 Agent ID(NULL=全Agent共享)")
+ workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID")
title = Column(String(500), nullable=False, comment="知识标题(一句话概括)")
category = Column(String(30), nullable=False, index=True,
comment="类别: bug_fix/best_practice/workaround/optimization/insight")
@@ -55,6 +57,7 @@ class KnowledgeEntry(Base):
return {
"id": self.id,
"agent_id": self.agent_id,
+ "workspace_id": self.workspace_id,
"title": self.title,
"category": self.category,
"tags": self.tags or [],
diff --git a/backend/app/models/node_template.py b/backend/app/models/node_template.py
index 973cf42..bbc233e 100644
--- a/backend/app/models/node_template.py
+++ b/backend/app/models/node_template.py
@@ -34,6 +34,7 @@ class NodeTemplate(Base):
is_featured = Column(Boolean, default=False, comment="是否精选")
use_count = Column(Integer, default=0, comment="使用次数")
user_id = Column(CHAR(36), ForeignKey("users.id"), nullable=False, comment="创建者ID")
+ workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID")
created_at = Column(DateTime, default=func.now(), comment="创建时间")
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间")
@@ -67,6 +68,7 @@ class NodeTemplate(Base):
"is_featured": self.is_featured,
"use_count": self.use_count,
"user_id": self.user_id,
+ "workspace_id": self.workspace_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None
}
diff --git a/backend/app/models/plugin.py b/backend/app/models/plugin.py
index 7a8e16a..49d653e 100644
--- a/backend/app/models/plugin.py
+++ b/backend/app/models/plugin.py
@@ -1,7 +1,7 @@
"""
插件模型 — 第三方节点扩展
"""
-from sqlalchemy import Column, String, Text, Boolean, DateTime, JSON, Integer, func
+from sqlalchemy import Column, String, Text, Boolean, DateTime, JSON, Integer, ForeignKey, func
from sqlalchemy.dialects.mysql import CHAR
from app.core.database import Base
import uuid
@@ -40,6 +40,7 @@ class NodePlugin(Base):
icon = Column(String(256), comment="图标URL")
tags = Column(JSON, comment="标签列表")
user_id = Column(CHAR(36), nullable=True, comment="上传者ID")
+ workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID")
created_at = Column(DateTime, default=func.now(), comment="创建时间")
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间")
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index 769e30b..73440cc 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -1,7 +1,7 @@
"""
用户模型
"""
-from sqlalchemy import Boolean, Column, String, DateTime, func
+from sqlalchemy import Boolean, Column, String, Integer, DateTime, Date, func
from sqlalchemy.dialects.mysql import CHAR
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -20,11 +20,18 @@ class User(Base):
feishu_open_id = Column(String(64), nullable=True, comment="飞书用户 open_id,用于推送通知")
feishu_default_agent_id = Column(CHAR(36), nullable=True, comment="飞书对话默认 Agent ID")
phone = Column(String(20), nullable=True, comment="手机号")
+ phone_verified = Column(Boolean, default=False, comment="手机号是否已验证")
+ phone_verified_at = Column(DateTime, nullable=True, comment="手机号验证时间")
status = Column(String(20), default="active", comment="账号状态: active/disabled/deleted")
is_email_verified = Column(Boolean, default=False, comment="邮箱是否已验证")
agreed_terms = Column(Boolean, default=False, comment="是否同意用户协议")
agreed_terms_version = Column(String(20), nullable=True, comment="同意的协议版本")
agreed_terms_at = Column(DateTime, nullable=True, comment="同意协议的时间")
+ # v1.2: 订阅与用量
+ subscription_tier = Column(String(20), default="free", comment="订阅等级: free/pro/enterprise")
+ subscription_expires_at = Column(DateTime, nullable=True, comment="订阅到期时间")
+ daily_usage_count = Column(Integer, default=0, comment="今日对话次数")
+ daily_usage_date = Column(Date, nullable=True, comment="用量统计日期")
created_at = Column(DateTime, default=func.now(), comment="创建时间")
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间")
diff --git a/backend/app/models/workflow_template.py b/backend/app/models/workflow_template.py
index 48e08aa..abb5688 100644
--- a/backend/app/models/workflow_template.py
+++ b/backend/app/models/workflow_template.py
@@ -51,6 +51,7 @@ class TemplateRating(Base):
id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="评分ID")
template_id = Column(CHAR(36), ForeignKey("workflow_templates.id"), nullable=False, comment="模板ID")
user_id = Column(CHAR(36), ForeignKey("users.id"), nullable=False, comment="用户ID")
+ workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID")
rating = Column(Integer, nullable=False, comment="评分: 1-5")
comment = Column(Text, comment="评论")
created_at = Column(DateTime, default=func.now(), comment="创建时间")
@@ -71,6 +72,7 @@ class TemplateFavorite(Base):
id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="收藏ID")
template_id = Column(CHAR(36), ForeignKey("workflow_templates.id"), nullable=False, comment="模板ID")
user_id = Column(CHAR(36), ForeignKey("users.id"), nullable=False, comment="用户ID")
+ workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID")
created_at = Column(DateTime, default=func.now(), comment="创建时间")
# 关系
diff --git a/backend/app/websocket/manager.py b/backend/app/websocket/manager.py
index 2e092e0..1fed788 100644
--- a/backend/app/websocket/manager.py
+++ b/backend/app/websocket/manager.py
@@ -1,49 +1,47 @@
"""
-WebSocket连接管理器
+WebSocket连接管理器 — 支持 workspace 隔离
"""
-from typing import Dict, Set
+from typing import Dict, Set, Optional
from fastapi import WebSocket
import json
-import asyncio
class WebSocketManager:
- """WebSocket连接管理器"""
-
+ """WebSocket连接管理器(workspace 感知)"""
+
def __init__(self):
- """初始化管理器"""
# execution_id -> Set[WebSocket]
self.active_connections: Dict[str, Set[WebSocket]] = {}
-
- async def connect(self, websocket: WebSocket, execution_id: str):
- """
- 建立WebSocket连接
-
- Args:
- websocket: WebSocket连接
- execution_id: 执行记录ID
- """
+ # websocket -> workspace_id (用于 workspace 隔离广播)
+ self._ws_workspace: Dict[int, str] = {}
+
+ async def connect(
+ self, websocket: WebSocket, execution_id: str, workspace_id: Optional[str] = None
+ ):
+ """建立 WebSocket 连接,标记 workspace。"""
await websocket.accept()
-
+
if execution_id not in self.active_connections:
self.active_connections[execution_id] = set()
-
+
self.active_connections[execution_id].add(websocket)
-
+
+ if workspace_id:
+ self._ws_workspace[id(websocket)] = workspace_id
+
def disconnect(self, websocket: WebSocket, execution_id: str):
- """
- 断开WebSocket连接
-
- Args:
- websocket: WebSocket连接
- execution_id: 执行记录ID
- """
+ """断开 WebSocket 连接,清理 workspace 标记。"""
+ self._ws_workspace.pop(id(websocket), None)
+
if execution_id in self.active_connections:
self.active_connections[execution_id].discard(websocket)
-
- # 如果没有连接了,删除该execution_id
+
if not self.active_connections[execution_id]:
del self.active_connections[execution_id]
+
+ def get_workspace_id(self, websocket: WebSocket) -> Optional[str]:
+ """获取连接关联的 workspace_id。"""
+ return self._ws_workspace.get(id(websocket))
async def send_personal_message(self, message: dict, websocket: WebSocket):
"""
diff --git a/backend/scripts/seed_billing_plans.py b/backend/scripts/seed_billing_plans.py
new file mode 100644
index 0000000..be88e86
--- /dev/null
+++ b/backend/scripts/seed_billing_plans.py
@@ -0,0 +1,112 @@
+"""
+种子数据:初始化 3 个默认付费套餐
+用法: cd backend && ./venv/Scripts/python.exe scripts/seed_billing_plans.py
+"""
+import sys
+import os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from app.core.database import SessionLocal, init_db
+from app.models.billing import BillingPlan
+import uuid
+
+PLANS = [
+ {
+ "id": "a0000000-0000-0000-0000-000000000001",
+ "name": "免费版",
+ "tier": "free",
+ "price_monthly": 0,
+ "price_yearly": 0,
+ "daily_quota": 50,
+ "agent_limit": 3,
+ "knowledge_limit": 10,
+ "file_upload_mb": 5,
+ "models": ["deepseek-v4-pro"],
+ "features": [
+ "每日 50 次对话",
+ "3 个自建智能体",
+ "基础模型 DeepSeek-V3",
+ "10 条全局知识",
+ "5 MB 文件上传",
+ "社区支持",
+ ],
+ "is_active": True,
+ "sort_order": 0,
+ },
+ {
+ "id": "a0000000-0000-0000-0000-000000000002",
+ "name": "专业版",
+ "tier": "pro",
+ "price_monthly": 29,
+ "price_yearly": 198,
+ "daily_quota": 500,
+ "agent_limit": 20,
+ "knowledge_limit": 500,
+ "file_upload_mb": 50,
+ "models": ["deepseek-v4-pro", "gpt-4o", "claude-sonnet-4-6", "gemini-2.5-pro"],
+ "features": [
+ "每日 500 次对话",
+ "20 个自建智能体",
+ "全部高级模型",
+ "500 条全局知识",
+ "50 MB 文件上传",
+ "优先响应队列",
+ "对话历史云端同步",
+ "对话导出 Markdown/PDF",
+ "专属客服通道",
+ ],
+ "is_active": True,
+ "sort_order": 1,
+ },
+ {
+ "id": "a0000000-0000-0000-0000-000000000003",
+ "name": "企业版",
+ "tier": "enterprise",
+ "price_monthly": 99,
+ "price_yearly": 899,
+ "daily_quota": 0,
+ "agent_limit": 0,
+ "knowledge_limit": 0,
+ "file_upload_mb": 200,
+ "models": ["deepseek-v4-pro", "gpt-4o", "claude-sonnet-4-6", "gemini-2.5-pro"],
+ "features": [
+ "无限对话次数",
+ "无限智能体",
+ "团队协作(10 人)",
+ "全部知识库功能",
+ "200 MB 文件上传",
+ "最高优先响应",
+ "API 调用额度 ¥50/月",
+ "管理员面板",
+ "专属成功经理 + 电话支持",
+ ],
+ "is_active": True,
+ "sort_order": 2,
+ },
+]
+
+
+def seed():
+ init_db()
+ db = SessionLocal()
+ try:
+ for plan_data in PLANS:
+ existing = db.query(BillingPlan).filter(BillingPlan.id == plan_data["id"]).first()
+ if existing:
+ print(f"跳过已存在: {plan_data['name']}")
+ continue
+ plan = BillingPlan(**plan_data)
+ db.add(plan)
+ print(f"创建套餐: {plan_data['name']} (tier={plan_data['tier']})")
+ db.commit()
+ print("种子数据初始化完成")
+ except Exception as e:
+ db.rollback()
+ print(f"错误: {e}")
+ raise
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ seed()
diff --git a/docs/index.md b/docs/index.md
index edd41e6..48e2d95 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -19,6 +19,7 @@
| [🛠️ 开发指南](./development-guide.md) | 开发环境搭建、编码规范、测试指南 | 开发者 |
| [🚀 部署与运维指南](./deployment-guide.md) | 环境要求、Docker 部署、配置说明 | DevOps、运维 |
| [🔌 API 参考](./api-reference.md) | RESTful API 端点说明、请求/响应格式(含场景契约) | 前端开发者、集成方 |
+| [🔐 多租户与 RBAC 权限指南](./multi-tenant-rbac-guide.md) | 工作区隔离、角色权限、用户管理、管理员功能 | 管理员、高级用户 |
| [🔧 插件开发指南](./plugin-development-guide.md) | 自定义工作流节点插件开发(Code/HTTP 类型) | 开发者 |
| [🤖 虚拟团队](./virtual-team.md) | 虚拟软件公司团队:PM+设计师+开发+QA+DevOps 自动化协作开发 | 所有用户、开发者 |
| [🤝 贡献指南](./contributing.md) | 如何参与贡献、代码审查流程 | 贡献者 |
diff --git a/docs/multi-tenant-rbac-guide.md b/docs/multi-tenant-rbac-guide.md
new file mode 100644
index 0000000..25af751
--- /dev/null
+++ b/docs/multi-tenant-rbac-guide.md
@@ -0,0 +1,570 @@
+# 多租户与 RBAC 权限管理指南
+
+> **Multi-Tenant & RBAC Guide** — 工作区隔离、角色权限、用户管理完整说明
+
+本文档涵盖天工智能体平台 v1.2+ 的多租户架构、RBAC 权限体系、工作区管理和平台管理员功能。
+
+---
+
+## 一、多租户架构概览
+
+### 1.1 什么是多租户
+
+天工平台采用 **工作区 (Workspace)** 作为租户隔离的基本单元:
+
+```
+┌────────────────────────────────────────────────────┐
+│ 天工平台 │
+│ ┌──────────────┐ ┌──────────────┐ ┌───────────┐ │
+│ │ 工作区 A │ │ 工作区 B │ │ 工作区 C │ │
+│ │ (研发团队) │ │ (市场团队) │ │ (个人空间) │ │
+│ │ │ │ │ │ │ │
+│ │ Agent: 5 个 │ │ Agent: 3 个 │ │ Agent: 2 │ │
+│ │ 工作流: 8 个 │ │ 工作流: 4 个 │ │ 工作流: 3 │ │
+│ │ 知识库: 3 个 │ │ 知识库: 1 个 │ │ 知识库: 1 │ │
+│ │ 成员: 12 人 │ │ 成员: 5 人 │ │ 成员: 1 │ │
+│ └──────────────┘ └──────────────┘ └───────────┘ │
+└────────────────────────────────────────────────────┘
+```
+
+### 1.2 核心隔离原则
+
+| 隔离维度 | 说明 |
+|----------|------|
+| **数据隔离** | 每个工作区的 Agent、工作流、知识库、执行记录等数据完全隔离 |
+| **成员隔离** | 工作区 A 的成员默认无法访问工作区 B 的任何资源 |
+| **API 隔离** | 所有资源 API 自动按工作区过滤,返回结果仅限当前工作区 |
+| **WebSocket 隔离** | 实时推送按工作区频道隔离,不会跨工作区串消息 |
+
+### 1.3 JWT Token 中的工作区标识
+
+登录或切换工作区后,JWT Token 中会携带 `ws` 字段:
+
+```json
+{
+ "sub": "user-uuid-xxx",
+ "ws": "workspace-uuid-xxx",
+ "exp": 1712345678
+}
+```
+
+所有后续 API 请求自动按此工作区范围过滤数据。
+
+---
+
+## 二、工作区管理
+
+### 2.1 工作区列表
+
+进入「工作区管理」页面,查看你有权限访问的所有工作区:
+
+```
+┌──────────────────────────────────────────────────────┐
+│ 工作区管理 [+ 创建工作区] │
+│ │
+│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
+│ │ 研发团队 │ │ 个人空间 │ │ AI实验室 │ │
+│ │ [默认] [管理员]│ │ [成员] │ │ [管理员] │ │
+│ │ 描述... │ │ 描述... │ │ 描述... │ │
+│ │ 状态: 启用 │ │ 状态: 启用 │ │ 状态: 停用 │ │
+│ │ [进入] [管理]│ │ [进入] │ │ [进入] [管理]│ │
+│ └─────────────┘ └─────────────┘ └─────────────┘ │
+└──────────────────────────────────────────────────────┘
+```
+
+### 2.2 创建工作区
+
+1. 点击「创建工作区」按钮
+2. 填写工作区信息:
+
+| 字段 | 说明 | 限制 |
+|------|------|------|
+| 名称 | 工作区唯一标识 | 1-100 字符 |
+| 描述 | 可选说明信息 | 最多 1000 字符 |
+| 最大成员数 | 工作区成员上限 | 1-500,默认 50 |
+
+3. 创建者自动成为该工作区的 **管理员 (admin)**
+
+### 2.3 切换工作区
+
+- 在工作区列表中点击「进入」自动切换
+- 或通过顶部导航栏的工作区下拉选择器切换
+- 切换后 JWT Token 刷新,后续所有操作在新工作区上下文中执行
+
+### 2.4 管理工作区
+
+点击「管理」进入工作区详情页,包含三个标签页:
+
+**基本信息**
+- 修改名称、描述、最大成员数
+- 启用/停用工作区(停用后成员无法访问)
+
+**成员管理**
+- 查看当前成员列表(用户名、邮箱、角色)
+- 添加新成员(按用户名查找)
+- 修改成员角色(管理员 / 成员)
+- 移除成员
+
+**危险操作**
+- 删除工作区(软删除,状态标记为 deleted)
+
+### 2.5 角色说明
+
+| 角色 | 权限范围 |
+|------|----------|
+| **admin** (工作区管理员) | 管理成员、修改设置、删除工作区、创建/编辑/删除所有资源 |
+| **member** (成员) | 查看和创建资源,编辑自己创建的资源 |
+
+> **注意**: 工作区管理员 ≠ 平台管理员。工作区管理员仅管理当前工作区,平台管理员拥有全局权限。
+
+---
+
+## 三、RBAC 权限体系
+
+### 3.1 系统角色
+
+天工平台定义了 4 个系统级角色:
+
+| 角色 | 说明 | 典型用户 |
+|------|------|----------|
+| **admin** (平台管理员) | 全局管理权限,绕过所有工作区限制 | 平台运维 |
+| **developer** (开发者) | 创建和管理 Agent、工作流、知识库 | 核心开发 |
+| **viewer** (只读用户) | 仅查看,无创建/编辑/删除权限 | 审计、管理层 |
+| **operator** (操作员) | 管理执行、监控告警,但不创建新 Agent | 运维 |
+
+### 3.2 权限列表 (23 项)
+
+所有权限按资源类型分组:
+
+| 权限码 | 说明 | developer | viewer | operator |
+|--------|------|:---------:|:------:|:--------:|
+| **workflow:create** | 创建工作流 | ✅ | ❌ | ❌ |
+| **workflow:read** | 查看工作流 | ✅ | ✅ | ✅ |
+| **workflow:update** | 编辑工作流 | ✅ | ❌ | ❌ |
+| **workflow:delete** | 删除工作流 | ✅ | ❌ | ❌ |
+| **workflow:execute** | 执行工作流 | ✅ | ❌ | ✅ |
+| **workflow:publish** | 发布工作流 | ✅ | ❌ | ❌ |
+| **agent:create** | 创建 Agent | ✅ | ❌ | ❌ |
+| **agent:read** | 查看 Agent | ✅ | ✅ | ✅ |
+| **agent:update** | 编辑 Agent | ✅ | ❌ | ❌ |
+| **agent:delete** | 删除 Agent | ✅ | ❌ | ❌ |
+| **agent:deploy** | 部署 Agent | ✅ | ❌ | ✅ |
+| **agent:stop** | 停止 Agent | ✅ | ❌ | ✅ |
+| **execution:read** | 查看执行记录 | ✅ | ✅ | ✅ |
+| **execution:stop** | 中止执行 | ✅ | ❌ | ✅ |
+| **data_source:create** | 创建数据源 | ✅ | ❌ | ❌ |
+| **data_source:read** | 查看数据源 | ✅ | ✅ | ✅ |
+| **data_source:update** | 编辑数据源 | ✅ | ❌ | ❌ |
+| **data_source:delete** | 删除数据源 | ✅ | ❌ | ❌ |
+| **model_config:create** | 创建模型配置 | ✅ | ❌ | ❌ |
+| **model_config:read** | 查看模型配置 | ✅ | ✅ | ✅ |
+| **model_config:update** | 编辑模型配置 | ✅ | ❌ | ❌ |
+| **model_config:delete** | 删除模型配置 | ✅ | ❌ | ❌ |
+| **permission:manage** | 管理权限 | ❌ | ❌ | ❌ |
+
+> **admin 角色拥有所有 23 项权限(通配符 `*`),且绕过所有工作区成员资格检查。**
+
+### 3.3 权限检查流程
+
+```
+API 请求
+ │
+ ├─ 1. JWT 认证 (get_current_user)
+ │ └─ 失败 → 401 Unauthorized
+ │
+ ├─ 2. 工作区成员资格检查 (get_workspace_membership)
+ │ ├─ 平台管理员 → 直接放行
+ │ ├─ 非成员 → 403 Forbidden
+ │ └─ 成员 → 继续
+ │
+ ├─ 3. 细粒度权限检查 (WorkspacePermission)
+ │ ├─ 无该权限 → 403 Forbidden
+ │ └─ 有权限 → 继续
+ │
+ └─ 4. 执行请求
+```
+
+### 3.4 敏感操作保护
+
+以下操作需要 **工作区管理员** 权限:
+
+| 操作 | 端点 | 要求 |
+|------|------|------|
+| 删除 Agent | DELETE /agents/{id} | 工作区管理员 |
+| 删除工作流 | DELETE /workflows/{id} | 工作区管理员 |
+| 删除数据源 | DELETE /data-sources/{id} | 工作区管理员 |
+| 删除模型配置 | DELETE /model-configs/{id} | 工作区管理员 |
+| 删除定时任务 | DELETE /agent-schedules/{id} | 工作区管理员 |
+| 部署 Agent | POST /agents/{id}/deploy | 工作区管理员 |
+| 停止 Agent | POST /agents/{id}/stop | 工作区管理员 |
+| 发布工作流 | POST /workflows/{id}/publish | 工作区管理员 |
+| 重载工具 | POST /tools/reload | 工作区管理员 |
+
+---
+
+## 四、平台管理员功能
+
+> 仅 `role=admin` 的用户可访问以下功能。
+
+### 4.1 用户管理
+
+进入「运维管理 → 用户管理」:
+
+**用户列表**
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ 搜索: [________] 状态: [全部▾] 角色: [全部▾] │
+├──────┬──────────┬──────┬──────┬──────┬────────┬──────┬──────────┤
+│ 用户名 │ 邮箱 │ 角色 │ 状态 │ 订阅 │ 工作区数 │ 创建 │ 操作 │
+├──────┼──────────┼──────┼──────┼──────┼────────┼──────┼──────────┤
+│ admin │admin@.. │ 管理员│ 启用 │ 免费 │ 2 │06-01 │编辑 重置 │
+│ 张三 │zhang@.. │ 开发者│ 启用 │ 专业 │ 1 │06-15 │编辑 重置 │
+│ 李四 │li@.. │ 观察者│ 停用 │ 免费 │ 0 │06-20 │编辑 重置 │
+└──────┴──────────┴──────┴──────┴──────┴────────┴──────┴──────────┘
+```
+
+**功能操作:**
+- **创建用户**: 填写用户名、邮箱、密码(6 位以上)、角色
+- **编辑用户**: 修改用户名、邮箱、角色、手机号、状态、订阅等级
+- **重置密码**: 为用户设置新密码
+- **禁用/删除**: 软删除用户(设置 status=deleted),平台管理员不可被删除
+
+### 4.2 工作区全局管理
+
+平台管理员在工作区管理页面看到全局视图:
+
+```
+┌── 所有工作区(平台管理员视图)──────────────────────────────────────┐
+│ 搜索: [________] 状态: [全部▾] │
+├──────────┬────────┬────────┬──────┬──────┬──────────┬────────────┤
+│ 名称 │ 所有者 │ 成员数 │ 上限 │ 状态 │ 创建时间 │ 操作 │
+├──────────┼────────┼────────┼──────┼──────┼──────────┼────────────┤
+│ 研发团队 │ admin │ 12 │ 50 │ 启用 │ 2026-06 │ [进入][管理]│
+│ AI实验室 │ 张三 │ 3 │ 20 │ 停用 │ 2026-07 │ [进入][管理]│
+└──────────┴────────┴────────┴──────┴──────┴──────────┴────────────┘
+```
+
+**管理操作:**
+- 点击「管理」可编辑任意工作区的名称和状态
+- 可强制删除任意工作区(包括默认工作区)
+- 所有操作记录在审计日志中
+
+### 4.3 审计日志
+
+进入「系统日志 → 审计日志」查看所有管理员操作记录:
+- 操作人、操作类型(CREATE/UPDATE/DELETE/LOGIN)
+- 资源类型、IP 地址
+- 操作时间、操作结果
+
+---
+
+## 五、API 使用指南
+
+### 5.1 认证与工作区切换
+
+```bash
+# 1. 登录获取 Token
+curl -X POST http://localhost:8037/api/v1/auth/login \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "username=admin&password=123456"
+
+# 响应: { "access_token": "eyJ...", "token_type": "bearer" }
+
+# 2. 查看当前用户信息(含工作区列表)
+curl http://localhost:8037/api/v1/auth/me \
+ -H "Authorization: Bearer $TOKEN"
+
+# 响应包含:
+# {
+# "id": "...", "username": "admin", "role": "admin",
+# "workspaces": [
+# {"id": "ws-1", "name": "研发团队", "role": "admin"},
+# {"id": "ws-2", "name": "个人空间", "role": "member"}
+# ],
+# "current_workspace_id": "ws-1"
+# }
+
+# 3. 切换工作区
+curl -X POST http://localhost:8037/api/v1/auth/switch-workspace/ws-2 \
+ -H "Authorization: Bearer $TOKEN"
+
+# 响应包含新的 Token(携带新工作区 ID)
+```
+
+### 5.2 工作区管理 API
+
+```bash
+# 创建工作区
+curl -X POST http://localhost:8037/api/v1/workspaces \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"name": "新项目", "description": "项目协作空间", "max_members": 50}'
+
+# 查看成员
+curl http://localhost:8037/api/v1/workspaces/{ws_id}/members \
+ -H "Authorization: Bearer $TOKEN"
+
+# 添加成员
+curl -X POST http://localhost:8037/api/v1/workspaces/{ws_id}/members \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"username": "zhangsan", "role": "member"}'
+
+# 修改成员角色
+curl -X PUT http://localhost:8037/api/v1/workspaces/{ws_id}/members/{user_id} \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"role": "admin"}'
+
+# 移除成员
+curl -X DELETE http://localhost:8037/api/v1/workspaces/{ws_id}/members/{user_id} \
+ -H "Authorization: Bearer $TOKEN"
+```
+
+### 5.3 管理员 API
+
+```bash
+# 用户列表(分页、搜索、过滤)
+curl "http://localhost:8037/api/v1/admin/users?page=1&page_size=20&search=admin&role=admin" \
+ -H "Authorization: Bearer $ADMIN_TOKEN"
+
+# 创建用户
+curl -X POST http://localhost:8037/api/v1/admin/users \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"username":"newuser","email":"new@test.com","password":"123456","role":"developer"}'
+
+# 编辑用户
+curl -X PUT http://localhost:8037/api/v1/admin/users/{user_id} \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"role":"viewer","status":"disabled"}'
+
+# 重置密码
+curl -X POST http://localhost:8037/api/v1/admin/users/{user_id}/reset-password \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"new_password":"newpass123"}'
+
+# 删除用户(软删除)
+curl -X DELETE http://localhost:8037/api/v1/admin/users/{user_id} \
+ -H "Authorization: Bearer $ADMIN_TOKEN"
+
+# 查看所有工作区
+curl "http://localhost:8037/api/v1/admin/workspaces?page=1&page_size=20" \
+ -H "Authorization: Bearer $ADMIN_TOKEN"
+
+# 查看任意工作区详情
+curl http://localhost:8037/api/v1/admin/workspaces/{ws_id} \
+ -H "Authorization: Bearer $ADMIN_TOKEN"
+
+# 编辑任意工作区
+curl -X PUT http://localhost:8037/api/v1/admin/workspaces/{ws_id} \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"status":"disabled"}'
+
+# 强制删除工作区
+curl -X DELETE http://localhost:8037/api/v1/admin/workspaces/{ws_id} \
+ -H "Authorization: Bearer $ADMIN_TOKEN"
+```
+
+### 5.4 WebSocket 连接
+
+WebSocket 连接需要传递 Token 作为 query 参数:
+
+```javascript
+// 执行状态实时推送
+const ws = new WebSocket(
+ `ws://localhost:8037/api/v1/ws/executions/${executionId}?token=${jwtToken}`
+);
+
+ws.onmessage = (event) => {
+ const data = JSON.parse(event.data);
+ // data.type: "status" | "progress" | "error" | "pong"
+ console.log(data);
+};
+
+// 协作编辑
+const collabWs = new WebSocket(
+ `ws://localhost:8037/api/v1/collaboration/ws/workflows/${workflowId}?token=${jwtToken}`
+);
+
+// 发送操作
+collabWs.send(JSON.stringify({
+ type: "operation",
+ operation: {
+ type: "node_add",
+ node: { id: "...", type: "llm", position: { x: 100, y: 200 } }
+ }
+}));
+```
+
+WebSocket 认证验证:
+- JWT Token 有效性
+- 用户存在且未被删除
+- 工作区成员资格
+- 资源归属当前工作区
+
+### 5.5 错误码说明
+
+| HTTP 状态码 | WebSocket 关闭码 | 说明 |
+|:-----------:|:----------------:|------|
+| 401 | 4001 | Token 无效或已过期 |
+| 403 | 4003 | 无权访问(非工作区成员 / 无所需权限) |
+| 403 | 4008 | 违反策略(如缺少 Token) |
+
+---
+
+## 六、数据隔离详解
+
+### 6.1 已隔离的资源
+
+以下资源通过 `workspace_id` 字段实现完全隔离:
+
+| 资源 | 表 | 隔离方式 |
+|------|-----|----------|
+| Agent | agents | 查询时自动按 workspace_id 过滤 |
+| 工作流 | workflows | 查询时自动按 workspace_id 过滤 |
+| 执行记录 | executions | 查询时自动按 workspace_id 过滤 |
+| 数据源 | data_sources | 查询时自动按 workspace_id 过滤 |
+| 模型配置 | model_configs | 查询时自动按 workspace_id 过滤 |
+| 定时任务 | agent_schedules | 查询时自动按 workspace_id 过滤 |
+| 知识库 | knowledge_bases | 查询时自动按 workspace_id 过滤 |
+| 聊天消息 | chat_messages | 查询时自动按 workspace_id 过滤 |
+| Agent 会话 | agent_sessions | 查询时自动按 workspace_id 过滤 |
+| 告警规则 | alert_rules | 查询时自动按 workspace_id 过滤 |
+| 节点模板 | node_templates | 查询时自动按 workspace_id 过滤 |
+| 插件 | plugins | 查询时自动按 workspace_id 过滤 |
+| 目标/任务 | goals / tasks | 查询时自动按 workspace_id 过滤 |
+| 编排模板 | orchestration_templates | 查询时自动按 workspace_id 过滤 |
+| 场景契约 | scene_contracts | 查询时自动按 workspace_id 过滤 |
+| 团队 | teams | 查询时自动按 workspace_id 过滤 |
+| 对话分支 | conversation_branches | 查询时自动按 workspace_id 过滤 |
+| LLM 日志 | agent_llm_logs | 查询时自动按 workspace_id 过滤 |
+| 知识条目 | knowledge_entries | 查询时自动按 workspace_id 过滤 |
+| 告警日志 | alert_logs | 查询时自动按 workspace_id 过滤 |
+
+### 6.2 创建时自动标记
+
+所有新建资源自动 stamp 当前工作区 ID,确保数据归属正确:
+
+```python
+# 后端自动处理(以 Agent 为例)
+agent = Agent(
+ ...
+ user_id=current_user.id,
+ workspace_id=workspace_id, # 从 JWT ws 字段自动提取
+)
+```
+
+---
+
+## 七、常见场景
+
+### 7.1 企业新员工入职
+
+1. **管理员创建用户账号** — 进入「用户管理」→ 创建用户,设置角色为 `developer`
+2. **将用户加入工作区** — 进入目标工作区「成员管理」→ 添加成员
+3. **用户登录** — 使用分配的账号密码登录
+4. **用户切换工作区** — 在工作区列表选择目标工作区进入
+5. **开始工作** — 创建 Agent、工作流等
+
+### 7.2 创建新团队协作空间
+
+1. **创建工作区** — 点击「创建工作区」,命名为"XX 项目"
+2. **添加团队成员** — 逐个添加成员账号
+3. **设置成员角色** — 核心成员设为 `admin`(可管理成员),普通成员设为 `member`
+4. **团队开始协作** — 所有成员在该工作区中创建的 Agent、工作流自动共享
+
+### 7.3 权限审查
+
+1. **平台管理员**进入「用户管理」查看所有用户及其角色
+2. 检查是否有用户拥有不当权限(如普通用户被设为 `admin`)
+3. 通过「编辑用户」调整角色
+4. 停用离职员工的账号(设置 status=disabled)
+
+### 7.4 审计追溯
+
+1. 进入「系统日志 → 审计日志」
+2. 按时间、用户、操作类型筛选
+3. 查看关键操作:用户创建/删除、权限变更、工作区删除等
+4. 所有敏感操作有完整审计轨迹
+
+---
+
+## 八、安全注意事项
+
+### 8.1 密码安全
+
+- 默认管理员密码 `123456` 应在首次部署后立即修改
+- 通过个人设置或管理员强制重置修改密码
+- 密码至少 6 位,建议使用强密码策略
+
+### 8.2 权限最小化
+
+- 给用户分配其工作需要的最低权限角色
+- 默认使用 `developer` 角色,而非 `admin`
+- 定期审查工作区成员列表,移除不再需要访问的用户
+
+### 8.3 工作区删除
+
+- 删除工作区是软删除(status=deleted),数据不会物理删除
+- 需要彻底清理数据请联系平台管理员
+- 默认工作区建议保留不删除
+
+### 8.4 Token 安全
+
+- JWT Token 包含工作区上下文,请妥善保管
+- Token 过期后需重新登录
+- WebSocket 连接的 Token 通过 query 参数传递,注意日志中不要泄露
+
+---
+
+## 附录 A: 角色权限矩阵速查
+
+| 操作 | platform admin | workspace admin | developer | viewer | operator |
+|------|:---:|:---:|:---:|:---:|:---:|
+| 查看所有工作区 | ✅ | ❌ | ❌ | ❌ | ❌ |
+| 管理所有工作区 | ✅ | ❌ | ❌ | ❌ | ❌ |
+| 管理所有用户 | ✅ | ❌ | ❌ | ❌ | ❌ |
+| 管理本工作区成员 | ✅ | ✅ | ❌ | ❌ | ❌ |
+| 修改工作区设置 | ✅ | ✅ | ❌ | ❌ | ❌ |
+| 创建 Agent/工作流 | ✅ | ✅ | ✅ | ❌ | ❌ |
+| 编辑自己的资源 | ✅ | ✅ | ✅ | ❌ | ❌ |
+| 删除自己的资源 | ✅ | ✅ | ✅ | ❌ | ❌ |
+| 编辑他人的资源 | ✅ | ✅ | ❌ | ❌ | ❌ |
+| 删除他人的资源 | ✅ | ✅ | ❌ | ❌ | ❌ |
+| 部署/停止 Agent | ✅ | ✅ | ✅ | ❌ | ✅ |
+| 执行工作流 | ✅ | ✅ | ✅ | ❌ | ✅ |
+| 查看资源 | ✅ | ✅ | ✅ | ✅ | ✅ |
+| 查看执行记录 | ✅ | ✅ | ✅ | ✅ | ✅ |
+| 查看审计日志 | ✅ | ❌ | ❌ | ❌ | ❌ |
+
+## 附录 B: 数据库表 workspace_id 标记
+
+所有支持多租户隔离的数据表均包含 `workspace_id` 列:
+
+```
+agents agent_schedules alert_rules
+alert_logs agent_execution_logs agent_llm_logs
+agent_sessions chat_messages conversation_branches
+data_sources executions goals
+knowledge_bases knowledge_entries model_configs
+node_templates plugins scene_contracts
+orchestration_templates tasks teams
+templates workflows tools
+```
+
+## 附录 C: 技术架构要点
+
+- **后端框架**: FastAPI + SQLAlchemy ORM
+- **认证**: JWT (python-jose) + bcrypt 密码哈希
+- **依赖注入**: FastAPI `Depends()` 链式调用实现认证→授权→执行
+- **数据隔离**: 所有查询通过 SQLAlchemy `filter(workspace_id=...)` 实现
+- **WebSocket**: 连接注册表按 workspace_id 标记,防止跨工作区消息泄露
+- **平台管理员绕过**: `user.role == "admin"` 跳过所有 workspace 成员资格检查
+
+---
+
+> 最后更新:2026-07-04 | 适用版本 v1.2+
diff --git a/frontend/src/components/MainLayout.vue b/frontend/src/components/MainLayout.vue
index 9860a8a..3f512b5 100644
--- a/frontend/src/components/MainLayout.vue
+++ b/frontend/src/components/MainLayout.vue
@@ -1,96 +1,125 @@
-
-
-
-
-
-
-
+
+
+
所有工作区(平台管理员视图)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.status === 'active' ? '启用' : row.status === 'deleted' ? '已删除' : '停用' }}
+
+
+
+
+
+ 是
+ -
+
+
+
+
+ {{ row.created_at ? new Date(row.created_at).toLocaleDateString() : '-' }}
+
+
+
+
+ 进入
+ 管理
+
+
+
+
+
+
+
+ 我的工作区
@@ -55,21 +108,45 @@
确定
+
+
+
+
+
+
+
+ {{ adminEditWs.owner_name }}
+ {{ adminEditWs.member_count }} / {{ adminEditWs.max_members }}
+
+
+
+
+
+
+
+
+ 取消
+ 删除工作区
+ 保存
+
+
@@ -146,6 +296,19 @@ onMounted(() => {
margin: 0;
}
+.admin-section {
+ margin-bottom: 8px;
+}
+
+.admin-section h3 {
+ margin: 0 0 8px 0;
+}
+
+.admin-toolbar {
+ display: flex;
+ align-items: center;
+}
+
.workspace-card {
margin-bottom: 20px;
cursor: pointer;