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}`) } } } }