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:
msh-agent
2026-04-11 15:20:10 +08:00
parent 2facd355ab
commit dce899f655
4 changed files with 1124 additions and 156 deletions

View File

@@ -324,6 +324,20 @@ function queryAsrStatus(taskId) {
// ==================== KieAI Gemini ChatBUG-005AI 营养师文本/多模态对话) ====================
/**
* 将 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,