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

@@ -81,13 +81,27 @@
@click="previewImage(msg.imageUrl)"
></image>
</view>
<!-- AI 回复播放按钮 -->
<!-- AI 回复操作按钮 -->
<view
v-if="msg.role === 'ai' && !msg.loading && !msg.streaming && msg.content && msg.type !== 'image'"
class="tts-play-btn"
@click="ttsPlayingIndex === index ? stopTTS() : playTTS(index)"
class="msg-actions"
>
<text class="tts-play-icon">{{ ttsPlayingIndex === index ? '⏹' : '▶' }}</text>
<!-- 复制 -->
<view class="action-btn" @click="copyMessage(index)">
<text class="action-icon">📋</text>
</view>
<!-- 重新生成仅最后一条AI消息显示 -->
<view class="action-btn" v-if="isLastAiMessage(index)" @click="regenerateMessage(index)">
<text class="action-icon">🔄</text>
</view>
<!-- 语音朗读 -->
<view class="action-btn" @click="ttsPlayingIndex === index ? stopTTS() : playTTS(index)">
<text class="action-icon">{{ ttsPlayingIndex === index ? '⏹' : '▶' }}</text>
</view>
<!-- 删除 -->
<view class="action-btn" @click="deleteMessage(index)">
<text class="action-icon">🗑</text>
</view>
</view>
</view>
</view>
@@ -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.contentmodels-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;