feat: 统一智能体路由 + 删除 onboarding
- 新增 Unified 智能体(合并 health/diet/medication/exercise/default 全部工具) - AI 自动判断意图并调用对应工具,前端胶囊自动切换 - 删除 onboarding 智能体及所有建档引导 UI - 前端始终发 unified 类型,通过 tool_result 事件同步胶囊状态
This commit is contained in:
189
AI提示词文档.md
189
AI提示词文档.md
@@ -1,189 +0,0 @@
|
|||||||
# AI System Prompt 总览
|
|
||||||
|
|
||||||
> 文件位置:`backend/src/Health.Infrastructure/AI/prompt_manager.cs`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 默认对话 (DefaultPrompt)
|
|
||||||
|
|
||||||
```
|
|
||||||
你是一位专业的AI健康管家,专注于为心脏术后康复患者提供贴心的健康管理服务。
|
|
||||||
|
|
||||||
职责:
|
|
||||||
1. 理解用户的健康需求,解析健康数据
|
|
||||||
2. 主动查看患者近期数据,发现异常时提醒
|
|
||||||
3. 回答健康知识问题
|
|
||||||
4. 每次回复末尾,如有需要提醒的事项,简短温馨地提醒一句
|
|
||||||
|
|
||||||
规则:
|
|
||||||
- 不要提供超出你能力范围的医疗建议
|
|
||||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
|
||||||
- 饮食/运动建议要结合患者档案中的疾病和限制
|
|
||||||
- 回复语气温暖、专业、像朋友一样关怀患者
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. AI 问诊 (ConsultationPrompt)
|
|
||||||
|
|
||||||
```
|
|
||||||
你是一个心血管内科医生助手,负责对心脏术后患者进行多轮问诊。
|
|
||||||
|
|
||||||
规则:
|
|
||||||
1. 每次只问一个问题,不要一次问多个
|
|
||||||
2. 给出 2-3 个快捷选项让患者点击
|
|
||||||
3. 问诊步骤:先问感受 → 持续时间 → 伴随症状 → 近期用药 → 给出初步分析
|
|
||||||
4. 遇到以下情况建议立即就医:剧烈胸痛、呼吸困难、心悸
|
|
||||||
5. 遇到以下情况建议转医生:血压持续>160/100、心率>120或<50
|
|
||||||
6. 所有分析末尾标注"以上为AI分析,具体请咨询医生"
|
|
||||||
7. 问诊结束给出结构化小结
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 记数据 (HealthDataPrompt)
|
|
||||||
|
|
||||||
```
|
|
||||||
你是一个健康数据录入助手。
|
|
||||||
|
|
||||||
规则:
|
|
||||||
1. 解析用户消息中的指标和数值(血压/心率/血糖/血氧/体重)
|
|
||||||
2. 指标明确+数值明确→直接录入
|
|
||||||
3. 数值明确但指标模糊(如只说"120")→追问是"收缩压还是血糖?"
|
|
||||||
4. 时间模糊→取当前时间,在确认卡片中告知用户
|
|
||||||
5. 数值超出正常范围→附带异常提醒
|
|
||||||
6. 录入后生成确认卡片格式的回复
|
|
||||||
|
|
||||||
正常值参考范围:
|
|
||||||
- 收缩压 90-139 mmHg,舒张压 60-89 mmHg
|
|
||||||
- 心率 60-100 次/分
|
|
||||||
- 空腹血糖 3.9-6.1 mmol/L
|
|
||||||
- 血氧 95-100%
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 拍饮食 (DietPrompt)
|
|
||||||
|
|
||||||
```
|
|
||||||
你是一个营养分析专家,专门为心脏术后患者提供饮食指导。
|
|
||||||
|
|
||||||
规则:
|
|
||||||
1. 收到VLM食物识别结果后,必须先调用 check_archive 查询患者档案
|
|
||||||
2. 逐项判断"能不能吃":
|
|
||||||
- 过敏食物 → 严格禁止,红色警告
|
|
||||||
- 饮食限制(如低盐/低脂/低糖)→ 黄色提醒
|
|
||||||
- 疾病禁忌(如冠心病忌高脂、糖尿病忌高糖)→ 红色警告
|
|
||||||
3. 给出 1-5 星健康评分
|
|
||||||
4. 结合患者档案中的诊断(如冠心病PCI术后)、慢病史(高血压/高血脂等)给出个性化建议
|
|
||||||
5. 单项警告 + 整体饮食建议 + 替代推荐
|
|
||||||
6. 追问餐次归属:早餐/午餐/晚餐/加餐
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 药管家 (MedicationPrompt)
|
|
||||||
|
|
||||||
```
|
|
||||||
你是一个用药管理专家。
|
|
||||||
|
|
||||||
规则:
|
|
||||||
1. 用户询问当前用药时,必须先调用 manage_medication(action="query") 查询已有用药记录
|
|
||||||
2. 解析用户口中的药品信息(药名/剂量/频次/时间)
|
|
||||||
3. "早饭后"等模糊时间→追问具体几点
|
|
||||||
4. 解析完成后展示确认卡片
|
|
||||||
5. 处方拍照→提取药品信息→生成用药计划→让用户确认
|
|
||||||
6. 回答用药相关疑问
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 看报告 (ReportPrompt)
|
|
||||||
|
|
||||||
```
|
|
||||||
你是一个医学报告解读专家。
|
|
||||||
|
|
||||||
规则:
|
|
||||||
1. 收到报告图片后,提取所有指标及其数值
|
|
||||||
2. 标注异常指标(偏高/偏低/正常)
|
|
||||||
3. 给出初步分析
|
|
||||||
4. 所有内容标注"AI预解读,待医生确认"
|
|
||||||
5. 图像类报告(彩超/CT)注明"需医生人工审阅"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 运动计划 (ExercisePrompt)
|
|
||||||
|
|
||||||
```
|
|
||||||
你是一个运动康复教练,专门为心脏术后患者制定运动计划。
|
|
||||||
|
|
||||||
规则:
|
|
||||||
1. 帮助用户设定每周运动计划(类型/时长/天数)
|
|
||||||
2. 以周为单位,每天指定运动类型和时长
|
|
||||||
3. 推荐适合心脏康复的运动:散步、慢跑、太极、游泳等
|
|
||||||
4. 运动强度要循序渐进
|
|
||||||
5. 避免剧烈运动
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 建档引导 (OnboardingPrompt) — 新增
|
|
||||||
|
|
||||||
```
|
|
||||||
你是一个健康管家助手,正在帮助新用户建立健康档案。
|
|
||||||
|
|
||||||
必须严格遵守以下流程,一次只问一个问题:
|
|
||||||
|
|
||||||
步骤1:询问主要诊断(冠心病/高血压/糖尿病/其他)
|
|
||||||
步骤2:询问手术史(PCI支架植入/心脏搭桥/其他/没有)→ 追问手术日期
|
|
||||||
步骤3:询问过敏史(青霉素/头孢/海鲜/鸡蛋/无过敏)→ 可多选
|
|
||||||
步骤4:询问慢性病史(高血脂/糖尿病/痛风/无)
|
|
||||||
步骤5:询问饮食限制(低盐/低脂/低糖/无限制)
|
|
||||||
|
|
||||||
规则:
|
|
||||||
- 每次只问一个问题,给出2-4个快捷选项让用户点击
|
|
||||||
- 用户回答每个问题后,立即调用 manage_archive 工具保存
|
|
||||||
- 用户说"跳过"/"以后再说"/"再说"/"不用了"→ 礼貌结束建档
|
|
||||||
- 全部完成后,生成结构化总结卡片
|
|
||||||
- 回复语气温暖、像朋友一样关怀患者
|
|
||||||
- 每步只问一个问题不要多问
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. VLM 食物识别 Prompt
|
|
||||||
|
|
||||||
> 文件位置:`backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` → `POST /api/ai/analyze-food-image`
|
|
||||||
|
|
||||||
```
|
|
||||||
仔细观察图片中的所有食物和饮品,逐一识别。
|
|
||||||
对每一项,用中文返回 JSON 数组,每项包含:
|
|
||||||
name: 食物或饮品的中文名称
|
|
||||||
portion: 份量估算(如"约1碗"、"约400g"、"约200ml")
|
|
||||||
calories: 估算热量(千卡整数)
|
|
||||||
要点:
|
|
||||||
必须根据图片实际内容识别,如实描述
|
|
||||||
饮品要标注清楚(如水、茶、咖啡、果汁、汤等)
|
|
||||||
热量参考常见食物数据库合理估算
|
|
||||||
只返回 JSON 数组,不要其他文字
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. VLM 报告分析 Prompt
|
|
||||||
|
|
||||||
> 文件位置:`backend/src/Health.Infrastructure/AI/AgentHandlers/report_agent_handler.cs`
|
|
||||||
|
|
||||||
```
|
|
||||||
你是一个医学报告解读专家。请分析以下检查报告图片,以JSON格式返回:
|
|
||||||
{
|
|
||||||
"reportType": "报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)",
|
|
||||||
"indicators": [
|
|
||||||
{"name":"指标名","value":"数值","unit":"单位","range":"参考范围","status":"normal/high/low"}
|
|
||||||
],
|
|
||||||
"summary": "初步分析摘要",
|
|
||||||
"needsDoctorReview": true/false
|
|
||||||
}
|
|
||||||
只返回JSON,不要其他内容。
|
|
||||||
```
|
|
||||||
@@ -139,7 +139,7 @@ public enum AgentType
|
|||||||
Medication, // 药管家
|
Medication, // 药管家
|
||||||
Report, // 看报告
|
Report, // 看报告
|
||||||
Exercise, // 运动计划
|
Exercise, // 运动计划
|
||||||
Onboarding // 建档引导
|
Unified // 统一入口(自动路由)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public sealed class PromptManager
|
|||||||
AgentType.Medication => MedicationPrompt,
|
AgentType.Medication => MedicationPrompt,
|
||||||
AgentType.Report => ReportPrompt,
|
AgentType.Report => ReportPrompt,
|
||||||
AgentType.Exercise => ExercisePrompt,
|
AgentType.Exercise => ExercisePrompt,
|
||||||
AgentType.Onboarding => OnboardingPrompt,
|
AgentType.Unified => UnifiedPrompt,
|
||||||
_ => DefaultPrompt
|
_ => DefaultPrompt
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,23 +119,24 @@ public sealed class PromptManager
|
|||||||
5. 避免剧烈运动
|
5. 避免剧烈运动
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private const string OnboardingPrompt = """
|
private const string UnifiedPrompt = """
|
||||||
你是一个健康管家助手,正在帮助新用户建立健康档案。
|
你是一个全能的AI健康管家,为心脏术后康复患者提供全方位服务。
|
||||||
|
|
||||||
必须严格遵守以下流程,一次只问一个问题:
|
核心规则:首先理解用户意图属于哪个领域,然后调用对应的工具。
|
||||||
|
|
||||||
步骤1:询问主要诊断(冠心病/高血压/糖尿病/其他)
|
领域判断与可用工具:
|
||||||
步骤2:询问手术史(PCI支架植入/心脏搭桥/其他/没有)→ 如果用户说有手术,追问手术日期
|
1. 健康数据(血压/心率/血糖/血氧/体重)→ 调用 record_health_data
|
||||||
步骤3:询问过敏史(青霉素/头孢/海鲜/鸡蛋/无过敏)→ 可多选
|
2. 饮食分析 → 调用 estimate_food_text
|
||||||
步骤4:询问慢性病史(高血脂/糖尿病/痛风/无)
|
3. 用药管理 → 调用 manage_medication
|
||||||
步骤5:询问饮食限制(低盐/低脂/低糖/无限制)
|
4. 运动计划 → 调用 manage_exercise
|
||||||
|
5. 健康档案查询/修改 → 调用 check_archive / manage_archive
|
||||||
|
6. 历史数据查询 → 调用 query_health_records
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
- 每次只问一个问题,给出2-4个快捷选项让用户点击
|
- 用户可能一句话涉及多个领域,全部处理
|
||||||
- 用户回答每个问题后,立即调用 manage_archive 工具保存
|
- 数值明确+指标明确→直接录入,不要追问
|
||||||
- 用户说"跳过"/"以后再说"/"再说"/"不用了"→ 礼貌结束建档:"好的,随时可以跟我说'完善档案'来继续"
|
- 录入完成后生成确认卡片
|
||||||
- 全部完成后,生成结构化总结卡片
|
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||||
- 回复语气温暖、像朋友一样关怀患者
|
- 回复语气温暖、专业
|
||||||
- 每步只问一个问题不要多问
|
|
||||||
""";
|
""";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -337,7 +337,13 @@ public static class AiChatEndpoints
|
|||||||
AgentType.Consultation => ConsultationAgentHandler.Tools,
|
AgentType.Consultation => ConsultationAgentHandler.Tools,
|
||||||
AgentType.Report => ReportAgentHandler.Tools,
|
AgentType.Report => ReportAgentHandler.Tools,
|
||||||
AgentType.Exercise => ExerciseAgentHandler.Tools,
|
AgentType.Exercise => ExerciseAgentHandler.Tools,
|
||||||
AgentType.Onboarding => [CommonAgentHandler.ManageArchiveTool, CommonAgentHandler.CheckArchiveTool],
|
AgentType.Unified => [
|
||||||
|
..HealthDataAgentHandler.Tools,
|
||||||
|
..DietAgentHandler.Tools,
|
||||||
|
..MedicationAgentHandler.Tools,
|
||||||
|
..ExerciseAgentHandler.Tools,
|
||||||
|
..CommonAgentHandler.Tools,
|
||||||
|
],
|
||||||
_ => CommonAgentHandler.Tools,
|
_ => CommonAgentHandler.Tools,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
825
docs/omron_bp_integration.md
Normal file
825
docs/omron_bp_integration.md
Normal file
@@ -0,0 +1,825 @@
|
|||||||
|
# 欧姆龙蓝牙血压计接入方案(iOS + Android 双平台)
|
||||||
|
|
||||||
|
|
||||||
|
## 一、选购建议
|
||||||
|
|
||||||
|
### 选用型号:J735
|
||||||
|
|
||||||
|
| 项目 | 详情 |
|
||||||
|
|------|------|
|
||||||
|
| **型号** | 欧姆龙 J735 |
|
||||||
|
| **价格** | ¥265-389(百亿补贴约 ¥265,日常 ¥350) |
|
||||||
|
| **蓝牙** | ✅ BLE 4.0,标准协议 `0x1810` |
|
||||||
|
| **精度** | ±3mmHg,AAMI 认证 |
|
||||||
|
| **特点** | 双人模式(各60组)、360°袖带、心律检测、体动检测、高血压红屏预警 |
|
||||||
|
| **产地** | 日本原装进口 |
|
||||||
|
| **购买** | 京东/天猫搜索「欧姆龙 J735」 |
|
||||||
|
|
||||||
|
### 为什么不需要官方 SDK
|
||||||
|
|
||||||
|
J735 的蓝牙通信走的是 **Bluetooth SIG 国际标准协议**(Blood Pressure Service `0x1810`),不是欧姆龙私有协议。任何支持 BLE GATT 的蓝牙库都能直连,跟品牌无关。
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ flutter_blue_plus (开源) │
|
||||||
|
│ ↓ BLE GATT 标准协议 │
|
||||||
|
│ ↓ Service: 0x1810 (Blood Pressure) │
|
||||||
|
│ ↓ Char: 0x2A35 (Measurement) │
|
||||||
|
│ ↓ │
|
||||||
|
│ 欧姆龙 J735 ←── 标准蓝牙芯片 ──→ 任何 BLE 血压计 │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
欧姆龙官方 SDK 做的是封装这些标准协议 + 提供云端API,不是必需的。我们的方案直接走标准 BLE,零依赖、零费用、全平台兼容。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、通信协议(实测可靠)
|
||||||
|
|
||||||
|
欧姆龙蓝牙血压计严格遵循 **Bluetooth SIG Blood Pressure Service 1.1.1**,这是国际标准协议,不是私有协议。
|
||||||
|
|
||||||
|
### 2.1 GATT 服务结构
|
||||||
|
|
||||||
|
```
|
||||||
|
Blood Pressure Service (0x1810)
|
||||||
|
├── Blood Pressure Measurement (0x2A35) [Indicate] ← 测量数据在这里
|
||||||
|
├── Blood Pressure Feature (0x2A49) [Read]
|
||||||
|
├── Intermediate Cuff Pressure (0x2A36) [Notify] ← 充气过程中的实时压力(可选)
|
||||||
|
└── Date Time (0x2A08) [Write] ← 同步时间
|
||||||
|
```
|
||||||
|
|
||||||
|
128-bit UUID 格式(Android/iOS 都用这个):
|
||||||
|
```
|
||||||
|
Blood Pressure Service: 00001810-0000-1000-8000-00805F9B34FB
|
||||||
|
Blood Pressure Measurement: 00002A35-0000-1000-8000-00805F9B34FB
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 测量数据格式(`0x2A35`)
|
||||||
|
|
||||||
|
BLE 推送的原始字节数组,设备每次测量完成后自动 Indicate(需客户端确认):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────┬──────────┬───────────┬────────┬──────────┬─────────┬─────────┬──────────────┐
|
||||||
|
│ Byte 0 │ Byte 1-2 │ Byte 3-4 │Byte 5-6│Byte 7-13 │Byte14-15│ Byte 16 │ Byte 17-18 │
|
||||||
|
│ Flags │ 收缩压 │ 舒张压 │ 平均压 │ 时间戳 │ 脉搏 │ 用户ID │ 测量状态 │
|
||||||
|
│ │ SFLOAT │ SFLOAT │ SFLOAT │ (可选) │ (可选) │ (可选) │ (可选) │
|
||||||
|
└────────┴──────────┴───────────┴────────┴──────────┴─────────┴─────────┴──────────────┘
|
||||||
|
|
||||||
|
Flags byte 各位含义:
|
||||||
|
bit 0 = 1 → kPa,0 → mmHg
|
||||||
|
bit 1 = 1 → 含时间戳
|
||||||
|
bit 2 = 1 → 含脉搏
|
||||||
|
bit 3 = 1 → 含用户 ID
|
||||||
|
bit 4 = 1 → 含测量状态(体动/袖带/心律等)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 SFLOAT 解码(IEEE-11073 16-bit)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// 通用解码函数(iOS/Android 一致)
|
||||||
|
double decodeSFloat(int raw) {
|
||||||
|
int mantissa = raw & 0x0FFF; // 低 12 位
|
||||||
|
if (mantissa & 0x0800 != 0) { // 符号扩展
|
||||||
|
mantissa = mantissa | 0xFFFFF000;
|
||||||
|
}
|
||||||
|
int exponent = (raw >> 12) & 0x0F; // 高 4 位
|
||||||
|
if (exponent & 0x08 != 0) {
|
||||||
|
exponent = exponent | 0xFFFFFFF0;
|
||||||
|
}
|
||||||
|
return mantissa * pow(10, exponent);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
实测例子(HEM-7600T 实测数据):
|
||||||
|
```
|
||||||
|
原始: [0xDE, 0x88, 0xF4, 0x20, 0xF3, 0xFF, 0x07, 0xE4, 0x07, 0x01, 0x13, 0x16, 0x37, 0x00, 0xBC, 0xF2, 0x01, 0x44, 0x01]
|
||||||
|
解析:
|
||||||
|
Flags = 0xDE → mmHg, 含时间戳, 含脉搏, 含用户ID, 含测量状态
|
||||||
|
收缩压 = decodeSFloat(0xF488) = 116.0 mmHg
|
||||||
|
舒张压 = decodeSFloat(0xF320) = 80.0 mmHg
|
||||||
|
脉搏 = decodeSFloat(0xF2BC) = 70 bpm
|
||||||
|
时间 = 2020-01-19 22:55:00
|
||||||
|
状态 = 心律不齐检测到
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 官方协议出处
|
||||||
|
|
||||||
|
- Bluetooth SIG Blood Pressure Service 1.1.1: [bluetooth.com/specifications/specs/blood-pressure-service-1-1-1](https://www.bluetooth.com/specifications/specs/blood-pressure-service-1-1-1/)
|
||||||
|
- 欧姆龙 B2B SDK 开发者门户: [public.omronhealthcare.com.cn/b2bsdk](https://public.omronhealthcare.com.cn/b2bsdk/)
|
||||||
|
- 欧姆龙全球开发者 API: [omronhealthcare.com/api](https://omronhealthcare.com/api)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、Flutter 实现(iOS + Android 双平台)
|
||||||
|
|
||||||
|
### 3.1 依赖
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# pubspec.yaml
|
||||||
|
dependencies:
|
||||||
|
flutter_blue_plus: ^1.34.0 # BLE 核心库(iOS + Android)
|
||||||
|
permission_handler: ^11.3.0 # 权限管理
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── services/
|
||||||
|
│ └── omron_ble_service.dart # 欧姆龙 BLE 连接 + 协议解析
|
||||||
|
├── providers/
|
||||||
|
│ └── omron_device_provider.dart
|
||||||
|
├── pages/
|
||||||
|
│ └── device/
|
||||||
|
│ ├── device_scan_page.dart
|
||||||
|
│ └── device_bind_page.dart
|
||||||
|
└── models/
|
||||||
|
└── bp_reading.dart # 血压数据模型
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 核心代码
|
||||||
|
|
||||||
|
#### 3.3.1 数据模型
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class BpReading {
|
||||||
|
final int systolic; // 收缩压
|
||||||
|
final int diastolic; // 舒张压
|
||||||
|
final int? pulse; // 脉搏(可选)
|
||||||
|
final DateTime timestamp;
|
||||||
|
final String? userId; // 设备用户ID(J760 双人模式)
|
||||||
|
final bool isKpa; // 是否 kPa 单位
|
||||||
|
final bool hasBodyMovement; // 体动
|
||||||
|
final bool hasIrregularPulse; // 心律不齐
|
||||||
|
|
||||||
|
// ... 构造函数
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.2 BLE 服务
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||||
|
|
||||||
|
class OmronBleService {
|
||||||
|
static const bpServiceUuid = '00001810-0000-1000-8000-00805F9B34FB';
|
||||||
|
static const bpMeasurementUuid = '00002A35-0000-1000-8000-00805F9B34FB';
|
||||||
|
static const bpFeatureUuid = '00002A49-0000-1000-8000-00805F9B34FB';
|
||||||
|
static const dateTimeUuid = '00002A08-0000-1000-8000-00805F9B34FB';
|
||||||
|
|
||||||
|
BluetoothDevice? _device;
|
||||||
|
StreamSubscription<List<int>>? _subscription;
|
||||||
|
final _readingsController = StreamController<BpReading>.broadcast();
|
||||||
|
Stream<BpReading> get readings => _readingsController.stream;
|
||||||
|
|
||||||
|
// ── 扫描 ──
|
||||||
|
Stream<ScanResult> scan({Duration timeout = const Duration(seconds: 15)}) {
|
||||||
|
return FlutterBluePlus.scan(
|
||||||
|
timeout: timeout,
|
||||||
|
withServices: [Guid(bpServiceUuid)], // 只扫描有血压服务的设备
|
||||||
|
).where((r) {
|
||||||
|
final name = r.device.platformName.toUpperCase();
|
||||||
|
return name.contains('OMRON') || name.contains('HEM') || name.contains('BLEsmart');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 连接 ──
|
||||||
|
Future<void> connect(BluetoothDevice device) async {
|
||||||
|
_device = device;
|
||||||
|
await device.connect(autoConnect: false);
|
||||||
|
await device.discoverServices();
|
||||||
|
|
||||||
|
final services = await device.discoverServices();
|
||||||
|
final bpService = services.firstWhere(
|
||||||
|
(s) => s.uuid.str128.toUpperCase() == bpServiceUuid.toUpperCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final measurementChar = bpService.characteristics.firstWhere(
|
||||||
|
(c) => c.uuid.str128.toUpperCase() == bpMeasurementUuid.toUpperCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 欧姆龙用 Indicate(不是 Notify),flutter_blue_plus 自动处理
|
||||||
|
await measurementChar.setNotifyValue(true);
|
||||||
|
|
||||||
|
_subscription = measurementChar.lastValueStream.listen(_parseReading);
|
||||||
|
|
||||||
|
// 连接后同步当前时间到设备(让测量时间戳准确)
|
||||||
|
await _syncTime(bpService);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 解析测量数据 ──
|
||||||
|
void _parseReading(List<int> raw) {
|
||||||
|
if (raw.length < 7) return;
|
||||||
|
|
||||||
|
final flags = raw[0];
|
||||||
|
final isKpa = (flags & 0x01) != 0;
|
||||||
|
final hasTimestamp = (flags & 0x02) != 0;
|
||||||
|
final hasPulse = (flags & 0x04) != 0;
|
||||||
|
final hasUserId = (flags & 0x08) != 0;
|
||||||
|
final hasStatus = (flags & 0x10) != 0;
|
||||||
|
|
||||||
|
int systolic = _decodeSFloat(raw[1] | (raw[2] << 8)).round();
|
||||||
|
int diastolic = _decodeSFloat(raw[3] | (raw[4] << 8)).round();
|
||||||
|
// byte 5-6: MAP (舍去)
|
||||||
|
|
||||||
|
if (isKpa) {
|
||||||
|
systolic = (systolic * 7.50062).round();
|
||||||
|
diastolic = (diastolic * 7.50062).round();
|
||||||
|
}
|
||||||
|
|
||||||
|
int offset = 7;
|
||||||
|
DateTime timestamp = DateTime.now();
|
||||||
|
|
||||||
|
if (hasTimestamp && raw.length >= offset + 7) {
|
||||||
|
int year = raw[offset] | (raw[offset + 1] << 8);
|
||||||
|
int month = raw[offset + 2];
|
||||||
|
int day = raw[offset + 3];
|
||||||
|
int hour = raw[offset + 4];
|
||||||
|
int minute = raw[offset + 5];
|
||||||
|
int second = raw[offset + 6];
|
||||||
|
timestamp = DateTime(year, month, day, hour, minute, second);
|
||||||
|
offset += 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? pulse;
|
||||||
|
if (hasPulse && raw.length >= offset + 2) {
|
||||||
|
pulse = _decodeSFloat(raw[offset] | (raw[offset + 1] << 8)).round();
|
||||||
|
offset += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? userId;
|
||||||
|
if (hasUserId && raw.length > offset) {
|
||||||
|
userId = raw[offset].toString();
|
||||||
|
offset += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool bodyMovement = false, irregularPulse = false;
|
||||||
|
if (hasStatus && raw.length >= offset + 2) {
|
||||||
|
int status = raw[offset] | (raw[offset + 1] << 8);
|
||||||
|
bodyMovement = (status & 0x0001) != 0;
|
||||||
|
irregularPulse = (status & 0x0800) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
_readingsController.add(BpReading(
|
||||||
|
systolic: systolic, diastolic: diastolic, pulse: pulse,
|
||||||
|
timestamp: timestamp, userId: userId, isKpa: isKpa,
|
||||||
|
hasBodyMovement: bodyMovement, hasIrregularPulse: irregularPulse,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SFLOAT 解码 ──
|
||||||
|
static double _decodeSFloat(int raw) {
|
||||||
|
int mantissa = raw & 0x0FFF;
|
||||||
|
if (mantissa & 0x0800 != 0) mantissa |= 0xFFFFF000;
|
||||||
|
int exponent = (raw >> 12) & 0x0F;
|
||||||
|
if (exponent & 0x08 != 0) exponent |= 0xFFFFFFF0;
|
||||||
|
return mantissa * pow(10, exponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
double pow(double x, int exp) {
|
||||||
|
if (exp == 0) return 1;
|
||||||
|
double r = 1;
|
||||||
|
for (int i = 0; i < exp.abs(); i++) r *= x;
|
||||||
|
return exp > 0 ? r : 1 / r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 同步时间 ──
|
||||||
|
Future<void> _syncTime(BluetoothService service) async {
|
||||||
|
try {
|
||||||
|
final dtChar = service.characteristics.firstWhere(
|
||||||
|
(c) => c.uuid.str128.toUpperCase() == dateTimeUuid.toUpperCase(),
|
||||||
|
);
|
||||||
|
final now = DateTime.now();
|
||||||
|
await dtChar.write([
|
||||||
|
now.year & 0xFF, now.year >> 8,
|
||||||
|
now.month, now.day,
|
||||||
|
now.hour, now.minute, now.second,
|
||||||
|
]);
|
||||||
|
} catch (_) {
|
||||||
|
// 部分型号不支持写入时间,忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 断开 ──
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
_subscription?.cancel();
|
||||||
|
await _device?.disconnect();
|
||||||
|
_device = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
disconnect();
|
||||||
|
_readingsController.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.3 数据同步到后端
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// 在 Provider 中监听
|
||||||
|
final omronReadingsProvider = StreamProvider<BpReading>((ref) {
|
||||||
|
return ref.read(omronBleServiceProvider).readings;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听并自动同步
|
||||||
|
ref.listen(omronReadingsProvider, (_, next) {
|
||||||
|
next.whenData((reading) async {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
await api.post('/api/health-records', data: {
|
||||||
|
'type': 'BloodPressure',
|
||||||
|
'systolic': reading.systolic,
|
||||||
|
'diastolic': reading.diastolic,
|
||||||
|
'source': 'Device',
|
||||||
|
'unit': 'mmHg',
|
||||||
|
'recordedAt': reading.timestamp.toIso8601String(),
|
||||||
|
});
|
||||||
|
ref.invalidate(latestHealthProvider); // 刷新健康概览
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 平台权限配置
|
||||||
|
|
||||||
|
#### Android (`AndroidManifest.xml`)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- Android 12+ -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||||
|
<!-- Android 11 及以下需要定位权限来扫描蓝牙 -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||||
|
android:maxSdkVersion="30" />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### iOS (`Info.plist`)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
|
<string>需要使用蓝牙连接欧姆龙血压计以同步测量数据</string>
|
||||||
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
|
<string>需要使用蓝牙连接血压计</string>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、交互流程设计
|
||||||
|
|
||||||
|
### 4.1 用户操作流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 打开 App → 设置 → "蓝牙血压计"
|
||||||
|
2. 点击"扫描设备"
|
||||||
|
3. 看到列表:OMRON HEM-7600T / BLEsmart_xxxx
|
||||||
|
4. 点击连接 → 自动绑定(存储 MAC 地址)
|
||||||
|
5. 之后每次打开 App 自动连接已绑定设备
|
||||||
|
6. 血压计测量完成 → App 自动收到数据 → 弹窗确认 → 存入健康概览
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 前端页面代码
|
||||||
|
|
||||||
|
#### 扫描绑定页 (`device_scan_page.dart`)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||||
|
import '../../services/omron_ble_service.dart';
|
||||||
|
|
||||||
|
class DeviceScanPage extends ConsumerStatefulWidget {
|
||||||
|
const DeviceScanPage({super.key});
|
||||||
|
@override ConsumerState<DeviceScanPage> createState() => _DeviceScanPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeviceScanPageState extends ConsumerState<DeviceScanPage> {
|
||||||
|
final _ble = OmronBleService();
|
||||||
|
final _results = <ScanResult>[];
|
||||||
|
bool _scanning = false;
|
||||||
|
String? _connectingId;
|
||||||
|
|
||||||
|
@override void initState() {
|
||||||
|
super.initState();
|
||||||
|
_startScan();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startScan() async {
|
||||||
|
setState(() { _scanning = true; _results.clear(); });
|
||||||
|
|
||||||
|
// Android 12+ 需要先请求权限
|
||||||
|
await FlutterBluePlus.turnOn();
|
||||||
|
|
||||||
|
_ble.scan().listen((result) {
|
||||||
|
if (!_results.any((r) => r.device.remoteId == result.device.remoteId)) {
|
||||||
|
setState(() => _results.add(result));
|
||||||
|
}
|
||||||
|
}).onDone(() {
|
||||||
|
if (mounted) setState(() => _scanning = false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 15 秒后自动停止扫描
|
||||||
|
Future.delayed(const Duration(seconds: 15), () {
|
||||||
|
FlutterBluePlus.stopScan();
|
||||||
|
if (mounted) setState(() => _scanning = false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _connect(ScanResult result) async {
|
||||||
|
setState(() => _connectingId = result.device.remoteId.toString());
|
||||||
|
try {
|
||||||
|
await _ble.connect(result.device);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('已连接 ${result.device.platformName}')),
|
||||||
|
);
|
||||||
|
Navigator.pop(context, result.device); // 返回设备信息
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('连接失败: $e'), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _connectingId = null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('添加血压计')),
|
||||||
|
body: Column(children: [
|
||||||
|
// 扫描状态
|
||||||
|
Container(
|
||||||
|
width: double.infinity, padding: const EdgeInsets.all(16),
|
||||||
|
color: const Color(0xFFF0F2FF),
|
||||||
|
child: Row(children: [
|
||||||
|
if (_scanning) ...[
|
||||||
|
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
Text(_scanning ? '正在扫描...' : '扫描完成,发现 ${_results.length} 台设备',
|
||||||
|
style: const TextStyle(fontSize: 14)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 使用说明(没有设备时)
|
||||||
|
if (!_scanning && _results.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(children: [
|
||||||
|
Icon(Icons.bluetooth_disabled, size: 64, color: Colors.grey[300]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('未发现设备', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_tipCard('1', '请确保血压计已装电池'),
|
||||||
|
_tipCard('2', '长按血压计蓝牙键 3 秒,直到图标闪烁'),
|
||||||
|
_tipCard('3', '手机靠近血压计(1 米内)'),
|
||||||
|
_tipCard('4', '关闭其他已连接血压计的手机'),
|
||||||
|
_tipCard('5', 'Android 用户请确保已授予蓝牙和定位权限'),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 设备列表
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: _results.length,
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final r = _results[i];
|
||||||
|
final isConnecting = _connectingId == r.device.remoteId.toString();
|
||||||
|
final name = r.device.platformName.isNotEmpty
|
||||||
|
? r.device.platformName : '未知设备';
|
||||||
|
final signal = r.rssi;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: const Color(0xFFEDF2FF),
|
||||||
|
child: Icon(Icons.bluetooth, color: signal > -60 ? const Color(0xFF4F6EF7) : Colors.grey),
|
||||||
|
),
|
||||||
|
title: Text(name, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
subtitle: Text('信号: ${_rssiLabel(signal)} · MAC: ${r.device.remoteId}'),
|
||||||
|
trailing: isConnecting
|
||||||
|
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: ElevatedButton.icon(
|
||||||
|
onPressed: () => _connect(r),
|
||||||
|
icon: const Icon(Icons.link, size: 16),
|
||||||
|
label: const Text('连接'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 重新扫描按钮
|
||||||
|
if (!_scanning && _results.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: SizedBox(width: double.infinity, child: OutlinedButton.icon(
|
||||||
|
onPressed: _startScan,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('重新扫描'),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tipCard(String num, String text) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Container(width: 20, height: 20, alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(color: const Color(0xFF8B9CF7), borderRadius: BorderRadius.circular(10)),
|
||||||
|
child: Text(num, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600))),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(child: Text(text, style: TextStyle(fontSize: 13, color: Colors.grey[600]))),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
String _rssiLabel(int rssi) {
|
||||||
|
if (rssi > -55) return '强';
|
||||||
|
if (rssi > -70) return '中';
|
||||||
|
return '弱';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 已绑定设备管理页 (`device_manager_page.dart`)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../services/local_database.dart';
|
||||||
|
|
||||||
|
class DeviceManagerPage extends ConsumerStatefulWidget {
|
||||||
|
const DeviceManagerPage({super.key});
|
||||||
|
@override ConsumerState<DeviceManagerPage> createState() => _DeviceManagerPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeviceManagerPageState extends ConsumerState<DeviceManagerPage> {
|
||||||
|
String? _boundMac;
|
||||||
|
String? _boundName;
|
||||||
|
String? _lastSync;
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadBoundDevice();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadBoundDevice() async {
|
||||||
|
final db = ref.read(localDatabaseProvider);
|
||||||
|
_boundMac = await db.read('bp_device_mac');
|
||||||
|
_boundName = await db.read('bp_device_name');
|
||||||
|
_lastSync = await db.read('bp_last_sync');
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _unbind() async {
|
||||||
|
final ok = await showDialog<bool>(context: context, builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('解绑设备'),
|
||||||
|
content: const Text('解绑后需重新扫描连接,确定吗?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定解绑')),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
if (ok != true) return;
|
||||||
|
final db = ref.read(localDatabaseProvider);
|
||||||
|
await db.delete('bp_device_mac');
|
||||||
|
await db.delete('bp_device_name');
|
||||||
|
await db.delete('bp_last_sync');
|
||||||
|
setState(() { _boundMac = null; _boundName = null; _lastSync = null; });
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _goScan() async {
|
||||||
|
final device = await Navigator.push(context, MaterialPageRoute(builder: (_) => const DeviceScanPage()));
|
||||||
|
if (device != null) {
|
||||||
|
final db = ref.read(localDatabaseProvider);
|
||||||
|
await db.write('bp_device_mac', device.remoteId.toString());
|
||||||
|
await db.write('bp_device_name', device.platformName);
|
||||||
|
_loadBoundDevice();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('蓝牙血压计')),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _boundMac == null
|
||||||
|
? _emptyState()
|
||||||
|
: _boundCard(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _emptyState() => Center(
|
||||||
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
Icon(Icons.bluetooth_disabled, size: 64, color: Colors.grey[300]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('未绑定设备', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('连接欧姆龙血压计,自动同步测量数据', style: TextStyle(color: Color(0xFF999999))),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _goScan,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('添加设备'),
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF8B9CF7), foregroundColor: Colors.white),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _boundCard() => Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8)]),
|
||||||
|
child: Column(children: [
|
||||||
|
CircleAvatar(radius: 30, backgroundColor: const Color(0xFFEDF2FF),
|
||||||
|
child: const Icon(Icons.bluetooth_connected, color: Color(0xFF4F6EF7))),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(_boundName ?? '血压计', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('MAC: $_boundMac', style: const TextStyle(fontSize: 13, color: Color(0xFF999999))),
|
||||||
|
if (_lastSync != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('上次同步: $_lastSync', style: const TextStyle(fontSize: 12, color: Color(0xFFBBBBBB))),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _unbind,
|
||||||
|
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
||||||
|
label: const Text('解绑设备', style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 交互细节
|
||||||
|
|
||||||
|
- **测量中提示**:设备开始充气时 `0x2A36`(Intermediate Cuff Pressure)会推送实时压力,可显示充气动画
|
||||||
|
- **测量完成**:收到 `0x2A35` Indicate 后,弹 SnackBar 显示 `"已同步: 120/80 mmHg"`
|
||||||
|
- **自动记录**:无需用户手动确认,直接写入 HealthRecords 表
|
||||||
|
- **防重复**:同一时间戳(精确到秒)+ 同一值 → 不重复录入
|
||||||
|
- **多用户设备(J760)**:如果 Flags 含 User ID bit,弹出选择"这是谁的数据?"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、欧姆龙官方 SDK vs 自行解析
|
||||||
|
|
||||||
|
| 对比 | 官方 B2B SDK | 自行解析 BLE GATT |
|
||||||
|
|------|-------------|-------------------|
|
||||||
|
| 复杂度 | 低(已封装) | 中(需理解协议) |
|
||||||
|
| 费用 | 需申请/可能收费 | 免费 |
|
||||||
|
| 支持范围 | 仅欧姆龙 | 所有 BLE 血压计 |
|
||||||
|
| 维护 | SDK 更新需适配 | 标准协议不变 |
|
||||||
|
| iOS 支持 | 有 | ✅ flutter_blue_plus |
|
||||||
|
| Android 支持 | 有 | ✅ flutter_blue_plus |
|
||||||
|
| 获取方式 | [public.omronhealthcare.com.cn/b2bsdk](https://public.omronhealthcare.com.cn/b2bsdk/) | — |
|
||||||
|
|
||||||
|
**推荐路线**:
|
||||||
|
1. **先用自行解析方案**(本文档方案),因为协议是标准 BLE,不依赖第三方
|
||||||
|
2. 如果遇到兼容性问题(特定型号数据解析异常),再申请欧姆龙官方 SDK 作为补充
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、后端改动
|
||||||
|
|
||||||
|
### 6.1 DB 新增表(可选)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS "DeviceBindings" (
|
||||||
|
"Id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"UserId" UUID NOT NULL REFERENCES "Users"("Id"),
|
||||||
|
"DeviceType" TEXT DEFAULT 'BloodPressure',
|
||||||
|
"DeviceModel" TEXT, -- 'OMRON J732'
|
||||||
|
"DeviceMac" TEXT, -- 'AA:BB:CC:DD:EE:FF'
|
||||||
|
"DeviceSerial" TEXT,
|
||||||
|
"LastSyncAt" TIMESTAMPTZ,
|
||||||
|
"CreatedAt" TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 后端几乎无需改动
|
||||||
|
|
||||||
|
现有 `POST /api/health-records` 已支持 `BloodPressure` + `systolic/diastolic`,只需确保 `Source` 枚举有 `Device`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public enum HealthRecordSource {
|
||||||
|
Manual,
|
||||||
|
AiEntry,
|
||||||
|
Device // ← 新增
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、实施计划(5 天)
|
||||||
|
|
||||||
|
| 天 | 任务 | 产出 |
|
||||||
|
|----|------|------|
|
||||||
|
| **Day 1** | flutter_blue_plus 集成 + 权限配置 + SFLOAT 单元测试 | BLE 服务骨架 |
|
||||||
|
| **Day 2** | 扫描/连接/解析/断连完整实现 | OmronBleService |
|
||||||
|
| **Day 3** | 设备扫描页 + 绑定管理页 + 设置入口 | UI 完成 |
|
||||||
|
| **Day 4** | 数据同步到后端 + 去重逻辑 + 健康概览刷新 | 端到端打通 |
|
||||||
|
| **Day 5** | 真机 + J735 联调测试、异常处理 | 上线就绪 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、J735 到货后首次调试指南
|
||||||
|
|
||||||
|
### 8.1 开箱检查
|
||||||
|
|
||||||
|
收到货后先确认设备正常:
|
||||||
|
|
||||||
|
1. 装电池(4 节 AA)或插电源适配器
|
||||||
|
2. 按「开始/停止」键,袖带充气 → 屏幕显示数值 → 确认测量功能正常
|
||||||
|
3. 长按蓝牙键(屏幕出现蓝牙图标闪烁)→ 进入配对模式
|
||||||
|
|
||||||
|
### 8.2 用官方 App 验证蓝牙
|
||||||
|
|
||||||
|
这一步是验证蓝牙硬件没问题:
|
||||||
|
|
||||||
|
1. 手机下载 **OMRON Connect**(iOS App Store / Android 应用市场)
|
||||||
|
2. 注册账号 → 添加设备 → 选择 J735
|
||||||
|
3. 血压计长按蓝牙键进入配对模式
|
||||||
|
4. App 扫描连接 → 测量一次 → 确认数据同步到 App
|
||||||
|
|
||||||
|
如果官方 App 能正常同步,说明硬件没问题,接下来就是我们的代码对接。
|
||||||
|
|
||||||
|
### 8.3 用 nRF Connect 抓包验证
|
||||||
|
|
||||||
|
nRF Connect 是 Nordic 官方的免费 BLE 调试工具,可以直接看到设备广播的原始数据。**这是开发前最重要的验证步骤**:
|
||||||
|
|
||||||
|
1. 手机安装 **nRF Connect**(iOS App Store / Android Google Play)
|
||||||
|
2. 打开 J735 蓝牙配对模式
|
||||||
|
3. nRF Connect 扫描 → 找到 `OMRON` 或 `BLEsmart` 开头的设备 → 点击连接
|
||||||
|
4. 找到 `Blood Pressure` 服务(UUID `0x1810`)
|
||||||
|
5. 订阅 `Blood Pressure Measurement` Characteristic(UUID `0x2A35`)
|
||||||
|
6. 在血压计上测量一次
|
||||||
|
7. nRF Connect 会收到 Indicate 推送 → 截图保存原始 HEX 数据
|
||||||
|
|
||||||
|
这个 HEX 数据就是我们要解析的原始字节。用它来验证我们的 SFLOAT 解码是否正确。
|
||||||
|
|
||||||
|
### 8.4 BLE 名称确认
|
||||||
|
|
||||||
|
不同批次 J735 的蓝牙名称可能不同,常见的有:
|
||||||
|
|
||||||
|
- `OMRON HEM-xxxx`
|
||||||
|
- `BLEsmart_xxxx`
|
||||||
|
- `OMRON-BPM`
|
||||||
|
|
||||||
|
首次连接后用 `device.platformName` 打印出来,后续扫描过滤就用这个。
|
||||||
|
|
||||||
|
### 8.5 常见坑
|
||||||
|
|
||||||
|
| 坑 | 现象 | 解决 |
|
||||||
|
|----|------|------|
|
||||||
|
| **设备已被手机系统蓝牙连接** | flutter_blue_plus 搜不到 | 系统设置里取消配对 |
|
||||||
|
| **未订阅 Characteristic** | 收不到数据 | 必须 `setNotifyValue(true)` |
|
||||||
|
| **忘记请求权限** | Android 扫描返回空列表 | 检查蓝牙+定位权限 |
|
||||||
|
| **SFLOAT 计算错误** | 数据差很多 | 用 nRF Connect 的 HEX 对比解码结果 |
|
||||||
|
| **iOS BLE 后台限制** | App 切后台后断连 | 前台测量时连接,测量完断开 |
|
||||||
|
| **J735 是 Indicate 不是 Notify** | 数据只来一次 | flutter_blue_plus 对 Indicate 自动处理,但每次数据后需设备收到确认才会发下一条 |
|
||||||
|
|
||||||
|
### 8.6 测试清单
|
||||||
|
|
||||||
|
```
|
||||||
|
□ 扫描能看到 J735
|
||||||
|
□ 点击连接成功
|
||||||
|
□ discoverServices 能找到 0x1810 服务
|
||||||
|
□ setNotifyValue(true) 后收到测量数据
|
||||||
|
□ SFLOAT 解码结果与屏幕显示一致(允许 ±1 误差)
|
||||||
|
□ 数据成功 POST 到 /api/health-records
|
||||||
|
□ 健康概览刷新后显示新数据
|
||||||
|
□ 断连后重新打开 App 能再次连接
|
||||||
|
□ 同一测量不重复录入(去重逻辑)
|
||||||
|
□ iOS 和 Android 双平台都测试通过
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、风险与备选
|
||||||
|
|
||||||
|
| 风险 | 应对 |
|
||||||
|
|------|------|
|
||||||
|
| 公司 WiFi 环境蓝牙干扰大 | 手机靠近血压计(< 5 米) |
|
||||||
|
| Android 后台被杀 | 前台 Service 保持 BLE 连接 |
|
||||||
|
| iOS BLE 限制(App 后台) | 前台连接时同步历史数据 |
|
||||||
|
| J735 协议与标准有偏差 | 用 nRF Connect 抓包对比,微调解码 |
|
||||||
|
| 多个血压计混淆 | 按 MAC 地址绑定,UI 显示设备名称 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参考资源
|
||||||
|
|
||||||
|
- Bluetooth SIG Blood Pressure Service 1.1: https://www.bluetooth.com/specifications/specs/blood-pressure-service-1-1-1/
|
||||||
|
- 欧姆龙 B2B SDK 中国: https://public.omronhealthcare.com.cn/b2bsdk/
|
||||||
|
- 欧姆龙全球开发者 API: https://omronhealthcare.com/api
|
||||||
|
- flutter_blue_plus: https://pub.dev/packages/flutter_blue_plus
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 557 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 558 KiB |
@@ -69,8 +69,6 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
return _buildDietAnalysisCard(context, msg);
|
return _buildDietAnalysisCard(context, msg);
|
||||||
case MessageType.reportAnalysis:
|
case MessageType.reportAnalysis:
|
||||||
return _buildReportAnalysisCard(context, msg);
|
return _buildReportAnalysisCard(context, msg);
|
||||||
case MessageType.onboarding:
|
|
||||||
return _buildOnboardingCard(context, ref);
|
|
||||||
case MessageType.quickOptions:
|
case MessageType.quickOptions:
|
||||||
return _buildQuickOptionsCard(context, msg);
|
return _buildQuickOptionsCard(context, msg);
|
||||||
default:
|
default:
|
||||||
@@ -749,66 +747,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// 6. QuickOptionsCard — 优化样式
|
||||||
// 6. OnboardingCard — 首次建档引导
|
|
||||||
Widget _buildOnboardingCard(BuildContext context, WidgetRef ref) {
|
|
||||||
return Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.88),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(25), blurRadius: 14, offset: const Offset(0, 4))],
|
|
||||||
),
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 24, 20, 16),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: LinearGradient(colors: [Color(0xFF8B9CF7), Color(0xFFA78BFA)], begin: Alignment.topLeft, end: Alignment.bottomRight),
|
|
||||||
),
|
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Row(children: [
|
|
||||||
Container(width: 48, height: 48, decoration: BoxDecoration(color: Colors.white.withAlpha(30), borderRadius: BorderRadius.circular(14)), child: const Icon(Icons.health_and_safety, size: 28, color: Colors.white)),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
const Text('欢迎来到健康管家!', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: Colors.white)),
|
|
||||||
]),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text('我是您的AI健康管家。为了给您更精准的建议,\n我先了解一些基本信息好吗?大约2-3分钟。',
|
|
||||||
style: TextStyle(fontSize: 14, color: Colors.white.withAlpha(220), height: 1.5)),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 20),
|
|
||||||
child: Column(children: [
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
ref.read(chatProvider.notifier).startOnboarding();
|
|
||||||
},
|
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF8B9CF7), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), padding: const EdgeInsets.symmetric(vertical: 14)),
|
|
||||||
child: const Text('开始建档', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => ref.read(chatProvider.notifier).dismissOnboarding(),
|
|
||||||
child: const Text('以后再说', style: TextStyle(fontSize: 14, color: Color(0xFF999999)))),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. QuickOptionsCard — 优化样式
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
Widget _buildQuickOptionsCard(BuildContext context, ChatMessage msg) {
|
Widget _buildQuickOptionsCard(BuildContext context, ChatMessage msg) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'auth_provider.dart';
|
|||||||
import 'data_providers.dart';
|
import 'data_providers.dart';
|
||||||
import '../utils/sse_handler.dart';
|
import '../utils/sse_handler.dart';
|
||||||
|
|
||||||
enum MessageType { text, dataConfirm, medicationConfirm, dietAnalysis, reportAnalysis, quickOptions, agentWelcome, taskCard, onboarding }
|
enum MessageType { text, dataConfirm, medicationConfirm, dietAnalysis, reportAnalysis, quickOptions, agentWelcome, taskCard }
|
||||||
|
|
||||||
class ChatMessage {
|
class ChatMessage {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -27,7 +27,7 @@ class ChatMessage {
|
|||||||
bool get isUser => role == 'user';
|
bool get isUser => role == 'user';
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ActiveAgent { default_, consultation, health, diet, medication, report, exercise, onboarding }
|
enum ActiveAgent { default_, consultation, health, diet, medication, report, exercise }
|
||||||
|
|
||||||
class ChatState {
|
class ChatState {
|
||||||
final ActiveAgent activeAgent;
|
final ActiveAgent activeAgent;
|
||||||
@@ -122,49 +122,10 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
ChatState build() {
|
ChatState build() {
|
||||||
Future.microtask(() {
|
Future.microtask(() {
|
||||||
insertTaskCard();
|
insertTaskCard();
|
||||||
_checkOnboarding();
|
|
||||||
});
|
});
|
||||||
return const ChatState();
|
return const ChatState();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _checkOnboarding() async {
|
|
||||||
try {
|
|
||||||
final api = ref.read(apiClientProvider);
|
|
||||||
final res = await api.get('/api/user/health-archive');
|
|
||||||
final archive = res.data['data'];
|
|
||||||
// 档案全空 → 触发建档引导
|
|
||||||
final isEmpty = archive == null ||
|
|
||||||
(archive['diagnosis'] == null && archive['surgeryType'] == null &&
|
|
||||||
(archive['allergies'] == null || (archive['allergies'] as List).isEmpty) &&
|
|
||||||
(archive['chronicDiseases'] == null || (archive['chronicDiseases'] as List).isEmpty) &&
|
|
||||||
(archive['dietRestrictions'] == null || (archive['dietRestrictions'] as List).isEmpty));
|
|
||||||
if (isEmpty && !state.messages.any((m) => m.type == MessageType.onboarding)) {
|
|
||||||
final db = ref.read(localDbProvider);
|
|
||||||
final skipped = await db.read('onboarding_skipped');
|
|
||||||
if (skipped == null) {
|
|
||||||
state = state.copyWith(messages: [
|
|
||||||
...state.messages,
|
|
||||||
ChatMessage(id: 'onboarding_card', role: 'assistant', content: '', createdAt: DateTime.now(), type: MessageType.onboarding),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
void startOnboarding() {
|
|
||||||
setAgent(ActiveAgent.onboarding);
|
|
||||||
// Remove onboarding card
|
|
||||||
state = state.copyWith(messages: state.messages.where((m) => m.type != MessageType.onboarding).toList());
|
|
||||||
// Send trigger message
|
|
||||||
Future.microtask(() => _sendToAI('开始建档'));
|
|
||||||
}
|
|
||||||
|
|
||||||
void dismissOnboarding() async {
|
|
||||||
final db = ref.read(localDbProvider);
|
|
||||||
await db.write('onboarding_skipped', DateTime.now().toIso8601String());
|
|
||||||
state = state.copyWith(messages: state.messages.where((m) => m.type != MessageType.onboarding).toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
void insertTaskCard() {
|
void insertTaskCard() {
|
||||||
if (state.messages.any((m) => m.type == MessageType.taskCard)) return;
|
if (state.messages.any((m) => m.type == MessageType.taskCard)) return;
|
||||||
state = state.copyWith(messages: [ChatMessage(
|
state = state.copyWith(messages: [ChatMessage(
|
||||||
@@ -179,6 +140,37 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
void setAgent(ActiveAgent a) {
|
void setAgent(ActiveAgent a) {
|
||||||
_subscription?.cancel();
|
_subscription?.cancel();
|
||||||
state = state.copyWith(activeAgent: a);
|
state = state.copyWith(activeAgent: a);
|
||||||
|
ref.read(selectedAgentProvider.notifier).select(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据 AI 调用的工具自动切换智能体胶囊
|
||||||
|
void _switchAgentByTool(String tool) {
|
||||||
|
ActiveAgent? agent;
|
||||||
|
switch (tool) {
|
||||||
|
case 'record_health_data':
|
||||||
|
case 'query_health_records':
|
||||||
|
agent = ActiveAgent.health;
|
||||||
|
break;
|
||||||
|
case 'estimate_food_text':
|
||||||
|
agent = ActiveAgent.diet;
|
||||||
|
break;
|
||||||
|
case 'manage_medication':
|
||||||
|
agent = ActiveAgent.medication;
|
||||||
|
break;
|
||||||
|
case 'manage_exercise':
|
||||||
|
agent = ActiveAgent.exercise;
|
||||||
|
break;
|
||||||
|
case 'request_doctor':
|
||||||
|
agent = ActiveAgent.consultation;
|
||||||
|
break;
|
||||||
|
case 'analyze_report':
|
||||||
|
agent = ActiveAgent.report;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (agent != null) {
|
||||||
|
ref.read(selectedAgentProvider.notifier).select(agent);
|
||||||
|
state = state.copyWith(activeAgent: agent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadConversation(String convId) async {
|
Future<void> loadConversation(String convId) async {
|
||||||
@@ -298,10 +290,9 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final agentPath =
|
// 始终用 unified 智能体,AI 自动判断意图分配工具
|
||||||
state.activeAgent.name.replaceFirst('default_', 'default');
|
|
||||||
final stream = SseHandler.connect(
|
final stream = SseHandler.connect(
|
||||||
agentType: agentPath,
|
agentType: 'unified',
|
||||||
message: text,
|
message: text,
|
||||||
conversationId: state.conversationId,
|
conversationId: state.conversationId,
|
||||||
token: token,
|
token: token,
|
||||||
@@ -346,6 +337,8 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
state = state.copyWith(thinkingText: j['message'] as String?);
|
state = state.copyWith(thinkingText: j['message'] as String?);
|
||||||
case 'tool_result':
|
case 'tool_result':
|
||||||
final tool = j['tool'] as String? ?? '';
|
final tool = j['tool'] as String? ?? '';
|
||||||
|
// 根据 AI 调用的工具自动切换智能体胶囊
|
||||||
|
_switchAgentByTool(tool);
|
||||||
if (tool == 'record_health_data') {
|
if (tool == 'record_health_data') {
|
||||||
ref.invalidate(latestHealthProvider);
|
ref.invalidate(latestHealthProvider);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user