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 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 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 = []; 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 args = {}; if (raw.isNotEmpty) { try { final decoded = json.decode(raw); if (decoded is Map) args = decoded.cast(); } 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 真机发现:Smartcar 车载助手下发的指令不在 `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 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 = {}; if (params is Map) { args.addAll(params.cast()); } 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 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 j) => AssistantDirective( id: (j['id'] ?? '').toString(), name: (j['name'] ?? '').toString(), args: (j['args'] as Map?)?.cast() ?? {}, 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: _normalizeDate((d.args['date'] ?? '').toString()), repeat: parsed.$1, repeatRaw: rawRepeat, weekday: parsed.$2, createdAt: d.createdAt, directiveId: d.id, roundId: d.roundId, ); } /// 把 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 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 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, ); } /// 提醒 → 记忆项的换算。 /// /// 收敛在这里而不是各写一份:上传服务端(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; } }