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:
@@ -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.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;
|
||||
|
||||
Reference in New Issue
Block a user