diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..a344e9c9a --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + + "amap-amap-sse": { + "url": "https://mcp.amap.com/sse?key=e5fdc9605eabdeb5626f18f5721f343d" + }, + "web-search": { + "url": "http://mcp.ideapsound.com:8000/sse" + } + + } + } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt index 1c4717f11..ffc3d256a 100644 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt @@ -172,6 +172,7 @@ class MainActivity: FlutterActivity() { var openaiModel: String? = null var volcanoSpeechAppId: String = "" var volcanoSpeechAppToken: String = "" + var mcpServer: String = "" // 安全存储相关常量 private const val SECURE_PREFS_FILENAME = "deep_voice_secure_prefs" @@ -184,6 +185,7 @@ class MainActivity: FlutterActivity() { private const val KEY_MCP_SERVER_ENDPOINT = "mcp_server_endpoint" private const val KEY_VOLCANO_SPEECH_APP_ID = "volcano_speech_app_id" private const val KEY_VOLCANO_SPEECH_APP_TOKEN = "volcano_speech_app_token" + private const val KEY_MCP_SERVER = "mcp_server" // 会话管理 private const val KEY_SESSION_ID = "session_id" @@ -224,6 +226,7 @@ class MainActivity: FlutterActivity() { .putString(KEY_OPENAI_MODEL, openaiModel) .putString(KEY_VOLCANO_SPEECH_APP_ID, volcanoSpeechAppId) .putString(KEY_VOLCANO_SPEECH_APP_TOKEN, volcanoSpeechAppToken) + .putString(KEY_MCP_SERVER, mcpServer) .apply() FileLogger.d("MainActivity", "密钥已安全保存到加密存储中") @@ -272,6 +275,7 @@ class MainActivity: FlutterActivity() { openaiModel = sharedPreferences.getString(KEY_OPENAI_MODEL, null) volcanoSpeechAppId = sharedPreferences.getString(KEY_VOLCANO_SPEECH_APP_ID, "") ?: "" volcanoSpeechAppToken = sharedPreferences.getString(KEY_VOLCANO_SPEECH_APP_TOKEN, "") ?: "" + mcpServer = sharedPreferences.getString(KEY_MCP_SERVER, "") ?: "" FileLogger.d("MainActivity", "已从加密存储加载密钥") // 检查是否成功获取所有必要密钥 @@ -520,7 +524,7 @@ class MainActivity: FlutterActivity() { openaiModel = call.argument("openai_model") volcanoSpeechAppId = call.argument("volcano_speech_app_id") ?: "" volcanoSpeechAppToken = call.argument("volcano_speech_app_token") ?: "" - + mcpServer = call.argument("mcp_config") ?: "" // 保存密钥到安全存储 saveKeysToSecureStorage(applicationContext) diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt index b885117c0..cf6bf560a 100644 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt @@ -25,7 +25,8 @@ class VoiceInteractionHandler( private val openaiBaseUrl: String = "", private val openaiModel: String = "", private val volcanoSpeechAppId: String, - private val volcanoSpeechAppToken: String + private val volcanoSpeechAppToken: String, + private val mcpServer: String ) { private val TAG = "VoiceInteractionHandler" @@ -80,7 +81,7 @@ class VoiceInteractionHandler( } // 初始化OpenAI服务 - openAIService.initialize(openaiApiKey, openaiBaseUrl, openaiModel) + openAIService.initialize(openaiApiKey, openaiBaseUrl, openaiModel, mcpServer) // 注册广播接收器 try { diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt index dbe64c79e..0497458d2 100644 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt @@ -147,6 +147,7 @@ class VoiceInteractionService : Service() { var openaiModel = MainActivity.openaiModel ?: "" // OpenAI模型 var volcanoSpeechAppId = MainActivity.volcanoSpeechAppId ?: "" var volcanoSpeechAppToken = MainActivity.volcanoSpeechAppToken ?: "" + var mcpServer = MainActivity.mcpServer ?: "" // 如果静态变量中没有配置,尝试从加密存储中加载 if (subscriptionKey.isEmpty() || serviceRegion.isEmpty() || openaiKey.isEmpty()) { @@ -164,7 +165,7 @@ class VoiceInteractionService : Service() { openaiModel = MainActivity.openaiModel ?: "" volcanoSpeechAppId = MainActivity.volcanoSpeechAppId ?: "" volcanoSpeechAppToken = MainActivity.volcanoSpeechAppToken ?: "" - + mcpServer = MainActivity.mcpServer ?: "" FileLogger.d(TAG, "已从加密存储加载配置信息") } else { FileLogger.e(TAG, "无法从加密存储加载配置信息") @@ -175,7 +176,8 @@ class VoiceInteractionService : Service() { voiceInteractionHandler = VoiceInteractionHandler(applicationContext, subscriptionKey, serviceRegion, openaiKey, openaiBaseUrl, openaiModel, - volcanoSpeechAppId, volcanoSpeechAppToken) + volcanoSpeechAppId, volcanoSpeechAppToken, + mcpServer) // 初始化回调 voiceInteractionHandler.setCallback(object : VoiceInteractionHandler.InteractionCallback { diff --git a/android/build.gradle.kts b/android/build.gradle.kts index ca0ce6220..88678470b 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -8,6 +8,8 @@ allprojects { maven { url = uri("https://artifact.bytedance.com/repository/Volcengine/") } + maven { url = uri("https://maven.pkg.jetbrains.space/public/p/ktor/eap") } + } } diff --git a/lib/data/services/open_ai_service_adapter.dart b/lib/data/services/open_ai_service_adapter.dart index b93ce6cf0..0e018524d 100644 --- a/lib/data/services/open_ai_service_adapter.dart +++ b/lib/data/services/open_ai_service_adapter.dart @@ -1,8 +1,12 @@ import 'dart:async'; +import 'dart:io'; +import 'dart:convert'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:open_ai_service/open_ai_service.dart'; import 'package:get/get.dart'; import 'ai_service.dart'; +import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; /// 定义流事件类型,用于区分不同类型的事件 enum StreamEventType { token, complete, error } @@ -105,11 +109,31 @@ class OpenAIServiceAdapter implements AiService { return false; } + // 读取.mcp.json文件 + String mcpConfig = ''; + try { + // 从Flutter资源包中加载.mcp.json + mcpConfig = await rootBundle.loadString('.mcp.json'); + printInfo(info: '成功从资源包加载.mcp.json配置文件'); + + // 验证JSON格式 + final jsonData = jsonDecode(mcpConfig); + if (jsonData is Map && jsonData.containsKey('mcpServers')) { + printInfo(info: '解析到有效的mcpServers配置'); + } else { + printInfo(info: '.mcp.json内容格式不正确,期望包含mcpServers字段'); + } + } catch (e) { + printError(info: '加载.mcp.json资源文件时出错: $e'); + + } + // 初始化OpenAI服务 final result = await _openAIService.initialize( apiKey: apiKey, baseUrl: baseUrl, model: model, + mcpServer: mcpConfig, ); if (result) { diff --git a/lib/data/services/voice_interaction_service.dart b/lib/data/services/voice_interaction_service.dart index 6adafa5c4..1c6e330dd 100644 --- a/lib/data/services/voice_interaction_service.dart +++ b/lib/data/services/voice_interaction_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; @@ -44,6 +45,9 @@ class VoiceInteractionService extends GetxService { late String _openaiModel; late String _volcanoSpeechAppId; late String _volcanoSpeechAppToken; + // MCP配置信息 + String? _mcpConfig; + // 聊天历史服务 late final ChatHistoryService _chatHistoryService; @@ -71,6 +75,21 @@ class VoiceInteractionService extends GetxService { Logger.warning('未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION'); } + // 加载MCP配置 + _loadMcpConfig(); + } + + /// 加载MCP配置 + void _loadMcpConfig() { + try { + rootBundle.loadString('.mcp.json').then((config) { + _mcpConfig = config; + Logger.info('成功加载MCP配置, 配置内容: $_mcpConfig'); + }); + } catch (e) { + Logger.warning('加载MCP配置失败: $e'); + _mcpConfig = null; + } } /// 处理来自原生层的事件 @@ -212,8 +231,8 @@ class VoiceInteractionService extends GetxService { try { Logger.info('启动语音交互服务...'); - // 使用已加载的配置信息 - final result = await _channel.invokeMethod('startService', { + // 构建参数 + final Map params = { 'azure_speech_key': _azureSpeechKey, 'azure_speech_region': _azureSpeechRegion, 'openai_api_key': _openaiApiKey, @@ -221,7 +240,14 @@ class VoiceInteractionService extends GetxService { 'openai_model': _openaiModel, 'volcano_speech_app_id': _volcanoSpeechAppId, 'volcano_speech_app_token': _volcanoSpeechAppToken, - }) ?? false; + }; + + // 如果有MCP配置,添加到参数中 + if (_mcpConfig != null) { + params['mcp_config'] = _mcpConfig; + } + + final result = await _channel.invokeMethod('startService', params) ?? false; if (result) { _isServiceRunning.value = true; diff --git a/lib/modules/jieli_test/controllers/jieli_test_controller.dart b/lib/modules/jieli_test/controllers/jieli_test_controller.dart index 8afa86cf4..b6d896836 100644 --- a/lib/modules/jieli_test/controllers/jieli_test_controller.dart +++ b/lib/modules/jieli_test/controllers/jieli_test_controller.dart @@ -1,13 +1,11 @@ import 'dart:async'; import 'dart:typed_data'; +import 'package:flutter/material.dart' hide ConnectionState; import 'package:get/get.dart'; -import 'package:logger/logger.dart'; import 'package:jieli/jieli.dart'; /// 杰理测试控制器 class JieliTestController extends GetxController { - static final Logger logger = Logger(); - // Jieli插件实例 late Jieli jieli; @@ -21,23 +19,23 @@ class JieliTestController extends GetxController { var audioDataSize = 0.obs; var receivedFrames = 0.obs; var audioDataReceiving = false.obs; + var volumeCommandResult = ''.obs; // 事件订阅 StreamSubscription? _scanSubscription; StreamSubscription? _connectionSubscription; StreamSubscription? _wakeupSubscription; StreamSubscription? _audioSubscription; + StreamSubscription? _commandResultSubscription; @override void onInit() { super.onInit(); - logger.d('JieliTestController 初始化'); _initializeJieli(); } @override void onClose() { - logger.d('JieliTestController 关闭'); _cancelAllSubscriptions(); _disposeJieli(); super.onClose(); @@ -49,16 +47,16 @@ class JieliTestController extends GetxController { _connectionSubscription?.cancel(); _wakeupSubscription?.cancel(); _audioSubscription?.cancel(); + _commandResultSubscription?.cancel(); } /// 初始化杰理SDK void _initializeJieli() { try { - logger.d('初始化Jieli插件'); jieli = Jieli(); _setupEventListeners(); } catch (e) { - logger.e('初始化Jieli插件错误', error: e); + // 初始化错误处理 } } @@ -66,13 +64,11 @@ class JieliTestController extends GetxController { void _setupEventListeners() { // 监听扫描结果 _scanSubscription = jieli.scanResults.listen((devices) { - // logger.d('扫描到设备: ${devices.length}个'); scannedDevices.assignAll(devices); }); // 监听连接状态 _connectionSubscription = jieli.connectionState.listen((state) { - logger.d('连接状态变化: $state'); switch (state) { case ConnectionState.connected: connectStatus.value = '已连接'; @@ -95,7 +91,6 @@ class JieliTestController extends GetxController { // 监听唤醒信号 _wakeupSubscription = jieli.wakeupStream.listen((wakeup) { - logger.d('收到唤醒信号'); wakeupReceived.value = true; Get.snackbar('收到唤醒信号', '耳机发送了唤醒信号'); }); @@ -106,10 +101,21 @@ class JieliTestController extends GetxController { audioDataSize.value += data.length; receivedFrames.value++; audioDataReceiving.value = true; - - // 每100帧更新一次日志,避免日志过多 - if (receivedFrames.value % 100 == 0) { - logger.d('已接收音频数据: ${audioDataSize.value} 字节, ${receivedFrames.value} 帧'); + }); + + // 监听命令结果 + _commandResultSubscription = jieli.commandResultStream.listen((result) { + // 处理音量调整命令结果 + if (result.commandType == Jieli.commandTypeVolume) { + volumeCommandResult.value = result.success ? '音量调整成功' : '音量调整失败'; + Get.snackbar( + result.success ? '音量调整成功' : '音量调整失败', + result.success ? '设备已更新音量' : '设备未能更新音量', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: result.success ? Colors.green.withOpacity(0.7) : Colors.red.withOpacity(0.7), + colorText: Colors.white, + duration: Duration(seconds: 2), + ); } }); } @@ -119,12 +125,10 @@ class JieliTestController extends GetxController { try { final isEnabled = await jieli.isBluetoothEnabled(); if (!isEnabled) { - logger.d('蓝牙未启用,请求启用蓝牙'); return await jieli.requestBluetoothEnable(); } return true; } catch (e) { - logger.e('检查蓝牙状态错误', error: e); return false; } } @@ -140,7 +144,6 @@ class JieliTestController extends GetxController { } try { - logger.d('开始扫描设备'); isScanning.value = true; // 清空之前的设备列表 @@ -150,7 +153,6 @@ class JieliTestController extends GetxController { final success = await jieli.startScan(scanTimeout: 10000); if (!success) { - logger.e('启动扫描失败'); isScanning.value = false; Get.snackbar('扫描失败', '启动扫描设备失败,请检查蓝牙权限'); } @@ -161,7 +163,6 @@ class JieliTestController extends GetxController { }); } catch (e) { - logger.e('扫描设备错误', error: e); isScanning.value = false; Get.snackbar('扫描错误', e.toString()); } @@ -172,11 +173,10 @@ class JieliTestController extends GetxController { if (!isScanning.value) return; try { - logger.d('停止扫描'); await jieli.stopScan(); isScanning.value = false; } catch (e) { - logger.e('停止扫描错误', error: e); + // 停止扫描错误处理 } } @@ -190,7 +190,6 @@ class JieliTestController extends GetxController { // 更新UI状态 connectStatus.value = '连接中...'; - logger.d('连接设备: ${device.name} (${device.id})'); // 重置音频和唤醒相关的状态 wakeupReceived.value = false; @@ -205,14 +204,11 @@ class JieliTestController extends GetxController { // 连接成功,更新当前设备 currentDevice.value = device; deviceInfo.value = '设备名称: ${device.name}\n设备ID: ${device.id}\nRSSI: ${device.rssi} dBm'; - logger.d('设备连接成功'); } else { deviceInfo.value = '连接失败'; - logger.d('设备连接失败'); } } catch (e) { deviceInfo.value = '连接错误: ${e.toString()}'; - logger.e('连接设备错误', error: e); } } @@ -222,19 +218,15 @@ class JieliTestController extends GetxController { try { connectStatus.value = '断开中...'; - logger.d('断开设备连接'); final success = await jieli.disconnect(); if (success) { deviceInfo.value = '未连接设备'; - logger.d('设备断开连接成功'); - } else { - logger.d('设备断开连接失败'); } } catch (e) { - logger.e('断开设备错误', error: e); + // 断开连接错误处理 } } @@ -246,18 +238,14 @@ class JieliTestController extends GetxController { } try { - logger.d('发送开始录音命令'); final success = await jieli.sendStartRecordingCommand(); if (success) { Get.snackbar('命令已发送', '已发送开始录音命令'); - logger.d('开始录音命令发送成功'); } else { Get.snackbar('命令发送失败', '发送开始录音命令失败'); - logger.d('开始录音命令发送失败'); } } catch (e) { - logger.e('发送开始录音命令错误', error: e); Get.snackbar('命令错误', e.toString()); } } @@ -270,29 +258,66 @@ class JieliTestController extends GetxController { } try { - logger.d('发送停止录音命令'); final success = await jieli.sendStopRecordingCommand(); if (success) { Get.snackbar('命令已发送', '已发送停止录音命令'); - logger.d('停止录音命令发送成功'); } else { Get.snackbar('命令发送失败', '发送停止录音命令失败'); - logger.d('停止录音命令发送失败'); } } catch (e) { - logger.e('发送停止录音命令错误', error: e); Get.snackbar('命令错误', e.toString()); } } + /// 增加音量 + Future increaseVolume() async { + if (currentDevice.value == null) { + Get.snackbar('操作失败', '未连接设备'); + return; + } + + try { + volumeCommandResult.value = '发送中...'; + final success = await jieli.increaseVolume(); + + if (!success) { + Get.snackbar('命令发送失败', '发送增加音量命令失败'); + volumeCommandResult.value = '发送失败'; + } + } catch (e) { + Get.snackbar('命令错误', e.toString()); + volumeCommandResult.value = '发送出错'; + } + } + + /// 减小音量 + Future decreaseVolume() async { + if (currentDevice.value == null) { + Get.snackbar('操作失败', '未连接设备'); + return; + } + + try { + volumeCommandResult.value = '发送中...'; + final success = await jieli.decreaseVolume(); + + if (!success) { + Get.snackbar('命令发送失败', '发送减小音量命令失败'); + volumeCommandResult.value = '发送失败'; + } + } catch (e) { + Get.snackbar('命令错误', e.toString()); + volumeCommandResult.value = '发送出错'; + } + } + /// 清理资源 void _disposeJieli() { try { - logger.d('释放Jieli插件资源'); jieli.dispose(); } catch (e) { - logger.e('释放Jieli插件资源错误', error: e); + // 释放资源错误处理 } } } \ No newline at end of file diff --git a/lib/modules/jieli_test/views/jieli_test_view.dart b/lib/modules/jieli_test/views/jieli_test_view.dart index aa6316366..46409651b 100644 --- a/lib/modules/jieli_test/views/jieli_test_view.dart +++ b/lib/modules/jieli_test/views/jieli_test_view.dart @@ -260,6 +260,80 @@ class JieliTestView extends GetView { ), ], ), + SizedBox(height: 8.h), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: controller.increaseVolume, + icon: Icon( + Icons.volume_up, + size: 18.sp, + ), + label: Text( + '增加音量', + style: TextStyle(fontSize: 14.sp), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue[600], + padding: EdgeInsets.symmetric(vertical: 12.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r), + ), + ), + ), + ), + SizedBox(width: 8.w), + Expanded( + child: ElevatedButton.icon( + onPressed: controller.decreaseVolume, + icon: Icon( + Icons.volume_down, + size: 18.sp, + ), + label: Text( + '减小音量', + style: TextStyle(fontSize: 14.sp), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange[600], + padding: EdgeInsets.symmetric(vertical: 12.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r), + ), + ), + ), + ), + ], + ), + SizedBox(height: 8.h), + Obx(() => controller.volumeCommandResult.value.isNotEmpty + ? Container( + width: double.infinity, + padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 16.w), + decoration: BoxDecoration( + color: controller.volumeCommandResult.value.contains('成功') + ? Colors.green[100] + : (controller.volumeCommandResult.value == '发送中...' + ? Colors.blue[100] + : Colors.red[100]), + borderRadius: BorderRadius.circular(8.r), + ), + child: Text( + '音量控制: ${controller.volumeCommandResult.value}', + style: TextStyle( + fontSize: 13.sp, + color: controller.volumeCommandResult.value.contains('成功') + ? Colors.green[900] + : (controller.volumeCommandResult.value == '发送中...' + ? Colors.blue[900] + : Colors.red[900]), + ), + textAlign: TextAlign.center, + ), + ) + : SizedBox.shrink() + ), ], ); } diff --git a/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt b/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt index dec530b53..4534aade2 100644 --- a/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt +++ b/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt @@ -12,34 +12,6 @@ import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.* import java.util.* -/** - * BLE回调接口,统一处理蓝牙事件 - */ -interface BleCallback { - /** - * 扫描结果回调 - * @param devices 扫描到的设备列表 - */ - fun onScanResult(devices: List>) - - /** - * 连接状态变化回调 - * @param state 连接状态 - */ - fun onConnectionStateChanged(state: Int) - - /** - * 音频数据回调 - * @param data 音频数据字节数组 - */ - fun onAudioDataReceived(data: ByteArray) - - /** - * 唤醒信号回调 - */ - fun onWakeupSignalReceived() -} - /** * BLE服务类,提供核心蓝牙功能 */ @@ -47,12 +19,11 @@ class BleService(private val context: Context) { companion object { private const val TAG = "JieliBleService" - // 服务和特征UUID - private const val AUDIO_SERVICE_UUID = "0000A100-0000-1000-8000-00805F9B34FB" - private const val AUDIO_DATA_CHAR_UUID = "0000A101-0000-1000-8000-00805F9B34FB" - private const val CONTROL_CHAR_UUID = "0000A102-0000-1000-8000-00805F9B34FB" - private const val CLIENT_CHAR_CONFIG_UUID = "00002902-0000-1000-8000-00805f9b34fb" - + // 服务和特征UUID - 根据协议规范重新定义 + private const val AUDIO_SERVICE_UUID = "0000faa0-0000-1000-8000-00805f9b34fb" // 主服务UUID + private const val NOTIFY_CHAR_UUID = "0000faa2-0000-1000-8000-00805f9b34fb" // 用于接收数据的特征(notify) + private const val WRITE_CHAR_UUID = "0000faa1-0000-1000-8000-00805f9b34fb" // 用于发送命令的特征(write without response) + private const val CLIENT_CHAR_CONFIG_UUID = "00002902-0000-1000-8000-00805f9b34fb" // 标准的客户端特征配置描述符 // 连接状态常量 const val STATE_DISCONNECTED = 0 const val STATE_CONNECTING = 1 @@ -62,6 +33,20 @@ class BleService(private val context: Context) { // 扫描结果更新间隔(毫秒) private const val SCAN_RESULT_UPDATE_INTERVAL = 500L + + // 命令类型常量 + const val COMMAND_VOLUME = 1 + } + + /** + * 回调接口 + */ + interface Callback { + fun onScanResult(devices: List>) + fun onConnectionStateChanged(state: Int) + fun onAudioDataReceived(data: ByteArray) + fun onWakeupSignalReceived() + fun onCommandResult(commandType: Int, success: Boolean) } // 蓝牙管理器和适配器 @@ -71,8 +56,8 @@ class BleService(private val context: Context) { // GATT连接和特征 private var bluetoothGatt: BluetoothGatt? = null - private var audioDataChar: BluetoothGattCharacteristic? = null - private var controlChar: BluetoothGattCharacteristic? = null + private var notifyChar: BluetoothGattCharacteristic? = null + private var writeChar: BluetoothGattCharacteristic? = null // 扫描相关 private val scanHandler = Handler(Looper.getMainLooper()) @@ -88,7 +73,7 @@ class BleService(private val context: Context) { val connectionState = MutableLiveData(STATE_DISCONNECTED) // 统一回调接口 - var callback: BleCallback? = null + var callback: Callback? = null /** * 开始扫描BLE设备 @@ -252,8 +237,8 @@ class BleService(private val context: Context) { // 清理资源 gatt.close() bluetoothGatt = null - audioDataChar = null - controlChar = null + notifyChar = null + writeChar = null } else if (status != BluetoothGatt.GATT_SUCCESS) { Log.e(TAG, "连接失败,status=$status") updateConnectionState(STATE_ERROR) @@ -272,13 +257,16 @@ class BleService(private val context: Context) { if (audioService != null) { Log.i(TAG, "找到音频服务") - // 获取音频数据和控制特征 - audioDataChar = audioService.getCharacteristic(UUID.fromString(AUDIO_DATA_CHAR_UUID)) - controlChar = audioService.getCharacteristic(UUID.fromString(CONTROL_CHAR_UUID)) + // 获取特征 + notifyChar = audioService.getCharacteristic(UUID.fromString(NOTIFY_CHAR_UUID)) + writeChar = audioService.getCharacteristic(UUID.fromString(WRITE_CHAR_UUID)) - if (audioDataChar != null && controlChar != null) { + if (notifyChar != null && writeChar != null) { Log.i(TAG, "找到所需特征") + // 设置特征写入类型为无响应模式 + writeChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + // 注册特征通知 setupNotifications(gatt) } else { @@ -297,22 +285,28 @@ class BleService(private val context: Context) { override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { val characteristicUuid = characteristic.uuid.toString() + Log.d(TAG, "收到特征变化通知: $characteristicUuid") when (characteristicUuid) { - AUDIO_DATA_CHAR_UUID -> { - // 收到音频数据 - val audioData = characteristic.value - Log.d(TAG, "收到音频数据帧,大小: ${audioData.size} 字节") + // NOTIFY_CHAR_UUID -> { + // // 收到音频数据 + // val audioData = characteristic.value + // Log.d(TAG, "收到音频数据帧,大小: ${audioData.size} 字节") - callback?.onAudioDataReceived(audioData) - } - CONTROL_CHAR_UUID -> { - // 收到唤醒信号 + // callback?.onAudioDataReceived(audioData) + // } + NOTIFY_CHAR_UUID -> { + // 处理控制消息 val value = characteristic.value Log.i(TAG, "收到控制通知: ${value.contentToString()}") + // 判断是否是协议格式响应 + if (value.isNotEmpty() && value[0].toInt() and 0xFF == 0xA5) { + // 处理设备返回的协议数据 + processResponseData(value) + } // 假设0x01表示唤醒信号 - if (value.isNotEmpty() && value[0].toInt() == 0x01) { + else if (value.isNotEmpty() && value[0].toInt() == 0x01) { Log.i(TAG, "收到唤醒信号") callback?.onWakeupSignalReceived() @@ -325,7 +319,7 @@ class BleService(private val context: Context) { } override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { - if (characteristic.uuid.toString() == CONTROL_CHAR_UUID) { + if (characteristic.uuid.toString() == WRITE_CHAR_UUID) { if (status == BluetoothGatt.GATT_SUCCESS) { Log.i(TAG, "命令发送成功: ${characteristic.value.contentToString()}") } else { @@ -337,13 +331,10 @@ class BleService(private val context: Context) { override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { if (status == BluetoothGatt.GATT_SUCCESS) { Log.i(TAG, "描述符写入成功: ${descriptor.uuid}") - - // 如果是音频数据特征的描述符,再订阅控制特征 - if (descriptor.characteristic.uuid.toString() == AUDIO_DATA_CHAR_UUID) { - setupControlNotification(gatt) - } + Log.i(TAG, "特征通知设置成功,设备就绪") } else { Log.e(TAG, "描述符写入失败,status=$status") + updateConnectionState(STATE_ERROR) } } } @@ -352,57 +343,92 @@ class BleService(private val context: Context) { * 设置特征通知 */ private fun setupNotifications(gatt: BluetoothGatt) { - // 先订阅音频数据通知 - if (audioDataChar?.properties?.and(BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) { - gatt.setCharacteristicNotification(audioDataChar, true) + // 启用 notify 特征通知 + if (notifyChar?.properties?.and(BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) { + gatt.setCharacteristicNotification(notifyChar, true) - val descriptor = audioDataChar?.getDescriptor(UUID.fromString(CLIENT_CHAR_CONFIG_UUID)) + val descriptor = notifyChar?.getDescriptor(UUID.fromString(CLIENT_CHAR_CONFIG_UUID)) descriptor?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE if (descriptor != null) { gatt.writeDescriptor(descriptor) - Log.i(TAG, "设置音频数据特征通知") + Log.i(TAG, "设置数据通知特征通知") } } else { - Log.e(TAG, "音频数据特征不支持通知") - setupControlNotification(gatt) + Log.e(TAG, "数据通知特征不支持通知") } } /** - * 设置控制特征通知 + * 处理设备返回的数据 + * 协议格式: + * 协议头(0xA5) + 字段类型(1byte) + 数据长度(1byte) + 数据域(N byte) + 校验和(1byte) */ - private fun setupControlNotification(gatt: BluetoothGatt) { - if (controlChar?.properties?.and(BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) { - gatt.setCharacteristicNotification(controlChar, true) - - val descriptor = controlChar?.getDescriptor(UUID.fromString(CLIENT_CHAR_CONFIG_UUID)) - descriptor?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - - if (descriptor != null) { - gatt.writeDescriptor(descriptor) - Log.i(TAG, "设置控制特征通知") + private fun processResponseData(data: ByteArray) { + if (data.size < 4) { + Log.e(TAG, "返回数据格式错误,长度不足") + return + } + + // 判断协议头 + if (data[0].toInt() and 0xFF != 0xA5) { + Log.e(TAG, "返回数据协议头错误: ${data[0].toInt() and 0xFF}") + return + } + + val fieldType = data[1].toInt() and 0xFF + val dataLen = data[2].toInt() and 0xFF + + // 验证长度 + if (data.size != dataLen + 4) { // 协议头+字段类型+数据长度+数据域+校验和 + Log.e(TAG, "返回数据长度不匹配: 应为${dataLen + 4},实际为${data.size}") + return + } + + // 计算校验和 + var checksum = 0 + for (i in 0 until data.size - 1) { + checksum += data[i].toInt() and 0xFF + } + checksum = checksum and 0xFF + + val receivedChecksum = data[data.size - 1].toInt() and 0xFF + + if (checksum != receivedChecksum) { + Log.e(TAG, "校验和错误: 计算值=$checksum, 接收值=$receivedChecksum") + return + } + + // 解析字段类型 + when (fieldType) { + 0x01 -> { + // 音量调整命令的响应 + val result = data[3].toInt() and 0xFF + val success = result == 0 + Log.i(TAG, "音量调整命令响应: ${if (success) "成功" else "失败"}") + callback?.onCommandResult(COMMAND_VOLUME, success) + } + // 其他命令类型可在此处添加 + else -> { + Log.d(TAG, "未知命令类型: ${String.format("0x%02X", fieldType)}") } - } else { - Log.e(TAG, "控制特征不支持通知") } } /** - * 发送开始录音命令(向控制特征写入0x01) + * 发送开始录音命令 */ fun sendStartRecordingCommand(): Boolean { - if (bluetoothGatt == null || controlChar == null || connectionState.value != STATE_CONNECTED) { + if (bluetoothGatt == null || writeChar == null || connectionState.value != STATE_CONNECTED) { Log.e(TAG, "无法发送开始录音命令,设备未连接") return false } try { val commandData = byteArrayOf(0x01) - controlChar?.value = commandData - controlChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + writeChar?.value = commandData - val success = bluetoothGatt?.writeCharacteristic(controlChar) ?: false + val success = bluetoothGatt?.writeCharacteristic(writeChar) ?: false if (success) { Log.i(TAG, "开始录音命令已发送") @@ -418,20 +444,19 @@ class BleService(private val context: Context) { } /** - * 发送停止录音命令(向控制特征写入0x02) + * 发送停止录音命令 */ fun sendStopRecordingCommand(): Boolean { - if (bluetoothGatt == null || controlChar == null || connectionState.value != STATE_CONNECTED) { + if (bluetoothGatt == null || writeChar == null || connectionState.value != STATE_CONNECTED) { Log.e(TAG, "无法发送停止录音命令,设备未连接") return false } try { val commandData = byteArrayOf(0x02) - controlChar?.value = commandData - controlChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + writeChar?.value = commandData - val success = bluetoothGatt?.writeCharacteristic(controlChar) ?: false + val success = bluetoothGatt?.writeCharacteristic(writeChar) ?: false if (success) { Log.i(TAG, "停止录音命令已发送") @@ -446,6 +471,70 @@ class BleService(private val context: Context) { } } + /** + * 发送音量调整命令 + * 协议格式: + * 协议头(0xAA) + 字段类型(0x01) + 数据长度(0x01) + 音量值(0~16) + 校验和 + */ + fun adjustVolume(volume: Int): Boolean { + if (bluetoothGatt == null || writeChar == null || connectionState.value != STATE_CONNECTED) { + Log.e(TAG, "无法发送音量调整命令,设备未连接") + return false + } + + // 限制音量范围为0-16 + val safeVolume = volume.coerceIn(0, 16) + + try { + // 构建命令数据 + val header: Byte = 0xAA.toByte() + val fieldType: Byte = 0x01 + val dataLen: Byte = 0x01 + val volumeData: Byte = safeVolume.toByte() + + // 计算校验和 + val checksum = ((header.toInt() and 0xFF) + + (fieldType.toInt() and 0xFF) + + (dataLen.toInt() and 0xFF) + + (volumeData.toInt() and 0xFF)) and 0xFF + + // 创建完整命令 + val commandData = byteArrayOf(header, fieldType, dataLen, volumeData, checksum.toByte()) + + // 写入特征 + writeChar?.value = commandData + + val success = bluetoothGatt?.writeCharacteristic(writeChar) ?: false + + if (success) { + Log.i(TAG, "音量调整命令已发送: $safeVolume") + } else { + Log.e(TAG, "音量调整命令发送失败") + } + + return success + } catch (e: Exception) { + Log.e(TAG, "发送音量调整命令时发生异常: ${e.message}") + return false + } + } + + /** + * 增加音量 + */ + fun increaseVolume(): Boolean { + // 假设当前音量保存在设备中,我们只需发送增加命令 + return adjustVolume(16) // 发送最大音量命令 + } + + /** + * 减小音量 + */ + fun decreaseVolume(): Boolean { + // 假设当前音量保存在设备中,我们只需发送减小命令 + return adjustVolume(0) // 发送最小音量命令 + } + /** * 清理资源 */ @@ -454,7 +543,7 @@ class BleService(private val context: Context) { scanHandler.removeCallbacks(scanResultUpdateRunnable) bluetoothGatt?.close() bluetoothGatt = null - audioDataChar = null - controlChar = null + notifyChar = null + writeChar = null } } \ No newline at end of file diff --git a/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/JieliPlugin.kt b/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/JieliPlugin.kt index 17cc5d21f..95a267e97 100644 --- a/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/JieliPlugin.kt +++ b/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/JieliPlugin.kt @@ -44,6 +44,7 @@ class JieliPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, EventChannel private const val EVENT_TYPE_CONNECTION_STATE = "connection_state" private const val EVENT_TYPE_SCAN_RESULT = "scan_result" private const val EVENT_TYPE_WAKEUP_SIGNAL = "wakeup_signal" + private const val EVENT_TYPE_COMMAND_RESULT = "command_result" } // 蓝牙相关请求码 @@ -75,7 +76,7 @@ class JieliPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, EventChannel */ private fun setupBleCallbacks() { // 设置统一回调接口 - bleService.callback = object : BleCallback { + bleService.callback = object : BleService.Callback { override fun onScanResult(devices: List>) { pluginScope.launch(Dispatchers.Main) { sendEvent(EVENT_TYPE_SCAN_RESULT, devices) @@ -99,6 +100,15 @@ class JieliPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, EventChannel sendEvent(EVENT_TYPE_WAKEUP_SIGNAL, true) } } + + override fun onCommandResult(commandType: Int, success: Boolean) { + pluginScope.launch(Dispatchers.Main) { + sendEvent(EVENT_TYPE_COMMAND_RESULT, mapOf( + "commandType" to commandType, + "success" to success + )) + } + } } } @@ -187,6 +197,19 @@ class JieliPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, EventChannel activity?.startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT) result.success(true) } + "adjustVolume" -> { + val volume = call.argument("volume") ?: 0 + val success = bleService.adjustVolume(volume) + result.success(success) + } + "increaseVolume" -> { + val success = bleService.increaseVolume() + result.success(success) + } + "decreaseVolume" -> { + val success = bleService.decreaseVolume() + result.success(success) + } else -> { result.notImplemented() } diff --git a/local_plugins/jieli/lib/jieli.dart b/local_plugins/jieli/lib/jieli.dart index 78ec090bb..fa65eca91 100644 --- a/local_plugins/jieli/lib/jieli.dart +++ b/local_plugins/jieli/lib/jieli.dart @@ -46,6 +46,7 @@ class EventType { static const String connectionState = 'connection_state'; static const String scanResult = 'scan_result'; static const String wakeupSignal = 'wakeup_signal'; + static const String commandResult = 'command_result'; } /// 杰理BLE插件主类 @@ -72,10 +73,21 @@ class Jieli { static final StreamController _wakeupController = StreamController.broadcast(); Stream get wakeupStream => _wakeupController.stream; + + /// 命令结果流 + static final StreamController _commandResultController = + StreamController.broadcast(); + Stream get commandResultStream => _commandResultController.stream; + + /// 原始事件流 + Stream>? _eventStream; /// 事件流订阅 StreamSubscription? _eventSubscription; + /// 命令类型常量 + static const int commandTypeVolume = 1; + /// 构造函数,初始化平台通道和事件监听 Jieli() { _setupEventListener(); @@ -118,6 +130,16 @@ class Jieli { _wakeupController.add(data); } break; + case EventType.commandResult: + if (data is Map) { + final commandType = data['commandType'] as int; + final success = data['success'] as bool; + _commandResultController.add(CommandResult( + commandType: commandType, + success: success, + )); + } + break; } } @@ -172,6 +194,7 @@ class Jieli { _connectionController.close(); _audioController.close(); _wakeupController.close(); + _commandResultController.close(); } /// 检查蓝牙是否已启用 @@ -185,4 +208,65 @@ class Jieli { final result = await _methodChannel.invokeMethod('requestBluetoothEnable'); return result ?? false; } + + /// 调整音量 + /// [volume] 音量值,范围0-16 + Future adjustVolume(int volume) async { + try { + final result = await _methodChannel.invokeMethod( + 'adjustVolume', + {'volume': volume}, + ); + return result ?? false; + } catch (e) { + print('调整音量失败: $e'); + return false; + } + } + + /// 增加音量 + Future increaseVolume() async { + try { + final result = await _methodChannel.invokeMethod('increaseVolume'); + return result ?? false; + } catch (e) { + print('增加音量失败: $e'); + return false; + } + } + + /// 减小音量 + Future decreaseVolume() async { + try { + final result = await _methodChannel.invokeMethod('decreaseVolume'); + return result ?? false; + } catch (e) { + print('减小音量失败: $e'); + return false; + } + } + + /// 命令结果回调 + Stream get onCommandResult => commandResultStream + .where((event) => event.commandType == commandTypeVolume) + .map((event) => event); +} + +/// 蓝牙设备模型 +class BluetoothDevice { + // ... existing code ... +} + +/// 命令结果 +class CommandResult { + final int commandType; + final bool success; + + CommandResult({ + required this.commandType, + required this.success, + }); + + @override + String toString() => 'CommandResult{commandType: $commandType, success: $success}'; } \ No newline at end of file diff --git a/local_plugins/open_ai_service/android/build.gradle.kts b/local_plugins/open_ai_service/android/build.gradle.kts index 57889671a..1f079fa6c 100644 --- a/local_plugins/open_ai_service/android/build.gradle.kts +++ b/local_plugins/open_ai_service/android/build.gradle.kts @@ -50,6 +50,11 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") implementation("com.fasterxml.jackson.core:jackson-databind:2.14.2") implementation("com.squareup.okhttp3:okhttp:4.9.3") + implementation("com.squareup.okhttp3:logging-interceptor:4.9.3") implementation("com.anthropic:anthropic-java:0.8.0") implementation("io.modelcontextprotocol:kotlin-sdk:0.4.0") + implementation("io.ktor:ktor-client-core:3.1.2") + implementation("io.ktor:ktor-client-cio:3.1.2") + implementation("io.ktor:ktor-client-okhttp:3.1.2") + implementation("io.ktor:ktor-client-logging:3.1.2") } \ No newline at end of file diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt index fef91ab05..a4625376c 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt @@ -9,14 +9,30 @@ import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper import io.ktor.client.HttpClient +import io.ktor.client.plugins.sse.* import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.ListToolsResult import io.modelcontextprotocol.kotlin.sdk.TextContent import io.modelcontextprotocol.kotlin.sdk.client.Client -import io.modelcontextprotocol.kotlin.sdk.client.WebSocketClientTransport +import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.JsonObject import org.json.JSONObject import kotlin.jvm.optionals.getOrNull +import java.io.File +import io.ktor.client.engine.okhttp.* +import okhttp3.OkHttpClient +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager +import java.security.SecureRandom +import java.util.concurrent.TimeUnit +import okhttp3.logging.HttpLoggingInterceptor +import okhttp3.Interceptor +import io.ktor.client.request.* +import io.ktor.http.* +import com.yunqiinnovation.open_ai_service.mcp.CustomSseClientTransport /** * 工具类型枚举 @@ -39,55 +55,45 @@ interface FunctionHandler { } /** - * MCP客户端,用于连接Model Context Protocol服务器和管理本地函数 + * 单个MCP子客户端 + * 负责连接到一个具体的MCP服务器并管理其工具 */ -class MCPClient(private val context: Context? = null) : AutoCloseable { - private val TAG = "MCPClient" - +class MCPSubClient( + private val serverId: String, + serverUrl: String, + private val customClient: HttpClient? = null +) : AutoCloseable { + private val TAG = "MCPSubClient[$serverId]" + + // 保存服务器URL + private val serverUrl: String = serverUrl + + init { + Log.d(TAG, "MCPSubClient初始化,URL: $serverUrl") + } + // 初始化MCP客户端 private val mcp: Client = Client( clientInfo = Implementation( - name = "deep-voice-mcp-client", + name = "deep-voice-mcp-client-$serverId", version = "1.0.0" ) ) - + // 服务器提供的工具列表 private var tools: List = emptyList() // 工具Map列表,用于OpenAI请求 private val toolMaps = mutableListOf>() - // 本地函数Map,函数名 -> 处理器 - private val localFunctions = mutableMapOf() - - // 本地函数定义Map,函数名 -> 定义 - private val localFunctionDefs = mutableMapOf() - // 是否已连接 private var isConnected = false - // 系统功能处理器 - private val systemFunctionHandler = SystemFunctionHandler(context!!) - - init { - // 如果提供了Context,自动注册语音功能处理器 - context?.let { initializeVoiceFunctions(it) } - } + // SSE传输层,使用自定义的传输层 + private var sseTransport: CustomSseClientTransport? = null - /** - * 初始化语音功能处理器 - */ - private fun initializeVoiceFunctions(context: Context) { - try { - // 使用SystemFunctionHandler注册所有功能 - systemFunctionHandler.registerAllFunctions(this) - - Log.d(TAG, "语音功能初始化完成") - } catch (e: Exception) { - Log.e(TAG, "语音功能初始化失败: ${e.message}", e) - } - } + // 工具名称到工具的映射,用于快速查找 + private val toolMapping = mutableMapOf() /** * 将JsonObject转换为JsonValue @@ -97,20 +103,35 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { val node = mapper.readTree(this.toString()) return JsonValue.fromJsonNode(node) } - + /** - * 连接到MCP服务器(通过WebSocket) + * 连接到MCP服务器(通过SSE) + * @return 是否连接成功 */ - suspend fun connectToWebSocket(serverUrl: String): Boolean { + suspend fun connect(): Boolean { try { - // 创建WebSocket传输层 - val transport = WebSocketClientTransport( - client = HttpClient(), - urlString = serverUrl + Log.d(TAG, "开始连接到服务器: $serverUrl") + + // 使用提供的客户端或创建带SSL配置的客户端 + val client = customClient ?: createSslTrustAllClient() + + // 使用自定义传输层,直接传递完整URL + sseTransport = CustomSseClientTransport( + client = client, + urlString = serverUrl, + requestBuilder = { + // 添加基本请求头 + headers { + append(HttpHeaders.Accept, "text/event-stream") + append(HttpHeaders.CacheControl, "no-cache") + } + } ) + Log.d(TAG, "创建自定义SSE传输层成功") + // 连接MCP客户端到服务器 - mcp.connect(transport) + mcp.connect(sseTransport!!) // 获取服务器提供的工具列表 val toolsResult = mcp.listTools() @@ -120,10 +141,12 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { processToolsList(toolsResult) isConnected = true - Log.d(TAG, "已连接到WebSocket服务器,可用工具: ${tools.map { it.tool().get().name() }.joinToString(", ")}") + Log.d(TAG, "成功连接到MCP服务器,可用工具: ${tools.map { it.tool().get().name() }.joinToString(", ")}") return true } catch (e: Exception) { - Log.e(TAG, "连接WebSocket服务器失败: ${e.message}", e) + Log.e(TAG, "连接服务器失败: ${e.message}", e) + // 记录详细的错误堆栈 + Log.e(TAG, "详细错误: ${e.stackTraceToString()}") return false } } @@ -131,7 +154,7 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { /** * 处理工具列表 */ - private fun processToolsList(toolsResult: io.modelcontextprotocol.kotlin.sdk.ListToolsResult?) { + private fun processToolsList(toolsResult: ListToolsResult?) { tools = toolsResult?.tools?.map { tool -> // 确保输入模式有效 val inputType = tool.inputSchema?.type ?: "object" @@ -153,10 +176,415 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { ) } ?: emptyList() - // 更新用于OpenAI的工具映射 + // 更新工具映射 + tools.forEach { toolUnion -> + val tool = toolUnion.tool().get() + toolMapping[tool.name()] = toolUnion + } + + // 更新工具Map列表 updateToolMaps() } - + + /** + * 更新工具Map列表,用于OpenAI请求 + */ + private fun updateToolMaps() { + toolMaps.clear() + + // 添加MCP工具 + tools.forEach { toolUnion -> + try { + val toolOptional = toolUnion.tool() + if (!toolOptional.isPresent) { + return@forEach + } + + val tool = toolOptional.get() + + // 创建基本参数结构 + val parametersMap = mutableMapOf() + parametersMap["type"] = "object" // 默认值 + parametersMap["properties"] = mutableMapOf() + parametersMap["required"] = emptyList() + + // 从工具的输入模式中提取信息 + try { + // 获取输入模式并转换为字符串 + val schema = tool.inputSchema() + val schemaStr = schema.toString() + Log.d(TAG, "解析工具Schema: $schemaStr") + + // 如果toString()提供了足够的信息,就使用正则表达式解析 + // 这是最可靠的方法,因为它不依赖于API的细节变化 + + // 解析类型 + val typePattern = "type=([^,}]+)".toRegex() + val typeMatch = typePattern.find(schemaStr) + if (typeMatch != null) { + parametersMap["type"] = typeMatch.groupValues[1].trim() + } + + // 尝试从字符串中解析完整的JSON结构 + try { + // 直接从Schema字符串中提取属性结构,手动构建properties映射 + val propertiesMap = mutableMapOf() + + // 匹配属性定义: 形如 fieldName={...} + val propertiesPattern = "properties=\\{([^}]+)\\}".toRegex() + val propertiesMatch = propertiesPattern.find(schemaStr) + + if (propertiesMatch != null) { + // 匹配属性定义: 形如 fieldName={...} + val fieldPattern = "([\\w]+)=\\{([^{}]+(?:\\{[^{}]*\\}[^{}]*)*)\\}".toRegex() + val fieldMatches = fieldPattern.findAll(propertiesMatch.groupValues[1]) + + fieldMatches.forEach { fieldMatch -> + val fieldName = fieldMatch.groupValues[1] + val fieldContent = fieldMatch.groupValues[2] + + // 为属性创建一个Map + val fieldMap = mutableMapOf() + + // 解析字段内容中的键值对 + val keyValuePattern = "(\\w+)=([^,}]+)".toRegex() + val keyValueMatches = keyValuePattern.findAll(fieldContent) + + keyValueMatches.forEach { kvMatch -> + val key = kvMatch.groupValues[1] + val value = kvMatch.groupValues[2].trim() + + // 根据值的类型进行适当处理 + val processedValue: Any = when { + value == "true" -> true + value == "false" -> false + value.matches("\\d+".toRegex()) -> value.toIntOrNull() ?: value + value.matches("\\d+\\.\\d+".toRegex()) -> value.toDoubleOrNull() ?: value + else -> value // 保持为字符串 + } + + fieldMap[key] = processedValue + } + + propertiesMap[fieldName] = fieldMap + } + parametersMap["properties"] = propertiesMap + Log.d(TAG, "成功解析属性: $propertiesMap") + } else { + parametersMap["properties"] = emptyMap() + } + + // 提取required部分 + val requiredPattern = "required=\\[([^\\]]+)\\]".toRegex() + val requiredMatch = requiredPattern.find(schemaStr) + + if (requiredMatch != null) { + val requiredStr = requiredMatch.groupValues[1] + val requiredList = requiredStr.split(",").map { it.trim() } + parametersMap["required"] = requiredList + } else { + parametersMap["required"] = emptyList() + } + } catch (e: Exception) { + Log.e(TAG, "从字符串解析schema细节失败: ${e.message}") + // 使用默认值 + parametersMap["properties"] = emptyMap() + parametersMap["required"] = emptyList() + } + } catch (e: Exception) { + Log.e(TAG, "解析工具输入模式失败: ${e.message}", e) + // 使用默认值继续 + } + + // 创建工具Map + toolMaps.add(mapOf( + "type" to "function", + "function" to mapOf( + "name" to tool.name(), + "description" to (tool.description() ?: ""), + "parameters" to parametersMap + ) + )) + + Log.d(TAG, "工具 ${tool.name()} 成功添加到工具映射") + + } catch (e: Exception) { + Log.e(TAG, "转换工具到Map失败: ${e.message}") + } + } + + Log.d(TAG, "更新工具映射完成,共 ${toolMaps.size} 个工具") + } + + /** + * 将JsonNode转换为Map + */ + private fun convertJsonNodeToMap(node: JsonNode): Any { + return when { + node.isTextual() -> node.asText() + node.isNumber() -> node.asDouble() + node.isBoolean() -> node.asBoolean() + node.isObject() -> { + val result = mutableMapOf() + node.fieldNames().forEach { fieldName -> + result[fieldName] = convertJsonNodeToMap(node.get(fieldName)) + } + result + } + node.isArray() -> { + val result = mutableListOf() + for (i in 0 until node.size()) { + result.add(convertJsonNodeToMap(node.get(i))) + } + result + } + node.isNull() -> "null" + else -> node.toString() + } + } + + /** + * 获取工具Map列表,用于OpenAI请求 + */ + fun getToolMaps(): List> { + return toolMaps + } + + /** + * 获取可用的工具列表 + */ + fun getTools(): List { + return tools + } + + /** + * 检查是否包含指定工具 + */ + fun containsTool(name: String): Boolean { + return toolMapping.containsKey(name) + } + + /** + * 调用工具 + */ + suspend fun callTool(name: String, arguments: Map): String? { + if (!isConnected) { + Log.e(TAG, "未连接到MCP服务器", null) + return "未连接到MCP服务器" + } + + if (!containsTool(name)) { + Log.w(TAG, "此客户端不包含工具: $name") + return null + } + + return try { + val result = mcp.callTool( + name = name, + arguments = arguments + ) + + // 处理工具调用结果 + result?.content?.joinToString("\n") { content -> + when (content) { + is TextContent -> content.text ?: "" + else -> "" + } + } + } catch (e: Exception) { + Log.e(TAG, "调用MCP工具失败: ${e.message}", e) + "调用MCP工具失败: ${e.message}" + } + } + + /** + * 检查是否已连接 + */ + fun isConnected(): Boolean { + return isConnected + } + + /** + * 关闭连接 + */ + override fun close() { + runBlocking { + try { + mcp.close() + sseTransport?.close() + isConnected = false + Log.d(TAG, "已关闭MCP服务器连接") + } catch (e: Exception) { + Log.e(TAG, "关闭MCP服务器连接失败: ${e.message}", e) + } + } + } +} + +/** + * MCP客户端,用于连接Model Context Protocol服务器和管理本地函数 + */ +class MCPClient(private val context: Context? = null) : AutoCloseable { + private val TAG = "MCPClient" + + // 本地函数Map,函数名 -> 处理器 + private val localFunctions = mutableMapOf() + + // 本地函数定义Map,函数名 -> 定义 + private val localFunctionDefs = mutableMapOf() + + // 子客户端列表,每个连接一个MCP服务器 + private val subClients = mutableMapOf() + + // 是否已连接 + private var isConnected = false + + // 系统功能处理器 + private val systemFunctionHandler = SystemFunctionHandler(context!!) + + // 共享的HttpClient,用于所有子客户端 + private val sharedHttpClient by lazy { createSslTrustAllClient() } + + init { + // 如果提供了Context,自动注册语音功能处理器 + context?.let { initializeVoiceFunctions(it) } + } + + /** + * 初始化语音功能处理器 + */ + private fun initializeVoiceFunctions(context: Context) { + try { + // 使用SystemFunctionHandler注册所有功能 + systemFunctionHandler.registerAllFunctions(this) + + Log.d(TAG, "语音功能初始化完成") + } catch (e: Exception) { + Log.e(TAG, "语音功能初始化失败: ${e.message}", e) + } + } + + /** + * 连接到MCP服务器(通过SSE) + * 直接接收完整的JSON配置字符串 + * + * @param mcpConfigJson 包含mcpServers字段的JSON配置字符串 + * @return 是否连接成功 + */ + suspend fun connectToSSE(mcpConfigJson: String): Boolean { + // 清除现有连接 + closeAllConnections() + + val serverMap = mutableMapOf() + var configLoaded = false + + // 尝试解析传入的JSON配置 + try { + if (mcpConfigJson.isNotEmpty()) { + val mapper = ObjectMapper() + try { + val config = mapper.readValue(mcpConfigJson, Map::class.java) + + // 处理mcpServers格式 + @Suppress("UNCHECKED_CAST") + val mcpServers = config["mcpServers"] as? Map> + if (mcpServers != null && mcpServers.isNotEmpty()) { + // 从服务器配置中提取URL + mcpServers.forEach { (serverId, serverConfig) -> + val url = serverConfig["url"] + if (!url.isNullOrEmpty()) { + serverMap[serverId] = url + Log.d(TAG, "从配置中添加服务器 [$serverId]: $url") + } + } + configLoaded = serverMap.isNotEmpty() + Log.d(TAG, "从传入的JSON配置加载了 ${serverMap.size} 个服务器") + } + } catch (e: Exception) { + Log.e(TAG, "解析传入的JSON配置失败: ${e.message}") + } + } + } catch (e: Exception) { + Log.e(TAG, "处理传入的配置失败: ${e.message}") + } + + + // 创建并连接所有子客户端 + val connectionResults = mutableMapOf() + + for ((serverId, serverUrl) in serverMap) { + try { + Log.d(TAG, "为服务器 [$serverId],[$serverUrl] 创建子客户端") + val subClient = MCPSubClient(serverId, serverUrl, sharedHttpClient) + val connected = subClient.connect() + + if (connected) { + subClients[serverId] = subClient + connectionResults[serverId] = true + Log.d(TAG, "子客户端 [$serverId] 连接成功") + } else { + subClient.close() + connectionResults[serverId] = false + Log.w(TAG, "子客户端 [$serverId] 连接失败") + } + } catch (e: Exception) { + Log.e(TAG, "创建子客户端 [$serverId] 失败: ${e.message}", e) + connectionResults[serverId] = false + } + } + + // 检查是否有任何客户端连接成功 + val successCount = connectionResults.count { it.value } + isConnected = successCount > 0 + + if (isConnected) { + Log.d(TAG, "已成功连接 $successCount/${serverMap.size} 个MCP服务器") + + // 记录所有可用工具 + val allToolNames = getAllToolNames() + Log.d(TAG, "所有可用工具: ${allToolNames.joinToString(", ")}") + + return true + } else { + Log.e(TAG, "所有MCP服务器连接均失败") + return false + } + } + + /** + * 关闭所有连接 + */ + private fun closeAllConnections() { + subClients.forEach { (serverId, client) -> + try { + client.close() + Log.d(TAG, "已关闭子客户端 [$serverId]") + } catch (e: Exception) { + Log.e(TAG, "关闭子客户端 [$serverId] 失败: ${e.message}") + } + } + subClients.clear() + } + + /** + * 获取所有工具名称 + */ + private fun getAllToolNames(): List { + val toolNames = mutableSetOf() + + // 添加本地函数 + toolNames.addAll(localFunctions.keys) + + // 添加所有子客户端的工具 + subClients.forEach { (_, client) -> + client.getTools().forEach { toolUnion -> + val tool = toolUnion.tool().get() + toolNames.add(tool.name()) + } + } + + return toolNames.toList() + } /** * 注册本地函数 @@ -211,9 +639,6 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { // 保存函数定义 localFunctionDefs[name] = ObjectMapper().writeValueAsString(functionDef) - // 更新工具映射 - updateToolMaps() - Log.d(TAG, "注册本地函数成功: $name") return true } catch (e: Exception) { @@ -231,128 +656,39 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { val removed = localFunctions.remove(name) != null if (removed) { localFunctionDefs.remove(name) - updateToolMaps() Log.d(TAG, "取消注册本地函数成功: $name") } return removed } /** - * 更新工具Map列表,用于OpenAI请求 + * 获取工具Map列表,用于OpenAI请求 */ - private fun updateToolMaps() { - toolMaps.clear() + fun getToolMaps(): List> { + val allToolMaps = mutableListOf>() // 首先添加本地函数 localFunctionDefs.forEach { (name, functionDefJson) -> try { val mapper = ObjectMapper() val functionDef = mapper.readValue(functionDefJson, object : TypeReference>() {}) - val toolMap = mapOf( + allToolMaps.add(mapOf( "type" to "function", "function" to functionDef - ) - toolMaps.add(toolMap) + )) } catch (e: Exception) { - Log.e(TAG, "解析本地函数定义失败: $name, ${e.message}", e) + Log.e(TAG, "解析本地函数定义失败: $name, ${e.message}") } } - // 然后添加MCP工具 - tools.forEach { toolUnion -> - try { - val tool = toolUnion.tool().get() - val parametersMap = mutableMapOf() - - // 提取工具类型及参数 - try { - val inputSchema = tool.inputSchema() - // 解析JsonValue - val mapper = ObjectMapper() - val inputSchemaStr = inputSchema.toString() - val inputSchemaJson = mapper.readTree(inputSchemaStr) - val typeValue = inputSchemaJson.path("type") - parametersMap["type"] = if (typeValue.isTextual) typeValue.asText() else "object" - - // 提取properties字段 - val propertiesMap = mutableMapOf() - val propertiesNode = inputSchemaJson.path("properties") - if (propertiesNode.isObject) { - propertiesNode.fieldNames().forEach { fieldName -> - val fieldNode = propertiesNode.get(fieldName) - propertiesMap[fieldName] = convertJsonNodeToMap(fieldNode) - } - } - parametersMap["properties"] = propertiesMap - - // 提取required字段 - val requiredList = mutableListOf() - val requiredNode = inputSchemaJson.path("required") - if (requiredNode.isArray) { - for (i in 0 until requiredNode.size()) { - if (requiredNode.get(i).isTextual) { - requiredList.add(requiredNode.get(i).asText()) - } - } - } - parametersMap["required"] = requiredList - } catch (e: Exception) { - Log.e(TAG, "解析工具参数失败: ${e.message}", e) - // 设置默认参数 - parametersMap["type"] = "object" - parametersMap["properties"] = emptyMap() - parametersMap["required"] = emptyList() - } - - // 创建工具Map - val toolMap = mapOf( - "type" to "function", - "function" to mapOf( - "name" to tool.name(), - "description" to (tool.description() ?: ""), - "parameters" to parametersMap - ) - ) - - toolMaps.add(toolMap) - } catch (e: Exception) { - Log.e(TAG, "转换工具到Map失败: ${e.message}", e) - } - } - } - - /** - * 将JsonNode转换为Map - */ - private fun convertJsonNodeToMap(node: JsonNode): Any { - return when { - node.isTextual() -> node.asText() - node.isNumber() -> node.asDouble() - node.isBoolean() -> node.asBoolean() - node.isObject() -> { - val result = mutableMapOf() - node.fieldNames().forEach { fieldName -> - result[fieldName] = convertJsonNodeToMap(node.get(fieldName)) - } - result - } - node.isArray() -> { - val result = mutableListOf() - for (i in 0 until node.size()) { - result.add(convertJsonNodeToMap(node.get(i))) - } - result - } - node.isNull() -> "null" - else -> node.toString() + // 然后添加所有子客户端的工具 + subClients.forEach { (serverId, client) -> + val clientTools = client.getToolMaps() + allToolMaps.addAll(clientTools) + Log.d(TAG, "添加子客户端 [$serverId] 的 ${clientTools.size} 个工具") } - } - - /** - * 获取工具Map列表,用于OpenAI请求 - */ - fun getToolMaps(): List> { - return toolMaps + + return allToolMaps } /** @@ -365,7 +701,7 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { return ToolType.LOCAL_FUNCTION } - if (tools.any { it.tool().get().name() == name }) { + if (subClients.any { it.value.containsTool(name) }) { return ToolType.MCP_TOOL } @@ -422,40 +758,40 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { return "未连接到MCP服务器" } - return try { - val result = mcp.callTool( - name = name, - arguments = arguments - ) - - // 处理工具调用结果 - result?.content?.joinToString("\n") { content -> - when (content) { - is TextContent -> content.text ?: "" - else -> "" - } - } - } catch (e: Exception) { - Log.e(TAG, "调用MCP工具失败: ${e.message}", e) - "调用MCP工具失败: ${e.message}" + // 查找包含此工具的子客户端 + val clientEntry = subClients.entries.find { it.value.containsTool(name) } + + if (clientEntry != null) { + val (serverId, client) = clientEntry + Log.d(TAG, "通过子客户端 [$serverId] 调用工具: $name") + return client.callTool(name, arguments) } + + // 没有找到包含此工具的客户端 + Log.e(TAG, "没有子客户端包含工具: $name") + return "没有可用的服务器提供工具: $name" } /** * 获取可用的工具列表 */ fun getTools(): List { - return tools + val allTools = mutableListOf() + + // 收集所有子客户端的工具 + subClients.forEach { (_, client) -> + allTools.addAll(client.getTools()) + } + + return allTools } /** * 检查工具是否存在 */ fun hasToolWithName(name: String): Boolean { - return localFunctions.containsKey(name) || toolMaps.any { - val functionMap = it["function"] as? Map - functionMap?.get("name") == name - } + return localFunctions.containsKey(name) || + subClients.any { it.value.containsTool(name) } } /** @@ -483,14 +819,71 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { override fun close() { runBlocking { try { - mcp.close() + closeAllConnections() isConnected = false - tools = emptyList() - toolMaps.clear() - Log.d(TAG, "已关闭MCP服务器连接", null) + Log.d(TAG, "已关闭所有MCP服务器连接") } catch (e: Exception) { Log.e(TAG, "关闭MCP服务器连接失败: ${e.message}", e) } } } +} + +/** + * 创建一个信任所有SSL证书的HttpClient + */ +private fun createSslTrustAllClient(): HttpClient { + // 创建信任所有证书的TrustManager + val trustAllCerts = arrayOf( + object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = arrayOf() + } + ) + + // 创建SSL上下文并初始化它 + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, trustAllCerts, SecureRandom()) + + // 创建HTTP日志拦截器 + val loggingInterceptor = HttpLoggingInterceptor { message -> + Log.d("OkHttp", message) + }.apply { + level = HttpLoggingInterceptor.Level.HEADERS + } + + // 创建自定义拦截器显示实际请求URL + val urlLoggingInterceptor = Interceptor { chain -> + val request = chain.request() + Log.d("OkHttp-URL", "实际请求URL: ${request.url}") + Log.d("OkHttp-Headers", "请求头: ${request.headers}") + + val response = chain.proceed(request) + + Log.d("OkHttp-Response", "状态码: ${response.code} - ${response.message}") + Log.d("OkHttp-Response", "响应头: ${response.headers}") + + response + } + + // 创建OkHttpClient并配置信任所有证书 + val okHttpClient = OkHttpClient.Builder() + .sslSocketFactory(sslContext.socketFactory, trustAllCerts[0] as X509TrustManager) + .hostnameVerifier { _, _ -> true } + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .addInterceptor(loggingInterceptor) // 添加HTTP日志拦截器 + .addInterceptor(urlLoggingInterceptor) // 添加URL日志拦截器 + .build() + + // 创建使用OkHttp引擎的HttpClient + return HttpClient(OkHttp) { + engine { + preconfigured = okHttpClient + } + + // 安装SSE插件 + install(SSE) + } } \ No newline at end of file diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt index 9ca318840..b91ec832a 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt @@ -94,7 +94,7 @@ class OpenAIService(private val context: Context? = null) { /** * 初始化OpenAI服务 */ - fun initialize(apiKey: String, baseUrl: String, model: String): Boolean { + fun initialize(apiKey: String, baseUrl: String, model: String, mcpServer: String): Boolean { this.apiKey = apiKey if (baseUrl.isNotEmpty()) { this.baseUrl = baseUrl @@ -107,7 +107,7 @@ class OpenAIService(private val context: Context? = null) { if (mcpClient == null) { mcpClient = MCPClient(context) } - + initializeMcpClient(mcpServer) isInitialized = apiKey.isNotEmpty() return isInitialized } @@ -129,9 +129,9 @@ class OpenAIService(private val context: Context? = null) { } /** - * 初始化MCP客户端(WebSocket方式) + * 初始化MCP客户端 */ - fun initializeMcpClient(serverUrl: String): Boolean { + fun initializeMcpClient(mcpServer: String): Boolean { if (mcpClient != null) { mcpClient?.close() } @@ -144,7 +144,7 @@ class OpenAIService(private val context: Context? = null) { var result = false scope.launch { try { - result = mcpClient?.connectToWebSocket(serverUrl) ?: false + result = mcpClient?.connectToSSE(mcpServer) ?: false isMcpInitialized = result Log.d(TAG, "MCP客户端初始化${if (result) "成功" else "失败"}") } catch (e: Exception) { diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt index 091af559c..03b4d5d9e 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt @@ -73,8 +73,9 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { val apiKey = call.argument("apiKey") ?: "" val baseUrl = call.argument("baseUrl") ?: "" val model = call.argument("model") ?: "" + val mcpServer = call.argument("mcpServer") ?: "" - val initialized = openAIService.initialize(apiKey, baseUrl, model) + val initialized = openAIService.initialize(apiKey, baseUrl, model, mcpServer) result.success(initialized) } diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/mcp/CustomSseClientTransport.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/mcp/CustomSseClientTransport.kt new file mode 100644 index 000000000..4b98b1c0f --- /dev/null +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/mcp/CustomSseClientTransport.kt @@ -0,0 +1,357 @@ +package com.yunqiinnovation.open_ai_service.mcp + +import android.util.Log +import io.ktor.client.* +import io.ktor.client.plugins.sse.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport +import kotlinx.coroutines.* +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.decodeFromString +import kotlin.properties.Delegates +import kotlin.time.Duration +import java.util.concurrent.atomic.AtomicBoolean +import org.json.JSONObject + +/** + * 自定义SSE客户端传输层,修复原始SseClientTransport中的URL拼接问题 + * 解决URL查询参数与路径拼接错误的问题,确保消息端点URL格式正确 + */ +class CustomSseClientTransport( + private val client: HttpClient, + private val urlString: String?, + private val reconnectionTime: Duration? = null, + private val requestBuilder: HttpRequestBuilder.() -> Unit = {}, +) : AbstractTransport() { + private val TAG = "CustomSseClientTransport" + + private val scope by lazy { + CoroutineScope(session.coroutineContext + SupervisorJob()) + } + + // 使用Java标准库的AtomicBoolean替代kotlinx.atomicfu + private val initialized = AtomicBoolean(false) + private var session: ClientSSESession by Delegates.notNull() + private val endpoint = CompletableDeferred() + + private var job: Job? = null + + // 创建JSON解析器,增强灵活性设置 + private val json = Json { + ignoreUnknownKeys = true // 忽略未知字段 + isLenient = true // 宽松解析模式 + coerceInputValues = true // 尝试强制转换类型 + encodeDefaults = true // 编码默认值 + explicitNulls = false // 不要求显式null值 + } + + // 保存基础URL(不包含查询参数)和查询参数 + private var baseUrlWithoutParams: String? = null + private var queryParams: Map = emptyMap() + private var hostPart: String = "" // 添加类级别变量 + private var pathPart: String = "" // 添加类级别变量 + + /** + * 解析URL,分离基础URL、路径和查询参数 + * 返回三元组: (主机部分URL, 路径部分, 查询参数Map) + */ + private fun parseUrl(url: String): Triple> { + return try { + val params = mutableMapOf() + + // 确保URL有协议部分 + var processedUrl = url.trim() + if (!processedUrl.startsWith("http://") && !processedUrl.startsWith("https://")) { + processedUrl = "https://$processedUrl" + Log.d(TAG, "添加默认协议: $processedUrl") + } + + val urlObj = java.net.URL(processedUrl) + + // 解析查询参数 + if (urlObj.query != null) { + urlObj.query.split("&").forEach { param -> + val parts = param.split("=", limit = 2) + if (parts.size == 2) { + params[parts[0]] = parts[1] + } + } + } + + // 构建主机部分URL(协议+主机+端口) + val port = if (urlObj.port == -1) "" else ":${urlObj.port}" + val hostUrl = "${urlObj.protocol}://${urlObj.host}$port" + + // 路径部分 + val path = urlObj.path + + Triple(hostUrl, path, params) + } catch (e: Exception) { + Log.e(TAG, "解析URL失败: $url, ${e.message}") + Triple(url, "", emptyMap()) + } + } + + /** + * 收集SSE事件 + */ + private suspend fun collectEvents() { + job = scope.launch(CoroutineName("CustomSseMcpClientTransport.collect#${hashCode()}")) { + session.incoming.collect { event -> + when (event.event) { + "error" -> { + val e = IllegalStateException("SSE error: ${event.data}") + Log.e(TAG, "SSE错误: ${event.data}") + _onError(e) + throw e + } + + "open" -> { + Log.d(TAG, "SSE连接已打开") + // 连接已打开,等待endpoint事件 + } + + "endpoint" -> { + try { + val eventData = event.data ?: "" + Log.d(TAG, "收到endpoint事件: $eventData") + + // 使用主机部分构建endpoint + val fullEndpoint = if (eventData.startsWith("/")) { + "$hostPart$eventData" + } else { + "$hostPart/$eventData" + } + + Log.d(TAG, "构建的endpoint路径(不含参数): $fullEndpoint") + + // 添加查询参数到endpoint + val endpointWithParams = if (queryParams.isNotEmpty()) { + // 检查endpoint是否已有查询参数 + if (fullEndpoint.contains("?")) { + // 已有查询参数,添加&并附加其他参数 + val queryString = queryParams.entries.joinToString("&") { "${it.key}=${it.value}" } + "$fullEndpoint&$queryString" + } else { + // 没有查询参数,添加?并附加参数 + val queryString = queryParams.entries.joinToString("&") { "${it.key}=${it.value}" } + "$fullEndpoint?$queryString" + } + } else { + fullEndpoint + } + + Log.d(TAG, "最终消息端点: $endpointWithParams") + endpoint.complete(endpointWithParams) + } catch (e: Exception) { + Log.e(TAG, "处理endpoint事件失败: ${e.message}", e) + _onError(e) + close() + error(e) + } + } + + else -> { + try { + // 解析JSON-RPC消息 + val data = event.data + if (data != null) { + Log.d(TAG, "收到事件数据: $data") + try { + // 尝试安全地解析JSON消息 + safeParseMessage(data) + } catch (e: Exception) { + Log.e(TAG, "解析JSON-RPC消息失败: ${e.message}", e) + // 错误已记录,但不中断连接,只发送错误通知 + _onError(e) + } + } + } catch (e: Exception) { + Log.e(TAG, "处理事件失败: ${e.message}", e) + _onError(e) + } + } + } + } + } + } + + /** + * 安全解析JSON-RPC消息 + */ + private suspend fun safeParseMessage(data: String) { + try { + // 先尝试使用标准解析 + val message = json.decodeFromString(data) + _onMessage(message) + } catch (e: Exception) { + // 如果标准解析失败,记录错误并尝试使用备用解析方式 + Log.w(TAG, "标准解析失败,尝试备用解析: ${e.message}") + + try { + // 尝试修复nextCursor缺失问题 + if (e.message?.contains("nextCursor") == true) { + // 尝试手动添加缺失的nextCursor字段 + val jsonObj = JSONObject(data) + + // 只有在解析ListToolsResult时处理 + if (data.contains("\"tools\"")) { + Log.d(TAG, "尝试修复ListToolsResult缺少nextCursor字段的问题") + + // 手动解析result部分并添加nextCursor + val resultJson = try { + if (jsonObj.has("result")) { + val resultObj = jsonObj.getJSONObject("result") + if (!resultObj.has("nextCursor")) { + resultObj.put("nextCursor", "") + jsonObj.put("result", resultObj) + } + jsonObj.toString() + } else { + // 如果没有result字段,可能是其他类型的消息 + data + } + } catch (ex: Exception) { + Log.e(TAG, "手动修复JSON失败: ${ex.message}") + data + } + + // 重新尝试解析修复后的JSON + val fixedMessage = json.decodeFromString(resultJson) + _onMessage(fixedMessage) + return + } + } + + // 通用错误处理 + Log.e(TAG, "无法解析消息,跳过: $data") + } catch (ex: Exception) { + Log.e(TAG, "备用解析也失败: ${ex.message}", ex) + // 不抛出异常,只记录错误 + _onError(e) + } + } + } + + /** + * 启动传输层 + */ + override suspend fun start() { + if (!initialized.compareAndSet(false, true)) { + Log.e(TAG, "传输层已经启动,不能重复启动") + error("CustomSseClientTransport already started!") + } + + // 解析URL和参数 + if (urlString != null) { + // 解析URL,提取主机部分、路径部分和查询参数 + val urlInfo = parseUrl(urlString) + hostPart = urlInfo.first + pathPart = urlInfo.second + queryParams = urlInfo.third + + // 存储不带查询参数的基础URL(主机+路径) + baseUrlWithoutParams = hostPart + pathPart + + Log.d(TAG, "原始URL: $urlString") + Log.d(TAG, "主机部分: $hostPart") + Log.d(TAG, "路径部分: $pathPart") + Log.d(TAG, "查询参数: $queryParams") + Log.d(TAG, "完整基础URL: $baseUrlWithoutParams") + } + + // 创建SSE会话 - 直接使用原始URL,不添加/sse后缀 + session = urlString?.let { + // 完整的SSE连接URL(主机部分+原始路径+查询参数) + val sseConnectUrl = if (queryParams.isNotEmpty()) { + // 如果路径已包含查询参数,就不再添加 + if (pathPart.contains("?")) { + "$hostPart$pathPart" + } else { + val queryString = queryParams.entries.joinToString("&") { "${it.key}=${it.value}" } + "$hostPart$pathPart?$queryString" + } + } else { + "$hostPart$pathPart" + } + + Log.d(TAG, "SSE连接URL: $sseConnectUrl") + + client.sseSession( + urlString = sseConnectUrl, + reconnectionTime = reconnectionTime, + block = requestBuilder, + ) + } ?: client.sseSession( + reconnectionTime = reconnectionTime, + block = requestBuilder, + ) + + // 收集SSE事件 + collectEvents() + + // 等待endpoint就绪 + endpoint.await() + Log.d(TAG, "传输层启动完成,消息端点已就绪") + } + + /** + * 发送消息 + */ + @OptIn(ExperimentalCoroutinesApi::class) + override suspend fun send(message: JSONRPCMessage) { + if (!endpoint.isCompleted) { + Log.e(TAG, "发送失败: 未连接") + error("Not connected") + } + + try { + val messageEndpoint = endpoint.getCompleted() + Log.d(TAG, "发送消息到: $messageEndpoint") + + // 序列化消息 + val jsonString = json.encodeToString(message) + + val response = client.post(messageEndpoint) { + headers.append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(jsonString) + } + + if (!response.status.isSuccess()) { + val text = response.bodyAsText() + Log.e(TAG, "发送消息失败: HTTP ${response.status}, $text") + error("Error POSTing to endpoint (HTTP ${response.status}): $text") + } + } catch (e: Exception) { + Log.e(TAG, "发送消息异常: ${e.message}", e) + _onError(e) + throw e + } + } + + /** + * 关闭传输层 + */ + override suspend fun close() { + if (!initialized.get()) { + Log.e(TAG, "关闭失败: 传输层未初始化") + error("CustomSseClientTransport is not initialized!") + } + + session.cancel() + _onClose() + job?.cancelAndJoin() + Log.d(TAG, "传输层已关闭") + } + + /** + * 检查传输层是否已初始化 + */ + fun isInitialized(): Boolean { + return initialized.get() + } +} \ No newline at end of file diff --git a/local_plugins/open_ai_service/lib/open_ai_service.dart b/local_plugins/open_ai_service/lib/open_ai_service.dart index 8666f0100..df0fa315d 100644 --- a/local_plugins/open_ai_service/lib/open_ai_service.dart +++ b/local_plugins/open_ai_service/lib/open_ai_service.dart @@ -94,10 +94,12 @@ class OpenAIService { /// [apiKey] OpenAI API密钥 /// [baseUrl] 可选,自定义API基础URL /// [model] 可选,自定义使用的模型 + /// [mcpServer] 可选,MCP服务器地址配置 Future initialize({ required String apiKey, String baseUrl = '', String model = '', + String mcpServer = '', }) async { try { final result = await _channel.invokeMethod( @@ -106,6 +108,7 @@ class OpenAIService { 'apiKey': apiKey, 'baseUrl': baseUrl, 'model': model, + 'mcpServer': mcpServer, }, ); diff --git a/pubspec.yaml b/pubspec.yaml index b08d2746e..a076d7687 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -91,3 +91,4 @@ flutter: - assets/images/explore2.jpg - assets/lottie/ - .env + - .mcp.json diff --git a/test.json b/test.json index 2411527e2..208c91d82 100644 --- a/test.json +++ b/test.json @@ -16,9 +16,19 @@ curl 'https://ark.cn-beijing.volces.com/api/v3/bots/chat/completions' \ } ] }' +"map-qq": { + "url": "https://mcp.map.qq.com/sse?key=E5ABZ-RIACZ-DX2XC-7DEU2-AXR2Q-V7BWQ" +}, - - +"amap-amap-sse": { + "url": "https://mcp.amap.com/sse?key=66b3f6199e3561f874a5a9cd9e9ed7f2" +}, + // "map-qq": { + // "url": "https://mcp.map.qq.com?key=E5ABZ-RIACZ-DX2XC-7DEU2-AXR2Q-V7BWQ" + // }, + "web-search": { + "url": "http://mcp.ideapsound.com:8000" + }, curl 'https://ark.cn-beijing.volces.com/api/v3/bots/chat/completions' \ -H 'Content-Type: application/json' \