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.
392 lines
12 KiB
392 lines
12 KiB
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:get/get.dart';
|
|
import 'package:get_storage/get_storage.dart';
|
|
import 'package:http/http.dart' as http;
|
|
|
|
import '../../core/utils/logger.dart';
|
|
import '../models/appconfig.dart';
|
|
import '../models/assistant_directive.dart';
|
|
import 'bailian_multimodal_service.dart';
|
|
import 'device_location_service.dart';
|
|
import 'emai_iflytek_turn.dart';
|
|
import 'language_manager.dart';
|
|
import 'mcp_token_service.dart';
|
|
import 'sparkchain_session.dart';
|
|
|
|
/// 耳机唤醒和按住说话共用的一轮编排:讯飞听写 → 文本对话 → 需要时手机调工具 → 合成。
|
|
///
|
|
/// 麦和喇叭不在这里。调用方把 PCM 推进来,把 [audioOut] 播出去。
|
|
class EmaiIflytekDialog {
|
|
EmaiIflytekDialog({this.source = 'emai'});
|
|
|
|
static const _tag = 'EmaiIflytek';
|
|
static const upstreamSampleRate = 16000;
|
|
static const downstreamSampleRate = 16000;
|
|
|
|
final String source;
|
|
final state = BailianDialogState.idle.obs;
|
|
final _events = StreamController<BailianEvent>.broadcast();
|
|
final _audioOut = StreamController<Uint8List>.broadcast();
|
|
final List<EmaiMemory> _history = [];
|
|
|
|
Stream<BailianEvent> get events => _events.stream;
|
|
Stream<Uint8List> get audioOut => _audioOut.stream;
|
|
|
|
bool _listening = false;
|
|
int _gen = 0;
|
|
bool _inSpeech = false;
|
|
|
|
Future<bool> start() async {
|
|
if (!await SparkChainSession.ensure()) return false;
|
|
SparkChainSession.asrListener = _onAsr;
|
|
final ok = await SparkChainSession.startMsftListen(_asrCode());
|
|
if (!ok) {
|
|
SparkChainSession.asrListener = null;
|
|
return false;
|
|
}
|
|
_listening = true;
|
|
state.value = BailianDialogState.listening;
|
|
_events.add(const BailianEvent(BailianEventType.started));
|
|
_events.add(BailianEvent(
|
|
BailianEventType.stateChanged,
|
|
state: BailianDialogState.listening,
|
|
));
|
|
return true;
|
|
}
|
|
|
|
void pushAudio(Uint8List pcm) {
|
|
if (!_listening || pcm.isEmpty) return;
|
|
SparkChainSession.write(pcm);
|
|
}
|
|
|
|
Future<void> stop() async {
|
|
_gen++;
|
|
_listening = false;
|
|
SparkChainSession.asrListener = null;
|
|
await SparkChainSession.stopListen();
|
|
await SparkChainSession.stopSpeak();
|
|
state.value = BailianDialogState.idle;
|
|
_events.add(const BailianEvent(BailianEventType.closed));
|
|
}
|
|
|
|
/// 问候语。不进对话模型,直接合成。
|
|
Future<void> speak(String text) async {
|
|
final gen = _gen;
|
|
await _say(text, gen);
|
|
}
|
|
|
|
void _onAsr(Map<String, dynamic> event) {
|
|
if (event['kind'] == 'error') {
|
|
Logger.w(_tag, '听写失败 code=${event['code']}');
|
|
return;
|
|
}
|
|
final text = (event['text'] as String?)?.trim() ?? '';
|
|
final isFinal = event['final'] == true;
|
|
if (text.isEmpty) return;
|
|
if (!_inSpeech) {
|
|
_inSpeech = true;
|
|
_events.add(const BailianEvent(BailianEventType.speechStarted));
|
|
}
|
|
_events.add(BailianEvent(
|
|
BailianEventType.speechContent,
|
|
text: text,
|
|
isFinal: isFinal,
|
|
));
|
|
if (!isFinal) return;
|
|
_inSpeech = false;
|
|
_events.add(BailianEvent(BailianEventType.speechEnded, text: text));
|
|
unawaited(_answer(text));
|
|
}
|
|
|
|
Future<void> _answer(String userText) async {
|
|
final gen = ++_gen;
|
|
await SparkChainSession.stopSpeak();
|
|
if (EmaiTurn.asksWhere(userText)) {
|
|
await DeviceLocationService.maybe?.ensureSpokenPlace();
|
|
}
|
|
if (gen != _gen) return;
|
|
state.value = BailianDialogState.thinking;
|
|
_events.add(BailianEvent(
|
|
BailianEventType.stateChanged,
|
|
state: BailianDialogState.thinking,
|
|
));
|
|
final chinese = _asrCode().toLowerCase().startsWith('zh');
|
|
final overseas = _overseas();
|
|
final tools = EmaiTurn.toolsJson(overseas: overseas);
|
|
var query = userText;
|
|
var fresh = true;
|
|
String spoken = '';
|
|
for (var round = 0; round < 2; round++) {
|
|
if (gen != _gen) return;
|
|
final SparkLlmReply reply;
|
|
try {
|
|
reply = await SparkChainSession.chat(
|
|
query: query,
|
|
system: EmaiTurn.systemPrompt(
|
|
now: DateTime.now(),
|
|
spokenPlace: DeviceLocationService.maybe?.placeText(),
|
|
answerLanguage: chinese ? '中文' : 'English',
|
|
history: List<EmaiMemory>.from(_history),
|
|
),
|
|
tools: tools,
|
|
fresh: fresh,
|
|
);
|
|
} catch (e) {
|
|
Logger.w(_tag, '对话失败: $e');
|
|
await _say(EmaiTurn.unavailable(chinese), gen);
|
|
return;
|
|
}
|
|
if (gen != _gen) return;
|
|
final calls = EmaiTurn.parseCalls(reply.functionCall);
|
|
if (calls.isEmpty) {
|
|
spoken = reply.text.trim();
|
|
break;
|
|
}
|
|
final call = calls.first;
|
|
if (call.name == 'quit') {
|
|
_events.add(BailianEvent(
|
|
BailianEventType.respondingContent,
|
|
text: reply.text,
|
|
directives: [
|
|
AssistantDirective(
|
|
id: 'quit-$gen',
|
|
name: 'quit',
|
|
args: const {},
|
|
rawArguments: '{}',
|
|
dialogId: '',
|
|
roundId: '$gen',
|
|
llmRequestId: '',
|
|
source: source,
|
|
createdAt: DateTime.now(),
|
|
),
|
|
],
|
|
));
|
|
final bye = reply.text.trim().isEmpty
|
|
? (chinese ? '好的' : 'Okay')
|
|
: reply.text.trim();
|
|
await _say(bye, gen);
|
|
return;
|
|
}
|
|
final result = await _runTool(call);
|
|
if (gen != _gen) return;
|
|
if (result == null) {
|
|
await _say(EmaiTurn.unavailable(chinese), gen);
|
|
return;
|
|
}
|
|
fresh = false;
|
|
query = '工具 ${call.name} 的结果如下,只根据它回答,不要编造:\n$result';
|
|
}
|
|
if (spoken.isEmpty) {
|
|
await _say(EmaiTurn.unavailable(chinese), gen);
|
|
return;
|
|
}
|
|
_history.add(EmaiMemory(true, userText));
|
|
_history.add(EmaiMemory(false, spoken));
|
|
if (_history.length > 8) {
|
|
_history.removeRange(0, _history.length - 8);
|
|
}
|
|
await _say(spoken, gen);
|
|
}
|
|
|
|
Future<void> _say(String text, int gen) async {
|
|
if (text.isEmpty || gen != _gen) return;
|
|
state.value = BailianDialogState.responding;
|
|
_events.add(const BailianEvent(BailianEventType.respondingStarted));
|
|
_events.add(BailianEvent(
|
|
BailianEventType.respondingContent,
|
|
text: text,
|
|
isFinal: true,
|
|
finishReason: 'stop',
|
|
));
|
|
try {
|
|
await SparkChainSession.speakNeural(
|
|
voice: _voice(),
|
|
text: text,
|
|
onPcm: (pcm, _) {
|
|
if (gen != _gen || pcm.isEmpty) return;
|
|
_audioOut.add(pcm);
|
|
},
|
|
);
|
|
} catch (e) {
|
|
Logger.w(_tag, '合成失败: $e');
|
|
}
|
|
if (gen != _gen) return;
|
|
_events.add(const BailianEvent(BailianEventType.respondingEnded));
|
|
state.value = BailianDialogState.listening;
|
|
_events.add(BailianEvent(
|
|
BailianEventType.stateChanged,
|
|
state: BailianDialogState.listening,
|
|
));
|
|
}
|
|
|
|
Future<String?> _runTool(EmaiToolCall call) async {
|
|
final now = DateTime.now();
|
|
final args = Map<String, dynamic>.from(call.args);
|
|
if (call.name == 'hefeng_weather') {
|
|
final city = (args['city'] ?? '').toString().trim();
|
|
if (city.isEmpty) {
|
|
final cached = _city();
|
|
if (cached.isEmpty) return '没有位置,请向用户询问要查哪个城市,不要编造天气。';
|
|
args['city'] = cached;
|
|
}
|
|
if ((args['time'] ?? '').toString().trim().isEmpty) {
|
|
args['time'] = EmaiTurn.dayText(now);
|
|
}
|
|
}
|
|
if (call.name == 'tavily_web_search' || call.name == 'bocha_web_search') {
|
|
args['query'] = EmaiTurn.searchQuery(
|
|
(args['query'] ?? '').toString(),
|
|
now,
|
|
);
|
|
}
|
|
final authed = EmaiTurn.authTools.contains(call.name);
|
|
String? token;
|
|
if (authed) {
|
|
token = await McpTokenService.get();
|
|
if (token == null || token.isEmpty) return null;
|
|
}
|
|
final url = _mcpUrl(call.name);
|
|
if (url == null) return null;
|
|
try {
|
|
final resp = await http
|
|
.post(
|
|
url,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json, text/event-stream',
|
|
if (token != null) 'Authorization': 'Bearer $token',
|
|
},
|
|
body: jsonEncode({
|
|
'jsonrpc': '2.0',
|
|
'id': 1,
|
|
'method': 'tools/call',
|
|
'params': {'name': call.name, 'arguments': args},
|
|
}),
|
|
)
|
|
.timeout(const Duration(seconds: 12));
|
|
if (resp.statusCode < 200 || resp.statusCode >= 300) return null;
|
|
return _toolText(resp.body);
|
|
} catch (e) {
|
|
Logger.w(_tag, '工具 ${call.name} 失败');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
String? _toolText(String body) {
|
|
final payloads = <String>[];
|
|
for (final line in body.split('\n')) {
|
|
final trimmed = line.trim();
|
|
if (trimmed.startsWith('data:')) {
|
|
payloads.add(trimmed.substring(5).trim());
|
|
}
|
|
}
|
|
if (payloads.isEmpty) payloads.add(body);
|
|
for (final payload in payloads.reversed) {
|
|
if (payload.isEmpty) continue;
|
|
try {
|
|
final decoded = jsonDecode(payload);
|
|
if (decoded is! Map) continue;
|
|
if (decoded['error'] != null) return null;
|
|
final result = decoded['result'];
|
|
if (result is! Map) continue;
|
|
if (result['isError'] == true) return null;
|
|
final content = result['content'];
|
|
if (content is List) {
|
|
final texts = content
|
|
.whereType<Map>()
|
|
.map((m) => (m['text'] ?? '').toString())
|
|
.where((s) => s.isNotEmpty)
|
|
.join('\n');
|
|
if (texts.isNotEmpty) return texts;
|
|
}
|
|
final text = result['text'];
|
|
if (text is String && text.isNotEmpty) return text;
|
|
} catch (_) {}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Uri? _mcpUrl(String tool) {
|
|
if (!AppConfig.isInitialized()) return null;
|
|
final raw = AppConfig.mcpConfig();
|
|
final servers = raw is Map ? raw['mcpServers'] : null;
|
|
if (servers is! Map) return null;
|
|
String? fallback;
|
|
for (final value in servers.values) {
|
|
if (value is! Map && value.runtimeType.toString() != 'DBMCPServer') {
|
|
// generated model
|
|
}
|
|
final url = _serverUrl(value);
|
|
final tools = _serverTools(value);
|
|
if (url == null) continue;
|
|
fallback ??= url;
|
|
if (tools.contains(tool)) return Uri.parse(url);
|
|
}
|
|
if (fallback == null) return null;
|
|
return Uri.parse(fallback);
|
|
}
|
|
|
|
String? _serverUrl(Object value) {
|
|
try {
|
|
final url = (value as dynamic).url?.toString().trim() ?? '';
|
|
if (url.isEmpty) return null;
|
|
if (url.contains('/mcp')) return url;
|
|
return url.endsWith('/') ? '${url}mcp' : '$url/mcp';
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
String _serverTools(Object value) {
|
|
try {
|
|
return ((value as dynamic).tools ?? '').toString();
|
|
} catch (_) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
String _city() {
|
|
final place = DeviceLocationService.maybe;
|
|
if (place == null) return '';
|
|
final loc = place.clientLocation();
|
|
return (loc?['city_name'] ?? '').trim();
|
|
}
|
|
|
|
bool _overseas() {
|
|
final raw = AppConfig.cred('sparkchain', 'api_type', 'SPARKCHAIN_API_TYPE');
|
|
final apiType = int.tryParse(raw ?? '') ?? 1;
|
|
return apiType != 0;
|
|
}
|
|
|
|
String _asrCode() {
|
|
final saved = GetStorage().read<String>('language') ?? 'system';
|
|
if (saved != 'system') {
|
|
final parts = saved.split('_');
|
|
if (parts.length == 2) {
|
|
final lang = parts[0].toLowerCase();
|
|
final country = parts[1].toUpperCase();
|
|
if (lang == 'tl' && country == 'PH') return 'fil-PH';
|
|
return '$lang-$country';
|
|
}
|
|
}
|
|
final locale = Get.locale ?? Get.deviceLocale;
|
|
if (locale != null && (locale.countryCode ?? '').isNotEmpty) {
|
|
return '${locale.languageCode.toLowerCase()}-${locale.countryCode!.toUpperCase()}';
|
|
}
|
|
return 'en-US';
|
|
}
|
|
|
|
String _voice() {
|
|
final code = _asrCode();
|
|
if (Get.isRegistered<LanguageManager>()) {
|
|
final voice = Get.find<LanguageManager>().getTtsVoiceNameByAsrCode(code);
|
|
if (voice != null && voice.isNotEmpty) return voice;
|
|
}
|
|
return code.toLowerCase().startsWith('zh')
|
|
? 'zh-CN-XiaoxiaoNeural'
|
|
: 'en-US-AriaNeural';
|
|
}
|
|
}
|
|
|