b194b343ad
- 微信小程序 wxml 模板不支持内联箭头函数 @submit 改为方法引用,submitClarification 自己找未提交卡片 - TextDecoder 在 mp-weixin 不可用 decodeChunk 改用纯 JS UTF-8 解码实现
164 lines
4.8 KiB
JavaScript
164 lines
4.8 KiB
JavaScript
import { getApiBaseUrl, getAuthHeader } from './request.js'
|
||
|
||
/**
|
||
* 短篇小说外部服务 API 封装
|
||
* 通过后端 SSE 代理调用外部 short-novel-service
|
||
*/
|
||
|
||
/**
|
||
* 首次发起短篇小说生成(SSE 流式)
|
||
* @param {Object} params
|
||
* @param {string} params.query - 用户心愿文本
|
||
* @param {Function} params.onEvent - 事件回调 (event) => void,event 包含 type、session_id、payload
|
||
* @param {Function} params.onError - 错误回调
|
||
* @returns {Object} uni.request 任务对象(用于 abort)
|
||
*/
|
||
export const startNovelStream = ({ query, onEvent, onError }) => {
|
||
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
|
||
}
|
||
// 仅在 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
|
||
}
|
||
|
||
/**
|
||
* 后续轮次(澄清回答、大纲确认/修改、重试)
|
||
* @param {Object} params
|
||
* @param {string} params.sessionId - 外部服务的 session_id
|
||
* @param {string} params.action - answer_clarification | confirm_outline | modify_outline | retry
|
||
* @param {Object} params.payload - 操作载荷
|
||
* @param {Function} params.onEvent
|
||
* @param {Function} params.onError
|
||
* @returns {Object} uni.request 任务对象
|
||
*/
|
||
export const followupStream = ({ sessionId, action, payload, onEvent, onError }) => {
|
||
let chunkProcessed = false
|
||
const task = uni.request({
|
||
url: `${getApiBaseUrl()}/shortNovel/followup`,
|
||
method: 'POST',
|
||
data: { sessionId, action, payload },
|
||
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
|
||
}
|
||
|
||
/**
|
||
* 解码 chunk 数据
|
||
* 兼容 mp-weixin(不支持 TextDecoder)
|
||
*/
|
||
function decodeChunk(chunk) {
|
||
if (typeof chunk === 'string') return chunk
|
||
// 小程序环境 chunk 可能是 ArrayBuffer,转成 UTF-8 字符串
|
||
const bytes = new Uint8Array(chunk)
|
||
let result = ''
|
||
let i = 0
|
||
while (i < bytes.length) {
|
||
const byte1 = bytes[i++]
|
||
if (byte1 < 0x80) {
|
||
result += String.fromCharCode(byte1)
|
||
} else if (byte1 < 0xE0) {
|
||
const byte2 = bytes[i++]
|
||
result += String.fromCharCode(((byte1 & 0x1F) << 6) | (byte2 & 0x3F))
|
||
} else if (byte1 < 0xF0) {
|
||
const byte2 = bytes[i++]
|
||
const byte3 = bytes[i++]
|
||
result += String.fromCharCode(((byte1 & 0x0F) << 12) | ((byte2 & 0x3F) << 6) | (byte3 & 0x3F))
|
||
} else {
|
||
const byte2 = bytes[i++]
|
||
const byte3 = bytes[i++]
|
||
const byte4 = bytes[i++]
|
||
const codePoint = ((byte1 & 0x07) << 18) | ((byte2 & 0x3F) << 12) | ((byte3 & 0x3F) << 6) | (byte4 & 0x3F)
|
||
// 转成 UTF-16 代理对
|
||
const adjusted = codePoint - 0x10000
|
||
result += String.fromCharCode(0xD800 | (adjusted >> 10)) + String.fromCharCode(0xDC00 | (adjusted & 0x3FF))
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* 解析 SSE 文本
|
||
* 外部服务事件格式:data: {"type":"status","session_id":"xxx","payload":{...}}
|
||
*/
|
||
function consumeSseText(text, onEvent, onError) {
|
||
const lines = text.split(/\r?\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}`)
|
||
}
|
||
}
|
||
}
|
||
} |