Browse Source

闹钟真正排进系统通知:排期不再只认服务端,且当场重排

真机上「对助手说定个闹钟」完全不响,三个原因叠加,每一个都不报错:

1. MemoryReminderScheduler.reschedule 的数据源只有 MemoryService.loadUpcoming,
   那是纯服务端接口。后端还没部署时它恒返回空,于是 SET_clock 把闹钟写进了
   本地 reminders、拾忆页也显示出来了,却一条通知都没排。用户看到的是
   「记下了但不会响」,而这跟「指令压根没落地」在表现上分不出来。
   → 加 _localSlots:未同步的本地闹钟也排进来,重复规则口径与
     CalendarController._expandItem 逐条对齐。合流后必须自己再排一次序,
     否则本地那批会按插入位置抢占前 N 个名额、把服务端更早的挤掉。

2. reschedule 只挂在 AppLifecycleManager 的「切回前台」上。新定的闹钟要等
   下次切前台才进系统排期——「5 分钟后提醒我」当场就废了;冷启动那一轮
   也根本不会排。
   → SET_clock 落地后(无论上传成败)当场重排;补传成功后也重排(来源从
     本地兜底变成服务端,不重排会响两遍);CalendarController.refreshFromServer
     末尾也排一次,覆盖冷启动。

3. 没有通知权限时 reschedule 直接 return,而 permissionDenied 这个标志
   **没有任何 UI 在读**(注释里写了要显示提示,实际没接)。测试机上
   POST_NOTIFICATIONS granted=false,所以无论如何都不会响、也没有任何解释。
   → 首页加一条常驻提示条指向权限管理页。刻意不是弹窗:申请必须由用户在
     权限管理里主动发起,PermissionUtil 有 48 小时合规冷却。
     文案 memNotifyDisabled 41 个语言文件里都已存在,无需补 key。

另外把指令链路的日志提到 warn 级(同上一轮恒玄那批的理由:release 的级别
门槛是 warning,写 info 等于真机上查不到)。协议层新增一行「指令帧」日志,
只在这一帧真带了 extra_info 或 command_calls 时打——它是区分
「百炼没下发」和「下发了但端侧没落地」的唯一证据。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 1 month ago
parent
commit
733367002a
  1. 34
      apps/client/lib/data/services/assistant_directive_service.dart
  2. 32
      apps/client/lib/data/services/bailian_multimodal_service.dart
  3. 88
      apps/client/lib/data/services/memory_reminder_scheduler.dart
  4. 14
      apps/client/lib/modules/home/controllers/calendar_controller.dart
  5. 53
      apps/client/lib/modules/home/views/home_view.dart

34
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<MemoryReminderScheduler>()) 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;

32
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;

88
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<MemoryRemindSlot> _localSlots(int days) {
if (!Get.isRegistered<AssistantDirectiveService>()) return const [];
final now = DateTime.now();
final until = now.add(Duration(days: days));
final out = <MemoryRemindSlot>[];
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<DateTime> _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 = <DateTime>[];
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 = <MemoryRemindSlot>[
...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) {

14
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<MemoryReminderScheduler>()) {
unawaited(MemoryReminderScheduler.to.reschedule());
}
}
@override

53
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<HomeController> {
@ -78,6 +79,7 @@ class HomeView extends GetView<HomeController> {
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<HomeController> {
);
}
/// 通知权限没开时的静态提示条。
///
/// ⚠️ 这是**唯一**能让用户知道「闹钟不会响」的地方。
/// MemoryReminderScheduler.permissionDenied 这个标志一直存在,但在此之前
/// 没有任何 UI 在读它 —— 于是没通知权限时的表现是:助手说闹钟定好了、
/// 拾忆页也确实列出来了,到点却一声不响,而且没有任何解释。
///
/// ⚠️ 刻意做成常驻的一条,**不是弹窗**:申请通知权限必须由用户在
/// 「设置 → 权限管理」里主动发起(PermissionUtil 有 48 小时合规冷却,
/// 拒过一次之后两天内 App 不被允许再弹窗解释)。这里只能引导,不能代劳。
Widget _buildNotifyDisabledBar(bool isDarkMode) {
if (!Get.isRegistered<MemoryReminderScheduler>()) {
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<CalendarController>();

Loading…
Cancel
Save