# M23：从流式 API 到全局 AI Chat Drawer，为什么先做公开站 MVP 闭环

做个人简历项目时，我们很容易把 AI 想成一个“附加按钮”：

- 页面上放一个 `AI Talk`
- 点进去能聊几句
- 看起来好像就已经有 AI 能力了

但如果真的把它当作一个产品入口去看，就会很快发现：这远远不够。

一个真实可用的 AI 入口，至少要回答下面这些问题：

- 用户从哪里进入？
- 刷新后会不会丢会话？
- 回答是一次性返回，还是能流式看到过程？
- 如果会话有问题，管理员能不能回看？
- 如果公开站要限流、限次、做最小治理，边界放在哪里？

这篇文章想复盘的，就是我们为什么没有继续把 `ai-talk` 停留在一个“说明页”，而是先把它收成一个公开站 MVP 闭环：

> 从流式 API，到全局 Drawer，到会话持久化，再到 admin 治理台，一步一步把 AI 入口做成真的能用。

## 一、问题背景：AI 入口不该只是说明页

在更早的阶段里，`ai-talk` 已经有了入口结构，也有了后续规划：

- RAG 对话
- 数字人自我介绍
- 会话历史

这些方向都没问题，但它们当时更偏“信息架构占位”，而不是真实能力闭环。

换句话说，我们已经知道未来想做什么，却还没有解决当前最关键的问题：

> 用户真的能从公开站顺手进入 AI，对话、返回、刷新、回看这一整条链路是否成立？

如果这一步不先做，后面无论继续加：

- 数字人
- 情绪检测
- 多模态
- 更复杂的知识治理

都只会叠在一个还不稳定的入口上。

所以这轮没有继续扩写说明页，而是先回到一个很朴素的问题：

> 怎样让公开站 AI 入口先变成一个真实可交互的 MVP？

## 二、技术方案：流式 API、全局 Drawer、公开会话、Admin 治理

这轮的核心方案，其实可以用四个词概括：

1. **流式 API**
2. **全局 Drawer**
3. **公开会话**
4. **Admin 治理**

### 1. 为什么先做流式 API

如果 AI 回答还是一次性整段返回，那体验上依然会像“提交表单等结果”，而不是聊天。

所以这里先把服务端问答接口做成 `text/event-stream`，让前端能逐步接收：

- `start`
- `token`
- `citation`
- `block`
- `summary`
- `done`
- `error`

这一步的价值不只是“更丝滑”，而是：

- 用户知道系统在工作
- 前端可以边显示正文、边显示引用、边显示结构化卡片
- 后续 summary 和治理信息也能沿着同一条流往前端走

也就是说，流式不是视觉优化，而是这条产品链路的基础协议。

### 2. 为什么入口做成全局 Drawer

如果继续优先做 `/ai-talk/chat` 页面，用户使用 AI 的方式就会变成：

1. 离开当前简历页
2. 进入聊天页
3. 开始提问

这当然能工作，但它不符合真实场景。

公开站 AI 更像一种“随手问一句”的能力：

- 你正在看某段经历
- 你突然想问“这个项目具体做了什么？”
- 你不想先跳走，再进入另一个页面

所以入口最后被收成了一个全局 Drawer：

- 点击头像就能触发
- 跨页面可用
- 关闭后缩成 Dock
- 需要时再恢复

这样 AI 才不是“另一个页面”，而是公开站的一层随时可用的辅助能力。

### 3. 为什么做公开会话，而不是先做完整账户体系

如果一开始就引入完整账户体系、多租户、复杂身份模型，这条线会一下子变得很重。

但完全没有治理边界也不行。

所以这轮选择了一个很适合学习项目的折中方案：

- 公开站访客通过最小 consent 进入
- 后端按 `IP + 当天日期` 认领或恢复会话
- 每个 IP 每天固定 20 轮问答
- 底层保留 `lead / useKey / session` 模型
- 但不把 `useKey` 暴露成公开站首屏门槛

