169 lines
5.4 KiB
JavaScript
169 lines
5.4 KiB
JavaScript
/** 拼接 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', '喝的': 'drinks', '午餐': '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', '喝的': 'drinks', '午餐': '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<object>} [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
|
||
}
|