feat(ai): 小程序 AI 链路改走 HTTP 流式,WS 细节收敛到 utils/chat-socket

与 web 端同因:生成期间普通 POST 零字节,会被 60s 空闲超时掐成 504。

- 新增 utils/chat-socket.js:地址统一拼 /api/chat/{userId}(网关只放行 /api),
  attachHeartbeat 每 25s 发 ping;三个页面不再各抄一份
- chat-service 新增 streamAiRequest:H5 走 fetch + ReadableStream,小程序走
  enableChunked + onChunkReceived,并手写 UTF-8 分块解码,避免中文被切半
- 写作 / 图片 / 大纲 / 稿件改走 /stream,结构化结果从末帧 {type:'final'} 取;
  流断但有正文时保留已生成部分,不整块覆盖成错误文案
- 对话仍先试 WebSocket,连不上回退 /ai/chat/message/stream,帧处理复用
  handleSocketMessage
This commit is contained in:
2026-10-21 19:18:13 +08:00
parent 5ce66be025
commit d449abe966
7 changed files with 456 additions and 53 deletions
+50 -15
View File
@@ -212,9 +212,11 @@
<script>
import {
attachHeartbeat,
buildWebSocketUrl,
getCurrentUser,
manuscriptGen,
streamManuscriptGen,
uploadTempFile
} from './service'
import {
@@ -261,6 +263,7 @@
activeConversationId: '',
socketTask: null,
socketConnectPromise: null,
socketHeartbeatStop: null,
streamStarted: false,
streamFailed: false,
streamResolver: null,
@@ -521,6 +524,10 @@
})
},
disconnectWebSocket() {
if (this.socketHeartbeatStop) {
this.socketHeartbeatStop()
this.socketHeartbeatStop = null
}
this.socketConnectPromise = null
this.wsConnected = false
const socketTask = this.socketTask
@@ -621,6 +628,8 @@
socketTask.onOpen(() => {
this.wsConnected = false
// 握手成功后再挂心跳:连接没建起来时发出去只会失败
this.socketHeartbeatStop = attachHeartbeat(socketTask)
})
socketTask.onMessage((event) => {
const raw = String((event && event.data) || '')
@@ -784,25 +793,23 @@
this.loading = true
this.clearGenerated()
this.generatedTitle = String(this.form.theme || '').trim()
this.responseSummary = '正在建立 WebSocket 连接...'
this.responseSummary = '正在提交生成任务...'
try {
await this.connectWebSocket()
// 一律走 HTTP 流式(/stream),不再先连 WebSocket:
// 触发生成的那个 POST 在整段生成期间是零字节的,会被链路上的
// 60s 空闲超时掐成 504 Gateway Time-out(自由创作、不选文体时最先撞上)。
// /stream 每 15s 有后端心跳续命,帧内容与 WS 完全一致。
const payload = this.buildPayload()
this.responseSummary = this.freeMode
? '自由创作已提交给 AI 创意助手,生成完成后一次性返回...'
: '连接成功,已提交生成任务,等待流式返回...'
: '已提交生成任务,等待流式返回...'
const waitStream = this.createStreamWaiter()
const result = await manuscriptGen(this.buildPayload())
if (!this.streamStarted && this.shouldTreatAsDirectResult(result)) {
this.applyParsedResult(
typeof result === 'string' ? this.parseSerializedResult(result) : result,
'接口直接返回生成结果'
)
this.loading = false
this.resolveStream()
this.disconnectWebSocket()
} else {
await waitStream
}
await streamManuscriptGen(payload, {
onMessage: (raw) => this.handleStreamFrame(raw)
}).promise
// 兜底:流结束但没收到 __END__ 时也别把页面卡在加载中
this.resolveStream()
await waitStream
if (this.streamFailed) {
// 后端判定工作流失败,正文区已显示失败文案,这里同步提示
this.showToast('生成失败,请稍后重试')
@@ -816,6 +823,34 @@
this.showToast((error && error.message) || '内容生成失败')
}
},
/**
* SSE 帧分发:末帧带着接口的返回值(自由创作模式整段都不推正文,只能靠它),
* 其余帧与 WebSocket 完全一致,继续交给 handleSocketMessage。
*/
handleStreamFrame(raw) {
let frame = null
try {
frame = JSON.parse(raw)
} catch (error) {
this.handleSocketMessage(raw)
return
}
if (!frame || frame.type !== 'final') {
this.handleSocketMessage(raw)
return
}
const result = frame.result
if (result && Number(result.code) === 0 && result.data) {
this.applyParsedResult(
typeof result.data === 'string' ? this.parseSerializedResult(result.data) : result.data,
'接口直接返回生成结果'
)
return
}
if (result && result.message) {
this.showToast(result.message)
}
},
copyGenerated() {
if (!this.markdownContent) {
this.showToast('暂无可复制的正文内容')