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
+25 -16
View File
@@ -158,8 +158,10 @@
<script>
import {
activityOutlineGen,
attachHeartbeat,
buildWebSocketUrl,
getCurrentUser
getCurrentUser,
streamActivityOutlineGen
} from './service'
import {
isFailedPayload,
@@ -195,6 +197,7 @@
activeMessageId: '',
socketTask: null,
socketConnectPromise: null,
socketHeartbeatStop: null,
streamStarted: false,
streamFailed: false,
streamResolver: null,
@@ -364,6 +367,10 @@
})
},
disconnectWebSocket() {
if (this.socketHeartbeatStop) {
this.socketHeartbeatStop()
this.socketHeartbeatStop = null
}
this.socketConnectPromise = null
const socketTask = this.socketTask
this.socketTask = null
@@ -471,6 +478,10 @@
}
}
socketTask.onOpen(() => {
// 握手成功后再挂心跳:连接没建起来时发出去只会失败
this.socketHeartbeatStop = attachHeartbeat(socketTask)
})
socketTask.onMessage((event) => {
const raw = String((event && event.data) || '')
if (raw === SOCKET_CONNECTED_TEXT) {
@@ -539,23 +550,21 @@
}
this.loading = true
this.clearResult()
this.resultSummary = '正在建立 WebSocket 连接...'
this.resultSummary = '正在提交生成任务...'
try {
await this.connectWebSocket()
this.resultSummary = '连接成功,已提交生成任务,等待流式返回...'
// 一律走 HTTP 流式(/stream),不再先连 WebSocket:
// 触发生成的那个 POST 在整段生成期间是零字节的,会被链路上的
// 60s 空闲超时掐成 504 Gateway Time-out。/stream 每 15s 有后端
// 心跳续命,帧内容与 WS 完全一致。
const payload = this.buildPayload()
this.resultSummary = '已提交生成任务,等待流式返回...'
const waitStream = this.createStreamWaiter()
const data = await activityOutlineGen(this.buildPayload())
this.result = data || null
if (!this.streamStarted && data && this.parseOutline(data)) {
this.outlineMarkdown = this.parseOutline(data)
this.syncEditableOutline()
this.resultSummary = this.buildSummary() || '接口直接返回生成结果'
this.loading = false
this.resolveStream()
this.disconnectWebSocket()
} else {
await waitStream
}
await streamActivityOutlineGen(payload, {
onMessage: (raw) => this.handleSocketMessage(raw)
}).promise
// 兜底:流结束但没收到 __END__ 时也别把页面卡在加载中
this.resolveStream()
await waitStream
if (this.streamFailed) {
// 后端判定工作流失败,正文区已显示失败文案,这里同步提示
this.showToast('生成失败,请稍后重试')