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

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