Files
happy-life-star/docs/superpowers/plans/2026-07-22-stream-timeout-fix.md

490 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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'` 检测,对不支持的环境降级或显示明确错误。
### 风险 2H5 环境检测失败
**检测方法**:如果 `isH5` 检测错误,可能在 H5 环境下走了小程序分支
**缓解措施**:检测条件同时检查 `typeof window !== 'undefined'``typeof window.fetch === 'function'`,确保只在现代浏览器中启用 H5 路径。
### 风险 3abort 接口不兼容
**检测方法**:检查 ScriptView.vue 是否调用 `task.abort()` 方法
**缓解措施**H5 实现返回 `{ abort: () => {...} }` 对象,与 uni.request 任务对象的 `abort` 方法对齐。如果 ScriptView.vue 使用其他属性,需要调整。
### 风险 4SSE 格式差异
**检测方法**:观察服务器发送的 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)