phase 5 part 1: typescript, vue, react, ssr(Next.js 16) topics with lessons, scenarios, interviews, verified examples

This commit is contained in:
Shikui Wang committed 2026-10-23 16:24:06 +08:00
1 parent 7a32d2c9a6
commit 7b5cae7d24
39 files changed
+2482 -48

No files matched your search

@@ -0,0 +1,96 @@
---
id: frontend.vue.scenario.ai-chat-stream
title: AI 对话界面:流式渲染、任务状态、停止生成和断线续传
type: scenario
domain: frontend
stack: [vue, typescript, fastapi, nginx]
tags: [流式输出, SSE, 任务状态, 断线重连, Last-Event-ID, 结构化输出, AI 原生前端]
level: advanced
prerequisites: [frontend.vue.reactivity-composition]
est_hours: 3
related: [frontend.vue.interview, python.fastapi.scenario.streaming-chat-api, devops.nginx.scenario.ai-gateway-sse-canary]
week: [7, 12]
---
# AI 对话界面:流式渲染、任务状态、停止生成和断线续传
## 业务背景与问题
企业知识库助手的前端(Vue 3),用户反馈:
1. 回答要等十几秒才一次性出现;
2. 回答到一半网络抖动,整段内容丢失,只能重新提问(重新计费);
3. 用户切换页面后,后台还在生成,浪费额度;
4. 不知道助手在做什么(检索?调用工具?);
5. 引用来源和答案混在文本里,无法点击跳转。
BOSS直聘 2026-10-23 的调研中,“AI 原生前后端”(流式、结构化输出、任务状态、异常恢复)是反复出现的要求,这个场景就是它的典型落地。
## 约束
- 需要 POST 请求携带问题和鉴权头(浏览器的 `EventSource` 只支持 GET、不能自定义请求头)。
- 经过 Nginx 网关。
## 方案设计
1. **事件协议**(前后端约定):`progress`(当前步骤:检索、调用工具)、`token`(增量文本)、`final`(结构化结果:答案 + 引用列表)、`error`;每个事件带递增的 `id`。
2. **前端**:`fetch` + `ReadableStream` 读取,增量 SSE 解析器(处理分块边界);状态机 `idle → connecting → streaming → done / error / aborted`,界面按状态显示。
3. **停止生成**:`AbortController` 取消请求;组件卸载时自动取消;后端检测到断开后停止调用模型。
4. **断线续传**:前端记录最后的事件 `id`,重试时带 `Last-Event-ID` 请求头;后端把生成中的事件缓存一段时间(例如 Redis 中按会话保存),从该 ID 之后继续发送,而不是重新生成。重试次数有限,带退避。
5. **结构化结果**:引用来源通过 `final` 事件以 JSON 返回,前端渲染成可点击的卡片,而不是从文本里正则提取。
6. **渲染性能**:正在生成的消息单独存放,Markdown 渲染节流;渲染前做 XSS 清洗。
7. **网关**:Nginx 关闭代理缓冲(见 [Nginx SSE 场景](../../../devops/nginx/scenarios/ai-gateway-sse-canary.md))。
## 关键代码
增量解析器(完整版本 [examples/ai-chat-stream/sse.ts](../examples/ai-chat-stream/sse.ts),`node sse-demo.ts` 验证分块切开、多行 data、心跳注释):
```ts
export function createSSEParser(onEvent: (e: SSEEvent) => void) {
let buffer = "";
return (chunk: string): void => {
buffer += chunk.replace(/\r\n?/g, "\n");
let sep: number;
while ((sep = buffer.indexOf("\n\n")) >= 0) { // 一个事件以空行结束
const block = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
// 解析 event / data / id 字段,忽略以 ":" 开头的注释(心跳)……
}
};
}
```
断线重试([useChatStream.ts](../examples/ai-chat-stream/useChatStream.ts)):
```ts
headers: { "Content-Type": "application/json", ...(lastEventId ? { "Last-Event-ID": lastEventId } : {}) },
...
if (attempt < (opts.maxRetries ?? 2)) {
await new Promise((r) => setTimeout(r, 300 * 2 ** attempt));
return send(question, attempt + 1);
}
```
测试([useChatStream.test.ts](../examples/ai-chat-stream/useChatStream.test.ts))覆盖:拼接 token 和解析 final、断线后带 `Last-Event-ID` 重试、停止后状态为 aborted。
## 取舍
| 选择 | 优点 | 缺点 |
| --- | --- | --- |
| **fetch + 自己解析 SSE(本方案)** | 支持 POST、鉴权头、取消 | 要自己处理解析和重连 |
| EventSource | 浏览器自动重连、自动带 Last-Event-ID | 只支持 GET,不能自定义请求头 |
| WebSocket | 双向通信 | 对“一问一答流式返回”来说过重;网关和鉴权更复杂 |
| 轮询 | 简单 | 延迟高、请求多 |
## 易错点
- 按网络分块直接 `JSON.parse`,分块切在事件中间就报错。
- 只在 `done` 时更新状态,异常时界面一直转圈。
- 断线重试时重新提问,重复计费,且用户看到内容“跳回开头”。
- 组件卸载没有取消请求。
- 引用来源从模型的文本里正则解析,格式一变就失效。
- 流式过程中每个 token 都触发完整的 Markdown 解析和渲染,长回答卡顿。
## 面试怎么讲
> “AI 对话前端我做了一个组合式函数:用 fetch + ReadableStream 读 SSE,因为要 POST 和带鉴权头;自己写了增量解析器处理分块边界;状态机区分连接中、生成中、完成、失败、已停止;停止生成用 AbortController,组件卸载自动取消。断线时带 Last-Event-ID 重试,后端从缓存的事件继续发,不重新生成。引用来源通过 final 事件的结构化 JSON 返回,前端渲染成卡片。这些都有单元测试。”