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.broadcast(); final _audioOut = StreamController.broadcast(); final List _history = []; Stream get events => _events.stream; Stream get audioOut => _audioOut.stream; bool _listening = false; int _gen = 0; bool _inSpeech = false; Future 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 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 speak(String text) async { final gen = _gen; await _say(text, gen); } void _onAsr(Map 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 _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.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 _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 _runTool(EmaiToolCall call) async { final now = DateTime.now(); final args = Map.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 = []; 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((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('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()) { final voice = Get.find().getTtsVoiceNameByAsrCode(code); if (voice != null && voice.isNotEmpty) return voice; } return code.toLowerCase().startsWith('zh') ? 'zh-CN-XiaoxiaoNeural' : 'en-US-AriaNeural'; } }