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

13 KiB
Raw Blame History

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 语句之后)添加:

// 环境检测: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 函数:

/**
 * 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 函数:

/**
 * 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 函数,将其修改为:

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 函数,将其修改为:

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: 构建小程序验证语法正确

cd mini-program
npm run build:mp-weixin

预期输出:DONE Build complete.

如果构建失败,检查 shortNovel.js 的语法错误并修复。

  • Step 2: 启动 H5 开发服务器
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: 下载服务器日志

cd G:/IdeaProjects/emotion-museun
python tools/download-server-log.py latest
  • Step 2: 搜索 Broken pipe 错误
python tools/download-server-log.py grep "Broken pipe" 10

验证:H5 模式测试后,服务器日志中不再出现 Broken pipe 错误。

  • Step 3: 验证 SSE 事件正常转发
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: 检查文件改动

cd G:/IdeaProjects/emotion-museun
git diff mini-program/src/services/shortNovel.js

验证:diff 显示添加了 H5 路径实现,保持了原有的 uni.request 逻辑。

  • Step 2: 提交修改
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 路径实现(h5NovelStreamh5ConsumeSseText
  2. startNovelStreamfollowupStream 根据环境自动分发
  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)