import 'dart:convert'; /// 助手指令:百炼多模态在 `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']; if (calls is! List || calls.isEmpty) 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 = []; 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(), )); } return result; } 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, ); }