项目4 · AI 聊天工作台
从零搭一个真正能用的 AI 聊天工作台:会话管理、SSE 流式输出、停止与重试、断流保存和用量统计。
- 设计聊天客户端的完整状态机:会话切换、乐观消息、流式增量、停止、失败与重试各自的状态边界
- 在 Next.js Route Handler 里做服务端流式代理,把 OpenAI 兼容上游的 SSE 转成稳定的 start / text / done / error 事件协议
- 用 parseSSEChunk 处理跨 chunk 的 data: 行,理解缓冲区、半包、多事件粘包和 [DONE] 的边界
- 用 AbortController 实现停止生成,并在客户端断开时把已生成内容落库、标记 cancelled
- 重试时复用最后一条用户消息,不重复写入,避免历史里出现两条相同提问
- 用 node:sqlite 持久化 conversations / messages / usage,记录 token、延迟与调用状态
- 对 not_configured / unreachable / quota / rate_limit / model_error / cancelled 做统一分类和可恢复提示
- 实现 Markdown + 代码块渲染和用量面板,完成生产构建与自动检查
- 01会话列表:新建、切换、重命名、删除、最近消息预览与时间排序
- 02消息流:用户/助手气泡、流式光标、自动滚动、Markdown 与代码块
- 03输入区:Enter 发送、Shift+Enter 换行、空内容禁用、生成中停止
- 04模型配置:模型名、系统提示词、mock / 真实上游状态提示
- 05错误状态:六类错误的中文提示、部分内容保留、重试入口
- 06用量面板:调用次数、prompt / completion / total tokens、平均延迟、按模型汇总
- 07Route Handlers:/api/chat、/api/conversations、/api/conversations/[id]、/api/usage
- 08服务端持久化:node:sqlite 三表、断流部分保存、usage 逐次记录
聊天页呈现会话侧栏、消息区、输入区和模型/用量面板;会话列表可新建、切换、重命名、删除,当前会话有高亮,空列表有引导文案。
lib/db.ts 用 node:sqlite 建 conversations、messages、usage 三表并写迁移;/api/conversations 支持列表与新建,/api/conversations/[id] 支持详情、改名/改配置与删除,删除会话级联删除消息与用量。
/api/chat 返回 text/event-stream,依次发送 start、若干 text、done 或 error;请求体支持 conversationId / message / retry / model / systemPrompt;没有 AI_API_KEY 时自动走 mock 逐段流,配置后改走 OpenAI 兼容的 /chat/completions 流式上游。
客户端用 reader.read() 读取响应,把 chunk 交给 parseSSEChunk 累积解析,跨 chunk 的半包不会丢;text 增量实时追加到助手消息,流结束后滚动到底部,Markdown 与代码块正常显示。
生成中显示停止按钮,点击后用 AbortController 中断请求;服务端捕获 cancelled,把已生成的部分内容写回 messages 并把 status 标为 cancelled,刷新会话仍能看到部分内容。
失败或取消后出现重试入口;重试请求带 retry: true,服务端删除最后一条用户消息之后未完成的 assistant 消息,复用该用户消息重新生成,messages 中不会出现第二条相同的用户提问。
not_configured / unreachable / quota / rate_limit / model_error / cancelled 六类错误有统一分类函数与中文提示;模型名和系统提示词按会话保存,修改后下一条消息生效,mock 与真实上游状态在界面上可区分。
助手消息支持标题、列表、行内代码和 fenced code block;用量面板展示调用次数、prompt / completion / total tokens、平均延迟与最近调用;solution npm run build 通过,node tests/check.mjs 判定 solution 全过、starter 存在缺口,SSE 与错误分类单测 node 直跑通过。
# AI 聊天工作台(p4-ai-chat)
用 Next.js 16 App Router + React 19 + `node:sqlite` 做一个真正能用的 AI 聊天工作台:会话管理、SSE 流式输出、停止、重试、断流保存、错误分类和用量统计。
这个项目的重点不是「页面上有气泡」,而是把客户端状态机、服务端流式代理、持久化和失败恢复连成一条完整链路。
## 学习目标
- 设计聊天客户端的完整状态:会话切换、乐观消息、流式增量、停止、失败、重试。
- 在 Route Handler 里做服务端流式代理:OpenAI 兼容上游 → 统一的 `start / text / done / error` 事件协议。
- 用 `parseSSEChunk` 处理跨 chunk 的 `data:` 行,理解缓冲、半包、粘包和 `[DONE]`。
- 用 `AbortController` 停止生成,并在客户端断开时把部分内容落库、标记 `cancelled`。
- 重试时复用最后一条用户消息,不重复写入。
- 用 `node:sqlite` 持久化 `conversations` / `messages` / `usage` 三张表。
- 对 `not_configured` / `unreachable` / `quota` / `rate_limit` / `model_error` / `cancelled` 做统一分类和可恢复提示。
- 实现 Markdown + 代码块渲染和用量面板。
## 环境要求
- Node.js **24+**(`node:sqlite` 在此版本稳定,不需要安装数据库服务)
- npm 10+
- 无网络字体依赖,构建不需要访问 Google Fonts
## 快速开始
### 运行起点(starter)
```bash
cd starter
npm install --no-audit --no-fund
npm run dev
```
打开 `http://localhost:3000`。起点只有聊天页静态壳和样式,缺口写在 `starter/TODO.md` 与页面上的 `TODO m1`–`TODO m8`。
### 运行参考实现(solution)
```bash
cd solution
npm install --no-audit --no-fund
npm run dev
```
打开 `http://localhost:3000`,直接发消息即可。
一条命令版本:
```bash
cd solution && npm install --no-audit --no-fund && npm run dev
```
### AI_API_KEY 行为
| 环境 | 行为 |
| --- | --- |
| `AI_API_KEY` 为空(默认) | 自动使用本地 mock 流,逐段返回带 Markdown 的示例回复;会话、流式、停止、重试、用量全部可用 |
| `AI_API_KEY` 已配置 | 请求 `{AI_BASE_URL}/chat/completions`,解析 OpenAI 兼容 SSE 并转发 `delta.content` |
| `AI_MOCK=1` | 即使配置了 key 也强制走 mock,方便离线演示 |
配置方式:
```bash
cp .env.example .env.local
# 编辑 .env.local,填入 AI_API_KEY / AI_BASE_URL / AI_MODEL
npm run dev
```
常用命令:
| 命令 | 作用 |
| --- | --- |
| `npm run dev` | 启动开发服务器 |
| `npm run build` | 生产构建 |
| `npm run start` | 启动生产服务器 |
| `npm run seed` | 把 `../fixtures/sample-conversation.json` 导入本地 SQLite(可选) |
| `npm run typecheck` | TypeScript 检查 |
## 自动检查
在项目根目录执行:
```bash
node tests/check.mjs # 结构 + API 特征 + 临时 SQLite + 纯函数
node tests/unit-sse.test.mjs # parseSSEChunk 缓冲边界
node tests/unit-errors.test.mjs # 六类错误分类
```
期望结果:solution 全部通过;starter 至少 8 项不通过;两个单测输出 `ALL ... TESTS PASSED`。详细走查步骤见 `tests/README.md`。
## 目录结构
```text
p4-ai-chat/
├── README.md
├── starter/ # 可启动的静态起点
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx # 聊天页静态壳 + TODO
│ │ └── globals.css
│ ├── next.config.ts
│ ├── package.json
│ ├── TODO.md
│ └── tsconfig.json
├── solution/ # 完整参考实现
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx # 首屏读 SQLite,交给客户端工作台
│ │ ├── globals.css
│ │ └── api/
│ │ ├── chat/route.ts # SSE 流式代理 + mock + 断流保存
│ │ ├── conversations/
│ │ │ ├── route.ts # 列表 / 新建
│ │ │ └── [id]/route.ts # 详情 / 改名 / 删除
│ │ └── usage/route.ts # 用量聚合
│ ├── components/
│ │ ├── chat-workbench.tsx # 会话侧栏 + 消息流 + 输入区 + 配置/用量
│ │ └── markdown.tsx # 简化 Markdown / 代码块
│ ├── lib/
│ │ ├── db.ts # node:sqlite 连接、迁移、三表
│ │ └── chat/
│ │ ├── errors.ts # 六类错误分类
│ │ ├── sse.ts # parseSSEChunk / flushSSEChunk
│ │ ├── mock.ts # 无 key 的 mock 回复与 token 估算
│ │ ├── markdown.ts # fenced code block 分块
│ │ ├── repo.ts # conversations / messages / usage 读写
│ │ └── types.ts # 共享类型
│ ├── scripts/seed.mjs # 导入样例会话
│ ├── .env.example
│ ├── next.config.ts
│ └── package.json
├── tests/
│ ├── check.mjs # 结构 / API / 临时数据库 / 纯函数
│ ├── unit-sse.test.mjs # SSE 解析器单测
│ ├── unit-errors.test.mjs # 错误分类单测
│ └── README.md # 自动检查与手动走查
├── docs/
│ ├── api.md # 事件协议、REST 接口、错误分类
│ └── data-model.md # 三表结构、状态流转、重试规则
└── fixtures/
└── sample-conversation.json # 一个含取消消息的样例会话
```
`node_modules/`、`.next/`、`data/*.db` 是运行产物,不提交。
## 实现要点
### 服务端流式代理
`app/api/chat/route.ts` 只做三件事:组装上下文、把上游增量转成自己的事件协议、在流结束时统一收尾。浏览器永远只看到四种事件,不直接接触 OpenAI 的 `choices[0].delta`。
没有 key 时走 `streamMock()`:同一套事件协议、同样的持久化与用量,方便离线练习。
### SSE 缓冲解析
```ts
const parsed = parseSSEChunk<ChatStreamEvent>(buffer, chunk);
buffer = parsed.buffer;
for (const event of parsed.events) {
// start / text / done / error
}
```
`parseSSEChunk` 以空行为帧边界,未闭合的尾部留在 `buffer`。一次 chunk 里可能同时包含多个事件,也可能只包含半个 JSON;客户端不能假设「一次 read = 一个事件」。
### 停止与断流
- 客户端:`AbortController.abort()`,`fetch` 抛 `AbortError`,本地消息标记 `cancelled`。
- 服务端:`request.signal` 触发上游 `AbortController`,mock/上游循环抛 `ChatError('cancelled')`,`finally` 里把 `fullText` 写回消息并把 `status` 设为 `cancelled`,同时记录一条 `usage`。
- 刷新后仍能看到部分内容,这是「断流保存」的验收点。
### 重试
重试请求只带 `{ conversationId, retry: true }`。服务端找到最后一条用户消息,删除它之后所有 assistant 消息,再生成新的回复。用户消息不重复写入。
### 用量
每次模型调用写一条 `usage`,包括失败和取消。token 优先用上游返回值,mock 或上游未返回时按 `ceil(字符数 / 3)` 估算。面板展示调用次数、prompt / completion / total、平均延迟和最近调用。
## 验收清单(m1–m8)
| 里程碑 | 验收 |
| --- | --- |
| m1 页面骨架 | 会话侧栏、消息区、输入区、模型/用量面板可用;会话可新建、切换、改名、删除 |
| m2 数据层 | `node:sqlite` 三表 + 迁移;会话 CRUD API 可用;删除会话级联删除消息与用量 |
| m3 SSE 接口 | `/api/chat` 返回 `text/event-stream`;无 key 时 mock 逐段流;配置后走 OpenAI 兼容上游 |
| m4 流式渲染 | `parseSSEChunk` 缓冲解析,跨 chunk 半包不丢;Markdown 与代码块正常显示 |
| m5 停止与断流 | 停止按钮生效;服务端保存部分内容并标记 `cancelled`;刷新可见 |
| m6 重试 | 失败/取消后可重试;用户消息不重复保存 |
| m7 错误与配置 | 六类错误有中文提示;模型名与系统提示词按会话保存 |
| m8 用量与验收 | 用量面板数据正确;`npm run build` 通过;`check.mjs` 对 solution 全过、starter 失败 |
逐项手动走查见 `tests/README.md`。
## 建议实现顺序
1. 先读 `docs/data-model.md` 和 `docs/api.md`,把三张表与四种事件画出来。
2. 补 `lib/db.ts` 与 `lib/chat/repo.ts`,用 `node tests/check.mjs` 看数据层是否达标。
3. 做 `/api/conversations` 与会话侧栏,让只读链路先跑通。
4. 做 `/api/chat` 的 mock 流,再用 curl 观察 `start / text / done`。
5. 接客户端 `parseSSEChunk`,完成流式渲染。
6. 补停止、断流保存、重试,最后做错误分类、Markdown 和用量面板。
conversations(id, title, model, system_prompt, created_at, updated_at) 保存会话与模型配置;messages(id, conversation_id, seq, role, content, status, error_category, created_at) 保存消息,role 取 user / assistant,status 取 streaming / done / error / cancelled,同一会话内 seq 唯一;usage(id, conversation_id, message_id, model, prompt_tokens, completion_tokens, total_tokens, latency_ms, status, created_at) 每次模型调用一行,失败和取消也记录已产生的 token。conversations 与 messages、usage 均为一对多,删除会话级联删除消息与用量;重试时删除最后一条用户消息之后未完成的 assistant 消息,复用该用户消息重新生成。