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.
 
 
 
 
 
 

190 lines
6.5 KiB

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<EmaiMemory> 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<String, dynamic> _str(String description) => {
'type': 'string',
'description': description,
};
static Map<String, dynamic> _fn(
String name,
String description,
Map<String, dynamic> properties,
List<String> required,
) {
return {
'type': 'function',
'function': {
'name': name,
'description': description,
'parameters': {
'type': 'object',
'properties': properties,
'required': required,
},
},
};
}
/// SDK 的 functionCall 可能是对象、对象数组,或 arguments 再包一层字符串。
static List<EmaiToolCall> 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<EmaiToolCall>().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<String, dynamic> _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<String, dynamic> args;
}