DeepTutor 伙伴 APP DEV DOC v1.0
随身与 DeepTutor 学习伙伴(Dina / Histira / Geona)对话的移动客户端
01项目概述
把 Web 端「伙伴」体验浓缩进手机:打开即聊、流式输出、会话延续。
做什么:一个移动 APP,登录自部署的 DeepTutor 实例,列出学习伙伴,选择伙伴进入聊天界面,支持流式回复(含 thinking 过程展示)、多会话管理、会话内上下文延续。
不做什么(明确出界):知识库管理、智能写作、书籍/学习空间、模型与插件配置——这些留在 Web 端。APP 专注「与伙伴对话」这一件事。
1.1北极星场景
- 孩子在平板/手机上直接找 Dina(英语老师) 要当天的阅读材料、报进度
- 临时想问 Histira(历史) 或 Geona(地理) 一个问题,不用开电脑
- 碎片时间翻看历史会话继续聊(Web 端的会话在 APP 内无缝接续)
1.2成功标准
| 维度 | 标准 |
|---|---|
| 冷启动到可输入 | < 3 秒(已登录态) |
| 首字延迟 | 伙伴回复第一个可见字符 < 3 秒(取决于 LLM) |
| 会话一致性 | APP 发起的会话在 Web 端可见可续,反之亦然 |
| 掉线韧性 | SSE 中断后可重发/恢复,不丢用户消息 |
02服务端现状(实测)
以下全部信息于 2026-09-15 用临时管理员账号对生产实例逐一探测验证,非文档想象。
| 项目 | 值 |
|---|---|
| 实例地址 | https://deeptutor.hms.jude.pub(Tencent CVM,nginx 反代 → 容器 127.0.0.1:3782) |
| 版本 | v1.5.16,Docker 单容器,数据卷 deeptutor-data |
| 认证 | 系统原生登录已启用(JWT + HttpOnly Cookie dt_token,30 天有效期,Secure 标记) |
| 注册 | 已关闭(首管理员存在后 /register 恒 403) |
| 伙伴 API 权限 | admin 角色专属(挂载 dependencies=_admin)→ APP 必须以管理员账号登录 |
| LLM | 已配置,Dina 实测可流式回复中文 |
2.1现有伙伴(3 个,全部 auto-start 运行中)
🐶
Dina partner_id="dina"
英语老师 · 自定义 Soul · 温暖直接的学习伴侣风格
🏛️
Histira partner_id="histira"
历史老师 · 伙伴库 Soul(companion)
🌍
Geona partner_id="geona"
地理老师 · 自定义 SVG 头像(data-uri 直出)
✓ 实测结论
伙伴列表、详情、会话列表、历史消息、SSE 流式对话、会话删除 6 条链路全部跑通;SSE 经 nginx 无缓冲正常(服务端已带 X-Accel-Buffering: no)。
03系统架构
零后端改造方案:APP 作为纯客户端直接复用 DeepTutor 既有 REST + SSE 接口。
┌─────────────────────┐ ┌─────────────────────────── 43.161.202.95 ──────────────────────────┐
│ 移动设备 (iOS/And) │ │ │
│ │ TLS │ ┌──────────┐ ┌──────────────────────────────────────┐ │
│ ┌───────────────┐ │ HTTPS │ │ nginx │─────▶│ DeepTutor 容器 (deeptutor:latest) │ │
│ │ Flutter APP │──┼───────▶│ │ :443 │ /api/*│ ┌────────────┐ ┌───────────────┐ │ │
│ │ │ │ │ │ LE证书 │─────▶│ │ Next 前端 │ │ FastAPI 后端 │ │ │
│ │ ·登录/JWT │ │ │ └──────────┘ 反代 │ │ :3782 │─▶│ :8001 内部 │ │ │
│ │ ·伙伴列表 │ │ │ ▲ │ └────────────┘ └───────┬───────┘ │ │
│ │ ·SSE 流式聊天 │ │ │ │ SSE(长连接) │ 数据卷 deeptutor-data │ │
│ │ ·会话管理 │ │ │ └──────────────────────────────────│──▶ SQLite/JSON │
│ │ ·SQLite 缓存 │ │ │ │ data/partners/ │
│ └───────────────┘ │ │ │ data/user/… │
└─────────────────────┘ └────────────────────────────────────────────────────┴──────────┘
请求路径: APP ──TLS──▶ nginx :443 ──▶ Next 中间件 /api/* 重写 ──▶ FastAPI :8001
SSE 路径: POST /api/v1/partners/{id}/chat/execute-stream (text/event-stream, 无 nginx 缓冲)
关键架构决策:
- 纯客户端方案——不改 DeepTutor 任何代码,升级镜像不影响 APP
- 所有请求走
/api/v1/*真实前缀(Next 中间件负责转发到 FastAPI :8001),APP 不感知内部端口 - 数据主权在服务端:APP 本地只做只读缓存,一切写操作(发消息/删会话)以服务端结果为准
04技术选型
单代码库覆盖 iOS + Android,一个人可维护。
| 方案 | SSE 支持 | 中文/生态 | 评价 |
|---|---|---|---|
| Flutter 推荐 | http 包流式 Response + 手写 SSE 解析(~80 行,文档附实现) |
极好(国内文档/插件生态成熟) | 单代码库双端、渲染性能接近原生、Dart 强类型适合 API 层建模;无 RN 的 bridge 开销 |
| React Native | EventSource polyfill 可用但移动端坑多 |
好 | JS 生态熟悉度高时备选;长连接与后台行为需要更多原生模块 |
| uni-app | H5 端可用,App 端受限 | 极好 | 仅当未来想顺带发微信小程序时考虑;原生体验与流式控制弱于 Flutter |
4.1推荐技术栈(Flutter)
| 关注点 | 选型 | 理由 |
|---|---|---|
| 网络 | dio + 拦截器 | token 注入 / 401 统一处理 / 日志 |
| SSE | dio ResponseType.stream + 自研解析器 | 见 §7,无第三方依赖 |
| 安全存储 | flutter_secure_storage | iOS Keychain / Android Keystore |
| 本地缓存 | drift(SQLite) | 会话/消息离线可读 |
| 状态管理 | riverpod | 伙伴/会话/登录态流转清晰 |
| Markdown 渲染 | flutter_markdown | Dina 回复实测为 Markdown(标题/加粗/引用/表情) |
05认证设计
复用 DeepTutor 原生 JWT,APP 侧只做存储、注入与续期。
5.1登录时序
APP DeepTutor API
│ │
│ POST /api/v1/auth/login │
│ {username, password} │
│─────────────────────────────▶│
│ │ 验证 bcrypt 哈希 (users.json)
│◀─────────────────────────────│
│ 200 {ok:true, role:"admin"} │
│ Set-Cookie: dt_token=JWT │ ← HttpOnly · Secure · 30天
│ (body 不含 token!) │
│ │
│ ── 提取 cookie 值存入 ──▶ Keychain/Keystore
│ │
│ GET /api/v1/partners │
│ Cookie: dt_token=JWT │ ← 或 Authorization: Bearer JWT
│─────────────────────────────▶│
│◀──── 200 [伙伴列表] ─────────│
⚠ 两个实测要点
① JWT 只在 Set-Cookie 里返回,响应 body 只有 {ok, user_id, username, role, is_admin}——登录后必须从 Set-Cookie: dt_token=… 解出 token 存本地,后续请求用 Authorization: Bearer 头携带(比 cookie 灵活,WebView/SSE 都好用)。② 伙伴 API 是 admin-gated:登录账号必须
is_admin=true,否则列表 403。
5.2Token 生命周期
| 环节 | 策略 |
|---|---|
| 存储 | flutter_secure_storage(Keychain/Keystore),绝不进 SharedPreferences/明文 |
| 注入 | dio 拦截器统一加 Authorization: Bearer |
| 过期检测 | 启动时先打 GET /api/v1/auth/status(轻量、免鉴权开销);authenticated:false 则清 token 进登录页 |
| 续期 | 服务端固定 30 天、无 refresh 接口 → 到期重新登一次即可(个人应用可接受);登录页记住用户名 |
| 401 处理 | 拦截器捕获 401 → 清 token → 跳登录页 → 重登录后自动重放失败请求 |
| 应用锁 | 可选:local_auth 生物识别解锁(家教场景,防小孩误触退出登录) |
06API 参考(实测端点)
Base URL:https://deeptutor.hms.jude.pub · 全部 JSON · 鉴权:Authorization: Bearer <jwt>
6.1认证
| 端点 | 说明 |
|---|---|
POST /api/v1/auth/login | 入参 {username,password};200 返回用户信息 + Set-Cookie: dt_token;错误密码 401(body 401 JSON) |
GET /api/v1/auth/status | 当前会话状态:{enabled, authenticated, username, role, is_admin};免 body 开销的启动探针 |
POST /api/v1/auth/logout | 注销(清 cookie) |
// 登录(实测响应)
POST /api/v1/auth/login
{"username":"<管理员账号>","password":"<密码>"}
HTTP/1.1 200 OK
Set-Cookie: dt_token=eyJhbGciOiJIUzI1NiIs...; HttpOnly; Secure; Max-Age=2592000
{"ok":true,"user_id":"u_fdc6…","username":"…","role":"admin","is_admin":true}
6.2伙伴
| 端点 | 说明 |
|---|---|
GET /api/v1/partners | 伙伴列表(含运行状态/头像/Soul 来源) |
GET /api/v1/partners/{id} | 单个伙伴详情(channels 为对象) |
GET /api/v1/partners/recent?limit=3 | 最近活跃伙伴(首页快捷入口) |
POST /api/v1/partners/{id}/start / stop | 启停伙伴进程(一般无需,auto-start 已开) |
// GET /api/v1/partners(实测,节选)
[{"partner_id":"dina","name":"Dina","description":"英语老师",
"channels":[],
"llm_selection":{"model_id":"llm-model-…","profile_id":"llm-profile-…"},
"language":"zh","emoji":"🐶","color":"","avatar":"",
"soul_origin":{"id":"","type":"custom"},
"running":true,"started_at":"2026-09-15T02:33:45Z","last_reload_error":null},
…]
头像渲染
avatar 可能为空(用 emoji 兜底)或 data:image/svg+xml;base64,…(Geona 实测)→ 组件需支持 data-uri 直载 + emoji/首字母三级 fallback。
6.3会话与历史
| 端点 | 说明 |
|---|---|
GET /api/v1/partners/{id}/sessions | 会话列表:{session_key,title,message_count,updated_at,last_message,archived} |
GET /api/v1/partners/{id}/history?session_key=…&limit=… | 历史消息:{role:"user"|"assistant",content},content 为 Markdown |
POST …/sessions/archive / resume / delete | body {session_key};delete 返回 {deleted:true} |
POST …/sessions/branch | 从某会话分叉新会话 |
// GET /api/v1/partners/dina/sessions(实测,节选)
[{"session_key":"web-kdprapow",
"title":"我正在学习这套教材,目前正在学习第一本的Unit9…",
"message_count":16,"updated_at":"2026-09-12T06:47:17Z",
"last_message":"第 7 期交付完毕 ✅ 进度已记录(下次 9 月 15 日 Unit 16)…",
"archived":false},…]
// GET …/history?session_key=web-kdprapow(实测,节选)
[{"role":"assistant","content":"9 月 9 日,第 6 期准时送达 🎉 今天进入 **第 1 册 Unit 14…"},…]
6.4发消息(两种模式)
| 模式 | 端点 | 适用 |
|---|---|---|
| 同步一次性 | POST /api/v1/partners/{id}/chat | MVP 阶段先做这个:发完等完整回复,实现最简单 |
| SSE 流式 正式版 | POST /api/v1/partners/{id}/chat/execute-stream | 逐字输出 + thinking 过程,体验对齐 Web 端 |
两种模式共享同一请求体(ChatMessageRequest):
{
"content": "用一句话介绍你自己", // 必填(或有附件)
"session_key": "web-kdprapow", // 续接已有会话;新会话传自定义 key(如 "app-xxx")或不传
"session_id": null, // 由 SSE session 事件返回的内部 id,续聊可回传
"chat_id": null,
"attachments": [], // 图片/文件附件结构
"llmSelection": null // 覆盖模型选择,一般不传
}
// 同步模式响应: {"partner_id":"dina","session_id":"5b5d…","content":"完整回复文本"}
07SSE 流式聊天实现
核心体验:thinking 逐字上屏 → 折叠 → 正文逐字上屏。协议已实测。
7.1事件序列(实测抓包)
event: session ← ① 第一帧: 建立会话
data: {"partner_id":"dina","session_id":"5b5d4b4a1e9b4e7c8a759ad3f035f474"}
event: thinking ← ② 推理过程, 多帧增量
data: {"content":"用户"}
data: {"content":"让我"}
data: {"content":"用一句话"}
…(几十帧, 每帧 2~6 字)
event: content ← ③ 正文(增量或整段, 以服务端为准累积)
data: {"content":"你好,我是 Dina——你的学习伙伴,擅长把复杂的知识拆成…"}
event: done ← ④ 结束帧: 持久化完成
data: {"partner_id":"dina","session_id":"5b5d…"}
7.2Flutter 解析器(参考实现)
final class SseClient {
final dio.Response<ResponseBody> _resp;
SseClient(this._resp);
Stream<SseEvent> events() async* {
final lines = _resp.data!.stream
.cast<List<int>() // 字节流
.transform(utf8.decoder)
.transform(const LineSplitter());
String? event;
await for (final line in lines) {
if (line.startsWith("event:")) {
event = line.substring(6).trim();
} else if (line.startsWith("data:") && event != null) {
yield SseEvent(event!, jsonDecode(line.substring(5).trim()));
if (event == "done") return; // 服务端 done 后流自然关闭
}
}
}
}
// 请求侧: dio.post(url, options: Options(responseType: ResponseType.stream, headers: {"Accept":"text/event-stream"}))
7.3状态机与韧性
| 场景 | 处理 |
|---|---|
| 发送中 | 输入框锁定 + 停止按钮;气泡显示 thinking(灰色小字、可折叠) |
| SSE 中断(弱网) | 重试 3 次(指数退避);期间用户消息保留在本地 outbox,成功后标记已送达 |
| 重复消费 | 服务端按 session 幂等累积,APP 只需保证 UI 侧 content 按 session_id 归位 |
| 切后台 | iOS 后台 SSE 会被系统掐断 → 回前台时若流已断,拉 history 补齐最后一轮(最可靠的对账方式) |
| 超时 | 首帧 30s 无 event 视为失败(nginx proxy_read_timeout=300s 上限之内) |
08数据模型与本地缓存
服务端是唯一事实源;本地 SQLite 只为秒开与离线回看。
/// 伙伴(映射 GET /partners)
class Partner {
final String partnerId, name, description, language;
final String? emoji, avatar; // avatar=data-uri | null
final String soulType; // custom | library
final bool running;
}
/// 会话(映射 GET /{id}/sessions)
class ChatSession {
final String partnerId, sessionKey, title, lastMessage;
final int messageCount;
final DateTime updatedAt;
final bool archived;
}
/// 消息(本地扩展持久化字段)
class Message {
final String partnerId, sessionKey;
final String role; // user | assistant
final String content; // Markdown
final String status; // sending | done | failed ← APP 私有
final DateTime createdAt;
}
- 同步策略:进入会话先展示本地缓存,
history返回后 diff 更新(以服务端为胜);伙伴/会话列表 pull-to-refresh + 5 分钟静默过期 - session_key 约定:APP 新建会话用
app-{uuid8}前缀(与 Web 端web-*区分,排查问题一眼可辨)
09页面与交互设计
5 个页面,深浅色双主题,中文优先。
| # | 页面 | 内容与要点 |
|---|---|---|
| 1 | 登录 | Logo + 服务器地址(预填 deeptutor.hms.jude.pub,可改)+ 账号/密码;错误内联提示(实测后端返回 401 + detail);登录成功即探 auth/status 确认 is_admin,非 admin 给出明确文案 |
| 2 | 伙伴列表(首页) | 大头像卡列表:emoji/头像 + 名称 + 描述 + 最近会话摘要 + running 状态点;点击进聊天;长按快捷「新会话/会话列表」;空态给「去 Web 端创建伙伴」引导 |
| 3 | 聊天(核心) | 顶部伙伴名+头像;消息流:user 右蓝 assistant 左白,Markdown 渲染(代码块可横滑复制);thinking 气泡:进行中灰色逐字+呼吸点,完成后折叠成「已思考 N 秒 ▸」可展开;输入框 + 发送/停止切换;附件按钮(M3) |
| 4 | 会话抽屉 | 聊天页左滑/汉堡唤出:当前伙伴的会话列表(标题+时间+条数),支持续接/归档/删除(删除需二次确认——服务端立即真删,实测) |
| 5 | 设置 | 当前账号、服务器地址、主题、应用锁开关(生物识别)、清空本地缓存、退出登录 |
设计语言
参考实例 Web 端的克制风格:白/深灰底 + 单一品牌蓝强调色;圆角 12px;气泡内 15px 正文;动画只用于「thinking→正文」的过渡与消息上屏(120ms fade+8px slide),尊重系统 reduced-motion。
10安全清单
| 项 | 要求 | 状态 |
|---|---|---|
| 传输加密 | 全流量 HTTPS(LE 证书,2026-12 到期前自动续) | 服务端已就绪 ✓ |
| 凭证存储 | JWT 进 Keychain/Keystore;密码只在登录请求中出现,绝不落盘 | APP 实现 |
| 注册面 | 服务端自助注册已封死,仅管理员可在 Web 端建号 | 服务端已就绪 ✓ |
| 暴力破解 | 登录失败本地退避(5 次后 30s 冷却);服务端 bcrypt 成本 12 | APP 实现 |
| 证书锁定 | 可选:pin LE 中间证书公钥(注意 ACME 续期轮换,需备用 pin) | M4 可选 |
| 越权 | 伙伴 API 天然 admin-gated;APP 不暴露任何「切换用户」捷径 | 已就绪 ✓ |
| 调试泄露 | release 构建关闭 dio 日志、禁用 HTTP 明文(usesCleartextTraffic=false / ATS) | APP 实现 |
11里程碑计划
| 里程碑 | 范围 | 验收 |
|---|---|---|
| M1 · 能聊 1~2 周 | 登录 → 伙伴列表 → 同步模式聊天(/chat 一次性返回)→ 本地缓存历史 | 手机上和 Dina 完成一轮真实对话;Web 端能看到该会话 |
| M2 · 流式体验 1 周 | SSE 流式 + thinking 折叠 UI + 断流重试 | 逐字上屏流畅;飞行模式切换后消息不丢 |
| M3 · 会话管理 1 周 | 会话抽屉、续接/删除/归档、多伙伴切换、应用锁 | 平板与 Web 交替续聊同一会话上下文不丢 |
| M4 · 打磨分发 按需 | 附件发送、通知(见 §13)、证书锁定、TestFlight + APK 分发 | 日常主力使用一周无 P0 问题 |
12测试要点
- 隔离原则:对着生产实例测试必须用专用
session_key(如app-test-*),测完调sessions/delete清理——本文档实测即用此模式,用户库与真实会话零污染 - SSE 用例:正常全流程 / 中途断网 / 发送后立刻切后台 / done 未到流被关 / 服务器 5xx
- 会话竞态:APP 与 Web 端同会话交替发消息,
history对账一致 - 登录边界:错误密码(实测 401)、非 admin 账号(403)、过期 token(status 探针)
- 头像边界:emoji 伙伴 / data-uri SVG 伙伴 / 两者皆空(首字母兜底)
13已知限制与风险
| 风险 | 影响 | 对策 |
|---|---|---|
| 伙伴 API 为 admin-gated | APP 必须持管理员凭证,等同于全权 | 个人使用可接受;若未来给他人用,推动上游加受限角色或专用只读 API |
| 无服务端推送 | 伙伴不会主动发起消息,APP 无离线通知 | 当前交互模型一问一答,无推送损失小;如需「每日阅读材料」提醒,用本地通知在固定时间唤起即可 |
| JWT 30 天硬过期 | 每月至少重登一次 | 登录页记住用户名 + 生物识别快捷填密码(存 Keychain) |
| LLM 成本随移动端使用上升 | 随手聊 > Web 端克制 | 观察用量;必要时在 Web 端给伙伴设置请求预算/限流 |
| DeepTutor 升级改 API | APP 端点失效 | API 层集中封装(本文 §6 全列);升级实例前 diff 上游 routers |
附参考资料指针
- 服务端源码:
/opt/web/deeptutor/deeptutor/api/routers/partners.py(端点权威定义)、auth.py(鉴权)、unified_ws.py(WS 备选通道) - 部署细节:
~/.hermes/skills/devops/nginx-site-setup/references/deeptutor-deployment.md - 实例配置:
auth.json(JWT 策略)、users.json(账号)路径见服务器文档 - 上游仓库:HKUDS/DeepTutor(多用户模式说明见 v1.3.8+ release notes)