这就是一个很典型的“先把门槛压低，再把治理放在底层”的取舍。

### 4. 为什么 admin 治理要同步落地

很多时候我们会把公开站体验和后台治理拆成先后两期。

但 AI 对话和普通静态页面不一样。  
它一旦开放给真实用户，就会立刻产生这些问题：

- 这是谁触发的？
- 现在会话到第几轮了？
- 总结有没有生成？
- 某个会话出问题了能不能删？

所以这轮没有等“以后再说”，而是同步补上了最小治理台：

- 看线索
- 看 useKey
- 看 session
- 看详情
- 删除记录

这不是为了把后台做重，而是为了让这条链路具备最基本的可见性和可控性。

## 三、关键实现：SSE、session 恢复、summary 节点、answer blocks

这轮最值得学的，不是“又写了多少页面”，而是几个关键实现点为什么选成现在这样。

### 1. SSE：让聊天从“提交等待”变成“真实流动”

前端不是一次性拿整条 assistant 消息，而是消费一个事件流。

这样做之后，前端可以分开处理：

- 文本逐字增长
- 引用列表追加
- 项目 / 经历卡片插入
- 总结节点预览更新
- 最终 session 快照收口

这带来的好处是，UI 不再只有一种“消息气泡”。

聊天消息开始变成一种可组合的输出容器：

- 普通文本
- citations
- `project_card`
- `experience_card`
- summary block

这为后面继续做更强的结构化回答打下了基础。

### 2. session 恢复：公开站体验不能靠“别刷新”

如果一个公开站 AI 会话刷新就丢，那它更像试玩组件，不像真实入口。

所以这轮专门补了最小恢复机制：

- 本地保存 `sessionId`
- 保存 `useKey`
- 保存当日 consent 状态

页面刷新后，前端会先尝试恢复会话，而不是重新从头开始。

这一步特别重要，因为它把体验从“临时小工具”推进成了“有连续性的入口能力”。

### 3. 为什么 summary 只放在 10 / 20 轮

很多 AI 功能做着做着，会想把每一轮都做成“大工作流”：

- 每轮总结
- 每轮分析
- 每轮打标

听起来很强，但很容易把系统变复杂，也把成本抬高。

所以这轮 deliberately 收住了：

- 第 10 轮：阶段总结
- 第 20 轮：最终总结并结束会话

这样做有两个好处：

1. 总结节点有明确产品意义
2. 这条能力不会把每轮对话都拖成重流程

对学习项目来说，这种“先在少量关键节点做重”往往比“每个地方都浅浅做一点”更健康。

### 4. answer blocks：先做最有价值的两种卡片

这轮没有一上来把所有回答都结构化，而是只先做：

- `project_card`
- `experience_card`

为什么？

因为在简历问答场景里，这两类是最有价值、也最自然的结构化信息：

- 项目经历本来就适合卡片展示
- 工作经历也天然适合被抽成一块独立内容

这种取舍很重要。  
不是所有“可以结构化”的内容都应该第一时间结构化。  
更好的顺序是：

> 先把最有价值的块做出来，再决定是否继续扩。

## 四、遇到的坑：HeroUI 契约、SSE facade 约束、SQLite 补表顺序、旧库兼容

这条线里最有学习价值的部分，往往不是“最后做成什么”，而是那些很真实的小坑。

### 1. HeroUI 的交互契约和直觉不完全一致

这类坑看起来小，但很典型。

比如：

- `Input` 用的是 `onChange`
- `Button` 没有你以为会有的 `isLoading` / `color`

这提醒我们一件事：

> UI 库不是“我脑子里以为它应该怎样”，而是“它现在的契约怎样”。

在复盘里记录这些小坑很有价值，因为它们会反复出现。

### 2. API facade 层对 `export async function` 有约束

`packages/api-client` 这一层不是随便怎么写都行。

为了保持 domain facade 的一致性，流式方法最后也不能随意直接暴露 `export async function`，而是要保持同步导出、内部再异步实现。

