DeepTutor 伙伴 APP DEV DOC v1.0

随身与 DeepTutor 学习伙伴(Dina / Histira / Geona)对话的移动客户端
目标平台 iOS + Android 后端 自部署 DeepTutor v1.5.16 文档日期 2026-09-15 数据来源 全部接口经生产实例实测

01项目概述

把 Web 端「伙伴」体验浓缩进手机:打开即聊、流式输出、会话延续。

做什么:一个移动 APP,登录自部署的 DeepTutor 实例,列出学习伙伴,选择伙伴进入聊天界面,支持流式回复(含 thinking 过程展示)、多会话管理、会话内上下文延续。

不做什么(明确出界):知识库管理、智能写作、书籍/学习空间、模型与插件配置——这些留在 Web 端。APP 专注「与伙伴对话」这一件事。

1.1北极星场景

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 · 温暖直接的学习伴侣风格
language=zh · soul_origin=custom · llm=已配置主+备份 · 活跃会话含教材进度跟踪
🏛️
Histira partner_id="histira"
历史老师 · 伙伴库 Soul(companion)
soul_origin=library:id=companion · backup_llm=已配置
🌍
Geona partner_id="geona"
地理老师 · 自定义 SVG 头像(data-uri 直出)
language=zh · avatar=data:image/svg+xml;base64,…
✓ 实测结论
伙伴列表、详情、会话列表、历史消息、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 缓冲)

关键架构决策:

  1. 纯客户端方案——不改 DeepTutor 任何代码,升级镜像不影响 APP
  2. 所有请求走 /api/v1/* 真实前缀(Next 中间件负责转发到 FastAPI :8001),APP 不感知内部端口
  3. 数据主权在服务端: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 统一处理 / 日志
SSEdio ResponseType.stream + 自研解析器见 §7,无第三方依赖
安全存储flutter_secure_storageiOS Keychain / Android Keystore
本地缓存drift(SQLite)会话/消息离线可读
状态管理riverpod伙伴/会话/登录态流转清晰
Markdown 渲染flutter_markdownDina 回复实测为 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 / deletebody {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}/chatMVP 阶段先做这个:发完等完整回复,实现最简单
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;
}

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 成本 12APP 实现
证书锁定可选: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测试要点

13已知限制与风险

风险影响对策
伙伴 API 为 admin-gatedAPP 必须持管理员凭证,等同于全权个人使用可接受;若未来给他人用,推动上游加受限角色或专用只读 API
无服务端推送伙伴不会主动发起消息,APP 无离线通知当前交互模型一问一答,无推送损失小;如需「每日阅读材料」提醒,用本地通知在固定时间唤起即可
JWT 30 天硬过期每月至少重登一次登录页记住用户名 + 生物识别快捷填密码(存 Keychain)
LLM 成本随移动端使用上升随手聊 > Web 端克制观察用量;必要时在 Web 端给伙伴设置请求预算/限流
DeepTutor 升级改 APIAPP 端点失效API 层集中封装(本文 §6 全列);升级实例前 diff 上游 routers

参考资料指针