feat: AI 意图路由/草稿存储 + Prompts 模块化 + UI 视觉统一 + AI 同意门 + 资源更新

后端:
- 新增 ai_intent_router 意图路由
- 新增 AiEntryDraft 草稿存储 + EF 迁移
- 新增 Prompts 模块化(global/modules/rag/router)
- AI Agent handlers 扩展

前端:
- 新增 AI 同意门 (ai_consent_gate/provider/details_page)
- HealthMetricVisuals 视觉统一
- 设备扫描/管理页面优化
- agent 插图替换 + 趋势指标图标

配置:
- DeepSeek 模型改 deepseek-v4-flash
- .gitignore 加 artifacts/audit
- build.gradle.kts 签名配置调整
This commit is contained in:
MingNian
2026-07-28 15:03:38 +08:00
parent 25a4078688
commit b4bc15b9b9
88 changed files with 5527 additions and 1240 deletions

View File

@@ -27,14 +27,10 @@ class DeviceScanPage extends ConsumerStatefulWidget {
class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
with SingleTickerProviderStateMixin {
static const _visibleDeviceWindow = Duration(seconds: 12);
final _results = <ScanResult>[];
StreamSubscription<List<ScanResult>>? _scanSub;
Timer? _scanStopTimer;
Timer? _resultPruneTimer;
String? _connectingId;
DateTime? _scanStartedAt;
bool _scanning = false;
late final AnimationController _pulseCtrl;
@@ -59,7 +55,6 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
void dispose() {
_pulseCtrl.dispose();
_scanStopTimer?.cancel();
_resultPruneTimer?.cancel();
_scanSub?.cancel();
unawaited(FlutterBluePlus.stopScan());
unawaited(ref.read(healthBleServiceProvider).disconnect());
@@ -99,7 +94,6 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
try {
await FlutterBluePlus.stopScan();
} catch (_) {}
_scanStartedAt = DateTime.now();
await _scanSub?.cancel();
_scanSub = FlutterBluePlus.onScanResults.listen(_onScanResults);
await FlutterBluePlus.startScan(
@@ -112,38 +106,24 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
_scanStopTimer = Timer(const Duration(seconds: 30), () {
if (mounted) setState(() => _scanning = false);
});
_resultPruneTimer?.cancel();
_resultPruneTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted || _connectingId != null) return;
final pruned = _visibleResults(_results);
if (_sameResults(_results, pruned)) return;
setState(() {
_results
..clear()
..addAll(pruned);
});
});
}
void _onScanResults(List<ScanResult> list) {
if (!mounted || _connectingId != null) return;
final scanStartedAt = _scanStartedAt;
if (scanStartedAt == null) return;
final boundDevices = ref.read(omronDeviceProvider).devices;
final supported = list.where((result) {
if (result.timeStamp.isBefore(scanStartedAt)) return false;
if (!result.advertisementData.connectable) return false;
if (DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
return false;
}
if (!HealthBleService.isSupportedHealthDevice(result)) return false;
final candidates = list.where((result) {
// Keep the result in the list first. Many health devices expose their
// standard service only after connection, not in the advertisement.
// Also avoid relying on ScanResult.timeStamp/connectable here: both can
// be stale or conservative while a device is in communication mode.
if (!_looksLikeHealthDevice(result)) return false;
return boundDevices.every(
(device) => device.id != result.device.remoteId.toString(),
);
});
final next = [..._results];
for (final result in supported) {
for (final result in candidates) {
final index = next.indexWhere(
(item) => item.device.remoteId == result.device.remoteId,
);
@@ -163,33 +143,32 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
}
List<ScanResult> _visibleResults(List<ScanResult> results) {
final visible =
results
.where(
(result) =>
DateTime.now().difference(result.timeStamp) <=
_visibleDeviceWindow,
)
.toList()
..sort(
(a, b) => a.device.remoteId.toString().compareTo(
b.device.remoteId.toString(),
),
);
final visible = [...results]
..sort(
(a, b) => a.device.remoteId.toString().compareTo(
b.device.remoteId.toString(),
),
);
return visible;
}
bool _looksLikeHealthDevice(ScanResult result) {
if (HealthBleService.deviceTypeFromScan(result) != null) return true;
final name = [
result.advertisementData.advName,
result.device.platformName,
].join(' ').toUpperCase();
return name.contains('OMRON') ||
name.contains('HEM') ||
name.contains('BLESMART') ||
name.contains('J735') ||
name.contains('BPM') ||
name.contains('BP');
}
Future<void> _connectBindAndSync(ScanResult result) async {
final remoteId = result.device.remoteId.toString();
if (_connectingId != null) return;
final scanStartedAt = _scanStartedAt;
if (scanStartedAt == null ||
result.timeStamp.isBefore(scanStartedAt) ||
!result.advertisementData.connectable ||
DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
AppToast.show(context, '设备已离线,请重新进入通信状态', type: AppToastType.warning);
return;
}
if (ref.read(omronDeviceProvider).isBound &&
ref.read(omronDeviceProvider).findById(remoteId) != null) {
AppToast.show(context, '该设备已绑定', type: AppToastType.info);
@@ -348,10 +327,15 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
}
Future<bool> _ensureBlePermissions() async {
final statuses = await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
].request();
final permissions = <Permission>[
if (Platform.isAndroid) Permission.bluetoothScan,
if (Platform.isAndroid) Permission.bluetoothConnect,
// Android 11 and below require a location runtime permission for BLE
// scanning. Requesting it here also keeps older devices from silently
// returning an empty scan stream.
if (Platform.isAndroid) Permission.locationWhenInUse,
];
final statuses = await permissions.request();
final granted = statuses.values.every((status) => status.isGranted);
if (granted) return true;
if (!mounted) return false;