这类约束的价值在于：

- 保持 API 风格统一
- 测试更稳定
- 避免某个模块越写越特例化

### 3. SQLite 新表和新列不能只靠“理想新库”

学习项目很容易默认：

> 大家都会重新建一个全新的数据库。

但真实使用里，旧库会一直存在。

这轮就踩到了很典型的问题：

- 新表缺失
- 新列缺失
- 如果索引创建顺序不对，启动直接失败

所以后面才需要在服务启动时补齐表结构，并且严格控制顺序：

> 先补列，再建索引。

这背后其实是一个更重要的方法：

> 不要只为“干净环境”写代码，也要为“升级中的真实环境”写代码。

### 4. 旧库兼容不是可有可无的善后

如果一条能力已经准备接近真实使用，那兼容旧库就不再只是“以后再说”的工作。

这轮能顺利收口，很大一部分原因就在于：

- 没有把新能力假设成只跑在全新 DB 上
- 而是把旧库升级路径也一并考虑进来了

这正是教程型项目往真实工程迈一步时，很值得练习的地方。

## 五、如何验证：从点击头像到 admin 回看整条链路

这条线做完之后，最怕的是“看起来功能很多，但不知道该怎么验证”。

所以最好的做法不是只记命令，而是按链路分层验证。

### 1. 先验证协议和契约

先跑 API Client 侧测试，确认：

- public claim
- fetch session
- close session
- SSE 事件消费

```bash
pnpm --filter @my-resume/api-client test -- src/ai.spec.ts
```

这一步解决的是：

> 事件流和基础契约本身有没有断。

### 2. 再验证 web 全局交互

重点看：

- consent modal
- Drawer 打开
- Dock 最小化
- session 恢复

```bash
pnpm --filter @my-resume/web test -- 'app/_shared/ai-chat/__tests__/ai-chat-context.spec.tsx' 'app/_shared/ai-chat/__tests__/ai-chat-drawer.spec.tsx'
pnpm --filter @my-resume/web typecheck
```

这一步解决的是：

> 用户看到的那条主流程顺不顺。

### 3. 再验证 server 行为边界

当前最低限度先确认：

```bash
pnpm --filter @my-resume/server typecheck
```

下一步如果继续补强，最值得补的是：

- `AiChatService` 单测
- `AiChatController` SSE 行为测试

这一步解决的是：

> 会话创建、流式输出、summary 持久化这些核心行为有没有被代码守住。

### 4. 最后做手工链路验证

真正的闭环验证，还是要从产品角度跑一遍：

1. 打开公开站简历页
2. 点击头像
3. 弹出 consent modal
4. 点击“同意并继续”
5. Drawer 打开并开始对话
6. 发送一条消息，看是否流式返回
7. 关闭 Drawer，看是否变成 Dock
8. 点击 Dock 恢复
9. 去 admin 的 `chat-governance` 查看会话
10. 确认能看到 summary、消息详情和 useKey 状态

如果这一整条链跑顺了，这轮能力就真的成型了。

## 六、最后的结论：为什么先做公开站 MVP 闭环

如果把这轮的取舍压缩成一句话，那就是：

> 我们没有急着把 AI 做成一个庞大的系统，而是先把“入口、会话、持久化、治理”这四个最基础的骨架收住。

这背后其实体现了一个很重要的方法论：

1. 不是一开始就做最复杂方案
2. 先让一条最小链路真实成立
3. 复用已有 `RagService.ask()`，避免再造第二套问答基础设施
4. 通过真实使用暴露问题，再继续补细节

这也是为什么这轮虽然叫“公开站 AI Chat MVP”，但它并不轻。  
它已经把最核心的问题都拉到了台面上：

- AI 入口怎么进
- 会话怎么管
- 流怎么走
- 总结怎么沉淀
- admin 怎么看

当这些骨架已经站住，后面再继续长：

- 更强治理
- 更复杂 summary
- 更多 answer blocks
- 数字人和多模态

就会顺得多。
