Compare commits

..

7 Commits

Author SHA1 Message Date
peanut 1cf75a2b40 fix: 修复微信开发者工具 60 秒超时错误处理问题
- abort() 只清理资源,不再触发 onError(避免正常流程误报错误)
- 真正的错误(超时、网络错误)通过 Promise.race 的 catch 正确传递给用户
- 修复微信开发者工具强制终止 fetch 时 onError 不被调用的问题
- 使用 errorHandled 标志防止 onError 重复调用
2026-07-23 22:21:46 +08:00
peanut 6eadbce6f2 fix: 修复微信开发者工具 60 秒超时问题
- 将 AbortController 方案改为 Promise.race + setTimeout 外部超时控制
- 解决微信开发者工具底层 fetch 不兼容 AbortController signal 的问题
- 保持 300 秒超时时间不变
- 简化 abort 接口,统一使用 cleanup 函数
2026-07-23 22:12:42 +08:00
peanut bd706e4a5b fix: 修复 H5 模式 stream 请求 60 秒超时问题
- 在 H5 环境使用 fetch + ReadableStream + AbortController
- 支持自定义 300 秒超时
- 小程序环境保持原有 uni.request 逻辑不变
- 添加 CRLF 兼容性、主动中止与超时区分、资源清理
2026-07-22 22:57:29 +08:00
peanut 81786495a6 docs: H5 模式 stream 超时修复实施计划 2026-07-22 22:32:55 +08:00
peanut c64bb4285a docs: H5 模式 stream 超时修复设计文档 2026-07-22 22:31:52 +08:00
peanut e0d9c4a84b fix:修复 ClarificationCard 选项无法切换的 bug(增强 toggleOption 容错性) 2026-07-22 21:50:38 +08:00
peanut 235b08e01d docs:ClarificationCard 选项切换修复设计 2026-07-22 21:49:21 +08:00
5 changed files with 1251 additions and 7 deletions
@@ -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'` 检测,对不支持的环境降级或显示明确错误。
### 风险 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)
@@ -0,0 +1,217 @@
---
author: AI Assistant
created_at: 2026-07-22
purpose: 修复 ClarificationCard 选项无法切换的 bug
---
# ClarificationCard 选项切换修复设计
## 问题概述
### 现象
在"心愿实现"页面(生成剧本页面),当后端返回澄清卡片(`clarification_card` 事件)时,用户在选项卡中选择了一个选项后,无法切换到其他选项。
### 根本原因
`ClarificationCard.vue` 组件的 `toggleOption` 函数(第 80-95 行)依赖 `card_type` 字段判断是单选还是多选:
```javascript
function toggleOption(opt) {
const value = opt.value
if (isSingle.value) { // 仅当 card_type === 'single_select' 时执行
selectedValues.value = [value]
} else if (isMulti.value) { // 仅当 card_type === 'multi_select' 或 'mixed' 时执行
// 多选逻辑...
}
// 如果 card_type 不是上述任何值,函数什么都不做!
}
```
**关键问题**:如果后端返回的 `card.card_type` 不是 `'single_select'``'multi_select'``'mixed'`(可能是 `undefined``null` 或其他未定义的值),`toggleOption` 函数**不会执行任何逻辑**,导致:
- 用户点击选项 A → `selectedValues` 不更新
- 用户点击选项 B → `selectedValues` 仍不更新
- 视觉上表现为"无法切换选项"
### 代码位置
**文件:** `mini-program/src/components/ClarificationCard.vue:80-95`
## 设计目标
1. **选项可自由切换**:无论 `card_type` 是什么值,用户都能正常切换选项
2. **保持现有功能**:不破坏单选、多选、文本输入的现有行为
3. **向后兼容**:对于正常的 `card_type` 值(`single_select``multi_select``text_input`),行为保持不变
4. **不破坏其他业务功能**:不修改 ScriptView.vue 或后端代码
## 技术方案
### 方案:增强 `toggleOption` 容错性
**核心思路**:将条件判断从"是否为单选"改为"是否为多选或文本输入",如果都不是则默认当作单选处理。
**修改位置:** `mini-program/src/components/ClarificationCard.vue:80-95`
**修改前的代码:**
```javascript
function toggleOption(opt) {
const value = opt.value
if (isSingle.value) {
selectedValues.value = [value]
} else if (isMulti.value) {
const idx = selectedValues.value.indexOf(value)
if (idx >= 0) {
selectedValues.value.splice(idx, 1)
} else {
const maxSel = props.card.max_selections || selectedValues.value.length + 1
if (selectedValues.value.length < maxSel) {
selectedValues.value.push(value)
}
}
}
}
```
**修改后的代码:**
```javascript
function toggleOption(opt) {
const value = opt.value
// 修复:默认当作单选处理(当不是多选也不是文本输入时)
if (isMulti.value) {
// 多选逻辑
const idx = selectedValues.value.indexOf(value)
if (idx >= 0) {
selectedValues.value.splice(idx, 1)
} else {
const maxSel = props.card.max_selections || selectedValues.value.length + 1
if (selectedValues.value.length < maxSel) {
selectedValues.value.push(value)
}
}
} else if (!isTextInput.value) {
// 单选逻辑(默认行为,包括 card_type 未定义或未知的情况)
selectedValues.value = [value]
}
}
```
**关键变更:**
-`if (isSingle.value)` 改为 `else if (!isTextInput.value)`
- 这意味着:只要不是多选(`isMulti.value`)也不是文本输入(`isTextInput.value`),就当作单选处理
- 这样即使 `card_type``undefined``null` 或其他未定义的值,选项也能正常切换
**保留的多选逻辑:**
- 多选条件(`isMulti.value`)保持不变
- 多选逻辑(添加/删除选项)保持不变
- 最大选择数限制(`max_selections`)保持不变
**保留的文本输入逻辑:**
- 文本输入卡片(`isTextInput.value`)不会有选项,所以 `toggleOption` 不会被调用
- 但保留这个判断是为了代码的可读性和未来的扩展性
## 行为对照表
| card_type 值 | 修改前行为 | 修改后行为 |
|--------------|-----------|-----------|
| `'single_select'` | ✅ 单选切换 | ✅ 单选切换(行为不变) |
| `'multi_select'` | ✅ 多选切换 | ✅ 多选切换(行为不变) |
| `'mixed'` | ✅ 多选切换 | ✅ 多选切换(行为不变) |
| `'text_input'` | ✅ 无选项,不调用 | ✅ 无选项,不调用(行为不变) |
| `undefined` / `null` | ❌ 无法切换 | ✅ 默认单选切换 |
| 其他未知值 | ❌ 无法切换 | ✅ 默认单选切换 |
## 实施步骤
### 第一步:修改 ClarificationCard.vue
打开 `mini-program/src/components/ClarificationCard.vue`,定位到第 80-95 行的 `toggleOption` 函数,将其修改为上述"修改后的代码"。
### 第二步:构建小程序
```bash
cd mini-program
npm run build:mp-weixin
```
预期输出:`DONE Build complete.`
### 第三步:提交修改
```bash
git add mini-program/src/components/ClarificationCard.vue
git commit -m "fix: 修复 ClarificationCard 选项无法切换的 bug(增强 toggleOption 容错性)"
```
### 第四步:用户验证
在微信小程序中:
1. 进入"心愿实现"页面
2. 输入心愿文本,触发澄清卡片
3. 点击选项 A(应该被选中)
4. 点击选项 B(应该切换到 B,A 取消选中)
5. 再次点击选项 A(应该切换回 A
6. 验证:选项可以自由切换
## 风险评估
### 风险 1:影响现有正常的卡片类型
**可能性**:低
**缓解措施**
- 修改前的行为对照表显示,所有正常的 `card_type` 值行为不变
- 只改变了未知值的默认行为(从"不响应"改为"单选响应"
### 风险 2:破坏其他业务功能
**可能性**:极低
**缓解措施**
- 只修改了 ClarificationCard.vue 一个文件
- 不修改 ScriptView.vue 的事件处理
- 不修改后端代码
### 风险 3:响应式更新问题
**可能性**:低
**缓解措施**
- `selectedValues.value = [value]` 是 Vue 3 标准的响应式赋值
- 在原有代码中已经使用这种方式,应该是有效的
## 测试用例
### 测试用例 1:单选卡片切换
1. 输入心愿文本
2. 收到澄清卡片(单选类型)
3. 点击选项 A
4. 验证:选项 A 被选中(有 ✓ 标记)
5. 点击选项 B
6. **验证:选项 B 被选中,选项 A 取消选中**(这是修复的关键点)
### 测试用例 2:多选卡片(保持原有行为)
1. 收到澄清卡片(多选类型)
2. 点击选项 A
3. 验证:选项 A 被选中
4. 点击选项 B
5. **验证:选项 A 和 B 都被选中**(多选行为不变)
### 测试用例 3:未知 card_type(修复场景)
1. 后端返回 `card_type: undefined` 或其他未知值
2. 收到澄清卡片
3. 点击选项 A
4. **验证:选项 A 被选中**
5. 点击选项 B
6. **验证:选项 B 被选中,选项 A 取消选中**(这是修复的核心场景)
## 完成标准
1. ✅ ClarificationCard.vue 的 `toggleOption` 函数已修改
2. ✅ 小程序构建成功
3. ✅ 修改已提交到 git
4. ✅ 用户验证:选项可以自由切换
5. ✅ 现有功能(单选、多选、文本输入)未受影响
@@ -0,0 +1,357 @@
---
author: AI Assistant
created_at: 2026-07-22
purpose: 修复 H5 模式下 stream 请求 60 秒超时问题,采用 fetch + ReadableStream + AbortController 方案
---
# H5 模式 Stream 超时修复设计
## 问题概述
### 现象
在小程序 H5 模式下(`http://localhost:5284`),"心愿实现"页面的 stream 请求在约 60 秒后超时,浏览器 DevTools Network 面板显示状态为 `(failed)`。用户无法收到上游服务约 67 秒后产生的 `clarification_card` 事件。
### 根本原因
1. **H5 模式下 `uni.request` 使用浏览器原生 `fetch` API**
2. **浏览器 `fetch` 不支持自定义超时参数**,即使设置了 `timeout: 300000` 也无效
3. **浏览器对 `fetch` 连接有约 60 秒的默认超时**
4. **上游服务响应延迟**:从 `status` 事件到 `clarification_card` 事件约需 67 秒
5. **后果**:前端连接在 60 秒时关闭,服务器在 67 秒尝试发送时遇到 `Broken pipe`
### 服务器日志证据
```
22:09:37 - 上游 status 事件收到并成功转发
22:10:44 - 上游 clarification_card 事件收到
22:10:44 - java.io.IOException: Broken pipe(前端连接已断开)
```
### 代码位置
**文件:** `mini-program/src/services/shortNovel.js`
问题代码片段(第 16-55 行):
```javascript
export const startNovelStream = ({ query, onEvent, onError }) => {
const task = uni.request({
// ...
enableChunked: true,
timeout: 300000, // ← H5 模式下无效
// ...
})
}
```
## 设计目标
1. **H5 模式正确接收延迟事件**:支持 300 秒超时,覆盖上游 67 秒延迟场景
2. **保持现有功能**:不破坏小程序 mp-weixin 模式的现有逻辑
3. **完全兼容性**`onEvent``onError` 回调接口不变,调用方无需修改
4. **真正流式读取**:避免 `responseText` 累积导致的内存问题
## 技术方案
### 方案 AH5 模式使用原生 fetch + ReadableStream(已选定)
**核心思路**:在 H5 环境下绕过 `uni.request`,直接使用浏览器原生的 `fetch` API + `response.body.getReader()` 读取流,并通过 `AbortController` 设置自定义超时。
### 架构设计
**双路径策略**
| 环境 | 实现方式 | 原因 |
|------|---------|------|
| H5(浏览器) | `fetch` + `ReadableStream` + `AbortController` | 完全控制超时,支持真正流式读取 |
| mp-weixin(小程序) | `uni.request` + `enableChunked` | 小程序原生支持,无需修改 |
**环境检测**
```javascript
const isH5 = typeof window !== 'undefined' && typeof window.fetch === 'function'
```
### 文件变更结构
**修改的文件**
1. `mini-program/src/services/shortNovel.js` - 添加 H5 路径实现
**不修改的文件**
- `mini-program/src/pages/main/ScriptView.vue` - 调用接口完全兼容
- `server/**` - 后端无需任何修改
- `nginx.conf` - 服务端配置无需修改
## 实现细节
### H5 核心实现
```javascript
/**
* H5 环境 SSE 流式读取
* 使用浏览器原生 fetch + ReadableStream + AbortController
*/
function h5NovelStream(url, body, onEvent, onError) {
// 1. 创建 AbortController 用于超时控制
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 300000)
// 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()
}
}
}
```
### SSE 事件解析(H5 专用)
```javascript
/**
* 解析单个 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}`)
}
}
}
}
```
### 集成入口
修改 `startNovelStream``followupStream`,在入口处根据环境分发:
```javascript
// 环境检测:H5 模式下存在 window 对象
const isH5 = typeof window !== 'undefined' && typeof window.fetch === 'function'
export const startNovelStream = ({ query, onEvent, onError }) => {
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: 300000,
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
}
```
`followupStream` 采用完全相同的改造模式,在函数开头添加 H5 分支判断。
## 错误处理策略
| 错误类型 | 处理方式 | 用户提示 |
|---------|---------|---------|
| 网络断开 | 触发 `controller.abort()` | "网络连接中断" |
| 超时(300秒) | `AbortController` 自动触发 | "请求超时(300秒)" |
| HTTP 4xx/5xx | 检查 `response.ok` | 显示后端返回的错误信息 |
| SSE 格式错误 | `try-catch` 包裹 `JSON.parse` | "SSE 解析失败"(不中断流) |
| Reader 读取异常 | 在 `pump()` 内部捕获 | 触发 `onError` 回调 |
## 关键设计决策
1. **超时时间统一 300 秒**:与小程序环境的 `uni.request timeout` 保持一致
2. **事件回调接口不变**`onEvent``onError` 接口完全兼容,调用方无需修改
3. **返回值接口对齐**H5 实现返回 `{ abort }`,与 uni.request 任务对象的 `abort` 方法对齐
4. **不修改后端**:纯前端修改,无需重新部署后端服务
5. **流式读取而非累积**:使用 `ReadableStream` 而非 `responseText`,避免内存累积问题
## 测试验证
### 测试用例 1:H5 模式正常生成
1. 启动 H5 开发服务器:`cd mini-program && npm run dev:h5`
2. 访问 `http://localhost:5284`
3. 进入"心愿实现"页面
4. 输入心愿文本,触发澄清卡片
5. **验证**
- 浏览器 Network 面板显示 stream 请求 200 状态,未超时
- 澄清卡片正常显示
- 完成整个流程后小说正常生成
### 测试用例 2:小程序模式不受影响
1. 构建小程序:`npm run build:mp-weixin`
2. 在微信开发者工具中运行
3. 触发完整的小说生成流程
4. **验证**:原有 `uni.request` 路径正常工作
### 测试用例 3:超时场景验证
1. 修改后端人为延迟 350 秒返回
2. 触发 H5 模式 stream 请求
3. **验证**:300 秒后前端收到 "请求超时(300秒)" 错误提示
### 测试用例 4:错误处理
1. 断网后触发请求
2. **验证**:收到 "网络连接中断" 错误
3. 恢复网络后重试
4. **验证**:请求成功
### 验证通过标准
- ✅ H5 模式 stream 请求在 67 秒后才到达的事件能正常接收
- ✅ 浏览器 Network 面板不再显示 `(failed)` 状态
- ✅ 小程序模式完全不受影响
- ✅ 错误处理覆盖所有边界场景
- ✅ 服务器日志中 `Broken pipe` 错误消失
## 风险评估
### 风险 1:浏览器兼容性
**可能性**:低
**说明**`fetch``ReadableStream``AbortController` 在所有现代浏览器(Chrome 76+、Firefox 71+、Safari 14.1+)中支持。我们假设用户使用现代浏览器。
**缓解措施**:通过 `typeof window.fetch === 'function'` 检测,对不支持的环境降级到原有逻辑或显示明确错误。
### 风险 2:内存累积
**可能性**:低
**说明**:如果服务端发送数据过快,可能导致缓冲区积累。
**缓解措施**:使用 `TextDecoder``stream: true` 模式,并及时清空已处理的 buffer。
### 风险 3:SSE 格式解析错误
**可能性**:中
**说明**:不同服务端实现的 SSE 格式可能有细微差异(如 `\r\n` vs `\n`、注释行等)。
**缓解措施**:使用 `dataBuffer` 累积 `data:` 行,直到遇到空行才解析,符合 SSE 规范。
## 完成标准
1.`shortNovel.js` 添加 H5 路径实现
2.`startNovelStream``followupStream` 根据环境自动分发
3. ✅ H5 模式通过浏览器验证,stream 请求不再超时
4. ✅ 小程序模式通过验证,未受影响
5. ✅ 服务器日志中 `Broken pipe` 错误消失
6. ✅ 修改提交到 git
## 后续优化建议
1. **统一超时配置**:将超时时间(300 秒)提取为常量
2. **添加重试机制**:网络断开后自动重试
3. **心跳检测**:定期发送心跳包检测连接活性
4. **性能监控**:记录流式读取的延迟和吞吐量
@@ -79,9 +79,9 @@ function isSelected(value) {
function toggleOption(opt) {
const value = opt.value
if (isSingle.value) {
selectedValues.value = [value]
} else if (isMulti.value) {
// 修复:默认当作单选处理(当不是多选也不是文本输入时)
if (isMulti.value) {
// 多选逻辑
const idx = selectedValues.value.indexOf(value)
if (idx >= 0) {
selectedValues.value.splice(idx, 1)
@@ -91,6 +91,9 @@ function toggleOption(opt) {
selectedValues.value.push(value)
}
}
} else if (!isTextInput.value) {
// 单选逻辑(默认行为,包括 card_type 未定义或未知的情况)
selectedValues.value = [value]
}
}
+181 -4
View File
@@ -1,5 +1,11 @@
import { getApiBaseUrl, getAuthHeader } from './request.js'
// 环境检测:H5 模式下存在 window.fetch
const isH5 = typeof window !== 'undefined' && typeof window.fetch === 'function'
// SSE 请求超时时间(毫秒)
const SSE_TIMEOUT_MS = 300000
/**
* 短篇小说外部服务 API 封装
* 通过后端 SSE 代理调用外部 short-novel-service
@@ -14,6 +20,17 @@ import { getApiBaseUrl, getAuthHeader } from './request.js'
* @returns {Object} uni.request 任务对象(用于 abort
*/
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`,
@@ -25,7 +42,7 @@ export const startNovelStream = ({ query, onEvent, onError }) => {
...getAuthHeader()
},
enableChunked: true,
timeout: 300000,
timeout: SSE_TIMEOUT_MS,
success: (res) => {
if (res.statusCode >= 400) {
onError?.(res.data?.message || '请求失败')
@@ -66,6 +83,17 @@ export const startNovelStream = ({ query, onEvent, onError }) => {
* @returns {Object} uni.request 任务对象
*/
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`,
@@ -77,7 +105,7 @@ export const followupStream = ({ sessionId, action, payload, originalQuery, onEv
...getAuthHeader()
},
enableChunked: true,
timeout: 300000,
timeout: SSE_TIMEOUT_MS,
success: (res) => {
if (res.statusCode >= 400) {
onError?.(res.data?.message || '请求失败')
@@ -139,6 +167,155 @@ function decodeChunk(chunk) {
return result
}
/**
* H5 环境 SSE 事件解析
* 解析单个 SSE 事件块(已经被 \r\n\r\n 或 \n\n 分隔)
* 事件格式:data: {"type":"status","session_id":"xxx","payload":{...}}
* 注意:本函数不依赖块内空行作为结束标记,由调用方负责按双换行切分事件
*/
function h5ConsumeSseText(block, onEvent, onError) {
const lines = block.split(/\r?\n/)
let dataBuffer = ''
for (const line of lines) {
// 移除行尾的 \r(CRLF 兼容)
const cleanLine = line.replace(/\r$/, '')
if (cleanLine.startsWith('data:')) {
dataBuffer += cleanLine.slice(5).trim()
}
// 注意:不再依赖空行作为事件结束标记
// 因为块已经被调用方按 \n\n 切分好了
}
// 遍历完所有行后,直接处理累积的数据
if (dataBuffer) {
if (dataBuffer === '[DONE]') return
try {
const event = JSON.parse(dataBuffer)
onEvent?.(event)
} catch (e) {
onError?.(`SSE 解析失败:${e.message}`)
}
}
}
/**
* H5 环境 SSE 流式读取核心实现
* 使用 Promise.race + setTimeout 实现外部超时控制(兼容微信开发者工具)
* @param {string} url - 请求 URL
* @param {Object} body - 请求体
* @param {Function} onEvent - 事件回调
* @param {Function} onError - 错误回调
* @returns {Object} 包含 abort 方法的对象
*/
function h5NovelStream(url, body, onEvent, onError) {
let reader = null
let timeoutTimer = null
let isCompleted = false
let errorHandled = false // 防止 onError 重复调用
// 统一的清理函数(不处理错误,只清理资源)
const cleanup = () => {
isCompleted = true
if (timeoutTimer) clearTimeout(timeoutTimer)
if (reader) {
reader.cancel().catch(() => {})
}
}
// 安全的错误处理(确保 onError 只调用一次)
const safeOnError = (msg) => {
if (!errorHandled) {
errorHandled = true
cleanup()
onError?.(msg)
}
}
// 创建超时 Promise(不依赖 AbortController
const timeoutPromise = new Promise((_, reject) => {
timeoutTimer = setTimeout(() => {
if (!isCompleted) {
reject(new Error('请求超时(300 秒)'))
}
}, SSE_TIMEOUT_MS)
})
// 创建 fetch Promise
const fetchPromise = fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
...getAuthHeader()
},
body: JSON.stringify(body)
}).then(response => {
// 检查响应状态
if (!response.ok) {
throw new Error(`请求失败:HTTP ${response.status}`)
}
// 获取 ReadableStream 读取器
reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
// 循环读取流数据
function pump() {
return reader.read().then(({ done, value }) => {
if (done) {
// 流正常结束,标记为已完成(后续 abort 不触发错误)
isCompleted = true
if (timeoutTimer) clearTimeout(timeoutTimer)
return
}
if (isCompleted) {
return
}
// 解码二进制块为文本
buffer += decoder.decode(value, { stream: true })
// 解析完整的事件(兼容 CRLF)
const events = buffer.split(/\r?\n\r?\n/)
buffer = events.pop() // 最后一个可能不完整,保留到下次
for (const event of events) {
h5ConsumeSseText(event, onEvent, onError)
}
// 继续读取
return pump()
})
}
return pump()
})
// 使用 Promise.race 竞争超时和 fetch
Promise.race([fetchPromise, timeoutPromise]).then(() => {
// 成功完成,清理资源
if (timeoutTimer) clearTimeout(timeoutTimer)
if (reader) reader.cancel().catch(() => {})
}).catch(err => {
// 任何错误都传递给用户(包括微信开发者工具的 60 秒强制终止)
safeOnError(err.message || '请求超时')
})
// 返回 abort 接口(只清理资源,不触发错误)
return {
abort: () => {
// abort 只负责取消请求和清理资源,不触发 onError
// 真正的错误(超时、网络错误)会通过 Promise.race 的 catch 触发
isCompleted = true
if (timeoutTimer) clearTimeout(timeoutTimer)
if (reader) reader.cancel().catch(() => {})
}
}
}
/**
* 解析 SSE 文本
* 外部服务事件格式:data: {"type":"status","session_id":"xxx","payload":{...}}
@@ -158,8 +335,8 @@ function consumeSseText(text, onEvent, onError) {
const event = JSON.parse(dataStr)
onEvent?.(event)
} catch (e) {
onError?.(`SSE 解析失败: ${e.message}`)
onError?.(`SSE 解析失败${e.message}`)
}
}
}
}
}