chat-miniagent APK 不是一个”把 Web 页面套进 Android 壳”的项目。它更像 mini-agent 的手机遥控器:用户在手机上输入自然语言,APK 把这句话送到服务端的 /message,服务端跑 agent、做意图判断、调用工具,再把结果通过 SSE 一段一段推回来。
这篇文章按后端开发者的视角来讲。你可以把 APK 想成一个很克制的 BFF 客户端:它负责登录、发消息、展示流式响应、保存本机滚动历史,以及给 todo / schedule 提供两个轻量 Tab。除此之外,它不试图在手机端复刻 mini-agent 的业务大脑。
一句话架构
Android 端只负责交互和状态呈现,mini-agent 服务端负责意图路由、任务执行和最终结果。
这条边界很重要。它决定了 APK 里不会出现这些东西:
- 不在客户端做自然语言意图识别。
- 不在客户端做 todo / schedule 的本地镜像同步。
- 不在客户端跑 agent、shell、任务编排或计划任务。
- 不用 WorkManager 在后台继续生成。
- 不依赖服务端
/history来做 Android 聊天滚动历史。
唯一的窄例外是 todo / schedule Tab。它们走服务端已有 REST 接口,目的是让手机上常用的查看、添加、完成、删除更顺手;但数据仍然以服务端为准,Android 不做离线数据库和双向同步。
- 架构图 chat-miniagent-apk-architecture.html — 本文配套架构图,暗亮主题可切换,可导出 PNG/JPEG/SVG
后端同学先看这张映射表
| 后端里常见的概念 | APK 里的对应物 | 它做什么 |
|---|---|---|
| Controller / Route | MiniAgentApi | 封装 /auth、/message、todo、schedule HTTP 调用 |
| Service | ChatRepository / TasksRepository | 把远程 API 和本地存储组合成 ViewModel 能用的接口 |
| 状态机 | ChatViewModel | 消费 ack / typing / delta / end / error,驱动聊天 UI |
| DTO | MessageRequest、TodoItemDto、ScheduleItemDto 等 | 用 kotlinx serialization 对齐后端 JSON |
| Stream parser | SseParser | 增量解析 data:{json}\n\n,能处理半包和 keepalive |
| Session middleware | AuthInterceptor + TokenHolder | 给请求加 Bearer token,遇到失效态回登录 |
| 本地表 | Room LocalChatStore | 只存 Android 本机聊天滚动历史 |
| Secret store | EncryptedSharedPreferences | 保存 token 和密码,用于恢复登录态 |
| DI container | Hilt AppModule | 组装 OkHttp、Room、Repository、AuthManager 等对象 |
如果你平时写 FastAPI、aiohttp、Spring 或 Express,这套分层应该不陌生。差异主要在两处:第一,聊天响应是一个长连接式的流;第二,Android 有自己的生命周期,用户点 Stop、切后台、旋转屏幕、网络断开,都要能把状态收干净。
一次聊天请求怎么走完
从用户点发送开始,链路大致是这样:
ChatScreen -> ChatViewModel.send(text) -> ChatRepository.send(text) -> MiniAgentApi.streamMessage(text) -> OkHttp POST /message -> mini-agent WebChannel._handle_message() -> MessageHandler._handle_chat() -> IntentRouter / executor / tools -> HttpResponder writes SSE frames -> SseParser emits SseEvent -> ChatViewModel updates UI stateAndroid 发给服务端的是普通 JSON:
POST /messageAuthorization: Bearer <token>Accept: text/event-streamContent-Type: application/json
{"text":"帮我看看今天还有什么待办"}服务端的 WebChannel 会看 Accept 头。如果包含 text/event-stream,它创建 aiohttp StreamResponse,再把这个响应交给 HttpResponder。如果不是 SSE,它会走 JSON fallback,用 BufferResponder 收集最终文本。
这意味着 Web、Telegram、Android 可以共用同一个 MessageHandler。区别只在 responder:Telegram 不逐 token 展示,HTTP SSE 可以逐段写回,JSON fallback 只拿最后答案。
SSE 协议:小而固定
APK 和服务端之间的流式协议没有做成 OpenAI 那种大而泛的 chunk 格式,而是固定成五类事件:
| type | 含义 | Android 怎么处理 |
|---|---|---|
ack | 服务端确认收到,通常是一句提示 | 显示顶部 chip |
typing | 服务端开始处理或仍在处理 | 显示打字状态 |
delta | 增量文本 | 追加到 streamingText |
end | 最终完整文本 | 用完整文本替换增量缓冲,并持久化 |
error | 出错 | 切到错误状态,显示错误 |
一个简化的 SSE 长这样:
data: {"type":"ack","text":"收到,开始处理"}
data: {"type":"delta","text":"今天"}
data: {"type":"delta","text":"还有 2 个待办。"}
data: {"type":"end","text":"今天还有 2 个待办。"}这里有个设计点:完成信号不是 [DONE],而是 type:end,并且 end.text 带完整最终答案。
为什么要这么做?因为移动网络和流式 UI 都会遇到半包、丢帧、重复渲染、Markdown 没闭合这些小问题。Android 可以一边用 delta 展示打字效果,一边在 end 到来时用服务端给的完整文本做一次自修正。最终保存到 Room 的也是 end.text,不是本地拼出来的临时 buffer。
Android 端怎么接 SSE
MiniAgentApi.streamMessage() 的核心是 callbackFlow 包一层 OkHttp Call:
callbackFlow { val call = client.newCall(request) val job = ioDispatcher.launch { val response = call.execute() response.body.byteStream().read(buffer) parser.feed(chunk).forEach { trySend(it) } }
awaitClose { call.cancel() job.cancel() }}这段逻辑解决了三个 Android 上很实际的问题。
第一,OkHttp 是阻塞读流,Compose / ViewModel 更适合用 Flow 表达状态。callbackFlow 把它们接起来。
第二,Stop 按钮不需要另做一套取消 API。ChatViewModel.stop() 取消 collector job,Flow 关闭时执行 awaitClose,最终落到 call.cancel()。这条链路短、可测试,也不让后台偷偷继续读网络流。
第三,SSE 解析不能按行拍脑袋切。网络 chunk 可能刚好停在半个 JSON 中间,所以 SseParser 是增量 parser:它保留未完成 frame,直到看到完整的 \n\n 再解析;空 data: keepalive 也不会把状态机弄乱。
ChatViewModel 是聊天状态机
后端同学看 Android ViewModel,可以把它理解成”前端 service + reducer”。它不负责执行业务,只负责把事件归并成屏幕状态。
发送消息时,ChatViewModel 会先把用户消息放进当前列表,然后进入 streaming 状态。之后它逐个消费 SseEvent:
- 收到
ack:更新状态 chip,比如”已收到”。 - 收到
typing:让 UI 显示等待中的点点。 - 收到
delta:追加到streamingText,屏幕上出现正在生成的回复。 - 收到
end:把最终 assistant 消息加入列表,清空 streaming buffer,并写入 Room。 - 收到
error:停止生成,保留用户消息,显示错误。
这里没有复杂魔法,反而是这套设计最稳的地方:服务端协议是有限状态,客户端状态机也跟着保持有限状态。后面如果要改 SSE schema,必须同时改 SseParser 和 ChatViewModel 的分支处理;这两个文件天然是一对。
Markdown 为什么分两种渲染
APK 里用了 Markwon 渲染 Markdown,并加了 Prism4j 语法高亮。最终消息和历史消息会走 Markdown 渲染,所以代码块、列表、标题都能正常显示。
但流式生成中的文本没有强行边收边 Markdown。原因很简单:AI 输出 Markdown 时经常先吐出半个代码围栏、半张表格、一个还没闭合的链接。边收边完整渲染容易闪、容易重排,也容易让 Compose 的测量成本变高。
所以当前策略是:
- streaming 中:用普通 Text 展示,优先保证响应及时、布局稳定。
- end 之后:用 Markwon 渲染完整 Markdown,并把完整答案写入历史。
这不是功能退让,而是移动端体验上的取舍。用户最先感知的是”它在回应我”,最终阅读时再拿到排版完整的 Markdown。
输入框和聊天页布局
这次 UI 重构后,聊天页的输入框采用自动增高设计:单行时保持紧凑,文本超过一行后向上扩展,达到最大高度后内部滚动。它有两个好处:
- 底部操作区不会一开始就占很大面积。
- 长问题不会被压成一个很窄的小窗口,用户能检查自己写了什么。
聊天返回消息的 Markdown 宽度也被固定在一个舒服的阅读范围里。移动端天然窄,问题不大;平板或横屏时,如果 Markdown 跟着屏幕无限拉长,代码和段落都会变难读。固定正文宽度后,聊天流更像文档阅读,而不是横向摊开的日志。
视觉上,APK 没有继续使用紫色,而是换成 Porcelain / Ink / Sage 这一组更安静的配色:浅底、深墨文字、绿色主色和低饱和的功能色。这个选择和项目气质更贴近:它是一个日常使用的 agent 客户端,不是营销页,也不是游戏化工具。
认证:把 444 当成 session invalid
mini-agent 的云实例有一个特殊点:开启 web.silent_errors=true 时,认证失败可能返回 HTTP 444,body 为空或不是 JSON。很多客户端会在这里犯错:看到 4xx 还尝试 JSON parse,最后把真正的登录失效吞成解析异常。
APK 的处理更直接:
/auth成功后拿 token。- token 和密码保存到 EncryptedSharedPreferences。
TokenHolder保存内存热拷贝,AuthInterceptor从这里给请求加 Bearer。- 遇到 401 或 444,不解析业务 JSON,直接认为 session invalid。
AuthManager.invalidate()清掉 token,UI 回到AuthGateScreen。
服务端 token 当前是 24 小时、内存态。服务端重启后,即使 Android 本地还有 token,也可能已经失效。因此 APK 启动恢复登录态时不能只相信本地存储,还要能处理下一次请求返回的 401 / 444。
本地历史:只为 Android 滚动服务
Android 端用了 Room 保存聊天历史,但它的角色很窄:只保存这台手机上的 scrollback。
这点容易和服务端 /history 混在一起。当前设计刻意不把 Android 做成 Web 历史的镜像,也不做多端一致性。原因有三个:
第一,mini-agent 的能力入口是自然语言 /message,历史不是 agent 执行所需的客户端状态。
第二,移动端本地历史的主要价值是”回到 App 时还能看到刚才聊了什么”,不等于跨端会话系统。
第三,一旦做同步,就会引入冲突、分页、删除、加密、账号切换、离线队列等一串问题。它们不是现在这个 APK 的核心目标。
所以 ChatRepository.loadHistory() 读 Room;send() 时先保存用户消息,收到 end 后保存最终 assistant 消息。streaming 中间态不落库,避免把半截回答写成正式历史。
todo / schedule:一个被允许的例外
虽然项目原则是”所有 agent 能力都在 /message 后面”,但 todo / schedule 被做成了底部 Tab。这个例外不是因为客户端要接管业务,而是因为这两类信息在手机上很适合快速查看和轻操作。
当前 REST 面包括:
| 功能 | 接口 |
|---|---|
| 查看待办 | GET /todo |
| 新增待办 | POST /todo/add |
| 完成待办 | POST /todo/done |
| 查看日程 | GET /schedule |
| 新增日程 | POST /schedule/add |
| 删除日程 | POST /schedule/remove |
Android 的 TasksViewModel 做的事很少:加载列表、提交新增、提交完成或删除、成功后刷新。失败时按类型处理:400 这类业务错误变成 snackbar;401 / 444 这类认证错误走统一登出。
它没有 Room cache,没有后台 sync,也没有复杂的本地 mutation queue。这样后端同学看接口行为时会很轻松:服务端仍然是 todo / schedule 的唯一真实来源。
APK 用到的主要组件
| 组件 | 用途 | 设计考虑 |
|---|---|---|
| Kotlin 2.1 | 主语言 | 协程、Flow、serialization 都是 Kotlin 生态里顺手的工具 |
| Jetpack Compose | UI | 聊天流、输入框、Tab、Dialog 都用声明式状态驱动 |
| Material 3 | 组件和主题 | 提供基础控件、色彩语义、暗亮主题适配 |
| Hilt | 依赖注入 | 把 API、Repository、Room、AuthManager 等绑定清楚 |
| OkHttp 4 | HTTP/SSE | 直接控制长连接、超时和 Call.cancel() |
| kotlinx serialization | JSON DTO | 与后端 JSON contract 对齐,避免手写 map |
| Room | 本地历史 | 保存 Android 本机聊天 scrollback |
| EncryptedSharedPreferences | 凭据保存 | 保存 token/password,支持重启后恢复 |
| Markwon + Prism4j | Markdown / 代码高亮 | 最终消息和历史消息可读性更好 |
| JUnit / Truth / Turbine / MockWebServer / Robolectric | 测试 | 覆盖 parser、ViewModel、Repository、HTTP fixture |
有一个细节值得单独说:OkHttp 的 read timeout 设置为 0。普通 REST 请求不应该这么干,但 SSE 是长时间读流,如果 read timeout 太短,服务端稍微思考久一点,客户端就会自己断开。
为什么不用更重的移动端方案
后端开发者很容易问:要不要后台任务?要不要 push?要不要本地同步?要不要多端历史?
这些都可以做,但现在不做。
原因不是技术上不会,而是它们会改变产品边界。chat-miniagent APK 当前要解决的是”手机上能稳定、清楚地使用 mini-agent”,不是重建一个完整移动协作系统。
如果加入 WorkManager,用户关掉页面后生成仍在后台跑,Stop 语义会变复杂。如果加入 FCM 或 WebSocket,服务端要维护设备、推送权限、离线补偿。如果加入 todo/schedule 本地库,就要处理冲突和一致性。每加一个能力,APK 就更像一个完整业务端,而不是薄客户端。
所以当前版本先把主链路做好:
- 登录能恢复,失效能回登录。
/message能稳定流式返回。- Stop 能取消真实网络请求。
- Markdown 最终展示清楚。
- 本机历史能回看。
- todo / schedule 能做轻量操作。
这个范围小,但闭合。
和 Web 版体验的差别
Web 端天然有更大的屏幕、更自由的 DOM、浏览器级调试工具,也更容易做复杂历史和多面板。Android 端则更在意:
- 屏幕窄,Markdown 宽度要受控。
- 输入法会顶起布局,底部输入区要稳。
- 网络切换频繁,SSE parser 要能处理 chunk 边界。
- 生命周期短,取消和恢复不能依赖页面一直活着。
- 本地凭据要放进系统加密存储。
所以 APK 没有照搬 Web 的历史语义,而是选择 Room 本机历史;没有照搬 Web 的大布局,而是做成聊天、待办、日程三个紧凑 Tab;没有把 streaming Markdown 做得很花,而是先保证流畅,再在最终态渲染完整 Markdown。
测试怎么覆盖
这类项目最该测的是协议和状态,而不是截图。
当前测试重点放在这些地方:
SseParserTest:半包、多个 frame、空 keepalive、错误帧。ChatViewModelTest:ack / typing / delta / end / error状态机。ChatRepositoryTest:MockWebServer 提供 SSE fixture,验证 Repository 行为。- API/任务相关测试:todo、schedule、401/444、非 2xx 错误处理。
工程验证上,当前实现已经跑过:
./gradlew :app:testDebugUnitTest./gradlew assembleDebug./gradlew lintDebug三者通过。真机或模拟器上的 connectedAndroidTest 还需要可用设备,这类验证不能用单元测试假装覆盖。
给后端开发者的阅读顺序
如果你想快速接手这个 APK,我建议按这条线读代码:
- 先看
MiniAgentApi,理解 HTTP contract、SSE、401/444。 - 再看
SseParser,理解为什么 parser 是增量的。 - 看
ChatViewModel,把五类 SSE 事件和 UI 状态对上。 - 看
ChatRepository,确认 Room 只存本地滚动历史。 - 看
AuthManager、TokenHolder、AuthInterceptor,理解 token 生命周期。 - 最后看
ChatScreen和主题文件,理解 UI 为什么这么组织。
读完这几处,基本就能知道:手机端什么时候发请求、后端什么时候回事件、什么东西会被保存、什么时候会回登录、Stop 到底停在哪里。
小结
chat-miniagent APK 的核心设计不是”Android 做得越多越好”,而是”Android 只做它最该做的事”。
它把手机端体验需要的部分做实:Compose UI、自动扩展输入框、Markdown 最终渲染、本地历史、token 恢复、SSE 停止。与此同时,它把 agent 能力、意图路由、工具执行、todo/schedule 真实数据都留在 mini-agent 后端。
对网站后端开发者来说,理解这个 APK 的关键不是学习一堆 Android API,而是抓住三条线:
/message是主链路,SSE 五类事件是前后端协议。- Android 是薄客户端,Room 只是本机 scrollback。
- todo / schedule 是窄 REST 例外,但服务端仍是唯一真实来源。
抓住这三条线,再看 Kotlin、Compose、Hilt、OkHttp、Room,就不会迷路。