- Rewrite api-reference.md: 245 endpoints across 38 modules, correct auth paths and response format - Rewrite 内置工具列表.md: all 56 real tools in 11 categories - Fix quickstart.md: local dev ports (3001/8038) vs Docker (8037/8038), --port 8038 - Add android-app-design.md: Kotlin/Compose/MVVM design with SSE, FCM, voice - Add 飞书智能体配置手册.md: all 6 bots config, capabilities, memory architecture - Add 产品化落地方案.md: PWA/voice/push/Flutter productization roadmap Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
25 KiB
25 KiB
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 基础配置
// 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<List<AgentResponse>> // 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<NotificationResponse>
// --- FCM Token 注册(新增后端接口) ---
@POST("api/v1/push/register")
suspend fun registerFcmToken(@Body request: FcmRegisterRequest)
}
3.2 关键接口参数
登录(form-encoded) — 后端使用 OAuth2PasswordRequestForm,不走 JSON:
// 请求
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"
)
对话请求/响应:
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,需手动解析:
// data/remote/SseClient.kt
class SseClient(private val okHttpClient: OkHttpClient) {
fun connect(url: String, token: String, body: ChatRequest): Flow<SseEvent> = 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 时自动重登录:
// 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 显示,光标闪烁 |
| 底部输入栏 | 文本输入框 + 语音按钮 + 发送按钮 + 加号(图片/文件) |
| 空状态 | 首次进入显示"你好,我是 <Agent名>" + 快捷提问建议 |
状态管理(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 振幅) |
// 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 客户端实现
// 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 数据库
// 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(关键依赖)
// 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 |