feat: 营养素AI生成落库 + AI营养师消息级loading占位(设计文档对齐)
后端:
- ToolKnowledgeService/Impl 新增 generateNutrientContent()
调用 Coze AI 批量生成6种营养素(蛋白质/钾/磷/钠/钙/水分)
科普内容并写入 v2_knowledge,已存在的自动跳过
- ToolController 新增 POST /tool/knowledge/generate-nutrients
端点(管理端一次性调用后自动补充封面图)
- 新增 SQL 备用脚本 migration_2026-03-25_nutrient_knowledge.sql
含6种营养素完整JSON,直接执行可跳过AI生成
前端(ai-nutritionist.vue,对齐功能开发详细设计文档任务3-2):
- 新增 sleep(ms) 工具方法
- sendToAI 发起前先推入 {loading:true} 占位气泡
- pollChatStatus 轮询间隔由 1000ms 调整为 1500ms
- getChatMessages 回调填充占位气泡(不再 push 新消息)
- 所有错误/超时/失败路径统一更新 aiMsg.loading=false
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -63,11 +63,17 @@
|
||||
|
||||
<!-- 消息气泡 -->
|
||||
<view :class="['message-bubble', msg.role === 'user' ? 'user-bubble' : 'ai-bubble']">
|
||||
<text v-if="msg.type !== 'image'" class="message-text">{{ msg.content }}</text>
|
||||
<image
|
||||
v-else
|
||||
:src="msg.imageUrl"
|
||||
mode="widthFix"
|
||||
<!-- AI 消息 loading 占位(等待回复时显示打字动画)-->
|
||||
<view v-if="msg.role === 'ai' && msg.loading" class="typing-indicator">
|
||||
<view class="typing-dot"></view>
|
||||
<view class="typing-dot"></view>
|
||||
<view class="typing-dot"></view>
|
||||
</view>
|
||||
<text v-else-if="msg.type !== 'image'" class="message-text">{{ msg.content }}</text>
|
||||
<image
|
||||
v-else
|
||||
:src="msg.imageUrl"
|
||||
mode="widthFix"
|
||||
class="message-image"
|
||||
@click="previewImage(msg.imageUrl)"
|
||||
></image>
|
||||
@@ -626,9 +632,19 @@ export default {
|
||||
return String(content);
|
||||
},
|
||||
|
||||
/** 工具方法:sleep ms 毫秒 */
|
||||
sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
},
|
||||
|
||||
async sendToAI(content, type) {
|
||||
this.isLoading = true;
|
||||
|
||||
// 添加 AI 占位消息(loading 状态,等待 Coze 返回后填充内容)
|
||||
const aiMsg = { role: 'ai', content: '', loading: true };
|
||||
this.messageList.push(aiMsg);
|
||||
this.scrollToBottom();
|
||||
|
||||
// 统一走 Coze API(文本、多模态、图片均使用 Coze Bot)
|
||||
const userId = this.uid || (uni.getStorageSync('userInfo') || {}).id || 'default_user';
|
||||
try {
|
||||
@@ -698,7 +714,7 @@ export default {
|
||||
const chatId = chat.id;
|
||||
if (conversationId && chatId) {
|
||||
this.conversationId = conversationId;
|
||||
await this.pollChatStatus(conversationId, chatId);
|
||||
await this.pollChatStatus(conversationId, chatId, aiMsg);
|
||||
} else {
|
||||
throw new Error('发起对话失败:未返回会话或对话ID');
|
||||
}
|
||||
@@ -708,26 +724,23 @@ export default {
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error);
|
||||
this.isLoading = false;
|
||||
this.messageList.push({
|
||||
role: 'ai',
|
||||
content: '抱歉,处理您的请求时出现错误,请稍后再试。'
|
||||
});
|
||||
aiMsg.content = '抱歉,处理您的请求时出现错误,请稍后再试。';
|
||||
aiMsg.loading = false;
|
||||
this.messageList = [...this.messageList]; // 触发响应式更新
|
||||
this.scrollToBottom();
|
||||
}
|
||||
},
|
||||
|
||||
async pollChatStatus(conversationId, chatId) {
|
||||
const maxAttempts = 60; // 最多轮询60次,即60秒
|
||||
async pollChatStatus(conversationId, chatId, aiMsg) {
|
||||
const maxAttempts = 60; // 最多轮询60次(每次1.5秒),即90秒
|
||||
let attempts = 0;
|
||||
|
||||
const checkStatus = async () => {
|
||||
attempts++;
|
||||
if (attempts > maxAttempts) {
|
||||
this.isLoading = false;
|
||||
this.messageList.push({
|
||||
role: 'ai',
|
||||
content: '抱歉,AI 响应超时,请稍后再试。'
|
||||
});
|
||||
if (aiMsg) { aiMsg.content = '抱歉,AI 响应超时,请稍后再试。'; aiMsg.loading = false; this.messageList = [...this.messageList]; }
|
||||
else { this.messageList.push({ role: 'ai', content: '抱歉,AI 响应超时,请稍后再试。' }); }
|
||||
this.scrollToBottom();
|
||||
return;
|
||||
}
|
||||
@@ -743,21 +756,19 @@ export default {
|
||||
const chatObj = res.data.chat || res.data;
|
||||
const status = chatObj && chatObj.status;
|
||||
|
||||
if (status === 'completed') {
|
||||
// 对话完成,获取消息详情
|
||||
await this.getChatMessages(conversationId, chatId);
|
||||
} else if (status === 'failed' || status === 'canceled') {
|
||||
this.isLoading = false;
|
||||
this.messageList.push({
|
||||
role: 'ai',
|
||||
content: `抱歉,对话${status === 'canceled' ? '已取消' : '失败'}。`
|
||||
});
|
||||
this.scrollToBottom();
|
||||
} else {
|
||||
// 继续轮询 (created, in_progress, requires_action)
|
||||
// 注意:requires_action 可能需要额外处理,这里暂时视为继续等待或需人工干预
|
||||
setTimeout(checkStatus, 1000);
|
||||
}
|
||||
if (status === 'completed') {
|
||||
// 对话完成,获取消息详情
|
||||
await this.getChatMessages(conversationId, chatId, aiMsg);
|
||||
} else if (status === 'failed' || status === 'canceled') {
|
||||
this.isLoading = false;
|
||||
const failMsg = `抱歉,对话${status === 'canceled' ? '已取消' : '失败'}。`;
|
||||
if (aiMsg) { aiMsg.content = failMsg; aiMsg.loading = false; this.messageList = [...this.messageList]; }
|
||||
else { this.messageList.push({ role: 'ai', content: failMsg }); }
|
||||
this.scrollToBottom();
|
||||
} else {
|
||||
// 继续轮询 (created, in_progress) 每 1.5 秒
|
||||
setTimeout(checkStatus, 1500);
|
||||
}
|
||||
} else {
|
||||
// 查询失败,重试
|
||||
setTimeout(checkStatus, 1000);
|
||||
@@ -771,7 +782,7 @@ export default {
|
||||
checkStatus();
|
||||
},
|
||||
|
||||
async getChatMessages(conversationId, chatId) {
|
||||
async getChatMessages(conversationId, chatId, aiMsg) {
|
||||
try {
|
||||
const res = await api.cozeMessageList({
|
||||
conversationId,
|
||||
@@ -786,29 +797,23 @@ export default {
|
||||
const answerMsgs = rawMessages.filter(msg => msg.role === 'assistant' && msg.type === 'answer');
|
||||
|
||||
if (answerMsgs.length > 0) {
|
||||
// 可能有多条回复,依次显示
|
||||
for (const msg of answerMsgs) {
|
||||
this.messageList.push({
|
||||
role: 'ai',
|
||||
content: msg.content
|
||||
});
|
||||
// 用第一条 answer 消息填充占位气泡,多条追加新气泡
|
||||
if (aiMsg) {
|
||||
aiMsg.content = answerMsgs[0].content;
|
||||
aiMsg.loading = false;
|
||||
for (let i = 1; i < answerMsgs.length; i++) {
|
||||
this.messageList.push({ role: 'ai', content: answerMsgs[i].content });
|
||||
}
|
||||
this.messageList = [...this.messageList]; // 触发响应式更新
|
||||
} else {
|
||||
for (const msg of answerMsgs) { this.messageList.push({ role: 'ai', content: msg.content }); }
|
||||
}
|
||||
} else {
|
||||
// 尝试查找其他类型的回复
|
||||
const otherMsgs = rawMessages.filter(msg => msg.role === 'assistant');
|
||||
if (otherMsgs.length > 0) {
|
||||
for (const msg of otherMsgs) {
|
||||
this.messageList.push({
|
||||
role: 'ai',
|
||||
content: msg.content
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.messageList.push({
|
||||
role: 'ai',
|
||||
content: '未能获取到有效回复。'
|
||||
});
|
||||
}
|
||||
const fallback = otherMsgs.length > 0 ? otherMsgs[0].content : '未能获取到有效回复。';
|
||||
if (aiMsg) { aiMsg.content = fallback; aiMsg.loading = false; this.messageList = [...this.messageList]; }
|
||||
else { this.messageList.push({ role: 'ai', content: fallback }); }
|
||||
}
|
||||
this.scrollToBottom();
|
||||
} else {
|
||||
@@ -817,10 +822,9 @@ export default {
|
||||
} catch (e) {
|
||||
console.error('获取消息详情失败:', e);
|
||||
this.isLoading = false;
|
||||
this.messageList.push({
|
||||
role: 'ai',
|
||||
content: '获取回复内容失败。'
|
||||
});
|
||||
const errContent = '获取回复内容失败。';
|
||||
if (aiMsg) { aiMsg.content = errContent; aiMsg.loading = false; this.messageList = [...this.messageList]; }
|
||||
else { this.messageList.push({ role: 'ai', content: errContent }); }
|
||||
this.scrollToBottom();
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user