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:
Developer
2026-03-25 15:32:53 +08:00
parent 24f75d198c
commit 6ec9487597
5 changed files with 269 additions and 55 deletions

View File

@@ -0,0 +1,51 @@
-- ============================================================
-- 营养素知识内容初始化(备用方案 B手动 SQL 插入)
-- 日期: 2026-03-25
-- 说明: 若 Coze AI 生成接口POST /api/front/tool/knowledge/generate-nutrients
-- 效果不理想,可直接执行本脚本将内置内容写入 v2_knowledge 表。
-- 内容已按 CKD 营养管理临床指南整理。
-- ============================================================
-- 执行前检查(可选):
-- SELECT nutrient_name, status FROM v2_knowledge WHERE type = 'nutrients';
INSERT INTO v2_knowledge
(type, nutrient_name, title, content, summary, status, sort_order, view_count, like_count, created_at, published_at)
VALUES
-- 1. 蛋白质
('nutrients', '蛋白质', '蛋白质 — CKD患者膳食管理',
'{"name":"蛋白质","english":"Protein","icon":"🥩","description":"构成人体组织的重要营养素","status":"需控制","statusDesc":"根据CKD分期调整摄入量","importance":"蛋白质是人体细胞的基本组成成分,参与免疫功能和组织修复。但过多蛋白质会增加肾脏代谢负担,加速肾功能恶化。透析患者因透析过程中蛋白质丢失,反而需要适当增加摄入。","recommendation":"CKD 1-2期0.8-1.0g/kg体重/天\\nCKD 3-5期未透析0.6-0.8g/kg体重/天\\n透析患者1.0-1.2g/kg体重/天","foodSources":["鸡蛋","鱼类","瘦肉","牛奶","豆腐","鸡胸肉"],"riskWarning":"过多蛋白质摄入会产生大量含氮废物,加重肾脏负担;过少则导致营养不良、免疫力下降,影响透析充分性。","suggestions":["优先选择优质蛋白(鸡蛋、鱼、瘦肉)","控制植物蛋白摄入(豆类适量)","每餐均匀分配蛋白质摄入","透析患者需适当增加蛋白质","定期监测血白蛋白水平","咨询营养师制定个性化方案"],"disclaimer":"以上建议仅供参考,具体方案请咨询您的主治医生或营养师"}',
'了解蛋白质在慢性肾病饮食中的重要性', 'published', 1, 0, 0, NOW(), NOW()),
-- 2. 钾
('nutrients', '', '钾 — CKD患者膳食管理',
'{"name":"钾","english":"Potassium (K)","icon":"🍌","description":"维持神经肌肉功能的重要元素","status":"严格控制","statusDesc":"高钾血症可危及生命","importance":"钾离子参与维持心脏节律、神经传导和肌肉收缩。肾功能下降时钾排泄减少血钾升高可导致心律失常甚至心脏骤停是CKD患者最危险的并发症之一。","recommendation":"CKD 3-5期1500-2000mg/天\\n透析患者2000-2500mg/天(透析间期严格控制)\\n血钾目标3.5-5.0mmol/L","foodSources":["香蕉","橙子","土豆","番茄","菠菜","蘑菇"],"riskWarning":"高钾血症可导致心律失常、肌肉无力,严重时危及生命;低钾同样有害,需保持动态平衡。","suggestions":["避免高钾水果(香蕉、橙子、猕猴桃)","蔬菜先焯水再烹饪可减少30-50%的钾","避免饮用浓缩果汁和菜汤","少吃坚果、巧克力、干果","定期监测血钾水平","透析日可适当放宽限制"],"disclaimer":"以上建议仅供参考,具体方案请咨询您的主治医生或营养师"}',
'了解钾在慢性肾病饮食中的重要性', 'published', 2, 0, 0, NOW(), NOW()),
-- 3. 磷
('nutrients', '', '磷 — CKD患者膳食管理',
'{"name":"磷","english":"Phosphorus (P)","icon":"🥜","description":"骨骼健康的重要矿物质","status":"严格控制","statusDesc":"高磷可导致骨病和血管钙化","importance":"磷与钙共同维持骨骼健康。肾功能下降时磷排泄减少,血磷升高可导致继发性甲状旁腺功能亢进、肾性骨病和血管钙化,显著增加心血管疾病风险。","recommendation":"CKD 3-5期及透析患者800-1000mg/天\\n血磷目标1.13-1.78mmol/L\\n注意透析只能清除约900mg磷/次,饮食控制不可或缺","foodSources":["坚果","动物内脏","可乐","加工食品","奶酪","蛋黄"],"riskWarning":"高磷血症可导致皮肤瘙痒、骨痛、血管钙化增加心血管疾病风险。磷酸盐添加剂无机磷吸收率可达90%,危害更大。","suggestions":["避免含磷添加剂的加工食品(看成分表)","限制坚果、动物内脏摄入","少喝碳酸饮料(含磷酸)","按医嘱服用磷结合剂(随餐服用)","选择低磷蛋白质来源(蛋白、鸡胸肉)","烹饪时焯水可减少蔬菜中的磷"],"disclaimer":"以上建议仅供参考,具体方案请咨询您的主治医生或营养师"}',
'了解磷在慢性肾病饮食中的重要性', 'published', 3, 0, 0, NOW(), NOW()),
-- 4. 钠
('nutrients', '', '钠 — CKD患者膳食管理',
'{"name":"钠","english":"Sodium (Na)","icon":"🧂","description":"调节体液平衡的电解质","status":"适量控制","statusDesc":"减少摄入,保护肾功能","importance":"钠参与调节体液平衡和血压,是控制水肿和高血压的关键。过多钠摄入会导致水肿、血压升高,加速肾功能恶化,增加心血管负担。","recommendation":"CKD患者<2000mg钠/天相当于5g食盐\\n高血压/水肿患者:<1500mg钠/天\\n透析患者严格控制防止透析间期体重增加过多","foodSources":["食盐","酱油","腌制食品","加工肉类","咸菜","速食方便面"],"riskWarning":"摄入过多会导致水肿、高血压、心力衰竭等问题,并加重肾脏负担;过少则可能引起低钠血症和低血压。","suggestions":["每日食盐控制在5g以内约一啤酒瓶盖","避免腌制、熏制食品","少用酱油、味精等调味品","可用葱姜蒜、柠檬汁增加风味","查看食品标签,选择低钠产品","透析患者严格控制两次透析间体重增长"],"disclaimer":"以上建议仅供参考,具体方案请咨询您的主治医生或营养师"}',
'了解钠在慢性肾病饮食中的重要性', 'published', 4, 0, 0, NOW(), NOW()),
-- 5. 钙
('nutrients', '', '钙 — CKD患者膳食管理',
'{"name":"钙","english":"Calcium (Ca)","icon":"🥛","description":"骨骼和牙齿的主要成分","status":"注意补充","statusDesc":"CKD患者易发生钙代谢紊乱","importance":"钙是骨骼和牙齿的主要成分参与肌肉收缩和神经传导。CKD患者因维生素D活化障碍、高磷血症等因素常出现低钙血症和肾性骨病需要在医生指导下补充。","recommendation":"CKD 3-5期800-1000mg/天(含饮食+补充剂)\\n透析患者按血钙水平调整\\n血钙目标2.1-2.5mmol/L\\n注意补钙同时需控磷避免钙磷乘积过高","foodSources":["低脂牛奶","豆腐","绿叶蔬菜","芝麻","小虾皮","钙强化食品"],"riskWarning":"低钙会导致骨质疏松、肌肉抽搐;高钙(尤其合并高磷时)会加重血管钙化,增加心血管风险。","suggestions":["在医生指导下补充钙剂","选择碳酸钙(随餐服用效果最佳)","补钙的同时需服用活性维生素D","避免与磷结合剂同时服用","定期检测血钙、血磷和PTH","避免高草酸食物(影响钙吸收)"],"disclaimer":"以上建议仅供参考,具体方案请咨询您的主治医生或营养师"}',
'了解钙在慢性肾病饮食中的重要性', 'published', 5, 0, 0, NOW(), NOW()),
-- 6. 水分
('nutrients', '水分', '水分 — CKD患者膳食管理',
'{"name":"水分","english":"Water / Fluid","icon":"💧","description":"生命之源CKD患者需精确管理","status":"严格限制","statusDesc":"透析患者须控制每日摄水量","importance":"水分维持体内环境稳定参与所有代谢反应。CKD晚期患者肾脏排水能力下降水分积聚可导致水肿、高血压、肺水肿甚至危及生命。","recommendation":"CKD 1-3期通常无需限制保持正常饮水\\nCKD 4-5期未透析根据尿量调整一般为尿量+500ml\\n透析患者每日摄入量 = 尿量 + 透析间期允许体重增加量一般不超过1kg/天)","foodSources":["白开水","茶","汤品","粥","水果","蔬菜(含水量高)"],"riskWarning":"水分摄入过多可导致水肿、呼吸困难、血压升高;过少则可能引起脱水和低血压,影响残余肾功能。","suggestions":["记录每日饮水量和尿量","口渴时小口慢饮,避免大量饮水","减少含水量高的食物(西瓜、汤面)","用冰块含服缓解口渴","避免过咸食物(会增加渴感)","透析患者严格控制两次透析间体重增长不超过5%干体重"],"disclaimer":"以上建议仅供参考,具体方案请咨询您的主治医生或营养师"}',
'了解水分在慢性肾病饮食中的重要性', 'published', 6, 0, 0, NOW(), NOW())
ON DUPLICATE KEY UPDATE
content = VALUES(content),
status = 'published',
updated_at = NOW();
-- 验证
SELECT knowledge_id, nutrient_name, status, created_at FROM v2_knowledge WHERE type = 'nutrients' ORDER BY sort_order;

