fix: 测试反馈0403修改 — 百科Bug修复/份数→克数/AI对话增强/流式输出
1. [P0] food-encyclopedia: 修复 goToFoodDetail TypeError 报错 - 增加 item 空值防御性校验 - 加固 filteredFoodList 过滤无效项 2. [P1] calculator-result: 食物份数建议改为克数 - 模板展示从"X份"改为"X克" - applyResult 数据适配:优先读 gram 字段,兜底 portion * gramPerServing 换算 3. [P2] ai-nutritionist: 新增消息操作按钮(复制/重新生成/删除) - AI消息气泡下方新增 msg-actions 按钮组 - 复制到剪贴板、删除单条消息、重新生成最后一条AI回复 4. [P2] ai-nutritionist + models-api: 启用流式输出改善响应速度 - 新增 kieaiGeminiChatStream 函数(SSE + enableChunked) - sendToAI 优先走流式,失败自动降级为非流式 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -324,6 +324,20 @@ function queryAsrStatus(taskId) {
|
||||
|
||||
// ==================== KieAI Gemini Chat(BUG-005:AI 营养师文本/多模态对话) ====================
|
||||
|
||||
/**
|
||||
* 将 CommonResult.data 规范为可直接读 choices 的 OpenAI 形态(避免偶发多包一层 data)。
|
||||
* 页面统一从返回值的 data.choices[0].message.content 取正文。
|
||||
*/
|
||||
function unwrapGeminiCompletionData(payload) {
|
||||
if (payload == null || typeof payload !== 'object') return payload
|
||||
if (Array.isArray(payload.choices) && payload.choices.length > 0) return payload
|
||||
const nested = payload.data
|
||||
if (nested && typeof nested === 'object' && Array.isArray(nested.choices) && nested.choices.length > 0) {
|
||||
return nested
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
/**
|
||||
* KieAI Gemini 对话
|
||||
* POST /api/front/kieai/gemini/chat
|
||||
@@ -346,9 +360,159 @@ function kieaiGeminiChat(data) {
|
||||
return request('/api/front/kieai/gemini/chat', {
|
||||
method: 'POST',
|
||||
data: { messages, stream }
|
||||
}).then((res) => {
|
||||
// HTTP 200 时仍可能是 CommonResult 业务失败,禁止把失败当成功、用空内容走本地固定话术
|
||||
const c = res && res.code
|
||||
if (c != null && Number(c) !== 200) {
|
||||
const msg = (res.message || res.msg || 'Gemini 对话失败').toString()
|
||||
return Promise.reject(new Error(msg))
|
||||
}
|
||||
let outData = res && res.data
|
||||
// 少数环境下 data 为 JSON 字符串,解析后便于页面读取 data.choices[0].message.content
|
||||
if (typeof outData === 'string') {
|
||||
try {
|
||||
outData = JSON.parse(outData)
|
||||
} catch (e) {
|
||||
return res
|
||||
}
|
||||
}
|
||||
outData = unwrapGeminiCompletionData(outData)
|
||||
return { ...res, data: outData }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* KieAI Gemini 流式对话 (SSE + enableChunked)
|
||||
* POST /api/front/kieai/gemini/chat stream=true
|
||||
* 返回与 cozeChatStream 相同的 controller 接口 { onMessage, onError, onComplete, abort }
|
||||
* onMessage(text) 每次收到一段增量文本
|
||||
* @param {object} data 请求体 { messages }
|
||||
* @returns {object} 控制器
|
||||
*/
|
||||
function kieaiGeminiChatStream(data) {
|
||||
const messages = data && data.messages
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
const ctrl = {
|
||||
onMessage() { return ctrl },
|
||||
onError(fn) { fn(new Error('messages 不能为空')); return ctrl },
|
||||
onComplete() { return ctrl },
|
||||
abort() {}
|
||||
}
|
||||
return ctrl
|
||||
}
|
||||
|
||||
let _onMessage = () => {}
|
||||
let _onError = () => {}
|
||||
let _onComplete = () => {}
|
||||
let _buffer = ''
|
||||
let _task = null
|
||||
let _gotChunks = false
|
||||
|
||||
const controller = {
|
||||
onMessage(fn) { _onMessage = fn; return controller },
|
||||
onError(fn) { _onError = fn; return controller },
|
||||
onComplete(fn) { _onComplete = fn; return controller },
|
||||
abort() { if (_task) _task.abort() },
|
||||
getTask() { return _task }
|
||||
}
|
||||
|
||||
/** 从 SSE data JSON 中提取增量文本 */
|
||||
const extractDeltaText = (evt) => {
|
||||
// OpenAI 兼容格式: choices[0].delta.content
|
||||
if (evt && Array.isArray(evt.choices) && evt.choices[0]) {
|
||||
const delta = evt.choices[0].delta
|
||||
if (delta && typeof delta.content === 'string') return delta.content
|
||||
// 非流式 fallback
|
||||
const msg = evt.choices[0].message
|
||||
if (msg && typeof msg.content === 'string') return msg.content
|
||||
}
|
||||
// Gemini 原生格式
|
||||
if (evt && Array.isArray(evt.candidates) && evt.candidates[0]) {
|
||||
const parts = evt.candidates[0].content && evt.candidates[0].content.parts
|
||||
if (Array.isArray(parts) && parts[0] && typeof parts[0].text === 'string') return parts[0].text
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const parseSseLines = (text) => {
|
||||
_buffer += text
|
||||
const lines = _buffer.split('\n')
|
||||
_buffer = lines.pop() || ''
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith(':')) continue
|
||||
if (trimmed === 'data: [DONE]') continue
|
||||
if (trimmed.startsWith('data:')) {
|
||||
const jsonStr = trimmed.slice(5).trim()
|
||||
if (!jsonStr) continue
|
||||
try {
|
||||
const evt = JSON.parse(jsonStr)
|
||||
const delta = extractDeltaText(evt)
|
||||
if (delta) _onMessage(delta)
|
||||
} catch (e) {
|
||||
console.warn('[kieaiGeminiChatStream] JSON parse failed:', jsonStr.slice(0, 200))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const token = store.state && store.state.app && store.state.app.token
|
||||
_task = uni.request({
|
||||
url: `${API_BASE_URL}/api/front/kieai/gemini/chat`,
|
||||
method: 'POST',
|
||||
data: { messages, stream: true },
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { [TOKENNAME]: token } : {})
|
||||
},
|
||||
enableChunked: true,
|
||||
responseType: 'text',
|
||||
success: (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
let errMsg = '请求失败: ' + res.statusCode
|
||||
try {
|
||||
const body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
||||
if (body && body.message) errMsg = body.message
|
||||
else if (body && body.msg) errMsg = body.msg
|
||||
} catch (e) { /* keep default */ }
|
||||
_onError(new Error(errMsg))
|
||||
return
|
||||
}
|
||||
// 处理剩余 buffer
|
||||
if (_buffer.trim()) parseSseLines('\n')
|
||||
// 如果 enableChunked 不支持(非微信小程序),从完整 response body 解析
|
||||
if (!_gotChunks && res && res.data) {
|
||||
const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data)
|
||||
parseSseLines(body + '\n')
|
||||
}
|
||||
_onComplete()
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('[kieaiGeminiChatStream] request fail:', err)
|
||||
_onError(err)
|
||||
}
|
||||
})
|
||||
|
||||
if (_task && _task.onChunkReceived) {
|
||||
_task.onChunkReceived((res) => {
|
||||
_gotChunks = true
|
||||
try {
|
||||
const bytes = new Uint8Array(res.data)
|
||||
let text = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
text += String.fromCharCode(bytes[i])
|
||||
}
|
||||
text = decodeURIComponent(escape(text))
|
||||
parseSseLines(text)
|
||||
} catch (e) {
|
||||
console.warn('[kieaiGeminiChatStream] chunk decode error:', e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
// ==================== 扣子Coze API ====================
|
||||
|
||||
/**
|
||||
@@ -658,6 +822,7 @@ export default {
|
||||
createAsrTask,
|
||||
queryAsrStatus,
|
||||
kieaiGeminiChat,
|
||||
kieaiGeminiChatStream,
|
||||
// Coze API
|
||||
cozeChat,
|
||||
cozeChatStream,
|
||||
|
||||
Reference in New Issue
Block a user