You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
301 lines
12 KiB
301 lines
12 KiB
import 'dart:async';
|
|
|
|
import 'package:get/get.dart';
|
|
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';
|
|
|
|
/// 助手指令的落地层:**解析 → 分发 → 落本地记录**。
|
|
///
|
|
/// 指令由 [BailianMultimodalService] 从 `RespondingContent.extra_info.tool_calls`
|
|
/// 解出来,App 内对话页(EmaiController)和耳机唤醒(BesAiSessionService)两条
|
|
/// 链路都会往这里送,这里是唯一的汇合点。
|
|
///
|
|
/// 目前只有 `SET_clock` 真正落地:**双写**——本地 reminders(迁移期保留一个版本)
|
|
/// 加服务端 memory_item(category=alarm),于是它会出现在拾忆页并跨设备可见。
|
|
/// 其余指令一律
|
|
/// 记一条 `unsupported` 流水——**不是丢掉**:将来要按这份流水看用户到底在用
|
|
/// 哪些能力、以及百炼后台配了但客户端没实现的有哪些。
|
|
///
|
|
/// 两份数据分开存:
|
|
/// - [directives] 是流水日志(含不支持、失败的),只增不改,用于回溯与将来上传;
|
|
/// - [reminders] 是业务实体(用户会看会勾会删),代办页读它。
|
|
///
|
|
/// ⚠️ 目前只在**本地**存。服务端同步接口尚未开发,两个模型的 `synced` 字段和
|
|
/// `toJson()` 已按可直接上传的形状设计好,接口就绪后补上传即可,不用改结构。
|
|
///
|
|
/// ⚠️ 另一个已知边界:指令是从 EMAI 的 WebSocket 会话里回来的,而会话只在
|
|
/// 对话页打开(或耳机唤醒)时才建立。要做到「App 没打开也能定闹钟」,得先有
|
|
/// 常驻的会话持有者,光把执行代码挪到原生层不解决这个问题。
|
|
class AssistantDirectiveService extends GetxService {
|
|
static AssistantDirectiveService get to => Get.find();
|
|
|
|
static const String _tag = 'AssistantDirective';
|
|
static const String _kDirectives = 'assistant_directives';
|
|
static const String _kReminders = 'assistant_reminders';
|
|
|
|
/// 流水最多留这么多条,超了丢最旧的——它只是排查/上传用的日志,不是账本
|
|
static const int _maxDirectives = 300;
|
|
|
|
final GetStorage _storage = GetStorage();
|
|
|
|
/// 各能力模块注册的指令处理器(`ModuleRegistry.directiveHandlers`)。
|
|
/// 查不到的指令走 [_dispatch] 里的内置处理(SET_clock)或记 unsupported。
|
|
final Map<String, DirectiveHandler> _handlers = {};
|
|
|
|
void registerHandlers(Map<String, DirectiveHandler> handlers) {
|
|
_handlers.addAll(handlers);
|
|
}
|
|
|
|
/// 指令流水,新的在前
|
|
final RxList<AssistantDirective> directives = <AssistantDirective>[].obs;
|
|
|
|
/// 提醒(代办页的数据源),按时间正序
|
|
final RxList<AssistantReminder> reminders = <AssistantReminder>[].obs;
|
|
|
|
/// 本版明确不做的指令:上一轮已经把电话/联系人能力整个删掉了
|
|
/// (权限、清单声明、原生实现都删了),这两条即使接上也执行不了。
|
|
static const Map<String, String> _declined = {
|
|
'SEND_message': '本版已移除短信与联系人能力',
|
|
'MAKE_A_PHONE_CALL_phone_call': '本版已移除拨号与联系人能力',
|
|
};
|
|
|
|
@override
|
|
void onInit() {
|
|
super.onInit();
|
|
_restore();
|
|
}
|
|
|
|
/// 收到一批指令:逐条分发并落记录。
|
|
///
|
|
/// 同一 `round_id` 重复送达时按 id 去重——耳机与对话页可能同时在监听同一个
|
|
/// 会话,重复执行会把同一个闹钟定两遍。
|
|
void handle(List<AssistantDirective> incoming) {
|
|
if (incoming.isEmpty) return;
|
|
|
|
var changed = false;
|
|
for (final d in incoming) {
|
|
if (directives.any((e) => e.id == d.id)) {
|
|
Logger.w(_tag, '忽略重复指令 ${d.id}(耳机与对话页可能都在听同一会话)');
|
|
continue;
|
|
}
|
|
_dispatch(d);
|
|
directives.insert(0, d);
|
|
changed = true;
|
|
// warn 级:这是「闹钟到底有没有落地」的唯一端侧证据,
|
|
// release 的级别门槛是 warning,写 info 等于真机上查不到。
|
|
Logger.w(_tag, '指令落地 $d');
|
|
}
|
|
if (!changed) return;
|
|
|
|
if (directives.length > _maxDirectives) {
|
|
directives.removeRange(_maxDirectives, directives.length);
|
|
}
|
|
_persist();
|
|
}
|
|
|
|
void _dispatch(AssistantDirective d) {
|
|
final declined = _declined[d.name];
|
|
if (declined != null) {
|
|
d.status = DirectiveStatus.unsupported;
|
|
d.note = declined;
|
|
return;
|
|
}
|
|
|
|
final h = _handlers[d.name];
|
|
if (h != null) {
|
|
try {
|
|
h(d);
|
|
} catch (e) {
|
|
d.status = DirectiveStatus.failed;
|
|
d.note = '模块处理失败: $e';
|
|
Logger.e(_tag, '指令 ${d.name} 处理失败: $e');
|
|
}
|
|
return;
|
|
}
|
|
switch (d.name) {
|
|
case 'SET_clock':
|
|
_handleSetClock(d);
|
|
break;
|
|
default:
|
|
// 已知但本版没接的:ROUTE_map(导航)、INCREASE_DEFAULT_volume(音量)…
|
|
// 以及百炼后台后加的任何新指令。留一条流水,不报错。
|
|
d.status = DirectiveStatus.unsupported;
|
|
d.note = '尚未实现的指令';
|
|
// 打出来:百炼后台加了新工具时,这行是唯一能发现「模型在下发一个
|
|
// 我们没接的指令」的地方——否则表现成「助手说做了、实际什么都没发生」,
|
|
// 和「压根没下发」长得一模一样。
|
|
Logger.w(_tag, '收到未实现的指令 ${d.name} args=${d.rawArguments}');
|
|
}
|
|
}
|
|
|
|
void _handleSetClock(AssistantDirective d) {
|
|
final reminder = AssistantReminder.fromSetClock(d);
|
|
if (reminder == null) {
|
|
d.status = DirectiveStatus.failed;
|
|
d.note = 'time 缺失或格式无法解析: ${d.rawArguments}';
|
|
Logger.w(_tag, 'SET_clock 解析失败: ${d.rawArguments}');
|
|
return;
|
|
}
|
|
reminders.add(reminder);
|
|
_sortReminders();
|
|
d.status = DirectiveStatus.done;
|
|
d.note = reminder.repeat == RepeatRule.unknown && reminder.repeatRaw.isNotEmpty
|
|
? '重复规则「${reminder.repeatRaw}」未识别,按单次处理'
|
|
: null;
|
|
|
|
// 同时写服务端,让它出现在拾忆页并跨设备可见。
|
|
//
|
|
// 迁移期**双写**:本地 reminders 保留一个版本,等确认服务端链路稳了再摘。
|
|
// 这样即使上传失败,用户至少还能在本机看到自己刚定的闹钟。
|
|
// ⚠️ 上传成不成功**都要**重排一次系统通知:
|
|
// - 成功 → 服务端 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());
|
|
}
|
|
|
|
/// 把所有**还没同步**的本地闹钟补传一遍。
|
|
///
|
|
/// 由拾忆页的刷新(进页面 / 下拉 / 切前台)驱动。存在的意义是自愈:
|
|
/// 服务端没部署、断网、或恰好那一下超时,闹钟都会先落在本地 `synced=false`,
|
|
/// 等下一次能连上时自动补上去,不需要用户做任何事、也不该反复弹提示催他。
|
|
///
|
|
/// 并发保护:多个入口可能同时触发刷新,重入会把同一条传两遍(服务端有
|
|
/// (uid, client_key) 唯一索引兜底不会插重,但白跑一趟)。
|
|
bool _syncing = false;
|
|
Future<int> syncPending() async {
|
|
if (_syncing) return 0;
|
|
if (!Get.isRegistered<MemoryService>() || !User.isLoggedIn()) return 0;
|
|
final pending = reminders.where((e) => !e.synced).toList();
|
|
if (pending.isEmpty) return 0;
|
|
_syncing = true;
|
|
var ok = 0;
|
|
try {
|
|
for (final r in pending) {
|
|
try {
|
|
final saved = await MemoryService.to.add(r.toMemoryItem());
|
|
if (saved != null) {
|
|
r.synced = true;
|
|
ok++;
|
|
} else {
|
|
// 服务端明确拒绝(业务错误)——多半是还没部署 memory 模块。
|
|
// 后面的也一样会失败,早点收手,别把整串请求都打一遍。
|
|
break;
|
|
}
|
|
} catch (_) {
|
|
break; // 网络层出问题,同上
|
|
}
|
|
}
|
|
if (ok > 0) {
|
|
_persist();
|
|
reminders.refresh();
|
|
Logger.w(_tag, '补传了 $ok 条本地闹钟到服务端');
|
|
// 补传之后这些条目的来源从「本地兜底」变成了「服务端」,重排一次,
|
|
// 否则同一个闹钟会同时出现在两边、响两遍。
|
|
_rescheduleNotifications();
|
|
}
|
|
} finally {
|
|
_syncing = false;
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
/// 把一条助手落下来的闹钟写进记忆中心。
|
|
///
|
|
/// ⚠️ **幂等键用指令 id**:端侧指令会重试,而本类的按 id 去重只在**本进程内存**里
|
|
/// ([_seen]),换手机、重装、进程重启后全部失效。服务端靠 (uid, client_key)
|
|
/// 唯一索引兜住,重复提交返回既有记录而不是插第二条。
|
|
Future<void> _uploadReminder(AssistantReminder r) async {
|
|
if (!Get.isRegistered<MemoryService>()) return;
|
|
if (!User.isLoggedIn()) return;
|
|
try {
|
|
// 换算收敛在 AssistantReminderToMemory 扩展里,与拾忆页的本地投影共用同一份
|
|
final item = r.toMemoryItem();
|
|
final saved = await MemoryService.to.add(item);
|
|
if (saved != null) {
|
|
r.synced = true;
|
|
_persist();
|
|
}
|
|
} catch (e) {
|
|
// 上传失败不影响本地闹钟——迁移期本地那份仍然是可用的
|
|
Logger.w(_tag, '闹钟上传服务端失败(本地仍可用): $e');
|
|
}
|
|
}
|
|
|
|
/// 用户在代办页勾选完成
|
|
void toggleReminderDone(String id, bool done) {
|
|
final r = reminders.firstWhereOrNull((e) => e.id == id);
|
|
if (r == null) return;
|
|
r.done = done;
|
|
reminders.refresh();
|
|
_persist();
|
|
}
|
|
|
|
void removeReminder(String id) {
|
|
reminders.removeWhere((e) => e.id == id);
|
|
_persist();
|
|
}
|
|
|
|
/// 排查用:清空本地记录
|
|
void clearAll() {
|
|
directives.clear();
|
|
reminders.clear();
|
|
_persist();
|
|
}
|
|
|
|
void _sortReminders() {
|
|
reminders.sort((a, b) {
|
|
final ad = a.date ?? '';
|
|
final bd = b.date ?? '';
|
|
final byDate = ad.compareTo(bd);
|
|
return byDate != 0 ? byDate : a.time.compareTo(b.time);
|
|
});
|
|
}
|
|
|
|
void _restore() {
|
|
try {
|
|
final rawD = _storage.read<List>(_kDirectives) ?? const [];
|
|
directives.assignAll(rawD
|
|
.whereType<Map>()
|
|
.map((e) => AssistantDirective.fromJson(e.cast<String, dynamic>())));
|
|
final rawR = _storage.read<List>(_kReminders) ?? const [];
|
|
reminders.assignAll(rawR
|
|
.whereType<Map>()
|
|
.map((e) => AssistantReminder.fromJson(e.cast<String, dynamic>())));
|
|
_sortReminders();
|
|
Logger.i(_tag,
|
|
'恢复本地记录: ${directives.length} 条指令 / ${reminders.length} 条提醒');
|
|
} catch (e) {
|
|
// 存档格式对不上(比如改过模型字段)不该让服务起不来,丢掉重来即可
|
|
Logger.e(_tag, '恢复本地记录失败,已重置: $e');
|
|
directives.clear();
|
|
reminders.clear();
|
|
}
|
|
}
|
|
|
|
void _persist() {
|
|
try {
|
|
_storage.write(_kDirectives, directives.map((e) => e.toJson()).toList());
|
|
_storage.write(_kReminders, reminders.map((e) => e.toJson()).toList());
|
|
} catch (e) {
|
|
Logger.e(_tag, '写本地记录失败: $e');
|
|
}
|
|
}
|
|
}
|
|
|