/** 拼接 API 根路径与相对路径,避免双斜杠 */ export function joinApiUrl(base, path) { const b = String(base || '').replace(/\/+$/, '') const p = String(path || '').replace(/^\/+/, '') return p ? `${b}/${p}` : b } function appendQuery(url, data) { if (!data || typeof data !== 'object') return url const keys = Object.keys(data) if (!keys.length) return url const qs = keys .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(data[k] ?? '')}`) .join('&') return url + (url.includes('?') ? '&' : '?') + qs } function decodeChunkData(data) { if (typeof data === 'string') return data if (!data) return '' if (typeof TextDecoder !== 'undefined' && data instanceof ArrayBuffer) { return new TextDecoder('utf-8').decode(new Uint8Array(data)) } if (data instanceof ArrayBuffer) { const bytes = new Uint8Array(data) let str = '' for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i]) try { return decodeURIComponent(escape(str)) } catch (e) { return str } } return '' } /** 从 SSE 文本缓冲中解析并消费完整事件 */ export function consumeSseBuffer(buffer, onEvent) { let rest = buffer let idx = rest.indexOf('\n\n') while (idx !== -1) { const block = rest.slice(0, idx) rest = rest.slice(idx + 2) let event = 'message' let dataLine = '' block.split('\n').forEach((line) => { if (line.startsWith('event:')) event = line.slice(6).trim() else if (line.startsWith('data:')) dataLine += line.slice(5).trim() }) if (dataLine) { try { onEvent(event, JSON.parse(dataLine)) } catch (e) { onEvent(event, dataLine) } } idx = rest.indexOf('\n\n') } return rest } /** 从流式纯文本中提取三餐(支持未写完的最后一行) */ export function parsePartialDietPlainText(text) { const out = {} if (!text) return out const lineRe = /^(早餐|午餐|晚餐|提示|少碰)[::]\s*(.*)$/gm let match while ((match = lineRe.exec(text)) !== null) { const key = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[match[1]] const val = (match[2] || '').trim() if (key === 'avoid') { out.avoid = val.split(/[、,,;;\s]+/).map((s) => s.trim()).filter(Boolean) } else { out[key] = val } } const tail = text.match(/(?:^|\n)(早餐|午餐|晚餐|提示|少碰)[::]\s*([^\n]*)$/) if (tail) { const key = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[tail[1]] const val = (tail[2] || '').trim() if (key === 'avoid') { if (val) out.avoid = val.split(/[、,,;;\s]+/).map((s) => s.trim()).filter(Boolean) } else { out[key] = val } } return out } /** @deprecated 使用 parsePartialDietPlainText */ export function parsePartialDietJson(text) { return parsePartialDietPlainText(text) } /** * SSE 流式请求(微信小程序 enableChunked;其它端走 fallback) * * @param {object} opts * @param {string} opts.baseUrl * @param {string} opts.url * @param {string} [opts.method] * @param {object} [opts.data] * @param {function(string, object):void} opts.onEvent * @param {function():Promise} [opts.fallback] 不支持流式时的降级请求 */ export function requestAiStream(opts) { const { baseUrl, url, method = 'GET', data = {}, onEvent, fallback } = opts // #ifdef MP-WEIXIN return new Promise((resolve, reject) => { const token = uni.getStorageSync('token') || '' let sseBuffer = '' let requestUrl = joinApiUrl(baseUrl, url) if (method === 'GET') { requestUrl = appendQuery(requestUrl, data) } const task = wx.request({ url: requestUrl, method, data: method === 'POST' ? data : {}, enableChunked: true, timeout: 60000, header: { token, 'content-type': 'application/x-www-form-urlencoded', 'Cache-Control': 'no-cache' }, success: (res) => { if (res && res.data) { sseBuffer += decodeChunkData(res.data) sseBuffer = consumeSseBuffer(sseBuffer, onEvent) } resolve(res) }, fail: (err) => reject(err) }) if (task && typeof task.onChunkReceived === 'function') { task.onChunkReceived((res) => { sseBuffer += decodeChunkData(res.data) sseBuffer = consumeSseBuffer(sseBuffer, onEvent) }) } else if (typeof fallback === 'function') { fallback().then(resolve).catch(reject) } else { reject(new Error('当前环境不支持流式请求')) } }) // #endif // #ifndef MP-WEIXIN if (typeof fallback === 'function') { return fallback() } return Promise.reject(new Error('当前环境不支持流式请求')) // #endif }