diff --git a/apps/client/lib/data/services/assistant_directive_service.dart b/apps/client/lib/data/services/assistant_directive_service.dart index b6e98c78..578e4918 100644 --- a/apps/client/lib/data/services/assistant_directive_service.dart +++ b/apps/client/lib/data/services/assistant_directive_service.dart @@ -6,6 +6,7 @@ import 'package:get_storage/get_storage.dart'; import '../../core/utils/logger.dart'; import '../models/assistant_directive.dart'; import '../models/user_Info.dart'; +import 'memory_reminder_scheduler.dart'; import 'memory_service.dart'; /// 助手指令的落地层:**解析 → 分发 → 落本地记录**。 @@ -71,13 +72,15 @@ class AssistantDirectiveService extends GetxService { var changed = false; for (final d in incoming) { if (directives.any((e) => e.id == d.id)) { - Logger.d(_tag, '忽略重复指令 ${d.id}'); + Logger.w(_tag, '忽略重复指令 ${d.id}(耳机与对话页可能都在听同一会话)'); continue; } _dispatch(d); directives.insert(0, d); changed = true; - Logger.i(_tag, '指令落地 $d'); + // warn 级:这是「闹钟到底有没有落地」的唯一端侧证据, + // release 的级别门槛是 warning,写 info 等于真机上查不到。 + Logger.w(_tag, '指令落地 $d'); } if (!changed) return; @@ -104,6 +107,10 @@ class AssistantDirectiveService extends GetxService { // 以及百炼后台后加的任何新指令。留一条流水,不报错。 d.status = DirectiveStatus.unsupported; d.note = '尚未实现的指令'; + // 打出来:百炼后台加了新工具时,这行是唯一能发现「模型在下发一个 + // 我们没接的指令」的地方——否则表现成「助手说做了、实际什么都没发生」, + // 和「压根没下发」长得一模一样。 + Logger.w(_tag, '收到未实现的指令 ${d.name} args=${d.rawArguments}'); } } @@ -126,7 +133,23 @@ class AssistantDirectiveService extends GetxService { // // 迁移期**双写**:本地 reminders 保留一个版本,等确认服务端链路稳了再摘。 // 这样即使上传失败,用户至少还能在本机看到自己刚定的闹钟。 - unawaited(_uploadReminder(reminder)); + // ⚠️ 上传成不成功**都要**重排一次系统通知: + // - 成功 → 服务端 loadUpcoming 会给出这条 + // - 失败 → 它留在本地 unsynced,走 scheduler 的 _localSlots 兜底 + // 不在这里排的话,新定的闹钟要等到下一次 App 切前台 + //(AppLifecycleManager 里那个 reschedule)才会真正进系统排期—— + // 「5 分钟后提醒我」这种当场就废了,而用户以为已经定好了。 + unawaited(_uploadReminder(reminder).whenComplete(_rescheduleNotifications)); + } + + /// 让排期服务按最新的本地/服务端数据重排系统通知。 + /// + /// 只在这里单向调用 scheduler;scheduler 反过来读本类的 reminders 做兜底。 + /// 两个文件互相 import 是有意的:注册顺序上 scheduler 先于本类, + /// 由 scheduler 挂 ever(reminders) 监听会在启动时找不到本类。 + void _rescheduleNotifications() { + if (!Get.isRegistered()) return; + unawaited(MemoryReminderScheduler.to.reschedule()); } /// 把所有**还没同步**的本地闹钟补传一遍。 @@ -164,7 +187,10 @@ class AssistantDirectiveService extends GetxService { if (ok > 0) { _persist(); reminders.refresh(); - Logger.i(_tag, '补传了 $ok 条本地闹钟到服务端'); + Logger.w(_tag, '补传了 $ok 条本地闹钟到服务端'); + // 补传之后这些条目的来源从「本地兜底」变成了「服务端」,重排一次, + // 否则同一个闹钟会同时出现在两边、响两遍。 + _rescheduleNotifications(); } } finally { _syncing = false; diff --git a/apps/client/lib/data/services/bailian_multimodal_service.dart b/apps/client/lib/data/services/bailian_multimodal_service.dart index 45bb4a09..31d6f190 100644 --- a/apps/client/lib/data/services/bailian_multimodal_service.dart +++ b/apps/client/lib/data/services/bailian_multimodal_service.dart @@ -462,13 +462,31 @@ class BailianMultimodalService extends GetxService { _events.add(const BailianEvent(BailianEventType.respondingStarted)); break; case 'RespondingContent': + final reason = (output['finish_reason'] ?? '').toString(); + final parsed = + AssistantDirective.parseFromOutput(output, source: directiveSource); + // ⚠️ warn 级、且**只在这一帧真的带了 extra_info 或 command_calls 时**打。 + // + // 「闹钟没定上」这个问题只有两种可能——百炼压根没下发指令,或者下发了 + // 但端侧没落地——而 release 包的级别门槛是 warning,用 info/debug 记等于 + // 真机上什么都查不到,只能靠猜。这一行就是把两种可能分开的那个证据: + // 有 tool_calls 说明模型发了,接下来看 AssistantDirectiveService 的日志; + // 一条都没有说明是百炼后台的工具没配上,改客户端没有意义。 + // + // 频率可控:extra_info 只在整轮收尾那一帧出现,不是每个流式分片都有。 + final extra = output['extra_info']; + if ((extra is Map && extra.isNotEmpty) || reason == 'command_calls') { + Logger.w( + _tag, + '[指令帧] finish_reason=$reason 解析出=${parsed.length} 条 ' + 'extra_info=${_brief(extra)}'); + } _events.add(BailianEvent( BailianEventType.respondingContent, text: text, isFinal: finished, - finishReason: (output['finish_reason'] ?? '').toString(), - directives: - AssistantDirective.parseFromOutput(output, source: directiveSource), + finishReason: reason, + directives: parsed, )); break; case 'RespondingEnded': @@ -495,6 +513,14 @@ class BailianMultimodalService extends GetxService { } } + /// 日志用:把任意结构截成一行,超长截断。 + /// Logger 单行上限 4000,这里再收紧一道,免得一帧把屏幕刷满。 + static String _brief(Object? v, {int max = 800}) { + if (v == null) return 'null'; + final s = v.toString(); + return s.length <= max ? s : '${s.substring(0, max)}…(共${s.length}字符)'; + } + void _cleanup() { _heartbeat?.cancel(); _heartbeat = null; diff --git a/apps/client/lib/data/services/memory_reminder_scheduler.dart b/apps/client/lib/data/services/memory_reminder_scheduler.dart index da90e39c..cf393e77 100644 --- a/apps/client/lib/data/services/memory_reminder_scheduler.dart +++ b/apps/client/lib/data/services/memory_reminder_scheduler.dart @@ -8,7 +8,9 @@ import 'package:timezone/data/latest_all.dart' as tzdata; import 'package:timezone/timezone.dart' as tz; import '../../core/utils/logger.dart'; +import '../models/assistant_directive.dart'; import '../models/memory_item.dart'; +import 'assistant_directive_service.dart'; import 'memory_service.dart'; /// 拾忆的提前提醒:把服务端展开好的提醒时刻排成系统本地通知。 @@ -131,6 +133,79 @@ class MemoryReminderScheduler extends GetxService { } } + /// 迁移期的本地兜底:把**还没同步到服务端**的助手闹钟也排成通知。 + /// + /// ⚠️ 不做这个,「对助手说定个闹钟」在真机上就是**完全不响**。 + /// 排期的数据源原先只有 `MemoryService.loadUpcoming`,那是纯服务端接口; + /// 后端还没部署时它恒返回空,于是 SET_clock 把闹钟写进了本地 reminders、 + /// 拾忆页也显示出来了,却一条通知都没排——用户看到的是「记下了但不会响」, + /// 而这跟「指令压根没落地」在表现上分不出来。 + /// + /// 已同步的跳过:那些服务端会在 loadUpcoming 里给出来,两边都排会响两遍。 + /// 口径与 CalendarController._localReminderEvents 保持一致。 + List _localSlots(int days) { + if (!Get.isRegistered()) return const []; + final now = DateTime.now(); + final until = now.add(Duration(days: days)); + final out = []; + for (final AssistantReminder r in AssistantDirectiveService.to.reminders) { + if (r.synced) continue; + if (r.done) continue; + final item = r.toMemoryItem(); + for (final happen in _occurrences(item, now, until)) { + final remind = happen.subtract(Duration(minutes: item.remindAhead)); + // 已经过去的不排。注意判据是**提醒时刻**不是发生时刻: + // 「10:00 的会、提前 5 分钟」在 9:58 定下来时仍然该响。 + if (!remind.isAfter(now)) continue; + out.add(MemoryRemindSlot( + // 本地项没有服务端 id。0 会被通知点击处理成 focusItem:0, + // 拾忆页查不到就只是不定位,不会出错。 + itemId: 0, + remindAt: remind.millisecondsSinceEpoch ~/ 1000, + happenAt: happen.millisecondsSinceEpoch ~/ 1000, + title: item.title, + detail: '', + category: item.category, + )); + } + } + return out; + } + + /// 把一条记忆项在 [from, until] 窗口内的发生时刻摊开。 + /// + /// 重复规则的判定与 CalendarController._expandItem 逐条对齐——两处口径不一致 + /// 会造成「日历上显示了但不会响」或者反过来,而且很难发现。 + List _occurrences(MemoryItem item, DateTime from, DateTime until) { + final anchor = item.happenDateTime; + if (!item.isRepeating) { + return anchor.isAfter(from) && anchor.isBefore(until) ? [anchor] : const []; + } + final out = []; + var day = DateTime(from.year, from.month, from.day); + final anchorDay = DateTime(anchor.year, anchor.month, anchor.day); + while (!day.isAfter(until)) { + if (!day.isBefore(anchorDay)) { + final hit = switch (item.repeatRule) { + MemoryRepeat.daily => true, + // weekday 用 1-7(周一~周日),与 DateTime.weekday 一致 + MemoryRepeat.weekly => day.weekday == item.weekday, + MemoryRepeat.weekdays => day.weekday <= DateTime.friday, + MemoryRepeat.weekend => day.weekday == DateTime.saturday || + day.weekday == DateTime.sunday, + MemoryRepeat.monthly => day.day == anchor.day, + _ => false, + }; + if (hit) { + out.add(DateTime( + day.year, day.month, day.day, anchor.hour, anchor.minute)); + } + } + day = day.add(const Duration(days: 1)); + } + return out; + } + /// 重新排期:先撤旧的,再按服务端给的时刻排新的。 /// /// 每次都全撤全排,不做增量 diff —— 改期后旧通知还在响比多排一次贵得多。 @@ -143,15 +218,20 @@ class MemoryReminderScheduler extends GetxService { await _ensureTz(); await _ensurePlugin(); - final slots = await MemoryService.to.loadUpcoming(days: _days); + final slots = [ + ...await MemoryService.to.loadUpcoming(days: _days), + ..._localSlots(_days), + ]; + // 合流之后**必须自己排一次**:服务端那份是升序的,本地补进来的不是, + // 直接拼起来会让本地闹钟按插入位置抢占前 N 个名额,把服务端更早的挤掉。 + slots.sort((a, b) => a.remindAt.compareTo(b.remindAt)); await _cancelAllOurs(); if (slots.isEmpty) { - Logger.d(_tag, '没有待排的提醒'); + Logger.w(_tag, '没有待排的提醒'); return; } + Logger.w(_tag, '待排提醒 ${slots.length} 条'); - // 服务端已按 remind_at 升序返回。按顺序取前 N 条 —— 顺序错了会把明天的 - // 排掉、留下一周后的,所以这里不再自己排序,直接信任服务端口径。 final now = DateTime.now(); var idx = 0; for (final slot in slots) { diff --git a/apps/client/lib/modules/home/controllers/calendar_controller.dart b/apps/client/lib/modules/home/controllers/calendar_controller.dart index 7ed1e326..bb21efdc 100644 --- a/apps/client/lib/modules/home/controllers/calendar_controller.dart +++ b/apps/client/lib/modules/home/controllers/calendar_controller.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:get/get.dart'; import 'package:intl/intl.dart'; import 'package:flutter/material.dart'; @@ -7,6 +9,7 @@ import '../../../core/utils/logger.dart'; import '../../../data/models/assistant_directive.dart'; import '../../../data/models/memory_item.dart'; import '../../../data/services/assistant_directive_service.dart'; +import '../../../data/services/memory_reminder_scheduler.dart'; import '../../../data/services/memory_service.dart'; import '../views/widgets/calendar/calendar_event.dart'; @@ -245,6 +248,17 @@ class CalendarController extends GetxController silent: silent, ); Logger.d('CalendarController', '刷新完成,共 ${events.length} 个日历事件'); + + // 顺手重排一次系统通知。 + // + // ⚠️ 不在这里排的话,冷启动那一轮**根本不会排**:reschedule 原先只挂在 + // AppLifecycleManager 的「切回前台」上,而冷启动不走那条路径。表现就是 + // 装好包、定个闹钟、一直没切过后台 —— 到点不响。 + // 它同时会刷新 permissionDenied,首页那条「通知权限未开启」的提示条 + // 也指望这一次调用才有值。 + if (Get.isRegistered()) { + unawaited(MemoryReminderScheduler.to.reschedule()); + } } @override diff --git a/apps/client/lib/modules/home/views/home_view.dart b/apps/client/lib/modules/home/views/home_view.dart index 7b16b5d9..4be226cd 100644 --- a/apps/client/lib/modules/home/views/home_view.dart +++ b/apps/client/lib/modules/home/views/home_view.dart @@ -6,6 +6,7 @@ import '../../../routes/app_routes.dart'; import 'widgets/calendar/calendar_card.dart'; import 'widgets/calendar/calendar_task_list.dart'; import '../controllers/calendar_controller.dart'; +import '../../../data/services/memory_reminder_scheduler.dart'; // 首页视图 class HomeView extends GetView { @@ -78,6 +79,7 @@ class HomeView extends GetView { padding: EdgeInsets.symmetric(horizontal: 16.w), child: _buildAppBar(context, isDarkMode), ), + _buildNotifyDisabledBar(isDarkMode), SizedBox(height: 16.h), // 日历 + 任务列表 + 翻译卡片/AI(随日历折叠/展开切换) Expanded(child: _buildCalendarArea(isDarkMode)), @@ -90,6 +92,57 @@ class HomeView extends GetView { ); } + /// 通知权限没开时的静态提示条。 + /// + /// ⚠️ 这是**唯一**能让用户知道「闹钟不会响」的地方。 + /// MemoryReminderScheduler.permissionDenied 这个标志一直存在,但在此之前 + /// 没有任何 UI 在读它 —— 于是没通知权限时的表现是:助手说闹钟定好了、 + /// 拾忆页也确实列出来了,到点却一声不响,而且没有任何解释。 + /// + /// ⚠️ 刻意做成常驻的一条,**不是弹窗**:申请通知权限必须由用户在 + /// 「设置 → 权限管理」里主动发起(PermissionUtil 有 48 小时合规冷却, + /// 拒过一次之后两天内 App 不被允许再弹窗解释)。这里只能引导,不能代劳。 + Widget _buildNotifyDisabledBar(bool isDarkMode) { + if (!Get.isRegistered()) { + return const SizedBox.shrink(); + } + return Obx(() { + if (!MemoryReminderScheduler.to.permissionDenied.value) { + return const SizedBox.shrink(); + } + return Padding( + padding: EdgeInsets.only(top: 8.h, left: 16.w, right: 16.w), + child: InkWell( + onTap: () => Get.toNamed(Routes.permissions), + borderRadius: BorderRadius.circular(8.r), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), + decoration: BoxDecoration( + color: const Color(0xFFFFF4E5), + borderRadius: BorderRadius.circular(8.r), + ), + child: Row( + children: [ + Icon(Icons.notifications_off_outlined, + size: 16.sp, color: const Color(0xFFB26A00)), + SizedBox(width: 8.w), + Expanded( + child: Text( + 'memNotifyDisabled'.tr, + style: TextStyle( + fontSize: 12.sp, color: const Color(0xFFB26A00)), + ), + ), + Icon(Icons.chevron_right, + size: 16.sp, color: const Color(0xFFB26A00)), + ], + ), + ), + ), + ); + }); + } + // 日历区域:日历卡片 + 任务列表(Expanded) + 翻译卡片/AI(随展开收起) Widget _buildCalendarArea(bool isDarkMode) { final cal = Get.find();