From 81786495a6b7a1455b43f61caff8174c3f506169 Mon Sep 17 00:00:00 2001 From: Peanut Date: Wed, 22 Jul 2026 22:32:55 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20H5=20=E6=A8=A1=E5=BC=8F=20stream=20?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E4=BF=AE=E5=A4=8D=E5=AE=9E=E6=96=BD=E8=AE=A1?= =?UTF-8?q?=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-07-22-stream-timeout-fix.md | 490 ++++++++++++++++++ 1 file changed, 490 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-22-stream-timeout-fix.md diff --git a/docs/superpowers/plans/2026-07-22-stream-timeout-fix.md b/docs/superpowers/plans/2026-07-22-stream-timeout-fix.md new file mode 100644 index 0000000..77d0a63 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-stream-timeout-fix.md @@ -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) \ No newline at end of file