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
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;
|
|
}
|
|
|