项目4 · AI 聊天工作台

从零搭一个真正能用的 AI 聊天工作台:会话管理、SSE 流式输出、停止与重试、断流保存和用量统计。

约 14 小时
学习重点
  • 设计聊天客户端的完整状态机:会话切换、乐观消息、流式增量、停止、失败与重试各自的状态边界
  • 在 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 逐次记录
验收清单
0 / 8(0%)

聊天页呈现会话侧栏、消息区、输入区和模型/用量面板;会话列表可新建、切换、重命名、删除,当前会话有高亮,空列表有引导文案。

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 直跑通过。

README(项目目录)
# 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 消息,复用该用户消息重新生成。

Command Palette

Search for a command to run...