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.
528 lines
18 KiB
528 lines
18 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 真机发现: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<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: _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<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,
|
|
);
|
|
}
|
|
|
|
|
|
/// 提醒 → 记忆项的换算。
|
|
///
|
|
/// 收敛在这里而不是各写一份:上传服务端(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;
|
|
}
|
|
}
|
|
|