From dce899f65598a4c775cbc633f8daefa40ac7b6e0 Mon Sep 17 00:00:00 2001 From: msh-agent Date: Sat, 11 Apr 2026 15:20:10 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=B5=8B=E8=AF=95=E5=8F=8D=E9=A6=880403?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=20=E2=80=94=20=E7=99=BE=E7=A7=91Bug=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D/=E4=BB=BD=E6=95=B0=E2=86=92=E5=85=8B=E6=95=B0/AI?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E5=A2=9E=E5=BC=BA/=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- msh_single_uniapp/api/models-api.js | 165 ++++ .../pages/tool/ai-nutritionist.vue | 233 ++++- .../pages/tool/calculator-result.vue | 87 +- .../pages/tool/food-encyclopedia.vue | 795 +++++++++++++++--- 4 files changed, 1124 insertions(+), 156 deletions(-) diff --git a/msh_single_uniapp/api/models-api.js b/msh_single_uniapp/api/models-api.js index 9a4297b..1a9e1c6 100644 --- a/msh_single_uniapp/api/models-api.js +++ b/msh_single_uniapp/api/models-api.js @@ -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, diff --git a/msh_single_uniapp/pages/tool/ai-nutritionist.vue b/msh_single_uniapp/pages/tool/ai-nutritionist.vue index 01be18a..8a0f549 100644 --- a/msh_single_uniapp/pages/tool/ai-nutritionist.vue +++ b/msh_single_uniapp/pages/tool/ai-nutritionist.vue @@ -81,13 +81,27 @@ @click="previewImage(msg.imageUrl)" > - + - {{ ttsPlayingIndex === index ? '⏹' : '▶' }} + + + 📋 + + + + 🔄 + + + + {{ ttsPlayingIndex === index ? '⏹' : '▶' }} + + + + 🗑️ + @@ -560,6 +574,56 @@ export default { this.sendMessage(); }); }, + // ---------- 消息操作方法(复制/删除/重新生成) ---------- + + copyMessage(index) { + const msg = this.messageList[index] + if (!msg || !msg.content) return + uni.setClipboardData({ + data: msg.content, + success: () => { + uni.showToast({ title: '已复制', icon: 'success' }) + } + }) + }, + + deleteMessage(index) { + uni.showModal({ + title: '提示', + content: '确定删除这条消息吗?', + success: (res) => { + if (res.confirm) { + this.messageList.splice(index, 1) + this.messageList = [...this.messageList] + } + } + }) + }, + + regenerateMessage(index) { + // 找到该 AI 消息对应的上一条用户消息 + let userMsgIndex = index - 1 + while (userMsgIndex >= 0 && this.messageList[userMsgIndex].role !== 'user') { + userMsgIndex-- + } + if (userMsgIndex < 0) return + const userMsg = this.messageList[userMsgIndex] + // 移除当前 AI 回复 + this.messageList.splice(index, 1) + this.messageList = [...this.messageList] + // 重新发送 + this.sendToAI(userMsg.content, userMsg.type || 'text') + }, + + isLastAiMessage(index) { + for (let i = this.messageList.length - 1; i >= 0; i--) { + if (this.messageList[i].role === 'ai' && !this.messageList[i].loading) { + return i === index + } + } + return false + }, + clearChat() { uni.showModal({ title: '提示', @@ -693,16 +757,74 @@ export default { return [{ role: 'user', content: parts }]; }, - /** BUG-005:严格从 CommonResult.data.choices[0].message.content 读取回复 */ + /** + * BUG-005:主路径为 response.data.choices[0].message.content(models-api 已尽量规范 data 形态)。 + * 兼容上游 candidates、或根级即 completion 等变体;仅使用接口返回字段, + * 成功路径不使用 api/tool.js 的 getAIResponse 或本地关键词话术。 + */ getGeminiReplyFromResponse(response) { - const content = response && - response.data && - Array.isArray(response.data.choices) && - response.data.choices[0] && - response.data.choices[0].message - ? response.data.choices[0].message.content - : ''; - return this.extractReplyContent(content); + if (!response || typeof response !== 'object') return ''; + const looksLikeCompletion = (o) => + o && typeof o === 'object' && + (Array.isArray(o.choices) || Array.isArray(o.candidates)); + let data = response.data; + if (typeof data === 'string') { + try { + data = JSON.parse(data); + } catch (e) { + return ''; + } + } + // 主路径:OpenAI 形态 choices[0].message.content(与需求 data.choices[0].message.content 一致) + if (data && typeof data === 'object' && Array.isArray(data.choices) && data.choices.length > 0) { + const choice0 = data.choices[0]; + const msg = choice0 && choice0.message; + if (msg && typeof msg === 'object') { + const fromMsg = this.extractReplyContent(msg.content); + if (fromMsg.trim()) return fromMsg; + } + const delta = choice0 && choice0.delta; + if (delta && typeof delta === 'object') { + const fromDelta = this.extractReplyContent(delta.content); + if (fromDelta.trim()) return fromDelta; + } + if (choice0 && typeof choice0.text === 'string' && choice0.text.trim()) { + return choice0.text; + } + } + if (!looksLikeCompletion(data) && looksLikeCompletion(response)) { + data = response; + } + if (!data || typeof data !== 'object') return ''; + const payload = this.getGeminiPayload(data); + if (!payload || typeof payload !== 'object') return ''; + const choices = payload.choices; + if (Array.isArray(choices) && choices.length > 0) { + const choice0 = choices[0]; + const msg = choice0 && choice0.message; + if (msg && typeof msg === 'object') { + const fromMsg = this.extractReplyContent(msg.content); + if (fromMsg.trim()) return fromMsg; + } + const delta = choice0 && choice0.delta; + if (delta && typeof delta === 'object') { + const fromDelta = this.extractReplyContent(delta.content); + if (fromDelta.trim()) return fromDelta; + } + if (choice0 && typeof choice0.text === 'string' && choice0.text.trim()) { + return choice0.text; + } + } + const cands = payload.candidates; + if (Array.isArray(cands) && cands.length > 0) { + const c0 = cands[0]; + if (c0 && typeof c0 === 'object') { + const fromCand = this.extractReplyContent(c0.content); + if (fromCand.trim()) return fromCand; + if (typeof c0.text === 'string' && c0.text.trim()) return c0.text; + } + } + return ''; }, async sendToAI(content, type) { @@ -711,16 +833,55 @@ export default { this.messageList.push(aiMsg); this.scrollToBottom(); + const messages = this.buildGeminiMessages(content, type); + + // 优先尝试流式输出以改善响应速度感知 try { - const response = await api.kieaiGeminiChat({ - messages: this.buildGeminiMessages(content, type), - stream: false - }); - const reply = this.getGeminiReplyFromResponse(response); - aiMsg.content = reply || '抱歉,未获取到模型回复。'; - } catch (error) { - console.error('KieAI Gemini 对话失败:', error); - aiMsg.content = '抱歉,处理您的请求时出现错误,请稍后再试。'; + await new Promise((resolve, reject) => { + const ctrl = api.kieaiGeminiChatStream({ messages }) + this._streamCtrl = ctrl + + // 收到第一个 chunk 后切换为 streaming 状态 + ctrl.onMessage((deltaText) => { + if (aiMsg.loading) { + aiMsg.loading = false + aiMsg.streaming = true + } + aiMsg.content += deltaText + this.messageList = [...this.messageList] + this.scrollToBottom() + }) + + ctrl.onError((err) => { + console.warn('[sendToAI] 流式请求失败,降级为非流式:', err) + this._streamCtrl = null + reject(err) + }) + + ctrl.onComplete(() => { + this._streamCtrl = null + resolve() + }) + }) + + // 流式完成,检查内容 + if (!aiMsg.content.trim()) { + aiMsg.content = '模型未返回有效内容,请稍后重试。' + } + } catch (streamError) { + // 流式失败,降级为非流式请求 + try { + const response = await api.kieaiGeminiChat({ + messages, + stream: false + }); + const reply = this.getGeminiReplyFromResponse(response); + aiMsg.content = reply.trim() ? reply : '模型未返回有效内容,请稍后重试。'; + } catch (error) { + console.error('KieAI Gemini 对话失败:', error); + const errText = error && error.message ? String(error.message) : ''; + aiMsg.content = errText || '抱歉,处理您的请求时出现错误,请稍后再试。'; + } } finally { aiMsg.loading = false; aiMsg.streaming = false; @@ -973,6 +1134,34 @@ export default { font-weight: 500; } +/* 消息操作按钮组 */ +.msg-actions { + display: flex; + gap: 16rpx; + margin-top: 8rpx; + align-items: center; +} + +.action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48rpx; + height: 48rpx; + background: rgba(76, 175, 80, 0.12); + border-radius: 50%; + cursor: pointer; +} + +.action-btn:active { + transform: scale(0.9); +} + +.action-icon { + font-size: 24rpx; + color: #4caf50; +} + .tts-play-btn { display: inline-flex; align-items: center; diff --git a/msh_single_uniapp/pages/tool/calculator-result.vue b/msh_single_uniapp/pages/tool/calculator-result.vue index 0cc42f0..de9d44b 100644 --- a/msh_single_uniapp/pages/tool/calculator-result.vue +++ b/msh_single_uniapp/pages/tool/calculator-result.vue @@ -87,27 +87,27 @@ 💡 - 相当于下方表中食物的推荐摄入量 + 相当于下方表中食物的每日推荐克数 - + 🥗 - 食物份数建议 + 每日食物建议 - {{ item.number }} {{ item.name }} - {{ item.portion }} 份 + {{ item.gram || item.portion }} 克 @@ -335,7 +335,11 @@ export default { } } if (Array.isArray(data.foodList)) { - this.foodList = data.foodList + // 份数→克数适配:优先使用 gram 字段,兜底通过 portion * gramPerServing 换算 + this.foodList = data.foodList.map(item => ({ + ...item, + gram: item.gram || (item.portion && item.gramPerServing ? Math.round(item.portion * item.gramPerServing) : null) + })) } if (data.mealPlan) { this.mealPlan = { @@ -472,52 +476,57 @@ export default { display: flex; padding: 0 32rpx; height: 96rpx; - align-items: center; + /* stretch:Tab 占满栏高,激活态 border-bottom 才能贴底显示(BUG-002) */ + align-items: stretch; position: sticky; // top: 88rpx; z-index: 99; } +/* Tab:未激活灰字、透明底边占位(无可见下划线);激活加粗+主色+橙色底边(BUG-002) */ .tab-item { flex: 1; - height: 100%; - min-height: 75rpx; + min-height: 0; border-radius: 0; display: flex; align-items: center; justify-content: center; gap: 16rpx; - transition: all 0.2s ease; box-sizing: border-box; - /* 未激活:明显变灰,无下划线(BUG-002) */ color: #9ca3af; font-weight: 400; - border-bottom: 3rpx solid transparent; - .tab-icon { - font-size: 28rpx; - color: inherit; - font-weight: inherit; - } - .tab-text { - font-size: 28rpx; - color: inherit; - font-weight: inherit; - } - /* 激活:加粗、主色、橙色底部下划线(BUG-002) */ - &.active { - background: transparent; - color: #f97316; - font-weight: 700; - border-bottom: 3px solid #f97316; - .tab-text { - color: #f97316; - font-weight: 700; - } - .tab-icon { - color: #f97316; - font-weight: 700; - } - } + text-decoration: none; + border-bottom: 3px solid transparent; + transition: color 0.2s ease, border-color 0.2s ease; +} + +.tab-item .tab-icon, +.tab-item .tab-text { + font-size: 28rpx; +} + +/* 未激活:灰字、常规字重(显式写到 text 节点,避免小程序不继承 view 颜色) */ +.tab-item:not(.active) .tab-icon, +.tab-item:not(.active) .tab-text { + color: #9ca3af; + font-weight: 400; + text-decoration: none; +} + +/* 激活:粗体、主色、橙色底边(与未激活同宽底边,避免切换时跳动) */ +.tab-item.active { + background: transparent; + color: #f97316; + font-weight: 700; + text-decoration: none; + border-bottom: 3px solid #f97316; +} + +.tab-item.active .tab-icon, +.tab-item.active .tab-text { + color: #f97316; + font-weight: 700; + text-decoration: none; } /* 内容滚动区域 */ diff --git a/msh_single_uniapp/pages/tool/food-encyclopedia.vue b/msh_single_uniapp/pages/tool/food-encyclopedia.vue index 5a51a03..5c1dfca 100644 --- a/msh_single_uniapp/pages/tool/food-encyclopedia.vue +++ b/msh_single_uniapp/pages/tool/food-encyclopedia.vue @@ -99,16 +99,29 @@ + + + + + + ⚠️ @@ -124,10 +137,11 @@ --> + {{ nut.label || '—' }} {{ nut.value != null ? nut.value : '—' }} @@ -143,11 +157,12 @@