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.
609 lines
21 KiB
609 lines
21 KiB
import 'dart:convert';
|
|
|
|
import 'package:get/get.dart';
|
|
|
|
import 'memory_item.dart';
|
|
|
|
/// 端侧指令处理器:由各能力模块注册,处理完自己把 [AssistantDirective.status]
|
|
/// / `note` 填好(见 `AssistantDirectiveService.registerHandlers`)。
|
|
typedef DirectiveHandler = void Function(AssistantDirective directive);
|
|
|
|
/// 助手指令:百炼多模态在 `RespondingContent.extra_info.tool_calls` 里下发的
|
|
/// 端侧函数调用。
|
|
///
|
|
/// 实测下来的协议形状(2026-08-29 抓包,见 bailian_raw_frames.jsonl):
|
|
///
|
|
/// ```json
|
|
/// "extra_info": { "tool_calls": [
|
|
/// { "type": "FUNCTION",
|
|
/// "function": { "name": "SET_clock",
|
|
/// "arguments": "{\"content\":\"开会\",\"time\":\"08:00\",\"repeat\":\"周一\"}" } } ] }
|
|
/// ```
|
|
///
|
|
/// 三个必须知道的点:
|
|
/// 1. `arguments` 是**字符串形式的 JSON**,不是对象,要二次 parse;
|
|
/// 2. 参数值是**中文自然语言**(`repeat="周一"`、`content="开会"`),
|
|
/// 哪怕用户全程说英文也一样,所以解析要按中文词表来,别指望 `MON` 这种枚举;
|
|
/// 3. `extra_info` 底下还有个 `tool_infos`,那是**服务端插件已经执行完**的结果
|
|
/// (天气就是这种),端侧不用做事,两者不能混。
|
|
///
|
|
/// 本类同时是「将来要同步给服务器」的记录单元——字段按可直接序列化上传设计,
|
|
/// 服务端接口还没有,先只落本地。
|
|
class AssistantDirective {
|
|
/// 本地唯一 id(`roundId#序号`,同一轮里可能有多条指令)
|
|
final String id;
|
|
|
|
/// 函数名,如 `SET_clock`
|
|
final String name;
|
|
|
|
/// 解析后的参数
|
|
final Map<String, dynamic> args;
|
|
|
|
/// 原始 arguments 字符串——解析失败时靠它排查,也一并上传
|
|
final String rawArguments;
|
|
|
|
final String dialogId;
|
|
final String roundId;
|
|
final String llmRequestId;
|
|
|
|
/// 来源:`emai`(App 内对话页)/ `earphone`(耳机按键唤醒)
|
|
final String source;
|
|
|
|
/// 同一帧里的回复文案(`output.text`)。
|
|
///
|
|
/// 留着不是为了展示,是为了**校正 12 小时制歧义**:实测模型会把"下午3:30"
|
|
/// 的 `time` 填成 `03:30`(文案却写着"下午3:30"),照单全收就会定到凌晨。
|
|
/// 见 [AssistantReminder.fromSetClock]。
|
|
final String replyText;
|
|
|
|
final DateTime createdAt;
|
|
|
|
/// 处理结果
|
|
DirectiveStatus status;
|
|
|
|
/// 处理说明(不支持的原因、失败原因等)
|
|
String? note;
|
|
|
|
/// 是否已同步到服务器(接口未开发,先恒为 false)
|
|
bool synced;
|
|
|
|
AssistantDirective({
|
|
required this.id,
|
|
required this.name,
|
|
required this.args,
|
|
required this.rawArguments,
|
|
required this.dialogId,
|
|
required this.roundId,
|
|
required this.llmRequestId,
|
|
required this.source,
|
|
required this.createdAt,
|
|
this.replyText = '',
|
|
this.status = DirectiveStatus.pending,
|
|
this.note,
|
|
this.synced = false,
|
|
});
|
|
|
|
/// 从 `payload.output` 里把 tool_calls 全部解出来。
|
|
///
|
|
/// 解析失败不抛异常:宁可留一条 `args` 为空、`rawArguments` 完整的记录,
|
|
/// 也不要因为一个畸形参数把整条会话事件吞掉。
|
|
static List<AssistantDirective> parseFromOutput(
|
|
Map output, {
|
|
required String source,
|
|
}) {
|
|
final extra = output['extra_info'];
|
|
if (extra is! Map) return const [];
|
|
final calls = extra['tool_calls'];
|
|
final commands = extra['commands'];
|
|
final hasCalls = calls is List && calls.isNotEmpty;
|
|
final hasCommands = commands is List && commands.isNotEmpty;
|
|
if (!hasCalls && !hasCommands) return const [];
|
|
|
|
final dialogId = (output['dialog_id'] ?? '').toString();
|
|
final roundId = (output['round_id'] ?? '').toString();
|
|
final llmRequestId = (output['llm_request_id'] ?? '').toString();
|
|
final now = DateTime.now();
|
|
|
|
final result = <AssistantDirective>[];
|
|
if (!hasCalls) {
|
|
_appendCommands(commands, result,
|
|
dialogId: dialogId,
|
|
roundId: roundId,
|
|
llmRequestId: llmRequestId,
|
|
source: source,
|
|
now: now,
|
|
replyText: (output['text'] ?? '').toString());
|
|
return result;
|
|
}
|
|
for (var i = 0; i < calls.length; i++) {
|
|
final call = calls[i];
|
|
if (call is! Map) continue;
|
|
final fn = call['function'];
|
|
if (fn is! Map) continue;
|
|
final name = (fn['name'] ?? '').toString();
|
|
if (name.isEmpty) continue;
|
|
|
|
final raw = (fn['arguments'] ?? '').toString();
|
|
Map<String, dynamic> args = {};
|
|
if (raw.isNotEmpty) {
|
|
try {
|
|
final decoded = json.decode(raw);
|
|
if (decoded is Map) args = decoded.cast<String, dynamic>();
|
|
} catch (_) {
|
|
// 保留 rawArguments,args 留空
|
|
}
|
|
}
|
|
|
|
result.add(AssistantDirective(
|
|
id: '${roundId.isEmpty ? now.microsecondsSinceEpoch : roundId}#$i',
|
|
name: name,
|
|
args: args,
|
|
rawArguments: raw,
|
|
dialogId: dialogId,
|
|
roundId: roundId,
|
|
llmRequestId: llmRequestId,
|
|
source: source,
|
|
createdAt: now,
|
|
replyText: (output['text'] ?? '').toString(),
|
|
));
|
|
}
|
|
if (hasCommands) {
|
|
_appendCommands(commands, result,
|
|
dialogId: dialogId,
|
|
roundId: roundId,
|
|
llmRequestId: llmRequestId,
|
|
source: source,
|
|
now: now,
|
|
replyText: (output['text'] ?? '').toString());
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// 解析 `extra_info.commands` —— **另一种指令形状**。
|
|
///
|
|
/// ⚠️ 2026-09-11 真机发现:EaiCar 车载助手下发的指令不在 `tool_calls` 里,
|
|
/// 而是 `extra_info.commands`,形如
|
|
/// ```json
|
|
/// "commands":[{"intent_info":{"domain":"general_command","intent":"quit"},
|
|
/// "name":"quit","params":[]}]
|
|
/// ```
|
|
/// 只解析 `tool_calls` 的话这里恒为 0 条 —— 表现就是"AI 说好的已经关闭了,
|
|
/// 但实际什么都没发生"。两种形状都要认,且**不能互斥**:同一帧可能都带。
|
|
static void _appendCommands(
|
|
List commands,
|
|
List<AssistantDirective> out, {
|
|
required String dialogId,
|
|
required String roundId,
|
|
required String llmRequestId,
|
|
required String source,
|
|
required DateTime now,
|
|
required String replyText,
|
|
}) {
|
|
for (var i = 0; i < commands.length; i++) {
|
|
final c = commands[i];
|
|
if (c is! Map) continue;
|
|
final name = (c['name'] ?? '').toString();
|
|
if (name.isEmpty) continue;
|
|
|
|
// params 形状没有文档,实测是空数组。能转成 Map 就转,转不成就原样留着,
|
|
// 别为了凑格式把信息丢了。
|
|
final params = c['params'];
|
|
final args = <String, dynamic>{};
|
|
if (params is Map) {
|
|
args.addAll(params.cast<String, dynamic>());
|
|
} else if (params is List) {
|
|
for (final p in params) {
|
|
if (p is Map && p['name'] != null) {
|
|
args[p['name'].toString()] = p['value'];
|
|
}
|
|
}
|
|
}
|
|
final intent = c['intent_info'];
|
|
if (intent is Map && intent['intent'] != null) {
|
|
args.putIfAbsent('intent', () => intent['intent']);
|
|
}
|
|
|
|
out.add(AssistantDirective(
|
|
id: '${roundId.isEmpty ? now.microsecondsSinceEpoch : roundId}#cmd$i',
|
|
name: name,
|
|
args: args,
|
|
rawArguments: json.encode(c),
|
|
dialogId: dialogId,
|
|
roundId: roundId,
|
|
llmRequestId: llmRequestId,
|
|
source: source,
|
|
createdAt: now,
|
|
replyText: replyText,
|
|
));
|
|
}
|
|
}
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'id': id,
|
|
'name': name,
|
|
'args': args,
|
|
'raw_arguments': rawArguments,
|
|
'dialog_id': dialogId,
|
|
'round_id': roundId,
|
|
'llm_request_id': llmRequestId,
|
|
'source': source,
|
|
'reply_text': replyText,
|
|
'created_at': createdAt.toIso8601String(),
|
|
'status': status.name,
|
|
'note': note,
|
|
'synced': synced,
|
|
};
|
|
|
|
factory AssistantDirective.fromJson(Map<String, dynamic> j) =>
|
|
AssistantDirective(
|
|
id: (j['id'] ?? '').toString(),
|
|
name: (j['name'] ?? '').toString(),
|
|
args: (j['args'] as Map?)?.cast<String, dynamic>() ?? {},
|
|
rawArguments: (j['raw_arguments'] ?? '').toString(),
|
|
dialogId: (j['dialog_id'] ?? '').toString(),
|
|
roundId: (j['round_id'] ?? '').toString(),
|
|
llmRequestId: (j['llm_request_id'] ?? '').toString(),
|
|
source: (j['source'] ?? '').toString(),
|
|
replyText: (j['reply_text'] ?? '').toString(),
|
|
createdAt:
|
|
DateTime.tryParse((j['created_at'] ?? '').toString()) ?? DateTime.now(),
|
|
status: DirectiveStatus.values.firstWhere(
|
|
(s) => s.name == j['status'],
|
|
orElse: () => DirectiveStatus.pending,
|
|
),
|
|
note: j['note'] as String?,
|
|
synced: j['synced'] == true,
|
|
);
|
|
|
|
@override
|
|
String toString() => '$name($rawArguments) → ${status.name}';
|
|
}
|
|
|
|
enum DirectiveStatus {
|
|
/// 已收到、还没处理
|
|
pending,
|
|
|
|
/// 已处理(闹钟已落成代办)
|
|
done,
|
|
|
|
/// 本版不支持这个指令
|
|
unsupported,
|
|
|
|
/// 处理时出错
|
|
failed,
|
|
}
|
|
|
|
/// 重复规则。`SET_clock` 的 `repeat` 是中文自然语言,这里归一成可判定的枚举,
|
|
/// 同时保留原文,将来同步服务器和排查都要用。
|
|
enum RepeatRule {
|
|
/// 不重复(一次性闹钟,通常带 date)
|
|
once,
|
|
daily,
|
|
weekly,
|
|
weekdays,
|
|
weekend,
|
|
|
|
/// 认不出来的写法——不猜,按一次性处理并留原文
|
|
unknown,
|
|
}
|
|
|
|
/// 从助手指令落下来的提醒/闹钟,也就是「代办」页要展示的东西。
|
|
///
|
|
/// 和 [AssistantDirective] 分开存:指令是**流水日志**(含不支持、失败的),
|
|
/// 提醒是**业务实体**(用户会看会勾会删)。将来上传服务器时两者都要传,
|
|
/// 但生命周期不一样。
|
|
class AssistantReminder {
|
|
final String id;
|
|
|
|
/// 提醒内容,来自指令的 `content`;模型没给就为空,展示时兜底成「闹钟」
|
|
final String content;
|
|
|
|
/// 时分,`HH:mm`
|
|
final String time;
|
|
|
|
/// 一次性闹钟的日期 `yyyy-MM-dd`;重复闹钟为空
|
|
final String? date;
|
|
|
|
/// 归一后的重复规则
|
|
final RepeatRule repeat;
|
|
|
|
/// 重复规则原文(`每天` / `周一` / `工作日`…),认不出来时全靠它
|
|
final String repeatRaw;
|
|
|
|
/// 周期为 weekly 时的星期(1=周一 … 7=周日),其余为 null
|
|
final int? weekday;
|
|
|
|
final DateTime createdAt;
|
|
|
|
/// 关联的指令,便于回溯是哪一轮对话产生的
|
|
final String directiveId;
|
|
final String roundId;
|
|
|
|
/// 用户是否已完成
|
|
bool done;
|
|
|
|
/// 是否已同步到服务器(接口未开发,先恒为 false)
|
|
bool synced;
|
|
|
|
AssistantReminder({
|
|
required this.id,
|
|
required this.content,
|
|
required this.time,
|
|
required this.date,
|
|
required this.repeat,
|
|
required this.repeatRaw,
|
|
required this.weekday,
|
|
required this.createdAt,
|
|
required this.directiveId,
|
|
required this.roundId,
|
|
this.done = false,
|
|
this.synced = false,
|
|
});
|
|
|
|
/// 从 `SET_clock` 指令构造。time 缺失或格式不对时返回 null——
|
|
/// 没有时间的闹钟没有意义,不如不落。
|
|
static AssistantReminder? fromSetClock(AssistantDirective d) {
|
|
var time = _normalizeTime((d.args['time'] ?? '').toString());
|
|
if (time == null) return null;
|
|
time = disambiguateAfternoon(time, d.replyText);
|
|
|
|
final rawRepeat = (d.args['repeat'] ?? '').toString().trim();
|
|
final parsed = parseRepeat(rawRepeat);
|
|
|
|
return AssistantReminder(
|
|
id: d.id,
|
|
content: (d.args['content'] ?? '').toString().trim(),
|
|
time: time,
|
|
date: rollForwardPastDate(
|
|
_normalizeDate((d.args['date'] ?? '').toString()),
|
|
time,
|
|
d.createdAt,
|
|
parsed.$1,
|
|
),
|
|
repeat: parsed.$1,
|
|
repeatRaw: rawRepeat,
|
|
weekday: parsed.$2,
|
|
createdAt: d.createdAt,
|
|
directiveId: d.id,
|
|
roundId: d.roundId,
|
|
);
|
|
}
|
|
|
|
/// 模型给的 `date` 落在过去时,挪到就近的未来。
|
|
///
|
|
/// 起因是真机实测(2026-09-19 09:25 定「下午2点去恒玄公司」,服务端 id=35):
|
|
/// 模型下发的 `date` 是 **2026-09-18**,比当天早一天。客户端原样信了,于是
|
|
/// 闹钟落在昨天那一页 —— 助手说"设好了",拾忆页翻到今天却空空如也,
|
|
/// 而且**永远不会响**(服务端 remind 只排未来的,那条的 next_remind_at=0)。
|
|
///
|
|
/// 根因在模型侧:会话 Start 里没有告诉百炼"今天是几号"(见
|
|
/// BailianMultimodalService 的 parameters),模型只能自己猜,偶尔差一天
|
|
/// (同一张表里 9-14 那条也差了一天,9-15 的两条又是对的)。
|
|
/// 正解是在百炼应用里注入当前时间,但那是控制台配置、不在本仓库;
|
|
/// 在那之前这道防线兜住最难受的表现。
|
|
///
|
|
/// 口径:**过去的一次性闹钟没有意义**,所以
|
|
/// - 今天这个点还没到 → 挪到今天;
|
|
/// - 已经过了 → 挪到明天。
|
|
///
|
|
/// ⚠️ 只管一次性的。重复闹钟的 `date` 是锚点,落在过去是正常的
|
|
/// ("每周一"从上周一起算),挪了反而会把第一次触发推迟一周。
|
|
///
|
|
/// ⚠️ 只在**确实早于今天**时才动。模型把"明天"算成今天这类偏差
|
|
/// 客户端分辨不了(时间仍在未来),不猜。
|
|
static String? rollForwardPastDate(
|
|
String? date,
|
|
String time,
|
|
DateTime now,
|
|
RepeatRule repeat,
|
|
) {
|
|
if (date == null) return null;
|
|
if (repeat != RepeatRule.once && repeat != RepeatRule.unknown) return date;
|
|
|
|
final parsed = DateTime.tryParse(date);
|
|
if (parsed == null) return date;
|
|
|
|
final today = DateTime(now.year, now.month, now.day);
|
|
if (!parsed.isBefore(today)) return date;
|
|
|
|
final parts = time.split(':');
|
|
final at = DateTime(now.year, now.month, now.day,
|
|
int.parse(parts[0]), int.parse(parts[1]));
|
|
final target = at.isAfter(now) ? today : today.add(const Duration(days: 1));
|
|
return '${target.year.toString().padLeft(4, '0')}-'
|
|
'${target.month.toString().padLeft(2, '0')}-'
|
|
'${target.day.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
/// 把 12 小时制的歧义时间按回复文案校正到 24 小时制。
|
|
///
|
|
/// 起因是实测拿到的这一帧:用户说 "3:30pm",模型回 "已经帮你设定了明天下午
|
|
/// 3:30 的闹钟",但 `time` 填的是 `"03:30"`。同一批样本里 "5pm"→`17:00`、
|
|
/// "9pm"→`21:00` 又是对的,所以这是模型的偶发行为,客户端不能无条件相信。
|
|
///
|
|
/// 判据保守:只有 `hour < 12`、且文案**明确说了下午/晚上而没同时提上午**时
|
|
/// 才 +12。宁可漏校正(用户看得见时间不对,可以再说一次),也不要把本来
|
|
/// 正确的早上闹钟推到晚上。
|
|
static String disambiguateAfternoon(String time, String replyText) {
|
|
final hour = int.parse(time.split(':')[0]);
|
|
if (hour == 0 || hour >= 12) return time;
|
|
|
|
final t = replyText.toLowerCase();
|
|
final saysMorning = t.contains('上午') ||
|
|
t.contains('早上') ||
|
|
t.contains('清晨') ||
|
|
t.contains('凌晨') ||
|
|
RegExp(r'\bam\b').hasMatch(t);
|
|
if (saysMorning) return time;
|
|
|
|
final saysAfternoon = t.contains('下午') ||
|
|
t.contains('晚上') ||
|
|
t.contains('傍晚') ||
|
|
t.contains('夜里') ||
|
|
RegExp(r'\bpm\b').hasMatch(t);
|
|
if (!saysAfternoon) return time;
|
|
|
|
return '${(hour + 12).toString().padLeft(2, '0')}:${time.split(':')[1]}';
|
|
}
|
|
|
|
/// 中文重复规则 → (规则, 星期几)
|
|
///
|
|
/// 词表按实测样本 + 常见说法整理:实测见过 `每天`、`周一`。
|
|
static (RepeatRule, int?) parseRepeat(String raw) {
|
|
final s = raw.replaceAll(' ', '');
|
|
if (s.isEmpty) return (RepeatRule.once, null);
|
|
if (s.contains('每天') || s.contains('每日') || s.contains('天天')) {
|
|
return (RepeatRule.daily, null);
|
|
}
|
|
if (s.contains('工作日')) return (RepeatRule.weekdays, null);
|
|
if (s.contains('周末')) return (RepeatRule.weekend, null);
|
|
|
|
const names = ['一', '二', '三', '四', '五', '六', '日', '天'];
|
|
for (var i = 0; i < names.length; i++) {
|
|
final n = names[i];
|
|
if (s.contains('周$n') || s.contains('星期$n') || s.contains('礼拜$n')) {
|
|
// '日' 和 '天' 都指周日 → 7
|
|
final weekday = i >= 6 ? 7 : i + 1;
|
|
return (RepeatRule.weekly, weekday);
|
|
}
|
|
}
|
|
return (RepeatRule.unknown, null);
|
|
}
|
|
|
|
/// `8:0` / `08:00` / `08:00:00` 都归一成 `08:00`;认不出来返回 null
|
|
static String? _normalizeTime(String raw) {
|
|
final s = raw.trim().replaceAll(':', ':');
|
|
final m = RegExp(r'^(\d{1,2}):(\d{1,2})').firstMatch(s);
|
|
if (m == null) return null;
|
|
final h = int.tryParse(m.group(1)!);
|
|
final min = int.tryParse(m.group(2)!);
|
|
if (h == null || min == null || h > 23 || min > 59) return null;
|
|
return '${h.toString().padLeft(2, '0')}:${min.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
static String? _normalizeDate(String raw) {
|
|
final s = raw.trim();
|
|
if (s.isEmpty) return null;
|
|
final d = DateTime.tryParse(s);
|
|
if (d == null) return null;
|
|
return '${d.year.toString().padLeft(4, '0')}-'
|
|
'${d.month.toString().padLeft(2, '0')}-'
|
|
'${d.day.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
int get hour => int.parse(time.split(':')[0]);
|
|
int get minute => int.parse(time.split(':')[1]);
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'id': id,
|
|
'content': content,
|
|
'time': time,
|
|
'date': date,
|
|
'repeat': repeat.name,
|
|
'repeat_raw': repeatRaw,
|
|
'weekday': weekday,
|
|
'created_at': createdAt.toIso8601String(),
|
|
'directive_id': directiveId,
|
|
'round_id': roundId,
|
|
'done': done,
|
|
'synced': synced,
|
|
};
|
|
|
|
factory AssistantReminder.fromJson(Map<String, dynamic> j) =>
|
|
AssistantReminder(
|
|
id: (j['id'] ?? '').toString(),
|
|
content: (j['content'] ?? '').toString(),
|
|
time: (j['time'] ?? '00:00').toString(),
|
|
date: j['date'] as String?,
|
|
repeat: RepeatRule.values.firstWhere(
|
|
(r) => r.name == j['repeat'],
|
|
orElse: () => RepeatRule.once,
|
|
),
|
|
repeatRaw: (j['repeat_raw'] ?? '').toString(),
|
|
weekday: j['weekday'] as int?,
|
|
createdAt:
|
|
DateTime.tryParse((j['created_at'] ?? '').toString()) ?? DateTime.now(),
|
|
directiveId: (j['directive_id'] ?? '').toString(),
|
|
roundId: (j['round_id'] ?? '').toString(),
|
|
done: j['done'] == true,
|
|
synced: j['synced'] == true,
|
|
);
|
|
}
|
|
|
|
|
|
/// 本地提醒里**还需要投影到拾忆页**的那些:服务端已经有的排除掉。
|
|
///
|
|
/// 双写迁移期,同一条闹钟可能同时存在于两处——本地 reminders 与服务端
|
|
/// memory_item。页面两边都投影就会出现「同一个闹钟两张卡,一张能编辑、
|
|
/// 点另一张说没同步到服务器」(2026-09-19 真机实测,服务端 id=36/37 各只有
|
|
/// 一行,重复纯在客户端)。
|
|
///
|
|
/// ⚠️ **不能只信本地的 `synced` 标志**。它是服务端事实的一份影子拷贝,
|
|
/// 丢了就会凭空多一条:上传成功后进程被杀(标志还没落盘)、重装、清缓存
|
|
/// 都会把一条服务端已有的闹钟打回 unsynced。服务端那份数据自己就带着答案——
|
|
/// `client_key` / `source_id` 存的正是指令 id(见 [AssistantReminderToMemory]),
|
|
/// 对上了就说明已经在服务端了。两个键都认:历史数据里有只写了其中一个的。
|
|
List<AssistantReminder> pendingLocalReminders(
|
|
Iterable<AssistantReminder> reminders,
|
|
Iterable<MemoryItem> serverItems,
|
|
) {
|
|
final uploaded = <String>{};
|
|
for (final it in serverItems) {
|
|
if (it.clientKey.isNotEmpty) uploaded.add(it.clientKey);
|
|
if (it.sourceId.isNotEmpty) uploaded.add(it.sourceId);
|
|
}
|
|
return [
|
|
for (final r in reminders)
|
|
if (!r.synced &&
|
|
!(r.directiveId.isNotEmpty && uploaded.contains(r.directiveId)) &&
|
|
!(r.id.isNotEmpty && uploaded.contains(r.id)))
|
|
r,
|
|
];
|
|
}
|
|
|
|
/// 提醒 → 记忆项的换算。
|
|
///
|
|
/// 收敛在这里而不是各写一份:上传服务端(AssistantDirectiveService._uploadReminder)
|
|
/// 与拾忆页的本地投影(CalendarController)都要用,两处各写一遍必然漂移。
|
|
extension AssistantReminderToMemory on AssistantReminder {
|
|
MemoryItem toMemoryItem() {
|
|
final now = DateTime.now();
|
|
final today = '${now.year}-'
|
|
'${now.month.toString().padLeft(2, '0')}-'
|
|
'${now.day.toString().padLeft(2, '0')}';
|
|
return MemoryItem(
|
|
category: MemoryCategory.alarm,
|
|
source: MemorySource.assistant,
|
|
sourceId: directiveId,
|
|
clientKey: directiveId.isNotEmpty ? directiveId : id,
|
|
title: content.isNotEmpty ? content : 'memCatAlarm'.tr,
|
|
happenDate: date ?? today,
|
|
happenTime: time,
|
|
repeatRule: _repeatRuleToRaw(repeat),
|
|
repeatRaw: repeatRaw,
|
|
weekday: weekday ?? 0,
|
|
remindAhead: 5,
|
|
state: done ? MemoryState.done : MemoryState.pending,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 本地 RepeatRule 枚举 → 服务端的字符串口径。
|
|
///
|
|
/// unknown 按单次处理:认不出的重复规则宁可只响一次,
|
|
/// 也不要按猜的规则天天响。
|
|
String _repeatRuleToRaw(RepeatRule r) {
|
|
switch (r) {
|
|
case RepeatRule.daily:
|
|
return MemoryRepeat.daily;
|
|
case RepeatRule.weekly:
|
|
return MemoryRepeat.weekly;
|
|
case RepeatRule.weekdays:
|
|
return MemoryRepeat.weekdays;
|
|
case RepeatRule.weekend:
|
|
return MemoryRepeat.weekend;
|
|
case RepeatRule.once:
|
|
case RepeatRule.unknown:
|
|
return MemoryRepeat.once;
|
|
}
|
|
}
|
|
|