# Android App 设计文档 天工智能体平台 Android 客户端,提供 AI 对话、语音交互、推送通知等核心移动端能力。 --- ## 一、技术选型 | 层面 | 选择 | 理由 | |------|------|------| | 语言 | Kotlin | Android 官方首选,协程天然适配 SSE 流式 | | UI 框架 | Jetpack Compose | 声明式 UI,与消息列表/流式更新天然契合 | | 架构 | MVVM + Repository | ViewModel 管理 UI 状态,Repository 统一数据源 | | 网络 | OkHttp 4 + Retrofit 2 | Retrofit 处理 REST,OkHttp 拦截器处理 JWT/重登录 | | SSE | 自定义 OkHttp EventSource | Retrofit 不原生支持 SSE,需基于 OkHttp 手动解析 | | 本地存储 | Room + DataStore | Room 存消息历史,DataStore 存 Token/偏好 | | 图片加载 | Coil | Kotlin 原生,Compose 集成好 | | 音频录制 | MediaRecorder | 系统原生 API,无额外依赖 | | 音频播放 | Media3 ExoPlayer | Google 官方推荐 | | 推送 | Firebase Cloud Messaging | Google 官方,免费 | --- ## 二、项目结构 ``` android/ ├── app/ │ ├── build.gradle.kts │ └── src/main/ │ ├── AndroidManifest.xml │ ├── java/com/tiangong/aiagent/ │ │ ├── TiangongApp.kt # Application 类 │ │ ├── MainActivity.kt # 单 Activity 入口 │ │ │ │ │ ├── data/ │ │ │ ├── remote/ │ │ │ │ ├── ApiService.kt # Retrofit 接口定义 │ │ │ │ ├── SseClient.kt # SSE 流式解析器 │ │ │ │ └── AuthInterceptor.kt # JWT 注入 + 401 自动重登录 │ │ │ ├── local/ │ │ │ │ ├── AppDatabase.kt # Room 数据库 │ │ │ │ ├── MessageDao.kt # 消息 DAO │ │ │ │ └── ConversationDao.kt # 会话 DAO │ │ │ └── repository/ │ │ │ ├── AuthRepository.kt # 登录/Token 管理 │ │ │ ├── ChatRepository.kt # 对话/SSE 流式 │ │ │ ├── AgentRepository.kt # Agent 列表/详情 │ │ │ └── NotificationRepository.kt # 通知轮询 │ │ │ │ │ ├── domain/ │ │ │ └── model/ │ │ │ ├── Agent.kt # 智能体 │ │ │ ├── Message.kt # 消息(user/assistant/tool/system) │ │ │ ├── Conversation.kt # 会话 │ │ │ ├── SseEvent.kt # SSE 事件密封类 │ │ │ ├── TokenUsage.kt # Token 用量 │ │ │ └── Notification.kt # 通知 │ │ │ │ │ ├── ui/ │ │ │ ├── navigation/ │ │ │ │ └── NavGraph.kt # 路由导航图 │ │ │ ├── theme/ │ │ │ │ └── Theme.kt # Material3 主题 │ │ │ ├── login/ │ │ │ │ ├── LoginScreen.kt │ │ │ │ └── LoginViewModel.kt │ │ │ ├── chat/ │ │ │ │ ├── ChatScreen.kt # 对话主界面 │ │ │ │ ├── ChatViewModel.kt # SSE 流式消费 + 消息管理 │ │ │ │ └── components/ │ │ │ │ ├── MessageBubble.kt # 气泡(支持 Markdown) │ │ │ │ ├── VoiceInputButton.kt # 语音录制按钮 │ │ │ │ ├── ToolCallCard.kt # 工具调用卡片 │ │ │ │ └── StreamingText.kt # 打字机流式文本 │ │ │ ├── agents/ │ │ │ │ ├── AgentListScreen.kt │ │ │ │ └── AgentListViewModel.kt │ │ │ └── settings/ │ │ │ └── SettingsScreen.kt │ │ │ │ │ ├── util/ │ │ │ ├── AudioRecorder.kt # 录音工具(MediaRecorder) │ │ │ ├── AudioPlayer.kt # TTS 播放器(ExoPlayer) │ │ │ ├── MarkdownRenderer.kt # Markdown 渲染 │ │ │ └── FcmTokenManager.kt # FCM Token 注册/同步 │ │ │ │ │ └── di/ │ │ └── AppModule.kt # Hilt 依赖注入 │ │ │ └── res/ │ ├── values/strings.xml │ └── drawable/ # 图标资源 │ ├── build.gradle.kts # 根构建文件 ├── settings.gradle.kts └── gradle.properties ``` --- ## 三、网络层设计 ### 3.1 基础配置 ```kotlin // data/remote/ApiService.kt interface ApiService { // --- 认证 --- @FormUrlEncoded @POST("api/v1/auth/login") suspend fun login( @Field("username") username: String, @Field("password") password: String ): LoginResponse @GET("api/v1/auth/me") suspend fun getCurrentUser(): UserResponse // --- Agent --- @GET("api/v1/agents") suspend fun getAgents( @Query("skip") skip: Int = 0, @Query("limit") limit: Int = 100 ): Response> // X-Total-Count 在 header @GET("api/v1/agents/{agentId}") suspend fun getAgent(@Path("agentId") agentId: String): AgentResponse // --- 对话(非流式) --- @POST("api/v1/agent-chat/{agentId}") suspend fun chat( @Path("agentId") agentId: String, @Body request: ChatRequest ): ChatResponse // --- 通知 --- @GET("api/v1/notifications/unread-count") suspend fun getUnreadCount(): UnreadCountResponse @GET("api/v1/notifications") suspend fun getNotifications( @Query("unread_only") unreadOnly: Boolean = false, @Query("limit") limit: Int = 50, @Query("offset") offset: Int = 0 ): List // --- FCM Token 注册(新增后端接口) --- @POST("api/v1/push/register") suspend fun registerFcmToken(@Body request: FcmRegisterRequest) } ``` ### 3.2 关键接口参数 **登录(form-encoded)** — 后端使用 OAuth2PasswordRequestForm,不走 JSON: ```kotlin // 请求 data class LoginRequest( val username: String, // Field: username val password: String // Field: password ) // 响应 data class LoginResponse( val access_token: String, val token_type: String // "bearer" ) ``` **对话请求/响应**: ```kotlin data class ChatRequest( val message: String, @SerializedName("session_id") val sessionId: String? = null, val streamlined: Boolean = false ) // 流式 SSE 事件密封类 sealed class SseEvent { data class Message(val content: String) : SseEvent() data class ToolCall(val toolName: String, val toolInput: String) : SseEvent() data class ToolResult(val toolName: String, val toolOutput: String, val success: Boolean) : SseEvent() data class Plan(val plan: PlanObject) : SseEvent() data class Done( val sessionId: String, val iterationsUsed: Int, val toolCallsMade: Int, val tokenUsage: TokenUsage ) : SseEvent() data class Error(val error: String) : SseEvent() } ``` ### 3.3 SSE 流式解析器 后端 SSE 格式为 `event: type\ndata: json\n\n`,需手动解析: ```kotlin // data/remote/SseClient.kt class SseClient(private val okHttpClient: OkHttpClient) { fun connect(url: String, token: String, body: ChatRequest): Flow = callbackFlow { val requestBody = body.toJson().toRequestBody("application/json".toMediaType()) val request = Request.Builder() .url(url) .header("Authorization", "Bearer $token") .header("Accept", "text/event-stream") .post(requestBody) .build() val call = okHttpClient.newCall(request) val response = call.execute() if (!response.isSuccessful) { close(IOException("HTTP ${response.code}")) return@callbackFlow } val source = response.body?.source() ?: return@callbackFlow var eventType = "" var data = "" while (!source.exhausted()) { val line = source.readUtf8Line() ?: break when { line.startsWith("event: ") -> eventType = line.removePrefix("event: ") line.startsWith("data: ") -> data = line.removePrefix("data: ") line.isEmpty() -> { // 空行 = 事件结束 if (data.isNotEmpty()) { val event = parseEvent(eventType, data) trySend(event) } eventType = "" data = "" } } } close() } private fun parseEvent(type: String, json: String): SseEvent = when (type) { "message" -> SseEvent.Message(extractContent(json)) "tool_call" -> { val obj = JsonParser.parseString(json).asJsonObject SseEvent.ToolCall( obj["tool_name"].asString, obj["tool_input"].asString ) } "tool_result" -> { val obj = JsonParser.parseString(json).asJsonObject SseEvent.ToolResult( obj["tool_name"].asString, obj["tool_output"].asString, obj["success"].asBoolean ) } "done" -> SseEvent.Done(/* parse full done payload */) "error" -> SseEvent.Error(extractError(json)) else -> SseEvent.Message("") // 未知类型忽略 } } ``` ### 3.4 JWT 拦截器 + 自动重登录 Token 30 分钟过期,无 refresh 端点,401 时自动重登录: ```kotlin // data/remote/AuthInterceptor.kt class AuthInterceptor( private val tokenStore: TokenDataStore, // DataStore 存 token private val credentialStore: CredentialStore // 加密存用户名密码 ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val token = runBlocking { tokenStore.getToken() } val request = if (token != null) { chain.request().newBuilder() .header("Authorization", "Bearer $token") .build() } else { chain.request() } val response = chain.proceed(request) // 401 自动重登录 if (response.code == 401) { response.close() val credentials = runBlocking { credentialStore.getCredentials() } if (credentials != null) { val newToken = runBlocking { reLogin(credentials) } if (newToken != null) { val retryRequest = request.newBuilder() .header("Authorization", "Bearer $newToken") .build() return chain.proceed(retryRequest) } } // 重登录也失败,跳转登录页(通过 EventBus 或回调通知 UI) } return response } private suspend fun reLogin(cred: Credentials): String? { // 使用单独的 OkHttp 客户端(不走本拦截器,避免死循环) val client = OkHttpClient() val body = FormBody.Builder() .add("username", cred.username) .add("password", cred.password) .build() val request = Request.Builder() .url("${BuildConfig.BASE_URL}/api/v1/auth/login") .post(body) .build() val response = client.newCall(request).execute() return if (response.isSuccessful) { val json = JsonParser.parseString(response.body?.string()!!).asJsonObject val newToken = json["access_token"].asString tokenStore.saveToken(newToken) newToken } else null } } ``` --- ## 四、屏幕设计 ### 4.1 整体导航 ``` MainActivity └── NavHost ├── LoginScreen (startDestination) ├── ChatScreen (需登录) ├── AgentListScreen (需登录,从 Chat 顶部切出) ├── SettingsScreen (需登录) └── AboutScreen ``` ### 4.2 登录页 — LoginScreen | 项 | 说明 | |------|------| | 元素 | 用户名输入框、密码输入框、登录按钮、服务器地址(可配置) | | 状态 | 空闲、加载中、错误提示 | | 逻辑 | 登录成功 → 存储 Token + 凭据 → 跳转 ChatScreen | | 安全 | 凭据存 EncryptedSharedPreferences(AndroidX Security) | ### 4.3 对话页 — ChatScreen 核心页面,承载主要交互。 | 区域 | 说明 | |------|------| | 顶部栏 | 当前 Agent 名称(点击切换)、Agent 在线状态、通知铃铛(红点未读数) | | 消息列表 | LazyColumn 倒序渲染,消息气泡(用户右对齐/助手左对齐),支持 Markdown 渲染 | | 工具调用卡片 | 可折叠卡片,显示工具名/输入/输出,加载状态 | | 流式文本 | 打字机效果,逐 token 显示,光标闪烁 | | 底部输入栏 | 文本输入框 + 语音按钮 + 发送按钮 + 加号(图片/文件) | | 空状态 | 首次进入显示"你好,我是 " + 快捷提问建议 | **状态管理(ChatViewModel)**: | 事件 | 处理 | |------|------| | 发送消息 | 创建 user Message → 添加到列表 → 创建 assistant 占位 Message → 开启 SSE 连接 | | SSE token | 追加到当前 assistant Message.content | | SSE tool_call | 插入 ToolCallCard(折叠状态) | | SSE tool_result | 更新 ToolCallCard 为展开状态,显示结果 | | SSE done | 停止流式,更新 token_usage,保存消息到 Room | | SSE error | 显示错误提示,消息气泡变红色 | | 切换 Agent | 结束当前 SSE → 清空列表 → 加载新 Agent 信息 | | 退后台/杀进程 | 保存 session_id,下次恢复时带 session_id 继续对话 | **消息气泡样式**: | 类型 | 对齐 | 样式 | |------|------|------| | user | 右对齐 | 主题色背景,白色字 | | assistant (Markdown) | 左对齐 | 卡片背景,Markdown 渲染正文 | | tool_call | 左对齐 | 缩进卡片,灰色边框,"正在调用 xxx 工具..." | | tool_result | 左对齐 | 缩进卡片,绿色边框(成功)/红色(失败),显示摘要 | | system | 居中 | 灰色小字,如"会话已创建" | ### 4.4 Agent 列表页 — AgentListScreen | 项 | 说明 | |------|------| | 触发 | 对话页顶部栏点击 Agent 名称 | | 样式 | BottomSheet 或 全屏页面 | | 列表项 | Agent 头像(首字母)、名称、描述、状态标签(published/stopped) | | 操作 | 点击切换当前对话 Agent,自动创建新 session | | 搜索 | 顶部搜索栏,调用 `GET /agents?search=` | ### 4.5 设置页 — SettingsScreen | 项 | 说明 | |------|------| | 服务器地址 | 可编辑,默认 `http://101.43.95.130:8038` | | 语音 | TTS 开关、音色选择(alloy/echo/fable/onyx/nova/shimmer) | | 推送 | 推送开关(注册/注销 FCM Token) | | 主题 | 亮色/暗色/跟随系统 | | 账户 | 当前用户信息、退出登录 | --- ## 五、语音交互 ### 5.1 语音输入(ASR) ``` 用户按住语音按钮 -> MediaRecorder 录制 AAC/WebM -> 松开按钮,停止录制 -> 上传到 POST /api/v1/voice/asr (multipart) -> 返回 { "text": "..." } -> 填入输入框(或直接发送) ``` | 项 | 实现 | |------|------| | 录制 | `MediaRecorder`,输出格式 `AAC_ADTS` 或 `WEBM`(兼容后端 Whisper) | | 采样率 | 16000 Hz(Whisper 推荐) | | 按钮交互 | 按住录音、上滑取消、松开发送(类似微信) | | 振幅动画 | 录音时显示波形动画(`AudioRecord.read()` 获取 PCM 振幅) | ```kotlin // util/AudioRecorder.kt class AudioRecorder(private val context: Context) { private var mediaRecorder: MediaRecorder? = null private var outputFile: File? = null fun startRecording(): File { outputFile = File(context.cacheDir, "voice_${System.currentTimeMillis()}.aac") mediaRecorder = MediaRecorder(context).apply { setAudioSource(MediaRecorder.AudioSource.MIC) setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS) setAudioEncoder(MediaRecorder.AudioEncoder.AAC) setAudioSamplingRate(16000) setOutputFile(outputFile!!.absolutePath) prepare() start() } return outputFile!! } fun stopRecording() { mediaRecorder?.apply { stop() release() } mediaRecorder = null } } ``` ### 5.2 语音输出(TTS) ``` 助手回复完成 -> 调用 POST /api/v1/voice/tts (JSON: text + voice) -> 返回 { "audio_url": "/api/v1/uploads/tts/xxx.mp3" } -> ExoPlayer 播放 -> 消息气泡右下角显示播放按钮 ``` | 项 | 实现 | |------|------| | 播放器 | `ExoPlayer` (Media3),支持缓存 | | 触发 | 消息气泡旁播放按钮 / 设置中开启自动朗读 | | 打断 | 用户开始录音时,停止当前 TTS | --- ## 六、推送通知 ### 6.1 架构 ``` 后端任务完成/告警 -> PushService (新增) -> Firebase Cloud Messaging -> Android 设备 -> 通知栏展示 -> 点击跳转 App 对应页面 ``` ### 6.2 客户端实现 ```kotlin // 1. 获取 FCM Token class TiangongApp : Application() { override fun onCreate() { super.onCreate() FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> if (task.isSuccessful) { val token = task.result // 同步到后端 CoroutineScope(Dispatchers.IO).launch { apiService.registerFcmToken(FcmRegisterRequest( token = token, platform = "android" )) } } } } } // 2. 前台消息处理 — 通知弹窗 + 红点更新 class TiangongMessagingService : FirebaseMessagingService() { override fun onMessageReceived(message: RemoteMessage) { val title = message.notification?.title ?: message.data["title"] ?: return val body = message.notification?.body ?: message.data["body"] ?: "" val url = message.data["url"] ?: "" showNotification(title, body, url) } private fun showNotification(title: String, body: String, deepLink: String) { val channelId = when (message.data["priority"]) { "urgent" -> "agent_urgent" else -> "agent_default" } // 创建通知渠道 + 显示通知 // 点击通知 -> deepLink 跳转 (如打开对话页) } } ``` ### 6.3 通知渠道 | 渠道 ID | 名称 | 级别 | 行为 | |------|------|------|------| | agent_reply | AI 回复 | DEFAULT | 声音 + 状态栏 | | agent_urgent | 紧急通知 | HIGH | 声音 + 振动 + 悬浮 | | agent_alert | 系统告警 | MAX | 全屏通知 | ### 6.4 后端需新增接口 | 方法 | 路径 | 说明 | |------|------|------| | POST | `/api/v1/push/register` | 注册 FCM Token(绑定 user_id) | | DELETE | `/api/v1/push/unregister` | 注销 FCM Token | | POST | `/api/v1/push/send` | (内部) 发送推送 — 由 notify_user 工具/告警服务调用 | 需新增 `push_tokens` 表: | 字段 | 类型 | 说明 | |------|------|------| | id | UUID | 主键 | | user_id | UUID | 用户 ID | | token | VARCHAR(512) | FCM token | | platform | VARCHAR(16) | android / ios / web | | created_at | DATETIME | 注册时间 | | last_used_at | DATETIME | 最后一次推送时间 | --- ## 七、数据层 ### 7.1 Room 数据库 ```kotlin // data/local/AppDatabase.kt @Database(entities = [MessageEntity::class, ConversationEntity::class], version = 1) abstract class AppDatabase : RoomDatabase() { abstract fun messageDao(): MessageDao abstract fun conversationDao(): ConversationDao } // MessageEntity @Entity(tableName = "messages") data class MessageEntity( @PrimaryKey val id: String, // UUID,客户端生成 val conversationId: String, // 会话 ID (即 session_id) val agentId: String?, // 来源 Agent val role: String, // user / assistant / tool / system val content: String, // 正文 val toolName: String?, // 工具名(role=tool 时) val toolInput: String?, // 工具输入 val toolOutput: String?, // 工具输出 val tokenUsage: String?, // JSON 序列化的 TokenUsage val createdAt: Long // 时间戳 ) // ConversationEntity @Entity(tableName = "conversations") data class ConversationEntity( @PrimaryKey val sessionId: String, // = 后端 session_id val agentId: String?, val agentName: String?, val title: String?, // 首条消息截取 val lastMessage: String?, val lastMessageAt: Long, val messageCount: Int ) ``` ### 7.2 DataStore | 键 | 类型 | 说明 | |------|------|------| | access_token | String | JWT Token | | server_url | String | 服务器地址 | | current_agent_id | String | 当前选中的 Agent ID | | tts_enabled | Boolean | 是否自动朗读 | | tts_voice | String | 音色选择 | | push_enabled | Boolean | 推送开关 | | theme_mode | String | light / dark / system | --- ## 八、构建配置 ### 8.1 build.gradle.kts(关键依赖) ```kotlin // app/build.gradle.kts dependencies { // Compose BOM implementation(platform("androidx.compose:compose-bom:2024.06.00")) implementation("androidx.compose.material3:material3") implementation("androidx.compose.ui:ui-tooling-preview") // Navigation implementation("androidx.navigation:navigation-compose:2.7.7") // Lifecycle + ViewModel implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.0") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.0") // Network implementation("com.squareup.retrofit2:retrofit:2.11.0") implementation("com.squareup.retrofit2:converter-gson:2.11.0") implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") // Room implementation("androidx.room:room-runtime:2.6.1") implementation("androidx.room:room-ktx:2.6.1") kapt("androidx.room:room-compiler:2.6.1") // DataStore implementation("androidx.datastore:datastore-preferences:1.1.1") // DI implementation("com.google.dagger:hilt-android:2.51") kapt("com.google.dagger:hilt-compiler:2.51") // Firebase (推送) implementation(platform("com.google.firebase:firebase-bom:33.1.0")) implementation("com.google.firebase:firebase-messaging-ktx") // Media (TTS 播放) implementation("androidx.media3:media3-exoplayer:1.3.1") // Markdown 渲染 implementation("io.noties.markwon:core:4.6.2") // Security (加密凭据) implementation("androidx.security:security-crypto:1.1.0-alpha06") } ``` ### 8.2 最低版本 | 项 | 值 | |------|------| | minSdk | 26 (Android 8.0) | | targetSdk | 34 (Android 14) | | compileSdk | 34 | | Kotlin | 2.0 | | AGP | 8.5 | --- ## 九、工作量估算 | 模块 | 内容 | 工作量 | |------|------|--------| | 项目搭建 | Gradle 配置、Hilt、主题、导航骨架 | 0.5天 | | 网络层 | Retrofit 接口 + OkHttp 拦截器 + SSE 解析器 | 1天 | | 登录 | 登录页 + ViewModel + Token 持久化 + 自动重登录 | 0.5天 | | 对话页 | ChatScreen + ChatViewModel + SSE 流式消费 + 消息气泡 | 2天 | | Markdown 渲染 | Markwon 集成 + 代码块 + 表格 + 图片 | 0.5天 | | 工具调用 | ToolCallCard + 折叠展开 + 实时更新 | 0.5天 | | Agent 列表 | AgentListScreen + 切换 Agent | 0.5天 | | 语音输入 | AudioRecorder + 语音按钮 + 上传 + ASR 调用 | 1天 | | 语音输出 | ExoPlayer + TTS 调用 + 播放按钮 | 0.5天 | | 推送 | FCM 集成 + 通知渠道 + 后端接口 | 1天 | | 本地存储 | Room 数据库 + DataStore + 消息持久化 | 1天 | | 设置页 | SettingsScreen + 主题切换 | 0.5天 | | 后端推送 | FCM 集成 + push_tokens 表 + 推送 API | 1天 | | 测试联调 | 端到端测试 + 多机型适配 | 2天 | | **合计** | | **12天** | --- ## 十、后端需配合改造 | 改动 | 说明 | 优先级 | |------|------|--------| | 新增 `/api/v1/voice/asr` | 接收音频文件,返回文本(已有 speech_to_text 工具,封装为 API) | P0 | | 新增 `/api/v1/voice/tts` | 接收文本,返回音频 URL(已有 text_to_speech 工具,封装为 API) | P0 | | 新增 `/api/v1/push/register` | 接收 FCM Token,绑定用户 | P1 | | 新增 `push_tokens` 表 | 存储设备推送 Token | P1 | | FCM Server SDK | 后端集成 `firebase-admin`,从 notify_user 触发推送 | P1 | | Token 过期延长 | 30分钟 → 7天(移动端场景),或增加 refresh_token 机制 | P1 |