diff --git a/mini-program/src/services/shortNovel.js b/mini-program/src/services/shortNovel.js new file mode 100644 index 0000000..912440c --- /dev/null +++ b/mini-program/src/services/shortNovel.js @@ -0,0 +1,134 @@ +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 }) => { + 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 (typeof res.data === 'string' && res.data) { + consumeSseText(res.data, onEvent, onError) + } + }, + fail: (error) => { + onError?.(error.errMsg || '网络请求失败') + } + }) + + task?.onChunkReceived?.((res) => { + 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 }) => { + 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 (typeof res.data === 'string' && res.data) { + consumeSseText(res.data, onEvent, onError) + } + }, + fail: (error) => { + onError?.(error.errMsg || '网络请求失败') + } + }) + + task?.onChunkReceived?.((res) => { + try { + const text = decodeChunk(res.data) + consumeSseText(text, onEvent, onError) + } catch (error) { + onError?.(error.message || '流式解析失败') + } + }) + + return task +} + +/** + * 解码 chunk 数据 + */ +function decodeChunk(chunk) { + if (typeof chunk === 'string') return chunk + const decoder = new TextDecoder('utf-8') + return decoder.decode(new Uint8Array(chunk)) +} + +/** + * 解析 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}`) + } + } + } +} \ No newline at end of file