import 'dart:convert'; /// 一轮 EMAI 对话里和 SDK 无关的部分:系统提示、工具清单、工具参数。 class EmaiTurn { EmaiTurn._(); static const authTools = [ 'search_meeting_notes', 'get_memory_items', 'get_memory_stats', 'get_memory_report', 'get_user_tasks', 'add_user_task', 'cancel_user_task', ]; static String clockText(DateTime t) { const week = ['星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日']; String two(int v) => v.toString().padLeft(2, '0'); final offset = t.timeZoneOffset; final sign = offset.isNegative ? '-' : '+'; final hours = offset.inHours.abs(); final mins = offset.inMinutes.abs() % 60; final zone = mins == 0 ? 'UTC$sign$hours' : 'UTC$sign$hours:${two(mins)}'; return '${t.year}年${t.month}月${t.day}日 ${week[t.weekday - 1]} ' '${two(t.hour)}:${two(t.minute)},$zone'; } static String dayText(DateTime t) { String two(int v) => v.toString().padLeft(2, '0'); return '${t.year}-${two(t.month)}-${two(t.day)}'; } /// 没有能念的地址时用这句。不要写「未知」,模型会把它当成地名。 static String placeLine(String? spoken) { final text = (spoken ?? '').trim(); if (text.isEmpty || text == '未知') return '当前没有位置'; return text; } static bool asksWhere(String text) { final s = text.toLowerCase(); return s.contains('在哪') || s.contains('我的位置') || s.contains('where am i') || s.contains('where i am'); } static String unavailable(bool chinese) => chinese ? '暂时查不到' : "I can't look that up right now."; static String systemPrompt({ required DateTime now, required String? spokenPlace, required String answerLanguage, required List history, }) { final lines = history .map((m) => '${m.user ? "用户" : "助手"}:${m.text}') .join('\n'); return ''' 你是 EAIMAR 的语音助手。用$answerLanguage回答,句子短,适合念出来。 当前时间:${clockText(now)} 当前位置:${placeLine(spokenPlace)} 时间、星期、日期、地点只用上面这两行,不要调用工具,也不要改口。 没有位置时说还没有位置,不要编一个城市,不要念经纬度。 天气用 hefeng_weather,城市用上面的城市名,日期用今天。有城市就不要反问哪个城市。没有位置时才问一次城市。 新闻和需要联网的问题用搜索工具,搜索词里带上今天的日期 ${dayText(now)}。 会议、记忆、待办用对应的个人工具。没有就说没有,不要编。 用户要结束对话时调用 quit。 ${lines.isEmpty ? '' : '最近对话:\n$lines'} '''.trim(); } static String toolsJson({required bool overseas}) { final search = overseas ? 'tavily_web_search' : 'bocha_web_search'; final tools = [ _fn('hefeng_weather', '按城市名查询天气', { 'city': _str('城市名,例如北京'), 'time': _str('日期 yyyy-MM-dd,可省略'), }, const ['city']), _fn(search, '联网搜索新闻和实时信息', { 'query': _str('搜索词,要带上日期'), 'count': {'type': 'integer', 'description': '条数,1到5'}, }, const ['query']), _fn('search_meeting_notes', '查用户自己的会议纪要', { 'keywords': _str('关键词,可空'), }, const []), _fn('get_memory_items', '查用户记下的事项', { 'category': _str('todo、alarm、idea、expense,可空'), }, const []), _fn('get_memory_stats', '查记忆的数量统计', {}, const []), _fn('get_memory_report', '查记忆的汇总', {}, const []), _fn('get_user_tasks', '查提醒和待办', {}, const []), _fn('add_user_task', '添加一条提醒', { 'task_name': _str('标题'), 'trigger_time': _str('带时区的 ISO 8601 时间'), }, const ['task_name', 'trigger_time']), _fn('cancel_user_task', '取消一条提醒', { 'task_id': {'type': 'integer', 'description': '任务 id'}, }, const ['task_id']), _fn('quit', '用户要结束这次对话', {}, const []), ]; return jsonEncode(tools); } static Map _str(String description) => { 'type': 'string', 'description': description, }; static Map _fn( String name, String description, Map properties, List required, ) { return { 'type': 'function', 'function': { 'name': name, 'description': description, 'parameters': { 'type': 'object', 'properties': properties, 'required': required, }, }, }; } /// SDK 的 functionCall 可能是对象、对象数组,或 arguments 再包一层字符串。 static List parseCalls(String raw) { final text = raw.trim(); if (text.isEmpty) return const []; try { final decoded = jsonDecode(text); if (decoded is List) { return decoded.map(_one).whereType().toList(); } if (decoded is Map) { final one = _one(decoded); return one == null ? const [] : [one]; } } catch (_) {} return const []; } static EmaiToolCall? _one(Object? value) { if (value is! Map) return null; final map = value.map((k, v) => MapEntry('$k', v)); final fn = map['function']; final nested = fn is Map ? fn.map((k, v) => MapEntry('$k', v)) : map; final name = (nested['name'] ?? map['name'] ?? '').toString().trim(); if (name.isEmpty) return null; final args = _args(nested['arguments'] ?? map['arguments']); args.remove('auth_token'); args.remove('uid'); return EmaiToolCall(name, args); } static Map _args(Object? value) { if (value is Map) return value.map((k, v) => MapEntry('$k', v)); if (value is String && value.trim().isNotEmpty) { try { final decoded = jsonDecode(value); if (decoded is Map) return decoded.map((k, v) => MapEntry('$k', v)); } catch (_) {} } return {}; } static String searchQuery(String query, DateTime now) { final day = dayText(now); if (query.contains(day)) return query; return '$query $day'.trim(); } } class EmaiMemory { EmaiMemory(this.user, this.text); final bool user; final String text; } class EmaiToolCall { EmaiToolCall(this.name, this.args); final String name; final Map args; }