docs: H5 模式 stream 超时修复实施计划
This commit is contained in:
@@ -0,0 +1,490 @@
|
|||||||
|
# H5 模式 Stream 超时修复实施计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 修复小程序 H5 模式下 stream 请求 60 秒超时问题,使用 fetch + ReadableStream + AbortController 实现真正的长连接流式读取
|
||||||
|
|
||||||
|
**Architecture:** 在 `mini-program/src/services/shortNovel.js` 中实现双路径策略 - H5 环境使用浏览器原生 fetch API(支持自定义超时),小程序环境保持原有 uni.request 逻辑
|
||||||
|
|
||||||
|
**Tech Stack:** Vue 3 (UniApp), fetch API, ReadableStream, AbortController, TextDecoder
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件结构映射
|
||||||
|
|
||||||
|
**修改的文件:**
|
||||||
|
1. `mini-program/src/services/shortNovel.js` - 添加 H5 环境检测和 fetch 实现
|
||||||
|
|
||||||
|
**不创建新文件** - 所有修改都是在现有文件中添加 H5 路径实现
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: 添加环境检测常量和 H5 SSE 核心函数
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `mini-program/src/services/shortNovel.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 在文件顶部添加环境检测常量**
|
||||||
|
|
||||||
|
打开 `mini-program/src/services/shortNovel.js`,在第 1 行(import 语句之后)添加:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 环境检测:H5 模式下存在 window.fetch
|
||||||
|
const isH5 = typeof window !== 'undefined' && typeof window.fetch === 'function'
|
||||||
|
|
||||||
|
// SSE 请求超时时间(毫秒)
|
||||||
|
const SSE_TIMEOUT_MS = 300000
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 添加 H5 SSE 事件解析函数**
|
||||||
|
|
||||||
|
在 `decodeChunk` 函数(第 112-140 行)之后、`consumeSseText` 函数之前,添加新的 `h5ConsumeSseText` 函数:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* H5 环境 SSE 事件解析
|
||||||
|
* 解析单个 SSE 事件块
|
||||||
|
* 事件格式:data: {"type":"status","session_id":"xxx","payload":{...}}
|
||||||
|
*/
|
||||||
|
function h5ConsumeSseText(text, onEvent, onError) {
|
||||||
|
const lines = text.split('\n')
|
||||||
|
let dataBuffer = ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data:')) {
|
||||||
|
dataBuffer += line.slice(5).trim()
|
||||||
|
} else if (line === '' && dataBuffer) {
|
||||||
|
// 空行表示事件结束
|
||||||
|
const dataStr = dataBuffer
|
||||||
|
dataBuffer = ''
|
||||||
|
if (dataStr === '[DONE]') return
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(dataStr)
|
||||||
|
onEvent?.(event)
|
||||||
|
} catch (e) {
|
||||||
|
onError?.(`SSE 解析失败: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 添加 H5 SSE 流式读取核心函数**
|
||||||
|
|
||||||
|
在 `h5ConsumeSseText` 函数之后,添加 `h5NovelStream` 函数:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* H5 环境 SSE 流式读取核心实现
|
||||||
|
* 使用浏览器原生 fetch + ReadableStream + AbortController
|
||||||
|
* @param {string} url - 请求 URL
|
||||||
|
* @param {Object} body - 请求体
|
||||||
|
* @param {Function} onEvent - 事件回调
|
||||||
|
* @param {Function} onError - 错误回调
|
||||||
|
* @returns {Object} 包含 abort 方法的对象
|
||||||
|
*/
|
||||||
|
function h5NovelStream(url, body, onEvent, onError) {
|
||||||
|
// 1. 创建 AbortController 用于超时控制
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), SSE_TIMEOUT_MS)
|
||||||
|
|
||||||
|
// 2. 发起 fetch 请求
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'text/event-stream',
|
||||||
|
...getAuthHeader()
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal
|
||||||
|
}).then(response => {
|
||||||
|
// 3. 检查响应状态
|
||||||
|
if (!response.ok) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
onError?.(`请求失败: HTTP ${response.status}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 获取 ReadableStream 读取器
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder('utf-8')
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
// 5. 循环读取流数据
|
||||||
|
function pump() {
|
||||||
|
return reader.read().then(({ done, value }) => {
|
||||||
|
if (done) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
// 处理缓冲区残留数据
|
||||||
|
if (buffer.trim()) h5ConsumeSseText(buffer, onEvent, onError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 解码二进制块为文本
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
|
||||||
|
// 7. 解析完整的事件(按 \n\n 分隔)
|
||||||
|
const events = buffer.split('\n\n')
|
||||||
|
buffer = events.pop() // 最后一个可能不完整,保留到下次
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
h5ConsumeSseText(event, onEvent, onError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 继续读取
|
||||||
|
return pump()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return pump()
|
||||||
|
}).catch(err => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
onError?.('请求超时(300秒)')
|
||||||
|
} else {
|
||||||
|
onError?.(err.message || '网络请求失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 9. 返回 abort 接口
|
||||||
|
return {
|
||||||
|
abort: () => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
controller.abort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: 修改 startNovelStream 函数支持 H5 环境
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `mini-program/src/services/shortNovel.js:16-55`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 在 startNovelStream 函数开头添加 H5 分支**
|
||||||
|
|
||||||
|
打开 `mini-program/src/services/shortNovel.js`,定位到第 16 行的 `startNovelStream` 函数,将其修改为:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export const startNovelStream = ({ query, onEvent, onError }) => {
|
||||||
|
// H5 环境:使用原生 fetch + ReadableStream
|
||||||
|
if (isH5) {
|
||||||
|
return h5NovelStream(
|
||||||
|
`${getApiBaseUrl()}/shortNovel/stream`,
|
||||||
|
{ query },
|
||||||
|
onEvent,
|
||||||
|
onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小程序环境:使用 uni.request
|
||||||
|
let chunkProcessed = false
|
||||||
|
const task = uni.request({
|
||||||
|
url: `${getApiBaseUrl()}/shortNovel/stream`,
|
||||||
|
method: 'POST',
|
||||||
|
data: { query },
|
||||||
|
header: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'text/event-stream',
|
||||||
|
...getAuthHeader()
|
||||||
|
},
|
||||||
|
enableChunked: true,
|
||||||
|
timeout: SSE_TIMEOUT_MS,
|
||||||
|
success: (res) => {
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
onError?.(res.data?.message || '请求失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 仅在 chunk 未处理时才处理完整 data(避免 H5 双重消费)
|
||||||
|
if (!chunkProcessed && typeof res.data === 'string' && res.data) {
|
||||||
|
consumeSseText(res.data, onEvent, onError)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (error) => {
|
||||||
|
onError?.(error.errMsg || '网络请求失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
task?.onChunkReceived?.((res) => {
|
||||||
|
chunkProcessed = true
|
||||||
|
try {
|
||||||
|
const text = decodeChunk(res.data)
|
||||||
|
consumeSseText(text, onEvent, onError)
|
||||||
|
} catch (error) {
|
||||||
|
onError?.(error.message || '流式解析失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: 修改 followupStream 函数支持 H5 环境
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `mini-program/src/services/shortNovel.js:68-106`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 在 followupStream 函数开头添加 H5 分支**
|
||||||
|
|
||||||
|
打开 `mini-program/src/services/shortNovel.js`,定位到第 68 行的 `followupStream` 函数,将其修改为:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export const followupStream = ({ sessionId, action, payload, originalQuery, onEvent, onError }) => {
|
||||||
|
// H5 环境:使用原生 fetch + ReadableStream
|
||||||
|
if (isH5) {
|
||||||
|
return h5NovelStream(
|
||||||
|
`${getApiBaseUrl()}/shortNovel/followup`,
|
||||||
|
{ sessionId, action, payload, originalQuery },
|
||||||
|
onEvent,
|
||||||
|
onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小程序环境:使用 uni.request
|
||||||
|
let chunkProcessed = false
|
||||||
|
const task = uni.request({
|
||||||
|
url: `${getApiBaseUrl()}/shortNovel/followup`,
|
||||||
|
method: 'POST',
|
||||||
|
data: { sessionId, action, payload, originalQuery },
|
||||||
|
header: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'text/event-stream',
|
||||||
|
...getAuthHeader()
|
||||||
|
},
|
||||||
|
enableChunked: true,
|
||||||
|
timeout: SSE_TIMEOUT_MS,
|
||||||
|
success: (res) => {
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
onError?.(res.data?.message || '请求失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!chunkProcessed && typeof res.data === 'string' && res.data) {
|
||||||
|
consumeSseText(res.data, onEvent, onError)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (error) => {
|
||||||
|
onError?.(error.errMsg || '网络请求失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
task?.onChunkReceived?.((res) => {
|
||||||
|
chunkProcessed = true
|
||||||
|
try {
|
||||||
|
const text = decodeChunk(res.data)
|
||||||
|
consumeSseText(text, onEvent, onError)
|
||||||
|
} catch (error) {
|
||||||
|
onError?.(error.message || '流式解析失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: 构建验证
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- 无文件修改,纯构建验证
|
||||||
|
|
||||||
|
- [ ] **Step 1: 构建小程序验证语法正确**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mini-program
|
||||||
|
npm run build:mp-weixin
|
||||||
|
```
|
||||||
|
|
||||||
|
预期输出:`DONE Build complete.`
|
||||||
|
|
||||||
|
如果构建失败,检查 `shortNovel.js` 的语法错误并修复。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 启动 H5 开发服务器**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mini-program
|
||||||
|
npm run dev:h5
|
||||||
|
```
|
||||||
|
|
||||||
|
预期输出:服务器在 `http://localhost:5284` 启动
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: 浏览器验证 - H5 模式
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- 无文件修改,纯验证任务
|
||||||
|
|
||||||
|
- [ ] **Step 1: 访问 H5 页面**
|
||||||
|
|
||||||
|
使用浏览器打开 `http://localhost:5284`,登录后进入"心愿实现"页面。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 触发 stream 请求**
|
||||||
|
|
||||||
|
1. 输入心愿文本(例如:"我想写一个关于时间旅行的故事")
|
||||||
|
2. 点击生成按钮
|
||||||
|
3. **验证**:页面进入等待状态
|
||||||
|
|
||||||
|
- [ ] **Step 3: 检查 Network 面板**
|
||||||
|
|
||||||
|
打开浏览器 DevTools 的 Network 面板:
|
||||||
|
|
||||||
|
1. 找到 `/shortNovel/stream` 请求
|
||||||
|
2. **验证**:
|
||||||
|
- 请求状态为 200(不是 `(failed)`)
|
||||||
|
- 请求在大约 67 秒后仍然保持连接(不会 60 秒后中断)
|
||||||
|
- 收到 `status` 事件
|
||||||
|
- 收到 `clarification_card` 事件
|
||||||
|
|
||||||
|
- [ ] **Step 4: 检查 Console 面板**
|
||||||
|
|
||||||
|
打开 Console 面板,验证:
|
||||||
|
- 没有 "请求超时" 错误
|
||||||
|
- 没有 fetch 相关错误
|
||||||
|
- 正常接收到所有 SSE 事件
|
||||||
|
|
||||||
|
- [ ] **Step 5: 验证澄清卡片显示**
|
||||||
|
|
||||||
|
确认页面上正确显示澄清卡片,用户可以正常交互(选项可点击切换)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: 验证小程序模式不受影响
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- 无文件修改,纯验证任务
|
||||||
|
|
||||||
|
- [ ] **Step 1: 在微信开发者工具中打开小程序**
|
||||||
|
|
||||||
|
1. 打开微信开发者工具
|
||||||
|
2. 导入 `mini-program/unpackage/dist/dev/mp-weixin` 目录
|
||||||
|
|
||||||
|
- [ ] **Step 2: 测试小说生成流程**
|
||||||
|
|
||||||
|
1. 进入"心愿实现"页面
|
||||||
|
2. 输入心愿文本
|
||||||
|
3. 完成澄清卡片、大纲确认等流程
|
||||||
|
4. **验证**:原有 uni.request 路径正常工作
|
||||||
|
|
||||||
|
- [ ] **Step 3: 检查 Network 请求**
|
||||||
|
|
||||||
|
在小程序调试器中查看网络请求:
|
||||||
|
- `/shortNovel/stream` 请求状态正常
|
||||||
|
- 事件正常接收
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: 验证服务器日志
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- 无文件修改,纯验证任务
|
||||||
|
|
||||||
|
- [ ] **Step 1: 下载服务器日志**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd G:/IdeaProjects/emotion-museun
|
||||||
|
python tools/download-server-log.py latest
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 搜索 Broken pipe 错误**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/download-server-log.py grep "Broken pipe" 10
|
||||||
|
```
|
||||||
|
|
||||||
|
**验证**:H5 模式测试后,服务器日志中不再出现 `Broken pipe` 错误。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 验证 SSE 事件正常转发**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/download-server-log.py grep "ShortNovel SSE" 20
|
||||||
|
```
|
||||||
|
|
||||||
|
**验证**:日志显示完整的事件处理流程:
|
||||||
|
- 开始读取上游响应
|
||||||
|
- 处理 status 事件
|
||||||
|
- 处理 clarification_card 事件
|
||||||
|
- 完成读取上游响应
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: 提交修改
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `mini-program/src/services/shortNovel.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 检查文件改动**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd G:/IdeaProjects/emotion-museun
|
||||||
|
git diff mini-program/src/services/shortNovel.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**验证**:diff 显示添加了 H5 路径实现,保持了原有的 uni.request 逻辑。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 提交修改**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add mini-program/src/services/shortNovel.js
|
||||||
|
git commit -m "fix: 修复 H5 模式 stream 请求 60 秒超时问题
|
||||||
|
|
||||||
|
- 在 H5 环境使用 fetch + ReadableStream + AbortController
|
||||||
|
- 支持自定义 300 秒超时
|
||||||
|
- 小程序环境保持原有 uni.request 逻辑不变"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完成标准
|
||||||
|
|
||||||
|
1. ✅ `shortNovel.js` 添加了 H5 路径实现(`h5NovelStream` 和 `h5ConsumeSseText`)
|
||||||
|
2. ✅ `startNovelStream` 和 `followupStream` 根据环境自动分发
|
||||||
|
3. ✅ 小程序构建成功,无语法错误
|
||||||
|
4. ✅ H5 模式测试通过,stream 请求 67 秒后仍能正常接收事件
|
||||||
|
5. ✅ 浏览器 Network 面板不再显示 `(failed)` 状态
|
||||||
|
6. ✅ 小程序模式验证通过,原有功能不受影响
|
||||||
|
7. ✅ 服务器日志中 `Broken pipe` 错误消失
|
||||||
|
8. ✅ 修改提交到 git
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风险评估与缓解
|
||||||
|
|
||||||
|
### 风险 1:浏览器兼容性
|
||||||
|
|
||||||
|
**检测方法**:在目标浏览器中运行 H5 开发服务器,触发 stream 请求
|
||||||
|
|
||||||
|
**缓解措施**:通过 `typeof window.fetch === 'function'` 检测,对不支持的环境降级或显示明确错误。
|
||||||
|
|
||||||
|
### 风险 2:H5 环境检测失败
|
||||||
|
|
||||||
|
**检测方法**:如果 `isH5` 检测错误,可能在 H5 环境下走了小程序分支
|
||||||
|
|
||||||
|
**缓解措施**:检测条件同时检查 `typeof window !== 'undefined'` 和 `typeof window.fetch === 'function'`,确保只在现代浏览器中启用 H5 路径。
|
||||||
|
|
||||||
|
### 风险 3:abort 接口不兼容
|
||||||
|
|
||||||
|
**检测方法**:检查 ScriptView.vue 是否调用 `task.abort()` 方法
|
||||||
|
|
||||||
|
**缓解措施**:H5 实现返回 `{ abort: () => {...} }` 对象,与 uni.request 任务对象的 `abort` 方法对齐。如果 ScriptView.vue 使用其他属性,需要调整。
|
||||||
|
|
||||||
|
### 风险 4:SSE 格式差异
|
||||||
|
|
||||||
|
**检测方法**:观察服务器发送的 SSE 事件格式
|
||||||
|
|
||||||
|
**缓解措施**:使用 `dataBuffer` 累积 `data:` 行,遇到空行才解析,符合 SSE 规范。如果某些事件不带空行分隔,可能需要调整解析逻辑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 自检清单
|
||||||
|
|
||||||
|
实施前请确认:
|
||||||
|
- [ ] 已阅读设计文档 `docs/superpowers/specs/2026-07-22-stream-timeout-fix-design.md`
|
||||||
|
- [ ] 已阅读 `mini-program/src/services/shortNovel.js` 当前实现
|
||||||
|
- [ ] 理解 H5 环境和 mp-weixin 环境的差异
|
||||||
|
- [ ] 准备好本地 H5 开发服务器(端口 5284)
|
||||||
Reference in New Issue
Block a user