View File

@@ -349,6 +349,17 @@ public class ToolController {
return CommonResult.success(updated);
}
/**
* 批量通过 Coze AI 生成 6 种营养素科普内容并写入 v2_knowledge管理端一次性调用
* 已存在的营养素自动跳过。调用前请确保 Coze PAT Token 有效。
*/
@ApiOperation(value = "AI生成营养素知识内容并落库")
@PostMapping("/knowledge/generate-nutrients")
public CommonResult<String> generateNutrientContent() {
int count = toolKnowledgeService.generateNutrientContent();
return CommonResult.success("成功生成 " + count + " 条营养素内容");
}
// ==================== AI 营养填充T06/T07 ====================
/**

View File

@@ -13,8 +13,13 @@ import com.zbkj.service.service.tool.ToolKnowledgeService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import com.zbkj.common.request.coze.CozeChatRequest;
import com.zbkj.common.response.CozeBaseResponse;
import com.zbkj.service.service.tool.ToolCozeService;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -35,6 +40,9 @@ public class ToolKnowledgeServiceImpl implements ToolKnowledgeService {
@Resource
private DishImageService dishImageService;
@Resource
private ToolCozeService toolCozeService;
/**
* 获取营养知识列表
* @param pageParamRequest 分页参数
@@ -176,4 +184,136 @@ public class ToolKnowledgeServiceImpl implements ToolKnowledgeService {
stats.put("byStatus", byStatus);
return stats;
}
/**
* 批量通过 Coze AI 生成 6 种营养素科普内容并写入 v2_knowledge 表
* 已存在的营养素自动跳过,避免重复插入。
*/
@Override
public int generateNutrientContent() {
String[] nutrients = {"蛋白质", "", "", "", "", "水分"};
int success = 0;
for (int i = 0; i < nutrients.length; i++) {
String nutrient = nutrients[i];
try {
// 1. 检查是否已存在已发布的记录
LambdaQueryWrapper<V2Knowledge> existQuery = new LambdaQueryWrapper<>();
existQuery.eq(V2Knowledge::getType, "nutrients")
.eq(V2Knowledge::getNutrientName, nutrient)
.eq(V2Knowledge::getStatus, "published");
Long count = v2KnowledgeDao.selectCount(existQuery);
if (count > 0) {
log.info("[generateNutrient] 营养素 {} 已存在,跳过", nutrient);
continue;
}
// 2. 调用 Coze AI 生成内容
String prompt = buildNutrientPrompt(nutrient);
CozeChatRequest req = new CozeChatRequest();
req.setBotId("7591133240535449654");
req.setUserId("system_admin");
req.setStream(false);
CozeChatRequest.ChatMessage msg = new CozeChatRequest.ChatMessage();
msg.setRole("user");
msg.setContent(prompt);
msg.setContentType("text");
req.setAdditionalMessages(java.util.Collections.singletonList(msg));
CozeBaseResponse<Object> resp = toolCozeService.chat(req);
String content = extractCozeContent(resp);
if (StrUtil.isBlank(content) || content.startsWith("AI生成失败")) {
log.warn("[generateNutrient] {} 内容生成为空或失败,跳过", nutrient);
continue;
}
// 3. 写入 v2_knowledge 表
V2Knowledge knowledge = new V2Knowledge();
knowledge.setType("nutrients");
knowledge.setNutrientName(nutrient);
knowledge.setTitle(nutrient + " — CKD患者膳食管理");
knowledge.setContent(content);
knowledge.setSummary("了解" + nutrient + "在慢性肾病饮食中的重要性");
knowledge.setStatus("published");
knowledge.setSortOrder(i + 1);
knowledge.setViewCount(0);
knowledge.setLikeCount(0);
knowledge.setCreatedAt(new Date());
knowledge.setPublishedAt(new Date());
v2KnowledgeDao.insert(knowledge);
success++;
log.info("[generateNutrient] 营养素 {} 生成成功knowledge_id={}", nutrient, knowledge.getKnowledgeId());
} catch (Exception e) {
log.error("[generateNutrient] 营养素 {} 生成失败", nutrient, e);
}
}
// 4. 自动补充封面图(最多 6 张)
if (success > 0) {
try {
fillMissingCoverImages(6);
} catch (Exception e) {
log.warn("[generateNutrient] 封面图生成失败(不影响主流程)", e);
}
}
return success;
}
/**
* 构建 Coze AI 营养素科普 Prompt要求返回严格 JSON
*/
private String buildNutrientPrompt(String nutrient) {
return "请为慢性肾脏病CKD患者生成关于「" + nutrient + "」的科普内容。\n"
+ "要求严格按以下 JSON 格式返回,不要包含任何 markdown 标记或代码块:\n"
+ "{\n"
+ " \"name\": \"" + nutrient + "\",\n"
+ " \"english\": \"对应英文名\",\n"
+ " \"icon\": \"一个合适的 emoji\",\n"
+ " \"description\": \"一句话描述15字以内\",\n"
+ " \"status\": \"控制建议(如:需控制/严格控制/适量补充)\",\n"
+ " \"statusDesc\": \"状态补充说明15字以内\",\n"
+ " \"importance\": \"为什么重要2-3句话针对CKD患者\",\n"
+ " \"recommendation\": \"推荐摄入量分CKD 1-2期、3-5期未透析、透析患者三种情况说明\",\n"
+ " \"foodSources\": [\"食物1\", \"食物2\", \"食物3\", \"食物4\", \"食物5\", \"食物6\"],\n"
+ " \"riskWarning\": \"风险提示2-3句话\",\n"
+ " \"suggestions\": [\"建议1\", \"建议2\", \"建议3\", \"建议4\", \"建议5\", \"建议6\"],\n"
+ " \"disclaimer\": \"以上建议仅供参考,具体方案请咨询您的主治医生或营养师\"\n"
+ "}";
}
/**
* 从 Coze 响应中提取文本内容(取最后一条 assistant answer 消息)
*/
private String extractCozeContent(CozeBaseResponse<Object> resp) {
try {
if (resp == null || resp.getData() == null) return "AI生成失败响应为空";
Object data = resp.getData();
// CozeBaseResponse.data 通常为 Map 结构content 字段在 messages 列表中
if (data instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> dataMap = (Map<String, Object>) data;
Object messages = dataMap.get("messages");
if (messages instanceof List) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> msgList = (List<Map<String, Object>>) messages;
for (int i = msgList.size() - 1; i >= 0; i--) {
Map<String, Object> m = msgList.get(i);
if ("assistant".equals(m.get("role")) && "answer".equals(m.get("type"))) {
return String.valueOf(m.get("content"));
}
}
}
// fallback: 直接取 content
Object content = dataMap.get("content");
if (content != null) return String.valueOf(content);
}
return String.valueOf(data);
} catch (Exception e) {
log.error("[extractCozeContent] 解析失败", e);
return "AI生成失败" + e.getMessage();
}
}
}

View File

@@ -50,5 +50,13 @@ public interface ToolKnowledgeService {
* @return 如 total, byType (guide/article/nutrients/recipe), byStatus (published/draft/deleted)
*/
Map<String, Object> getStats();
/**
* 批量通过 Coze AI 生成 6 种营养素科普内容并写入 v2_knowledge 表(一次性管理接口)
* 已存在的营养素自动跳过,避免重复插入。
*
* @return 本次成功插入条数
*/
int generateNutrientContent();
}

View File

@@ -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();
}
},