diff --git a/AI提示词文档.md b/AI提示词文档.md deleted file mode 100644 index a11a2f4..0000000 --- a/AI提示词文档.md +++ /dev/null @@ -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,不要其他内容。 -``` diff --git a/backend/src/Health.Domain/Enums/health_enums.cs b/backend/src/Health.Domain/Enums/health_enums.cs index 72212bd..ee68547 100644 --- a/backend/src/Health.Domain/Enums/health_enums.cs +++ b/backend/src/Health.Domain/Enums/health_enums.cs @@ -139,7 +139,7 @@ public enum AgentType Medication, // 药管家 Report, // 看报告 Exercise, // 运动计划 - Onboarding // 建档引导 + Unified // 统一入口(自动路由) } /// diff --git a/backend/src/Health.Infrastructure/AI/prompt_manager.cs b/backend/src/Health.Infrastructure/AI/prompt_manager.cs index b528fc1..46ef573 100644 --- a/backend/src/Health.Infrastructure/AI/prompt_manager.cs +++ b/backend/src/Health.Infrastructure/AI/prompt_manager.cs @@ -17,7 +17,7 @@ public sealed class PromptManager AgentType.Medication => MedicationPrompt, AgentType.Report => ReportPrompt, AgentType.Exercise => ExercisePrompt, - AgentType.Onboarding => OnboardingPrompt, + AgentType.Unified => UnifiedPrompt, _ => DefaultPrompt }; @@ -119,23 +119,24 @@ public sealed class PromptManager 5. 避免剧烈运动 """; - private const string OnboardingPrompt = """ - 你是一个健康管家助手,正在帮助新用户建立健康档案。 + private const string UnifiedPrompt = """ + 你是一个全能的AI健康管家,为心脏术后康复患者提供全方位服务。 - 必须严格遵守以下流程,一次只问一个问题: + 核心规则:首先理解用户意图属于哪个领域,然后调用对应的工具。 - 步骤1:询问主要诊断(冠心病/高血压/糖尿病/其他) - 步骤2:询问手术史(PCI支架植入/心脏搭桥/其他/没有)→ 如果用户说有手术,追问手术日期 - 步骤3:询问过敏史(青霉素/头孢/海鲜/鸡蛋/无过敏)→ 可多选 - 步骤4:询问慢性病史(高血脂/糖尿病/痛风/无) - 步骤5:询问饮食限制(低盐/低脂/低糖/无限制) + 领域判断与可用工具: + 1. 健康数据(血压/心率/血糖/血氧/体重)→ 调用 record_health_data + 2. 饮食分析 → 调用 estimate_food_text + 3. 用药管理 → 调用 manage_medication + 4. 运动计划 → 调用 manage_exercise + 5. 健康档案查询/修改 → 调用 check_archive / manage_archive + 6. 历史数据查询 → 调用 query_health_records 规则: - - 每次只问一个问题,给出2-4个快捷选项让用户点击 - - 用户回答每个问题后,立即调用 manage_archive 工具保存 - - 用户说"跳过"/"以后再说"/"再说"/"不用了"→ 礼貌结束建档:"好的,随时可以跟我说'完善档案'来继续" - - 全部完成后,生成结构化总结卡片 - - 回复语气温暖、像朋友一样关怀患者 - - 每步只问一个问题不要多问 + - 用户可能一句话涉及多个领域,全部处理 + - 数值明确+指标明确→直接录入,不要追问 + - 录入完成后生成确认卡片 + - 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医 + - 回复语气温暖、专业 """; } diff --git a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs index 987ca76..e9507f7 100644 --- a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs @@ -337,7 +337,13 @@ public static class AiChatEndpoints AgentType.Consultation => ConsultationAgentHandler.Tools, AgentType.Report => ReportAgentHandler.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, }; diff --git a/docs/omron_bp_integration.md b/docs/omron_bp_integration.md new file mode 100644 index 0000000..2985f64 --- /dev/null +++ b/docs/omron_bp_integration.md @@ -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>? _subscription; + final _readingsController = StreamController.broadcast(); + Stream get readings => _readingsController.stream; + + // ── 扫描 ── + Stream 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 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 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 _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 disconnect() async { + _subscription?.cancel(); + await _device?.disconnect(); + _device = null; + } + + void dispose() { + disconnect(); + _readingsController.close(); + } +} +``` + +#### 3.3.3 数据同步到后端 + +```dart +// 在 Provider 中监听 +final omronReadingsProvider = StreamProvider((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 + + + + + + + +``` + +#### iOS (`Info.plist`) + +```xml +NSBluetoothAlwaysUsageDescription +需要使用蓝牙连接欧姆龙血压计以同步测量数据 +NSBluetoothPeripheralUsageDescription +需要使用蓝牙连接血压计 +``` + +--- + +## 四、交互流程设计 + +### 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 createState() => _DeviceScanPageState(); +} + +class _DeviceScanPageState extends ConsumerState { + final _ble = OmronBleService(); + final _results = []; + bool _scanning = false; + String? _connectingId; + + @override void initState() { + super.initState(); + _startScan(); + } + + Future _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 _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 createState() => _DeviceManagerPageState(); +} + +class _DeviceManagerPageState extends ConsumerState { + String? _boundMac; + String? _boundName; + String? _lastSync; + bool _loading = true; + + @override void initState() { + super.initState(); + _loadBoundDevice(); + } + + Future _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 _unbind() async { + final ok = await showDialog(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 _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 diff --git a/health_app/flutter_01.png b/health_app/flutter_01.png deleted file mode 100644 index b47e9f2..0000000 Binary files a/health_app/flutter_01.png and /dev/null differ diff --git a/health_app/flutter_02.png b/health_app/flutter_02.png deleted file mode 100644 index e6d759b..0000000 Binary files a/health_app/flutter_02.png and /dev/null differ diff --git a/health_app/lib/pages/home/widgets/chat_messages_view.dart b/health_app/lib/pages/home/widgets/chat_messages_view.dart index 73de979..fa4cac4 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -69,8 +69,6 @@ class ChatMessagesView extends ConsumerWidget { return _buildDietAnalysisCard(context, msg); case MessageType.reportAnalysis: return _buildReportAnalysisCard(context, msg); - case MessageType.onboarding: - return _buildOnboardingCard(context, ref); case MessageType.quickOptions: return _buildQuickOptionsCard(context, msg); default: @@ -749,66 +747,7 @@ class ChatMessagesView extends ConsumerWidget { ); } - // ═══════════════════════════════════════════════════════════ - // 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 — 优化样式 + // 6. QuickOptionsCard — 优化样式 // ═══════════════════════════════════════════════════════════ Widget _buildQuickOptionsCard(BuildContext context, ChatMessage msg) { diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index 75a3454..8d46ab2 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -5,7 +5,7 @@ import 'auth_provider.dart'; import 'data_providers.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 { final String id; @@ -27,7 +27,7 @@ class ChatMessage { 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 { final ActiveAgent activeAgent; @@ -122,49 +122,10 @@ class ChatNotifier extends Notifier { ChatState build() { Future.microtask(() { insertTaskCard(); - _checkOnboarding(); }); 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() { if (state.messages.any((m) => m.type == MessageType.taskCard)) return; state = state.copyWith(messages: [ChatMessage( @@ -179,6 +140,37 @@ class ChatNotifier extends Notifier { void setAgent(ActiveAgent a) { _subscription?.cancel(); 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 loadConversation(String convId) async { @@ -298,10 +290,9 @@ class ChatNotifier extends Notifier { return; } - final agentPath = - state.activeAgent.name.replaceFirst('default_', 'default'); + // 始终用 unified 智能体,AI 自动判断意图分配工具 final stream = SseHandler.connect( - agentType: agentPath, + agentType: 'unified', message: text, conversationId: state.conversationId, token: token, @@ -346,6 +337,8 @@ class ChatNotifier extends Notifier { state = state.copyWith(thinkingText: j['message'] as String?); case 'tool_result': final tool = j['tool'] as String? ?? ''; + // 根据 AI 调用的工具自动切换智能体胶囊 + _switchAgentByTool(tool); if (tool == 'record_health_data') { ref.invalidate(latestHealthProvider); }