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 @@