From 8cbe7bcd16c23c7d7b8471d7578d0e58d022dc34 Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Thu, 26 Jun 2025 18:00:20 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E6=B7=BB=E5=8A=A0askai?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting/views/ai_bottom_sheet.dart | 141 ++++++++++++++++++ lib/modules/meeting/views/overview_tab.dart | 18 +++ lib/modules/meeting/views/speech_tab.dart | 19 +++ lib/modules/meeting/views/summary_tab.dart | 18 +++ 4 files changed, 196 insertions(+) create mode 100644 lib/modules/meeting/views/ai_bottom_sheet.dart diff --git a/lib/modules/meeting/views/ai_bottom_sheet.dart b/lib/modules/meeting/views/ai_bottom_sheet.dart new file mode 100644 index 000000000..9243acad8 --- /dev/null +++ b/lib/modules/meeting/views/ai_bottom_sheet.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; + +import '../controllers/meeting_details_controller.dart'; + +class AIBottomSheet extends StatefulWidget { + const AIBottomSheet({super.key}); + + @override + State createState() => _AIBottomSheetState(); +} + +class _AIBottomSheetState extends State { + final _controller = Get.find(); + + List _dataList = []; + + final List _cruxList = [ + '添加代办事项', + '提取结论', + '生成关键指标', + ]; + + @override + Widget build(BuildContext context) { + return Container( + height: 1.sh - 50.h, + padding: EdgeInsets.symmetric(horizontal: 12.w), + child: Column( + children: [ + Container( + padding: EdgeInsets.symmetric(vertical: 10.w), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + 26.horizontalSpace, + Text( + 'Ask AI', + style: TextStyle( + fontSize: 16.sp, + color: Colors.black87, + fontWeight: FontWeight.bold, + ), + ), + GestureDetector( + onTap: () => Get.back(), + child: Container( + width: 26.w, + height: 26.w, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(26.r), + ), + child: Icon( + Icons.close, + size: 18.w, + color: Colors.grey[400], + ), + ), + ) + ], + ), + ), + Expanded( + child: _dataList.isNotEmpty ? SizedBox() : _empty(), + ), + _inputBox(), + ], + ), + ); + } + + Widget _empty() { + return Padding( + padding: EdgeInsets.symmetric(vertical: 10.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '今天我能帮您做点什么呢?', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.bold, + ), + ), + Padding( + padding: EdgeInsets.only(top: 10.w, bottom: 30.w), + child: Row( + children: [ + Icon( + Icons.audio_file_outlined, + size: 14.w, + color: Colors.grey[500], + ), + 2.horizontalSpace, + Text( + _controller.meetingData.value.title, + style: TextStyle( + color: Colors.grey[500], + fontSize: 12.sp, + ), + ), + ], + ), + ), + ...List.generate( + _cruxList.length, + (int index) => Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 15.w, vertical: 8.w), + margin: EdgeInsets.symmetric(vertical: 5.w), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(20.r), + border: Border.all( + color: Colors.grey[300]!, + ), + ), + child: Text( + _cruxList[index], + style: TextStyle(fontSize: 12.sp), + ), + ), + ), + ], + ), + ); + } + + Widget _inputBox() { + return Container( + height: 40.w, + padding: EdgeInsets.symmetric(horizontal: 10.w), + decoration: BoxDecoration(), + child: Row( + children: [], + ), + ); + } +} diff --git a/lib/modules/meeting/views/overview_tab.dart b/lib/modules/meeting/views/overview_tab.dart index 3f65ed1a9..0dde125c7 100644 --- a/lib/modules/meeting/views/overview_tab.dart +++ b/lib/modules/meeting/views/overview_tab.dart @@ -4,6 +4,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import '../controllers/meeting_details_controller.dart'; +import 'ai_bottom_sheet.dart'; import 'generate_bottom_sheet.dart'; class OverviewTab extends StatefulWidget { @@ -94,6 +95,23 @@ class _OverviewTabState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ + if (_controller.meetingDetails.value.tasktype >= 3) + _button( + Icons.auto_awesome, + 'Ask AI', + () { + Get.bottomSheet( + const AIBottomSheet(), + isScrollControlled: true, + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(12.r), + ), + ), + ); + }, + ), if (_controller.meetingDetails.value.tasktype == 0) SizedBox( width: 150.w, diff --git a/lib/modules/meeting/views/speech_tab.dart b/lib/modules/meeting/views/speech_tab.dart index 8c043cfa5..c2530aa0d 100644 --- a/lib/modules/meeting/views/speech_tab.dart +++ b/lib/modules/meeting/views/speech_tab.dart @@ -5,6 +5,7 @@ import 'package:get/get.dart'; import 'package:lottie/lottie.dart'; import '../controllers/meeting_details_controller.dart'; +import 'ai_bottom_sheet.dart'; import 'generate_bottom_sheet.dart'; import 'speaker_all_bottom_sheet.dart'; import 'speaker_bottom_sheet.dart'; @@ -278,6 +279,24 @@ class SpeechTab extends GetView { ); }, ), + if (controller.meetingDetails.value.tasktype >= 2 && + !controller.isSpeakerText.value) + _button( + Icons.auto_awesome, + 'Ask AI', + () { + Get.bottomSheet( + const AIBottomSheet(), + isScrollControlled: true, + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(12.r), + ), + ), + ); + }, + ), if (controller.meetingDetails.value.tasktype == 0) SizedBox( width: 150.w, diff --git a/lib/modules/meeting/views/summary_tab.dart b/lib/modules/meeting/views/summary_tab.dart index 65fe8b17e..42b050d3c 100644 --- a/lib/modules/meeting/views/summary_tab.dart +++ b/lib/modules/meeting/views/summary_tab.dart @@ -6,6 +6,7 @@ import 'package:get/get.dart'; import '../../../data/services/db/sqflite_api.dart'; import '../controllers/meeting_details_controller.dart'; +import 'ai_bottom_sheet.dart'; import 'generate_bottom_sheet.dart'; import 'speaker_all_bottom_sheet.dart'; @@ -310,6 +311,23 @@ class _SummaryTabState extends State { ); }, ), + if (_controller.meetingDetails.value.tasktype >= 3) + _button( + Icons.auto_awesome, + 'Ask AI', + () { + Get.bottomSheet( + const AIBottomSheet(), + isScrollControlled: true, + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(12.r), + ), + ), + ); + }, + ), if (_controller.meetingDetails.value.tasktype == 0) SizedBox( width: 150.w, From e8cbb5415b1702ac3562d7ad9d01c48cea3c1464 Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Thu, 26 Jun 2025 18:52:55 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E5=8A=A0=E5=85=A5=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/modules/settings/views/settings_view.dart | 44 +++++++++---------- .../yunqiinnovation/ble_service/BleConst.kt | 15 +++++-- .../yunqiinnovation/ble_service/BleService.kt | 20 ++++++++- .../com/example/ota/tool/ota/OTAManager.kt | 2 +- 4 files changed, 53 insertions(+), 28 deletions(-) diff --git a/lib/modules/settings/views/settings_view.dart b/lib/modules/settings/views/settings_view.dart index 6c33ea86e..7cadd9704 100644 --- a/lib/modules/settings/views/settings_view.dart +++ b/lib/modules/settings/views/settings_view.dart @@ -569,28 +569,28 @@ class SettingsView extends GetView { // color: isDarkMode // ? Colors.white.withOpacity(0.1) // : Colors.grey[200]), - // // BLE测试 - // _buildSimpleNavigationSetting( - // title: 'opus解码测试', - // subtitle: '测试opus解码', - // icon: Icons.bluetooth_searching, - // iconBgColor: isDarkMode - // ? Colors.green[900]!.withOpacity(0.3) - // : Colors.green[100]!, - // iconColor: - // isDarkMode ? Colors.green[300]! : Colors.green[600]!, - // titleColor: isDarkMode ? Colors.white : null, - // subtitleColor: isDarkMode ? Colors.white70 : null, - // onTap: () { - // Get.toNamed(Routes.opusTest); - // }, - // isDarkMode: isDarkMode, - // ), - // Divider( - // height: 1, - // color: isDarkMode - // ? Colors.white.withOpacity(0.1) - // : Colors.grey[200]), + // BLE测试 + _buildSimpleNavigationSetting( + title: 'opus解码测试', + subtitle: '测试opus解码', + icon: Icons.bluetooth_searching, + iconBgColor: isDarkMode + ? Colors.green[900]!.withOpacity(0.3) + : Colors.green[100]!, + iconColor: + isDarkMode ? Colors.green[300]! : Colors.green[600]!, + titleColor: isDarkMode ? Colors.white : null, + subtitleColor: isDarkMode ? Colors.white70 : null, + onTap: () { + Get.toNamed(Routes.opusTest); + }, + isDarkMode: isDarkMode, + ), + Divider( + height: 1, + color: isDarkMode + ? Colors.white.withOpacity(0.1) + : Colors.grey[200]), // BLE测试 // _buildSimpleNavigationSetting( diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt index 29246eabf..9ae9dd608 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt @@ -10,11 +10,18 @@ object BleConst { /** 主服务UUID - 文档中定义为0000ABC0-0000-1111-2222-123456789ABC */ val PRIMARY_SERVICE_UUID: UUID = UUID.fromString("0000abc0-0000-1111-2222-123456789abc") - /** 音频服务UUID - 文档中定义为00001801-0000-1000-8000-00805f9b34fb */ - val AUDIO_SERVICE_UUID: UUID = UUID.fromString("0000ABC0-0001-1111-2222-123456789ABC") + + /** 音频服务UUID - 文档中定义为0000ABC0-0001-1111-2222-123456789ABC */ + val AUDIO_SERVICE_UUID1: UUID = UUID.fromString("0000ABC0-0001-1111-2222-123456789ABC") + + /** 接收音频特征UUID - 文档中定义为0000ABC2-0001-1111-2222-123456789ABC */ + val RECEIVE_AUDIO_CHAR_UUID1: UUID = UUID.fromString("0000ABC2-0001-1111-2222-123456789ABC") + + /** 音频服务UUID - 文档中定义为00001801-0000-1000-8000-00805f9b34fb */ + val AUDIO_SERVICE_UUID: UUID = UUID.fromString("0000ae00-0000-1000-8000-00805f9b34fb") - /** 接收音频特征UUID - 文档中定义为0000ABC1-0000-1111-2222-123456789ABC */ - val RECEIVE_AUDIO_CHAR_UUID: UUID = UUID.fromString("0000ABC2-0001-1111-2222-123456789ABC") + /** 接收音频特征UUID - 文档中定义为0000ABC2-0001-1111-2222-123456789ABC */ + val RECEIVE_AUDIO_CHAR_UUID: UUID = UUID.fromString("0000ae02-0000-1000-8000-00805f9b34fb") /** 写入特征UUID - 文档中定义为0000ABC1-0000-1111-2222-123456789ABC */ val WRITE_CHAR_UUID: UUID = UUID.fromString("0000abc1-0000-1111-2222-123456789abc") diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index 2e9d93f87..70c58d3d0 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -72,6 +72,7 @@ object BleService { private var notifyChar: BluetoothGattCharacteristic? = null private var writeChar: BluetoothGattCharacteristic? = null private var audioChar: BluetoothGattCharacteristic? = null + private var audioChar1: BluetoothGattCharacteristic? = null var recordfile: RecordingFile? = null // 扫描相关 @@ -419,6 +420,7 @@ object BleService { notifyChar = null writeChar = null audioChar = null + audioChar1 = null } } @@ -491,6 +493,10 @@ object BleService { val audioSvc = g.getService(BleConst.AUDIO_SERVICE_UUID) audioChar = audioSvc?.getCharacteristic(BleConst.RECEIVE_AUDIO_CHAR_UUID) + + // 获取音频服务1特征 + val audioSvc1 = g.getService(BleConst.AUDIO_SERVICE_UUID1) + audioChar1 = audioSvc1?.getCharacteristic(BleConst.RECEIVE_AUDIO_CHAR_UUID1) if (notifyChar == null || writeChar == null) { Log.e(TAG, "未找到主服务所需特征") updateConnectionState(BleConst.STATE_ERROR) @@ -505,6 +511,12 @@ object BleService { Log.i(TAG, "音频服务特征找到并设置通知") } else { Log.w(TAG, "音频服务特征未找到") + } + if (audioChar1 != null) { + setupNotifications(g, audioChar1) + Log.i(TAG, "音频服务特征找到并设置通知") + } else { + Log.w(TAG, "音频服务特征未找到") } // 设置主服务的通知 setupNotifications(g, notifyChar) @@ -518,10 +530,16 @@ object BleService { // 根据特征UUID区分处理 when (c.uuid) { + // 音频特征数据 + BleConst.RECEIVE_AUDIO_CHAR_UUID1 -> { + + Log.i(TAG, "RECEIVE_AUDIO_CHAR_UUID1") + processAudioData(data) + } // 音频特征数据 BleConst.RECEIVE_AUDIO_CHAR_UUID -> { - // Log.i(TAG, "收到音频特征数据") + Log.i(TAG, "RECEIVE_AUDIO_CHAR_UUID") processAudioData(data) } // 通知特征数据(命令和控制) diff --git a/local_plugins/ota/android/src/main/kotlin/com/example/ota/tool/ota/OTAManager.kt b/local_plugins/ota/android/src/main/kotlin/com/example/ota/tool/ota/OTAManager.kt index 8001b29ee..733551f6c 100644 --- a/local_plugins/ota/android/src/main/kotlin/com/example/ota/tool/ota/OTAManager.kt +++ b/local_plugins/ota/android/src/main/kotlin/com/example/ota/tool/ota/OTAManager.kt @@ -106,7 +106,7 @@ class OTAManager(context: Context) : BluetoothOTAManager(context) { "是否启用设备认证流程(与固件工程师确认) >>> ${configHelper.isUseDeviceAuth()} >>> ${bluetoothOption.isUseReconnect} " ) //是否启用设备认证流程(与固件工程师确认) - bluetoothOption.isUseAuthDevice = configHelper.isUseDeviceAuth() + bluetoothOption.isUseAuthDevice = false //设置BLE的MTU bluetoothOption.mtu = BluetoothConstant.BLE_MTU_MIN //是否需要改变BLE的MTU From ebbd837b7b082e3139b2c921e0eafdcde07cce80 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Thu, 26 Jun 2025 19:08:44 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=B8=8A=E4=BC=A0mac=E6=96=AD=E5=BC=80?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=20=E7=9A=84=E5=A4=84=E7=90=86=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat_api/CustomSseClientTransport.kt | 176 ++++++++++++------ .../yunqiinnovation/chat_api/MCPSubClient.kt | 63 ++++++- 2 files changed, 173 insertions(+), 66 deletions(-) diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/CustomSseClientTransport.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/CustomSseClientTransport.kt index 12982f0fb..aa469e62a 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/CustomSseClientTransport.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/CustomSseClientTransport.kt @@ -25,6 +25,7 @@ class CustomSseClientTransport( private val urlString: String?, private val reconnectionTime: Duration? = null, private val requestBuilder: HttpRequestBuilder.() -> Unit = {}, + private val onConnectionLost: (() -> Unit)? = null ) : AbstractTransport() { private val TAG = "CustomSseClientTransport" @@ -35,8 +36,10 @@ class CustomSseClientTransport( private val initialized = AtomicBoolean(false) private var session: ClientSSESession by Delegates.notNull() private val endpoint = CompletableDeferred() + private val isConnected = AtomicBoolean(false) private var job: Job? = null + private var connectionMonitorJob: Job? = null // 创建JSON解析器 private val json = Json { @@ -95,74 +98,118 @@ class CustomSseClientTransport( */ 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 - } + try { + session.incoming.collect { event -> + when (event.event) { + "error" -> { + Log.e(TAG, "SSE错误: ${event.data}") + isConnected.set(false) + val exception = Exception("SSE Error: ${event.data}") + _onError(exception) + onConnectionLost?.invoke() + throw exception + } - "open" -> { - // SSE连接已打开 - } - "ping" -> { - // 心跳 - } - "endpoint" -> { - try { - val eventData = event.data ?: "" + "open" -> { + // SSE连接已打开 + Log.d(TAG, "SSE连接已打开") + isConnected.set(true) + } + "ping" -> { + // 心跳 + } + "endpoint" -> { + try { + val eventData = event.data ?: "" - // 构建完整的端点URL - val fullEndpoint = if (eventData.contains(hostPart)) { - eventData - } else if (eventData.startsWith("/")) { - "$hostPart$eventData" - } else { - eventData - } - - // 添加查询参数 - val endpointWithParams = if (queryParams.isNotEmpty()) { - if (fullEndpoint.contains("?")) { - val queryString = queryParams.entries.joinToString("&") { "${it.key}=${it.value}" } - "$fullEndpoint&$queryString" + // 构建完整的端点URL + val fullEndpoint = if (eventData.contains(hostPart)) { + eventData + } else if (eventData.startsWith("/")) { + "$hostPart$eventData" } else { - val queryString = queryParams.entries.joinToString("&") { "${it.key}=${it.value}" } - "$fullEndpoint?$queryString" + eventData } - } else { - fullEndpoint + + // 添加查询参数 + val endpointWithParams = if (queryParams.isNotEmpty()) { + 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 + } + + endpoint.complete(endpointWithParams) + } catch (e: Exception) { + Log.e(TAG, "处理endpoint事件失败: ${e.message}", e) + _onError(e) + close() + error(e) } - - endpoint.complete(endpointWithParams) - } catch (e: Exception) { - Log.e(TAG, "处理endpoint事件失败: ${e.message}", e) - _onError(e) - close() - error(e) } - } - else -> { - try { - val data = event.data - if (data != null) { - try { - val message = json.decodeFromString(data) - _onMessage(message) - } catch (e: Exception) { - Log.e(TAG, "解析JSON-RPC消息失败: ${e.message}", e) - _onError(e) + else -> { + try { + val data = event.data + if (data != null) { + try { + val message = json.decodeFromString(data) + _onMessage(message) + } catch (e: Exception) { + Log.e(TAG, "解析JSON-RPC消息失败: ${e.message}", e) + _onError(e) + } } + } catch (e: Exception) { + Log.e(TAG, "处理事件失败: ${e.message}", e) + _onError(e) } - } catch (e: Exception) { - Log.e(TAG, "处理事件失败: ${e.message}", e) - _onError(e) } } } + } catch (e: CancellationException) { + Log.d(TAG, "SSE事件收集被取消") + throw e + } catch (e: Exception) { + Log.e(TAG, "SSE连接异常断开: ${e.message}", e) + isConnected.set(false) + _onError(e) + onConnectionLost?.invoke() + throw e + } + } + + // 启动连接监控 + startConnectionMonitor() + } + + /** + * 启动连接监控,定期检查连接状态 + */ + private fun startConnectionMonitor() { + connectionMonitorJob = scope.launch { + while (isActive && isConnected.get()) { + try { + delay(10000) // 每10秒检查一次 + + // 检查session是否仍然活跃 + if (session.coroutineContext[Job]?.isCancelled == true) { + Log.w(TAG, "检测到SSE会话已取消") + isConnected.set(false) + onConnectionLost?.invoke() + break + } + } catch (e: Exception) { + Log.e(TAG, "连接监控异常: ${e.message}", e) + isConnected.set(false) + onConnectionLost?.invoke() + break + } } } } @@ -212,6 +259,8 @@ class CustomSseClientTransport( // 等待endpoint就绪 endpoint.await() + + Log.d(TAG, "CustomSseClientTransport启动完成") } /** @@ -245,6 +294,13 @@ class CustomSseClientTransport( } } + /** + * 检查连接状态 + */ + fun isConnectionActive(): Boolean { + return isConnected.get() && session.coroutineContext[Job]?.isActive == true + } + /** * 关闭传输层 */ @@ -253,8 +309,14 @@ class CustomSseClientTransport( Log.e(TAG, "关闭失败: 传输层未初始化") error("CustomSseClientTransport is not initialized!") } + + isConnected.set(false) + connectionMonitorJob?.cancel() session.cancel() _onClose() job?.cancelAndJoin() + connectionMonitorJob?.cancelAndJoin() + + Log.d(TAG, "CustomSseClientTransport已关闭") } -} \ No newline at end of file +} \ No newline at end of file diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPSubClient.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPSubClient.kt index 77393a44a..d04ce3bb6 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPSubClient.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPSubClient.kt @@ -44,13 +44,14 @@ class MCPSubClient( private var mcpClient: Client? = null private var isConnected = false private var availableTools = mutableListOf() + private var transport: CustomSseClientTransport? = null /** * 连接到MCP服务器 */ suspend fun connect(): Boolean = connectionMutex.withLock { if (isConnected) return true - + Log.e(TAG, "[$serverId] 开始连接mcp服务器: $serverUrl") return try { // 创建MCP客户端实例 val client = Client( @@ -61,13 +62,20 @@ class MCPSubClient( ) // 根据URL类型选择传输方式 - val transport = when { + val newTransport = when { serverUrl.startsWith("http://") || serverUrl.startsWith("https://") -> { // SSE传输 - 使用自定义的CustomSseClientTransport val mcpHttpClient = httpClient ?: createMcpHttpClient() CustomSseClientTransport( client = mcpHttpClient, - urlString = serverUrl + urlString = serverUrl, + onConnectionLost = { + // 连接断开回调 + Log.w(TAG, "[$serverId] 检测到连接断开") + scope.launch { + handleConnectionLost() + } + } ) } else -> { @@ -76,8 +84,10 @@ class MCPSubClient( } } + transport = newTransport + // 连接到服务器 - client.connect(transport) + client.connect(newTransport) // 获取可用工具列表 try { @@ -101,9 +111,9 @@ class MCPSubClient( retryCount = 0 currentReconnectDelay = initialReconnectDelay // 启动心跳检测 - startHeartbeat() +// startHeartbeat() + Log.e(TAG, "[$serverId] 连接mcp服务器成功: $serverUrl") true - } catch (e: Exception) { Log.e(TAG, "[$serverId] MCP连接失败: ${e.message}", e) false @@ -321,7 +331,15 @@ class MCPSubClient( */ suspend fun checkConnection(): Boolean { if (!isConnected) { - Log.d(TAG, "当前未连接,尝试重新连接...") + Log.d(TAG, "[$serverId] 当前未连接,尝试重新连接...") + return connect() + } + + // 检查传输层连接状态 + val transportActive = transport?.isConnectionActive() ?: false + if (!transportActive) { + Log.w(TAG, "[$serverId] 传输层连接已断开") + isConnected = false return connect() } @@ -330,11 +348,34 @@ class MCPSubClient( mcpClient?.ping() return true } catch (e: Exception) { - Log.e(TAG, "连接检查失败: ${e.message}") + Log.e(TAG, "[$serverId] 连接检查失败: ${e.message}") isConnected = false return false } } + + /** + * 处理连接断开事件 + */ + private suspend fun handleConnectionLost() { + connectionMutex.withLock { + if (isConnected) { + Log.w(TAG, "[$serverId] 连接已断开,更新状态") + isConnected = false + stopHeartbeat() + + // 可以在这里添加自动重连逻辑 + // 或者通知上层应用连接已断开 + } + } + } + + /** + * 获取连接状态 + */ + fun getConnectionStatus(): Boolean { + return isConnected && (transport?.isConnectionActive() ?: false) + } /** * 停止心跳检测 */ @@ -393,10 +434,14 @@ class MCPSubClient( scope.launch { connectionMutex.withLock { try { + stopHeartbeat() mcpClient?.close() + transport?.close() mcpClient = null + transport = null isConnected = false availableTools.clear() + Log.d(TAG, "[$serverId] MCP连接已关闭") } catch (e: Exception) { Log.e(TAG, "[$serverId] 关闭MCP连接时出错: ${e.message}", e) } @@ -404,4 +449,4 @@ class MCPSubClient( } scope.cancel() } -} \ No newline at end of file +} \ No newline at end of file From 1899e8960844b5eb06383d993b2d7d8d30e16a2d Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Thu, 26 Jun 2025 21:51:50 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=20=E7=AD=89=E5=BE=85?= =?UTF-8?q?=E9=9F=B3=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent_service/AgentService.kt | 33 +++++++++--------- .../android/src/main/res/raw/await.mp3 | Bin 0 -> 24756 bytes .../ios/agent_service/Package.swift | 1 + 3 files changed, 18 insertions(+), 16 deletions(-) create mode 100644 local_plugins/agent_service/android/src/main/res/raw/await.mp3 diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt index e18de276f..adda12336 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt @@ -668,7 +668,7 @@ object AgentService : CoroutineScope { try { // 设置状态为正在流式输出 _isAiStreaming.set(true) - + audioPlayer?.playAudio(R.raw.await, true,0.3f) // 使用历史记录作为上下文发送到OpenAI val responseBuilder = StringBuilder() var aiMetadata:String = "" @@ -736,6 +736,9 @@ object AgentService : CoroutineScope { responseBuilder.append(token) if (speakResponse && broadcast) { ttsService?.speakStream(token) + if (token.length > 0){ + audioPlayer?.stopAudio() + } } if (broadcast){ // 发送流式回复token @@ -783,7 +786,7 @@ object AgentService : CoroutineScope { override fun onFunctionCall(call: JSONObject) { try { - audioPlayer?.playAudio(R.raw.calling, true) +// audioPlayer?.playAudio(R.raw.calling, true) val name = call.getString("name") sendEvent("function_call", mapOf( "name" to name, @@ -1120,34 +1123,32 @@ object AgentService : CoroutineScope { /** * 播放音频资源 * @param resId 资源ID + * @param isLooping 是否循环播放 + * @param volume 音量大小,范围0.0-1.0,默认1.0 */ - fun playAudio(resId: Int, isLooping: Boolean = false) { + fun playAudio(resId: Int, isLooping: Boolean = false, volume: Float = 1.0f) { try { // 释放之前的资源 release() - + // 创建播放器并设置资源 mediaPlayer = MediaPlayer().apply { // 设置资源 context.resources.openRawResourceFd(resId)?.use { fd -> setDataSource(fd.fileDescriptor, fd.startOffset, fd.length) } - - // 播放完成后自动释放资源 + + this.isLooping = isLooping // 设置循环属性 + setVolume(volume, volume) // 设置音量(左声道,右声道) + setOnCompletionListener { - release() - } - - // 准备并播放 - prepare() - if (isLooping) { - start() - } else { - start() - setOnCompletionListener { + if (!isLooping) { release() } } + + prepare() + start() } } catch (e: Exception) { Log.e(TAG, "播放音频资源异常: ${e.message}", e) diff --git a/local_plugins/agent_service/android/src/main/res/raw/await.mp3 b/local_plugins/agent_service/android/src/main/res/raw/await.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..ae1bfcc9def863deb3c26a60ccb7b188010d2148 GIT binary patch literal 24756 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