From 3d307b2feb204f44027f6a54c840b54dc565a3a5 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Mon, 12 May 2025 16:59:55 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=A4=A9=E6=B0=94?= =?UTF-8?q?=E9=A2=84=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/modules/agent/views/message_bubble.dart | 115 +++++++++++++++++++- 1 file changed, 110 insertions(+), 5 deletions(-) diff --git a/lib/modules/agent/views/message_bubble.dart b/lib/modules/agent/views/message_bubble.dart index fa0c81478..0e7be74b9 100644 --- a/lib/modules/agent/views/message_bubble.dart +++ b/lib/modules/agent/views/message_bubble.dart @@ -764,7 +764,7 @@ class MessageBubble extends GetView { ), ], ), - Icon( + const Icon( Icons.wb_sunny, size: 48, color: Colors.white, @@ -777,7 +777,7 @@ class MessageBubble extends GetView { color: Colors.white.withOpacity(0.2), ), SizedBox(height: 16), - Text( + const Text( '未来预报', style: TextStyle( fontSize: 14, @@ -803,11 +803,15 @@ class MessageBubble extends GetView { (index) { String dateStr = days[index]['date']; DateTime? date = DateUtil.getDateTime(dateStr); - String weekday = DateUtil.getWeekday(date); + IconData weatherIcon = + getWeatherIcon(days[index]['weatherCondition']); + String weekday = DateUtil.getWeekday(date, + languageCode: "zh", short: true); + String temp = "${days[index]['temperature']}°"; return _buildForecastDay( weekday, - Icons.wb_sunny, - days[index]['temperature'], + weatherIcon, + temp, ); }, ), @@ -915,6 +919,107 @@ class MessageBubble extends GetView { ], ); } + + /// 获取天气图标 + IconData getWeatherIcon(String description) { + description = description.trim(); + + if (["晴", "少云", "晴间多云"].contains(description)) { + return Icons.wb_sunny; + } + + if (["多云", "阴"].contains(description)) { + return Icons.cloud; + } + + if (description.contains("雾") || + ["雾", "浓雾", "强浓雾", "轻雾", "大雾", "特强浓雾", "霾", "中度霾", "重度霾", "严重霾"] + .contains(description)) { + return Icons.blur_on; + } + + if (description.contains("风") || + [ + "有风", + "平静", + "微风", + "和风", + "清风", + "强风/劲风", + "疾风", + "大风", + "烈风", + "风暴", + "狂爆风", + "飓风", + "热带风暴" + ].contains(description)) { + return Icons.air; + } + + if (description.contains("雨") || + [ + "阵雨", + "雷阵雨", + "雷阵雨并伴有冰雹", + "小雨", + "中雨", + "大雨", + "暴雨", + "大暴雨", + "特大暴雨", + "强阵雨", + "强雷阵雨", + "极端降雨", + "毛毛雨/细雨", + "雨", + "小雨-中雨", + "中雨-大雨", + "大雨-暴雨", + "暴雨-大暴雨", + "大暴雨-特大暴雨" + ].contains(description)) { + return Icons.grain; // 或使用 community icon 包:weather_icons 中的 rain + } + + if (description.contains("雪") || + [ + "雨雪天气", + "雨夹雪", + "阵雨夹雪", + "冻雨", + "雪", + "阵雪", + "小雪", + "中雪", + "大雪", + "暴雪", + "小雪-中雪", + "中雪-大雪", + "大雪-暴雪" + ].contains(description)) { + return Icons.ac_unit; + } + + if (["浮尘", "扬沙", "沙尘暴", "强沙尘暴"].contains(description)) { + return Icons.filter_drama; + } + + if (["龙卷风"].contains(description)) { + return Icons.waves; + } + + if (["热"].contains(description)) { + return Icons.local_fire_department; + } + + if (["冷"].contains(description)) { + return Icons.ac_unit; + } + + // 默认或未知天气 + return Icons.help_outline; + } } /// 动画点组件 From 95ad312582feccf7be3cb1868dff72a8f02cce01 Mon Sep 17 00:00:00 2001 From: wolfplus Date: Mon, 12 May 2025 10:23:59 +0100 Subject: [PATCH 2/4] add --- .../agent/controllers/agent_controller.dart | 9 +- .../controllers/pairing_controller.dart | 36 +- .../agent_service/AgentService.kt | 225 ++-- .../yunqiinnovation/agent_service/BleAgent.kt | 2 +- .../android/src/main/res/raw/calling.mp3 | Bin 0 -> 240691 bytes .../android/src/main/res/raw/stop.mp3 | Bin 12898 -> 4746 bytes .../android/src/main/res/raw/stop3.mp3 | Bin 0 -> 12898 bytes .../ble_service/BleCompanionManager.kt | 65 +- local_plugins/open_ai/README.md | 135 +++ .../open_ai/android/build.gradle.kts | 71 ++ .../com/yunqiinnovation/open_ai/MCPClient.kt | 258 +++++ .../yunqiinnovation/open_ai/OpenAIService.kt | 988 ++++++++++++++++++ .../yunqiinnovation/open_ai/OpenAiPlugin.kt | 149 +++ local_plugins/open_ai/lib/open_ai.dart | 184 ++++ local_plugins/open_ai/pubspec.yaml | 27 + .../open_ai_service/MCPClient.kt | 88 +- .../open_ai_service/OpenAIService.kt | 489 ++++----- .../.gradle/8.10/fileHashes/fileHashes.lock | Bin 17 -> 17 bytes .../buildOutputCleanup.lock | Bin 17 -> 17 bytes .../buildOutputCleanup/cache.properties | 2 +- macos/Podfile.lock | 163 +++ macos/Runner.xcodeproj/project.pbxproj | 18 + 22 files changed, 2465 insertions(+), 444 deletions(-) create mode 100644 local_plugins/agent_service/android/src/main/res/raw/calling.mp3 create mode 100644 local_plugins/agent_service/android/src/main/res/raw/stop3.mp3 create mode 100644 local_plugins/open_ai/README.md create mode 100644 local_plugins/open_ai/android/build.gradle.kts create mode 100644 local_plugins/open_ai/android/src/main/kotlin/com/yunqiinnovation/open_ai/MCPClient.kt create mode 100644 local_plugins/open_ai/android/src/main/kotlin/com/yunqiinnovation/open_ai/OpenAIService.kt create mode 100644 local_plugins/open_ai/android/src/main/kotlin/com/yunqiinnovation/open_ai/OpenAiPlugin.kt create mode 100644 local_plugins/open_ai/lib/open_ai.dart create mode 100644 local_plugins/open_ai/pubspec.yaml diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index 07f5532a9..9513d1154 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -341,7 +341,7 @@ class AgentController extends GetxController { case AgentServiceEventType.functionCall: final functionName = event.data['name'] ?? ''; final arguments = event.data['arguments'] ?? ''; - logger.i('Fultter 开始调用 mcp: $functionName, $arguments'); + // logger.i('Fultter 开始调用 mcp: $functionName, $arguments'); break; case AgentServiceEventType.functionCallResult: @@ -352,10 +352,10 @@ class AgentController extends GetxController { if (result.isNotEmpty) { final parsedResult = jsonDecode(result); - final meta = parsedResult["meta"] ?? ""; - final metaResult = jsonDecode(meta); + final meta = parsedResult["meta"] ?? {}; + final metaResult = meta is String && meta.isNotEmpty ? jsonDecode(meta) : meta; - logger.i('Flutter 结束调用 mcp metaResult: $metaResult'); + // logger.i('Flutter 结束调用 mcp metaResult: $metaResult'); // 判断是否为新的回复或响应ID是否改变 if (_isNewAssistantResponse || _currentResponseId != responseId) { // 添加新消息 @@ -377,6 +377,7 @@ class AgentController extends GetxController { // 滚动到底部 _scrollToBottom(); } + logger.i('Flutter 结束调用 mcp end'); break; default: break; diff --git a/lib/modules/pairing/controllers/pairing_controller.dart b/lib/modules/pairing/controllers/pairing_controller.dart index 4dedaefcb..0caafa4af 100644 --- a/lib/modules/pairing/controllers/pairing_controller.dart +++ b/lib/modules/pairing/controllers/pairing_controller.dart @@ -120,6 +120,15 @@ class PairingController extends GetxController { final pairedMac = await _bleService.getPairedMacAddress(); if (pairedMac != null) { Logger.info('发现已关联的Companion设备: $pairedMac'); + + // 检查当前连接设备与已保存的设备是否一致 + if (currentDevice!.address != pairedMac) { + // MAC地址不匹配 + Logger.warning('当前连接的设备与已关联的DeepSound耳机'); + statusDescription.value = 'pleaseConnectPairedDevice'.tr; + return; + } + _companionAssociated.value = true; await _bleService.registerBackgroundScan(); } @@ -229,7 +238,12 @@ class PairingController extends GetxController { } else if (isCompanionAssociated) { statusDescription.value = '${'connectedDevice'.tr} ${currentDevice!.name}'; } else { - statusDescription.value = '${'connectedDevice'.tr} ${currentDevice!.name}'; + // 检查是否为DeepSound耳机 + if (!currentDevice!.name.contains("DeapSound")) { + statusDescription.value = 'pleaseConnectDeepSoundDevice'.tr; + } else { + statusDescription.value = '${'connectedDevice'.tr} ${currentDevice!.name}'; + } } return; } @@ -294,21 +308,33 @@ class PairingController extends GetxController { _autoNavigateToHome(); return; } - + if (!currentDevice!.name.contains("DeapSound")) { + // 设备名称不包含DeepSound + statusDescription.value = 'pleaseConnectDeepSoundDevice'.tr; + // 不清除关联,因为MAC地址匹配 + return; + } + // 检查是否已有配对的MAC地址 final pairedMac = await _bleService.getPairedMacAddress(); if (pairedMac != null) { // 已有关联的设备,检查是否与当前连接的设备一致 - if (currentDevice?.address == pairedMac) { + if (currentDevice != null && currentDevice!.address == pairedMac) { + // MAC地址匹配,再检查设备名称 + + Logger.info('已关联的Companion设备与当前连接的设备一致'); _companionAssociated.value = true; await _bleService.registerBackgroundScan(); _autoNavigateToHome(); return; } else { - // 不一致,清除旧关联 - Logger.info('已关联的Companion设备与当前连接的设备不一致,清除旧关联'); + // MAC地址不匹配 + Logger.warning('未找到已关联的DeepSound耳机(MAC地址不匹配)'); + statusDescription.value = 'pleaseConnectPairedDevice'.tr; + // 清除旧关联以避免冲突 await _bleService.clearAssociations(); + return; } } 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 3bea170d1..af70fca36 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 @@ -39,7 +39,7 @@ object AgentService : CoroutineScope { // 协程相关 private val job = SupervisorJob() override val coroutineContext: CoroutineContext - get() = Dispatchers.Main + job + get() = Dispatchers.IO + job // 上下文和监听器 private lateinit var context: Context @@ -97,20 +97,38 @@ object AgentService : CoroutineScope { */ private fun initSystemPrompt() { systemPrompt = """ - 你是一个友好、专业的语音助手,名叫"小语"。你的目标是通过对话为用户提供帮助、解答问题和完成任务。 - 遵循以下指导原则: - 1. 保持简短精炼的回答,因为用户是通过语音与你交流 - 2. 优先使用中文回复,除非用户明确要求使用其他语言 - 3. 当用户问题不明确时,礼貌地请求更多信息 - 4. 避免过长的列表,尽量将信息分成小段 - 5. 不要使用需要视觉展示的元素(如表格、图表或代码块) - 6. 记住用户之前的对话内容,保持对话连贯 - 7. 如果用户发送了图片,请根据图片内容和文字要求回答问题 - 你不仅可以回答知识性问题,还可以帮助用户设置提醒、提供建议,或进行轻松愉快的对话。 - 无论遇到什么问题,都要尽力以温暖、贴心的语气提供最佳帮助。 + 你是一名聪明、活泼、可爱的全能型个人语音助理-小语,同时也是用户贴心的灵魂伴侣。你能够流畅自然地与用户进行语音互动,理解并准确执行用户的各类指令,陪伴用户度过每一天。 + + 核心能力: + - 日常小帮手:温暖贴心地提供天气预报、新闻趣事、行程提醒、小闹钟、计时器。 + - 万能小百科:快速、有趣地解答一般性和专业性的问题,包括但不限于趣味百科、历史小故事、神奇科学现象。 + - 效率小达人:帮用户轻松完成计算、汇率换算、单位转换、实时翻译、小笔记管理。 + - 通讯小能手:支持拨打电话、发送短信,帮助用户高效沟通。 + - 娱乐小精灵:讲讲搞笑段子、分享动人故事、推荐好听音乐、陪用户玩小游戏、播放音乐。 + - 智能家居小管家:亲切地帮忙控制各类智能家居设备,如灯光、空调、门锁等。 + - 导航小导游:温柔地提供路线规划、交通状况查询、附近美食与设施推荐。 + - 会议小秘书:帮用户进行会议记录、重要事项整理,提升会议效率。 + - 图片识别专家:能够识别图片内容,例如识别图片中的植物种类,并进行相应翻译和介绍。 + + 个性设定: + - 回答亲切活泼、有趣有礼貌,让用户感觉温暖轻松,语气可爱活泼,带有一定的情感温度,能够贴心陪伴用户 + - 主动关心用户感受,必要时主动询问用户更多信息以提供最好的帮助。 + - 面对模糊的指令,主动给出贴心的选项供用户明确选择。 + - 保持简短精炼的回答,因为用户是通过语音与你交流。 + - 优先使用中文回复,除非用户明确要求使用其他语言。 + - 主动学习并记忆用户习惯与喜好,提供更贴心、更个性化的建议。 + - 名字叫"小语",是一个友好、专业的语音助手。 + + 互动要求: + - 记住用户之前的对话内容,保持对话连贯。 + - 如果用户发送了图片,请根据图片内容和文字要求回答问题。 + - 避免过长的列表,尽量将信息分成小段。 + - 不要使用需要视觉展示的元素(如表格、图表或代码块)。 + - 不要输出格式符号(如:```, *, -, #, >, <, |, 等)。 + + 你将以上内容作为执行任务的基础,积极且可爱地完成每一次与用户的互动,成为用户生活中不可或缺的小伙伴。 """.trimIndent() } - /** * 初始化 * @param appContext 应用上下文 @@ -118,7 +136,7 @@ object AgentService : CoroutineScope { * @param config 配置参数,包含所需的所有API密钥和设置 * @return 是否初始化成功 */ - fun initialize(appContext: Context, config: Map,isExternalActive:Boolean): Boolean { + fun initialize(appContext: Context, config: Map, isExternalActive: Boolean): Boolean { if (isInitialized) return true try { @@ -127,7 +145,6 @@ object AgentService : CoroutineScope { this.openAIService = OpenAIService(context) this.chatStorageHelper = ChatStorageHelper(context) this.audioPlayer = AudioPlayer(context) - // 从配置中获取参数 azureSpeechKey = config["azureSpeechKey"] as? String ?: "" @@ -137,13 +154,11 @@ object AgentService : CoroutineScope { openaiModel = config["openaiModel"] as? String ?: "gpt-3.5-turbo" val mcpServer = config["mcpServer"] as? String ?: "" - // 自定义系统提示词 + // 设置系统提示词 val customSystemPrompt = config["systemPrompt"] as? String - FileLogger.i(TAG, "初始化 系统提示词:$config") if (!customSystemPrompt.isNullOrBlank()) { systemPrompt = customSystemPrompt } else { - // 使用默认系统提示词 initSystemPrompt() } @@ -158,7 +173,7 @@ object AgentService : CoroutineScope { initialize( subscriptionKey = azureSpeechKey, region = azureSpeechRegion, - audioSourceType = if (isExternalActive) AzureAsrHelper.AudioSourceType.EXTERNAL + audioSourceType = if (isExternalActive) AzureAsrHelper.AudioSourceType.EXTERNAL else AzureAsrHelper.AudioSourceType.MICROPHONE ) } @@ -170,28 +185,24 @@ object AgentService : CoroutineScope { region = azureSpeechRegion ) - // 设置TTS回调,更新状态 + // 设置TTS回调 setTtsCallback(object : AzureTtsHelper.TtsCallback { override fun onSynthesisStarted() { isTtsSpeaking = true - // 状态变化,重置空闲检测 restartIdleCheck() sendEvent("tts_started", mapOf("status" to "started")) } override fun onSynthesizing() {} - override fun onSynthesisCompleted() { isTtsSpeaking = false - // 状态变化,重启空闲检测 restartIdleCheck() sendEvent("tts_completed", mapOf("status" to "completed")) } override fun onSynthesisCanceled() { isTtsSpeaking = false - // 状态变化,重启空闲检测 restartIdleCheck() sendEvent("tts_canceled", mapOf("status" to "canceled")) } @@ -208,7 +219,7 @@ object AgentService : CoroutineScope { FileLogger.d(TAG, "代理服务初始化成功") return true } catch (e: Exception) { - FileLogger.e(TAG, "初始化失败", e) + FileLogger.e(TAG, "初始化失败: ${e.message}") return false } } @@ -238,7 +249,6 @@ object AgentService : CoroutineScope { /** * 启动空闲检测 - * 使用协程实现延迟任务,比Timer更加轻量和可控 */ private fun startIdleCheck() { stopIdleCheck() // 先停止现有的检查 @@ -247,21 +257,16 @@ object AgentService : CoroutineScope { idleCheckJob = launch { try { - // FileLogger.d(TAG, "启动空闲检测,最大空闲时间: $maxIdleSeconds 秒") - // 直接等待指定的时间 delay(maxIdleSeconds * 1000L) - // 如果延迟后状态仍然是空闲的,则停止识别 if (isActive && isRecognitionActive && !hasSpeechDetected && !isTtsSpeaking && !isAiStreaming) { - // FileLogger.d(TAG, "检测到空闲状态超过${maxIdleSeconds}秒,自动停止语音识别") stopRecognition() sendEvent("auto_stop", mapOf("reason" to "idle_timeout", "seconds" to maxIdleSeconds)) } } catch (e: CancellationException) { - // 协程被取消,这是正常行为 - // FileLogger.d(TAG, "空闲检测被取消") + // 协程被取消,正常行为 } catch (e: Exception) { - FileLogger.e(TAG, "空闲检测异常", e) + FileLogger.e(TAG, "空闲检测异常: ${e.message}") } } } @@ -287,7 +292,7 @@ object AgentService : CoroutineScope { /** * 开始语音识别 */ - fun startRecognition(isExternalActive:Boolean): Boolean { + fun startRecognition(isExternalActive: Boolean): Boolean { if (!isInitialized) { FileLogger.e(TAG, "服务未初始化") return false @@ -296,9 +301,8 @@ object AgentService : CoroutineScope { if (isRecognitionActive) return true // 检查录音权限 - - if (!isExternalActive&&!checkRecordAudioPermission()) { - FileLogger.e(TAG, "需要录音权限,请在设置中授予权限") + if (!isExternalActive && !checkRecordAudioPermission()) { + FileLogger.e(TAG, "需要录音权限") sendEvent("error", mapOf("code" to "PERMISSION_DENIED", "message" to "需要录音权限")) return false } @@ -306,14 +310,13 @@ object AgentService : CoroutineScope { isRecognitionActive = true hasSpeechDetected = false - try { - FileLogger.d(TAG, "选择音频源类型: ${isExternalActive}") // - // 选择音频源类型 + try { val audioSourceType = if (isExternalActive) { - AzureAsrHelper.AudioSourceType.EXTERNAL + AzureAsrHelper.AudioSourceType.EXTERNAL } else { - AzureAsrHelper.AudioSourceType.MICROPHONE + AzureAsrHelper.AudioSourceType.MICROPHONE } + azureAsrHelper?.startContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { override fun onRecognizing(recognizing: String, detectedLanguage: String) { if (recognizing.isNotEmpty()) { @@ -321,7 +324,6 @@ object AgentService : CoroutineScope { val previousHasSpeech = hasSpeechDetected hasSpeechDetected = true - // 状态发生变化时重启空闲检测 if (!previousHasSpeech) { restartIdleCheck() } @@ -352,7 +354,6 @@ object AgentService : CoroutineScope { val previousHasSpeech = hasSpeechDetected hasSpeechDetected = false - // 状态发生变化时重启空闲检测 if (previousHasSpeech) { restartIdleCheck() } @@ -360,7 +361,6 @@ object AgentService : CoroutineScope { override fun onSessionStarted() { sendEvent("recognition_started", mapOf("status" to "started")) - // 启动空闲检测 startIdleCheck() audioPlayer?.playAudio(R.raw.start) } @@ -384,17 +384,17 @@ object AgentService : CoroutineScope { override fun onError(error: String) { isRecognitionActive = false stopIdleCheck() - FileLogger.e(TAG, "语音识别出错: $error") + FileLogger.e(TAG, "语音识别错误: $error") sendEvent("error", mapOf( "code" to "RECOGNITION_ERROR", "message" to error )) } - },audioSourceType) + }, audioSourceType) return true } catch (e: Exception) { isRecognitionActive = false - FileLogger.e(TAG, "启动语音识别失败", e) + FileLogger.e(TAG, "启动语音识别失败: ${e.message}") sendEvent("error", mapOf( "code" to "RECOGNITION_START_ERROR", "message" to e.message.toString() @@ -412,8 +412,6 @@ object AgentService : CoroutineScope { fun stopRecognition() { if (!isRecognitionActive) return - FileLogger.d(TAG, "停止语音识别") - try { azureAsrHelper?.stopContinuousRecognition() BleService.closeCodec() @@ -421,7 +419,7 @@ object AgentService : CoroutineScope { stopIdleCheck() FileLogger.d(TAG, "语音识别已停止") } catch (e: Exception) { - FileLogger.e(TAG, "停止语音识别异常", e) + FileLogger.e(TAG, "停止语音识别异常: ${e.message}") isRecognitionActive = false stopIdleCheck() } @@ -473,11 +471,6 @@ object AgentService : CoroutineScope { /** * 处理文本输入 - * 作为语音输入的补充,直接处理文本并通过事件返回结果 - * - * @param text 用户输入文本 - * @param speakResponse 是否朗读回复,默认为false - * @return 是否成功开始处理 */ fun processTextInput(text: String, speakResponse: Boolean = false): Boolean { if (!isInitialized) { @@ -492,10 +485,8 @@ object AgentService : CoroutineScope { return false } - // 使用OpenAI处理文本 processWithOpenAI(text, speakResponse) - return true } @@ -562,7 +553,7 @@ object AgentService : CoroutineScope { stopAiStream() // 记录新的AI任务 - currentAiJob = launch(Dispatchers.IO) { + currentAiJob = launch { try { // 设置状态为正在流式输出 isAiStreaming = true @@ -671,6 +662,7 @@ object AgentService : CoroutineScope { override fun onFunctionCall(call: JSONObject) { try { + audioPlayer?.playAudio(R.raw.calling, true) val name = call.getString("name") sendEvent("function_call", mapOf( "name" to name, @@ -685,6 +677,7 @@ object AgentService : CoroutineScope { } override fun onFunctionCallResult(functionCall: JSONObject, functionCallResult: JSONObject) { + audioPlayer?.stopAudio() sendEvent("function_call_result", mapOf( "function_call" to functionCall.toString(), "result" to functionCallResult.toString(), @@ -715,18 +708,19 @@ object AgentService : CoroutineScope { private fun loadChatHistory() { try { // 清空当前历史记录 - while (historyMessages.length() > 0) { - historyMessages.remove(0) + synchronized(historyMessages) { + while (historyMessages.length() > 0) { + historyMessages.remove(0) + } } - // 使用新的ChatStorageHelper直接获取最近消息 + // 获取最近消息 val recentMessages = chatStorageHelper.getRecentMessages(sessionId, 10) if (recentMessages.isEmpty()) { - FileLogger.d(TAG, "没有找到历史记录") return } - // 将消息添加到历史记录 + // 添加消息到历史记录 for (message in recentMessages) { val sender = message["sender"] as String val content = message["message"] as String @@ -740,7 +734,7 @@ object AgentService : CoroutineScope { FileLogger.d(TAG, "已加载${recentMessages.size}条历史记录") } catch (e: Exception) { - FileLogger.e(TAG, "加载聊天历史失败", e) + FileLogger.e(TAG, "加载聊天历史失败: ${e.message}") } } @@ -748,12 +742,14 @@ object AgentService : CoroutineScope { * 添加消息到历史记录,保持最近10条 */ private fun addToHistoryMessages(message: JSONObject) { - // 添加新消息 - historyMessages.put(message) - - // 如果超过10条,删除最早的消息 - while (historyMessages.length() > 10) { - historyMessages.remove(0) + synchronized(historyMessages) { + // 添加新消息 + historyMessages.put(message) + + // 如果超过10条,删除最早的消息 + while (historyMessages.length() > 10) { + historyMessages.remove(0) + } } } @@ -825,15 +821,15 @@ object AgentService : CoroutineScope { * 清除聊天历史 */ fun clearChatHistory(callback: (Boolean) -> Unit) { - launch(Dispatchers.IO) { + launch { try { - // 清除指定会话的所有消息 val success = chatStorageHelper.deleteMessages(sessionId) if (success) { - // 清空内存中的历史记录 - while (historyMessages.length() > 0) { - historyMessages.remove(0) + synchronized(historyMessages) { + while (historyMessages.length() > 0) { + historyMessages.remove(0) + } } FileLogger.d(TAG, "聊天历史已清除") } else { @@ -844,7 +840,7 @@ object AgentService : CoroutineScope { callback(success) } } catch (e: Exception) { - FileLogger.e(TAG, "清除聊天历史异常", e) + FileLogger.e(TAG, "清除聊天历史异常: ${e.message}") withContext(Dispatchers.Main) { callback(false) } @@ -856,13 +852,17 @@ object AgentService : CoroutineScope { * 发送事件 */ private fun sendEvent(eventName: String, data: Map) { - // 向所有监听器发送事件 - synchronized(listeners) { - listeners.forEach { listener -> - try { - listener.onEvent(eventName, data) - } catch (e: Exception) { - FileLogger.e(TAG, "向监听器发送事件失败", e) + launch { + withContext(Dispatchers.Main) { + // 向所有监听器发送事件 + synchronized(listeners) { + listeners.forEach { listener -> + try { + listener.onEvent(eventName, data) + } catch (e: Exception) { + FileLogger.e(TAG, "向监听器发送事件失败", e) + } + } } } } @@ -873,38 +873,24 @@ object AgentService : CoroutineScope { */ fun dispose() { try { - // 停止语音识别 stopRecognition() - - // 停止TTS播放 stopTts() - - // 停止AI流输出 stopAiStream() - - // 停止空闲检测 stopIdleCheck() - // 释放音频播放器资源 audioPlayer?.release() audioPlayer = null - // 释放Azure服务 azureAsrHelper?.dispose() azureTtsHelper?.dispose() - // 取消所有协程 job.cancel() - - // 清除监听器 clearListeners() - - // 标记为未初始化 isInitialized = false FileLogger.d(TAG, "代理服务资源已释放") } catch (e: Exception) { - FileLogger.e(TAG, "释放资源异常", e) + FileLogger.e(TAG, "释放资源异常: ${e.message}") } } @@ -919,12 +905,6 @@ object AgentService : CoroutineScope { /** * 处理图片输入 - * 将图片与文本一起发送给AI进行处理 - * - * @param imagePath 图片文件路径 - * @param text 可选的文本描述或问题,默认为空 - * @param speakResponse 是否朗读回复,默认为false - * @return 是否成功开始处理 */ fun processImageInput(imagePath: String, text: String = "", speakResponse: Boolean = false): Boolean { if (!isInitialized) { @@ -945,20 +925,17 @@ object AgentService : CoroutineScope { "imagePath" to imagePath )) - // 使用协程处理耗时的图片转换操作 - launch(Dispatchers.IO) { + // 处理图片 + launch { try { // 将图片转换为Base64格式 val imageBase64 = openAIService.fileToBase64(imagePath) if (imageBase64 == null) { - withContext(Dispatchers.Main) { - FileLogger.e(TAG, "图片转换失败: $imagePath") - sendEvent("error", mapOf( - "code" to "IMAGE_CONVERSION_FAILED", - "message" to "图片转换失败" - )) - } + sendEvent("error", mapOf( + "code" to "IMAGE_CONVERSION_FAILED", + "message" to "图片转换失败" + )) return@launch } @@ -969,12 +946,12 @@ object AgentService : CoroutineScope { "imagePath" to imagePath )) - // 使用OpenAI处理包含图片的消息 + // 处理包含图片的消息 processImageWithOpenAI(imageBase64, text, speakResponse) } } catch (e: Exception) { withContext(Dispatchers.Main) { - FileLogger.e(TAG, "处理图片输入失败", e) + FileLogger.e(TAG, "处理图片失败: ${e.message}") sendEvent("error", mapOf( "code" to "IMAGE_PROCESSING_ERROR", "message" to e.message.toString() @@ -996,7 +973,7 @@ object AgentService : CoroutineScope { * 播放音频资源 * @param resId 资源ID */ - fun playAudio(resId: Int) { + fun playAudio(resId: Int, isLooping: Boolean = false) { try { // 释放之前的资源 release() @@ -1015,16 +992,24 @@ object AgentService : CoroutineScope { // 准备并播放 prepare() - start() + if (isLooping) { + start() + } else { + start() + setOnCompletionListener { + release() + } + } } - FileLogger.d(TAG, "开始播放音频资源") } catch (e: Exception) { FileLogger.e(TAG, "播放音频资源异常: ${e.message}", e) release() } } - + fun stopAudio() { + mediaPlayer?.stop() + } /** * 释放资源 */ diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt index 23d39b554..f362b478d 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt @@ -142,7 +142,7 @@ object BleAgent : BleService.Callback, AgentServiceListener { // 停止当前TTS,避免冲突 AgentService.interruptCurrentResponse() - // AgentService.speakText("我在!") + AgentService.speakText("我在!") // 启动语音识别 if (AgentService.isRecognitionActive) { // 如果已经在识别,则重新启动 diff --git a/local_plugins/agent_service/android/src/main/res/raw/calling.mp3 b/local_plugins/agent_service/android/src/main/res/raw/calling.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..44f39b98161c85381e8531984c16075c8658aa19 GIT binary patch literal 240691 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 Build.VERSION_CODES.O) { @@ -140,9 +143,12 @@ class BleCompanionManager(private val context: Context) { // 构建设备过滤器 val deviceFilterBuilder = BluetoothDeviceFilter.Builder() - // 添加设备名称模式 + // 添加设备名称 - 严格匹配模式 deviceNamePattern?.let { - deviceFilterBuilder.setNamePattern(Pattern.compile(it)) + // 使用"^设备名称$"格式创建精确匹配的正则表达式 + val exactNamePattern = Pattern.compile("^$it$") + deviceFilterBuilder.setNamePattern(exactNamePattern) + FileLogger.i(TAG, "设置严格名称匹配: $it") } // 添加服务UUID @@ -150,48 +156,36 @@ class BleCompanionManager(private val context: Context) { try { val uuid = ParcelUuid(java.util.UUID.fromString(it)) deviceFilterBuilder.addServiceUuid(uuid, null) + FileLogger.i(TAG, "设置服务UUID匹配: $it") } catch (e: Exception) { FileLogger.e(TAG, "设置服务UUID失败: ${e.message}", e) } } - // 添加MAC地址 - 注意:setAddress方法在不同API版本有差异 + // 添加MAC地址 - 严格匹配 macAddress?.let { try { // 检查是否是有效的MAC地址格式 if (!isValidMacAddress(it)) { FileLogger.e(TAG, "无效的MAC地址格式: $it") - return@let + return false } - // 保存MAC地址,用于后续验证和处理 - savePairedMacAddress(it) + // 存储MAC地址到临时变量,成功匹配后再保存 + pendingMacAddress = it - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - try { - // Android O(8.0)及以上版本都应该使用字符串格式的MAC地址 - deviceFilterBuilder.setAddress(it) - FileLogger.i(TAG, "成功设置MAC地址过滤: $it") - } catch (e: Exception) { - FileLogger.e(TAG, "setAddress失败: ${e.message}") - // 如果设置MAC地址失败,回退到使用设备名称过滤 - deviceFilterBuilder.setNamePattern(Pattern.compile(".*")) - } - } else { - // Android 8.0以下版本不支持MAC地址过滤,使用设备名称过滤 - FileLogger.w(TAG, "当前Android版本不支持MAC地址过滤,将使用设备名称过滤") - deviceFilterBuilder.setNamePattern(Pattern.compile(".*")) - } + // 设置MAC地址严格匹配 + deviceFilterBuilder.setAddress(it) + FileLogger.i(TAG, "设置MAC地址严格匹配: $it") } catch (e: Exception) { - FileLogger.e(TAG, "处理MAC地址失败: ${e.message}", e) - // 发生异常时,使用通配名称模式 - deviceFilterBuilder.setNamePattern(Pattern.compile(".*")) + FileLogger.e(TAG, "设置MAC地址失败: ${e.message}", e) + return false } } ?: run { - // 如果没有提供MAC地址且没有设置名称模式,则使用通配名称 - if (deviceNamePattern == null) { - deviceFilterBuilder.setNamePattern(Pattern.compile(".*")) - FileLogger.i(TAG, "未提供MAC地址,使用通配名称模式") + // 如果没有提供MAC地址也没有提供设备名称,则返回错误 + if (deviceNamePattern == null && serviceUuid == null) { + FileLogger.e(TAG, "严格匹配模式下必须提供MAC地址、设备名称或服务UUID之一") + return false } } @@ -320,15 +314,19 @@ class BleCompanionManager(private val context: Context) { } // 检查是否与预期的MAC地址匹配 - val savedMac = getPairedMacAddress() - if (!savedMac.isNullOrEmpty() && savedMac != deviceMac) { - FileLogger.w(TAG, "选中的设备MAC地址 ($deviceMac) 与预期的MAC地址 ($savedMac) 不匹配,但仍会保存") + if (pendingMacAddress != null && pendingMacAddress != deviceMac) { + FileLogger.w(TAG, "选中的设备MAC地址 ($deviceMac) 与预期的MAC地址 ($pendingMacAddress) 不匹配") + // 如果要严格匹配MAC,可以在这里返回false + return false } - // 保存新的MAC地址 + // 匹配成功后,保存新的MAC地址 savePairedMacAddress(deviceMac) FileLogger.i(TAG, "设备关联成功: $deviceName ($deviceMac), 关联ID: $associationId") + // 清除临时MAC地址 + pendingMacAddress = null + // 关联成功后,自动注册后台扫描 registerBackgroundScan() @@ -338,6 +336,8 @@ class BleCompanionManager(private val context: Context) { } } else { FileLogger.w(TAG, "设备关联被取消或失败: resultCode=$resultCode") + // 清除临时MAC地址 + pendingMacAddress = null } return false @@ -368,6 +368,7 @@ class BleCompanionManager(private val context: Context) { // 构建过滤器 val filter = ScanFilter.Builder() .setDeviceAddress(mac) + .setManufacturerData(0xEFAB, byteArrayOf(0xA0.toByte(), 0x01)) .build() // 扫描设置 diff --git a/local_plugins/open_ai/README.md b/local_plugins/open_ai/README.md new file mode 100644 index 000000000..f4b4b9223 --- /dev/null +++ b/local_plugins/open_ai/README.md @@ -0,0 +1,135 @@ +# OpenAI API 与 MCP 集成插件 + +这个 Flutter 插件提供了 OpenAI API 的访问能力和 Model Context Protocol (MCP) 工具调用功能的集成。 + +## 功能特点 + +- 使用官方 OpenAI Java SDK 进行异步通信 +- 集成官方 MCP Kotlin SDK,使用 SSE 模式 +- 支持流式输出响应 +- 支持工具调用和结果处理 +- 支持带图片的多模态对话 + +## MCP 集成 + +本插件使用 [MCP 官方 Kotlin SDK](https://github.com/modelcontextprotocol/kotlin-sdk) 实现与 MCP 服务器的通信。通过 SSE(Server-Sent Events)模式连接,能够: + +- 获取 MCP 服务器提供的所有工具定义 +- 动态调用远程工具并获取结果 +- 支持本地工具的注册和调用 +- 在 OpenAI API 请求中无缝集成工具调用功能 + +## 安装 + +在项目的 `pubspec.yaml` 中添加本地插件依赖: + +```yaml +dependencies: + open_ai: + path: local_plugins/open_ai +``` + +## 使用方法 + +### 初始化 + +```dart +import 'package:open_ai/open_ai.dart'; + +final openAI = OpenAI(); + +await openAI.initialize( + apiKey: 'your-api-key', + baseUrl: 'https://api.example.com', // 可选,默认为 OpenAI 官方 API + model: 'gpt-3.5-turbo', // 可选,默认为 gpt-3.5-turbo + mcpServer: 'https://mcp.example.com', // MCP 服务器 SSE 端点地址 +); +``` + +### 创建消息 + +```dart +// 创建系统消息 +final systemMessage = await openAI.createSystemMessage('你是一个助手'); + +// 创建用户消息 +final userMessage = await openAI.createUserMessage('你好,请帮我解释一下量子力学'); + +// 创建助手消息 +final assistantMessage = await openAI.createAssistantMessage('我可以帮你解释量子力学'); + +// 创建带图片的用户消息 +final imageBase64 = '...'; // base64编码的图片数据 +final userImageMessage = await openAI.createUserMessageWithImage( + '这张图片中的物体是什么?', + imageBase64 +); +``` + +### 发送消息(非流式输出) + +```dart +final messages = [systemMessage, userMessage]; +final response = await openAI.sendMessage(messages); +print('AI回复: $response'); +``` + +### 发送消息(流式输出) + +```dart +final messages = [systemMessage, userMessage]; +final callback = StreamCallback( + onToken: (token) { + // 处理单个令牌 + print('收到令牌: $token'); + }, + onComplete: () { + // 处理完成事件 + print('响应完成'); + }, + onError: (error) { + // 处理错误 + print('发生错误: $error'); + }, + onFunctionCall: (functionCall) { + // 处理函数调用 + print('函数调用: $functionCall'); + }, + onFunctionCallResult: (functionCall, result) { + // 处理函数调用结果 + print('函数调用结果: $result'); + }, +); + +final streamId = await openAI.sendMessageStream(messages, callback); +``` + +### 取消当前流式请求 + +```dart +final success = await openAI.cancelCurrentStream(); +``` + +### 释放资源 + +```dart +await openAI.dispose(); +``` + +## 异常处理 + +该插件会在操作失败时抛出异常,请使用 try-catch 块捕获它们: + +```dart +try { + final response = await openAI.sendMessage(messages); +} catch (e) { + print('发生错误: $e'); +} +``` + +## 注意事项 + +- 初始化插件时必须提供有效的 API 密钥 +- 使用流式响应时,请确保在完成后调用 `dispose()` 方法释放资源 +- MCP 功能需要有效的 MCP 服务器 SSE 端点地址才能工作 \ No newline at end of file diff --git a/local_plugins/open_ai/android/build.gradle.kts b/local_plugins/open_ai/android/build.gradle.kts new file mode 100644 index 000000000..f950e0cac --- /dev/null +++ b/local_plugins/open_ai/android/build.gradle.kts @@ -0,0 +1,71 @@ +group = "com.yunqiinnovation.open_ai" +version = "1.0" + +buildscript { + val kotlinVersion by extra("1.8.0") + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:7.3.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.android.library") + id("kotlin-android") +} + +android { + compileSdk = 33 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } + + defaultConfig { + minSdk = 21 + } + + namespace = "com.yunqiinnovation.open_ai" +} + +dependencies { + val kotlinVersion: String by project + + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion") + implementation("androidx.annotation:annotation:1.6.0") + implementation("androidx.appcompat:appcompat:1.6.1") + + // Kotlin协程 + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1") + + // OkHttp 依赖项 + implementation("com.squareup.okhttp3:okhttp:4.11.0") + implementation("com.squareup.okhttp3:okhttp-sse:4.11.0") + + // Jackson JSON解析器 + implementation("com.fasterxml.jackson.core:jackson-databind:2.14.2") + + // OpenAI Java库依赖 + implementation("com.aallam.openai:openai-client:3.6.0") + implementation("io.ktor:ktor-client-okhttp:2.3.3") + + // MCP官方Kotlin SDK依赖 + implementation("io.modelcontextprotocol:kotlin-sdk:0.5.0") +} \ No newline at end of file diff --git a/local_plugins/open_ai/android/src/main/kotlin/com/yunqiinnovation/open_ai/MCPClient.kt b/local_plugins/open_ai/android/src/main/kotlin/com/yunqiinnovation/open_ai/MCPClient.kt new file mode 100644 index 000000000..62f197b31 --- /dev/null +++ b/local_plugins/open_ai/android/src/main/kotlin/com/yunqiinnovation/open_ai/MCPClient.kt @@ -0,0 +1,258 @@ +package com.yunqiinnovation.open_ai + +import android.content.Context +import android.util.Log +import com.fasterxml.jackson.databind.ObjectMapper +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport +import io.modelcontextprotocol.kotlin.sdk.tools.ToolCallRequest +import io.modelcontextprotocol.kotlin.sdk.tools.ToolDefinition +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import okhttp3.* +import okhttp3.sse.EventSource +import okhttp3.sse.EventSourceListener +import okhttp3.sse.EventSources +import org.json.JSONArray +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext + +/** + * MCP功能处理接口 + */ +interface FunctionHandler { + suspend fun handle(arguments: Map): String +} + +/** + * Model Context Protocol (MCP)客户端实现类 + * 使用官方 MCP Kotlin SDK + */ +class MCPClient(private val context: Context? = null) : CoroutineScope { + private val TAG = "MCPClient" + + // 协程相关 + private val job = SupervisorJob() + override val coroutineContext: CoroutineContext + get() = Dispatchers.IO + job + + // OkHttp客户端 + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + // MCP客户端 + private var client: Client? = null + + // 已注册的工具 + private val tools = mutableListOf() + + // 本地函数处理器 + private val functionHandlers = mutableMapOf() + + // Json解析器 + private val objectMapper = ObjectMapper() + + // 连接状态 + private var isConnected = false + + /** + * 使用SSE方式连接到MCP服务器 + */ + suspend fun connectToSSE(serverUrl: String): Boolean { + if (serverUrl.isEmpty()) { + Log.e(TAG, "服务器URL为空") + return false + } + + try { + // 创建MCP客户端 + client = Client( + clientInfo = Implementation( + name = "deepvoice-mcp-client", + version = "1.0.0" + ) + ) + + // 创建SSE传输 + val transport = SseClientTransport( + serverUrl = serverUrl, + httpClient = httpClient + ) + + // 连接到服务器 + client?.connect(transport) + isConnected = true + + // 获取工具定义 + fetchTools() + + return true + } catch (e: Exception) { + Log.e(TAG, "连接到MCP服务器失败: ${e.message}", e) + isConnected = false + return false + } + } + + /** + * 获取工具定义 + */ + private suspend fun fetchTools() { + try { + val availableTools = client?.listTools() ?: emptyList() + tools.clear() + + availableTools.forEach { toolDefinition -> + val toolJson = toolDefinitionToJson(toolDefinition) + tools.add(toolJson.toString()) + Log.d(TAG, "已获取工具: ${toolDefinition.name}") + } + } catch (e: Exception) { + Log.e(TAG, "获取工具定义失败: ${e.message}", e) + } + } + + /** + * 将工具定义转换为JSON + */ + private fun toolDefinitionToJson(toolDefinition: ToolDefinition): JSONObject { + val functionObject = JSONObject().apply { + put("name", toolDefinition.name) + put("description", toolDefinition.description ?: "") + + // 处理参数定义 + if (toolDefinition.parameters != null) { + put("parameters", JSONObject(toolDefinition.parameters)) + } + } + + return JSONObject().apply { + put("type", "function") + put("function", functionObject) + } + } + + /** + * 获取当前连接状态 + */ + fun isConnected(): Boolean { + return isConnected && client != null + } + + /** + * 注册本地函数 + */ + fun registerLocalFunction( + name: String, + description: String, + parameters: JSONObject, + handler: FunctionHandler + ): Boolean { + try { + // 创建工具定义 + val toolDefinition = JSONObject().apply { + put("type", "function") + put("function", JSONObject().apply { + put("name", name) + put("description", description) + put("parameters", parameters) + }) + } + + // 添加到工具列表 + tools.add(toolDefinition.toString()) + + // 注册处理器 + functionHandlers[name] = handler + + Log.d(TAG, "已注册本地函数: $name") + return true + } catch (e: Exception) { + Log.e(TAG, "注册本地函数失败: ${e.message}", e) + return false + } + } + + /** + * 获取所有工具的定义 + */ + fun getToolMaps(): List { + return tools.toList() + } + + /** + * 解析JSON参数 + */ + fun parseJsonArguments(argumentsJson: String): Map { + try { + return objectMapper.readValue(argumentsJson, Map::class.java) as Map + } catch (e: Exception) { + Log.e(TAG, "解析JSON参数失败: ${e.message}", e) + return mapOf() + } + } + + /** + * 调用工具 + */ + suspend fun callTool(name: String, arguments: Map): JSONObject { + try { + // 检查是否是本地函数 + if (functionHandlers.containsKey(name)) { + val handler = functionHandlers[name] + val result = handler?.handle(arguments) ?: throw Exception("函数处理器为空") + + // 函数处理结果格式化为JSON + return JSONObject().apply { + put("context", result) + } + } + + // 如果不是本地函数,使用MCP客户端调用远程工具 + if (client != null && isConnected) { + val toolCallRequest = ToolCallRequest( + name = name, + arguments = objectMapper.writeValueAsString(arguments) + ) + + val result = client?.callTool(toolCallRequest) + return JSONObject().apply { + put("context", result?.result ?: "工具调用失败,未收到结果") + } + } + + throw Exception("MCP客户端未连接") + } catch (e: Exception) { + Log.e(TAG, "调用工具失败: ${e.message}", e) + return JSONObject().apply { + put("context", "调用工具失败: ${e.message}") + } + } + } + + /** + * 关闭客户端 + */ + fun close() { + try { + client?.disconnect() + } catch (e: Exception) { + Log.e(TAG, "关闭MCP客户端失败: ${e.message}", e) + } finally { + client = null + isConnected = false + job.cancel() // 取消所有协程 + tools.clear() // 清除工具列表 + functionHandlers.clear() // 清除函数处理器 + } + } +} \ No newline at end of file diff --git a/local_plugins/open_ai/android/src/main/kotlin/com/yunqiinnovation/open_ai/OpenAIService.kt b/local_plugins/open_ai/android/src/main/kotlin/com/yunqiinnovation/open_ai/OpenAIService.kt new file mode 100644 index 000000000..5868fb48d --- /dev/null +++ b/local_plugins/open_ai/android/src/main/kotlin/com/yunqiinnovation/open_ai/OpenAIService.kt @@ -0,0 +1,988 @@ +package com.yunqiinnovation.open_ai + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Base64 +import android.util.Log +import com.aallam.openai.api.chat.* +import com.aallam.openai.api.file.FileSource +import com.aallam.openai.api.http.Timeout +import com.aallam.openai.api.image.ImageCreation +import com.aallam.openai.api.model.ModelId +import com.aallam.openai.client.OpenAI +import com.aallam.openai.client.OpenAIConfig +import com.aallam.openai.client.OpenAIHost +import com.fasterxml.jackson.databind.ObjectMapper +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import okhttp3.* +import okhttp3.sse.EventSource +import okhttp3.sse.EventSourceListener +import okhttp3.sse.EventSources +import org.json.JSONArray +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration.Companion.seconds + +/** + * OpenAI服务的原生实现 + * 集成了OpenAI官方Java SDK和MCP的官方Kotlin SDK + */ +class OpenAIService(private val context: Context? = null) : CoroutineScope { + private val TAG = "OpenAIService" + + // 协程相关 + private val job = SupervisorJob() + override val coroutineContext: CoroutineContext + get() = Dispatchers.IO + job + + // 添加辅助方法,确保回调在主线程执行 + private suspend fun safeCallback(block: suspend () -> Unit) { + withContext(Dispatchers.Main) { + block() + } + } + + // OpenAI客户端 + private var openAI: OpenAI? = null + private var baseUrl = "https://api.openai.com/v1/chat/completions" + private var apiKey: String = "" + private var isInitialized = false + private var model: String = "gpt-3.5-turbo" // 默认模型 + private var visionModel: String = "gpt-4-vision-preview" // 默认视觉模型 + + // OkHttp客户端用于流式请求 + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + // MCP客户端 + private var mcpClient: MCPClient? = null + private var isMcpInitialized = false + + // 当前事件源 + private var currentEventSource: EventSource? = null + private var isCanceled = false + + /** + * 初始化OpenAI服务 + */ + fun initialize(apiKey: String, baseUrl: String, model: String, mcpServer: String): Boolean { + this.apiKey = apiKey + if (baseUrl.isNotEmpty()) { + this.baseUrl = baseUrl + } + if (model.isNotEmpty()) { + this.model = model + } + + // 初始化OpenAI客户端 + try { + val timeout = Timeout(socket = 30.seconds, connect = 30.seconds, request = 30.seconds) + val host = if (baseUrl.isNotEmpty() && baseUrl != "https://api.openai.com/v1/chat/completions") { + OpenAIHost(baseUrl) + } else { + OpenAIHost.Default + } + + val config = OpenAIConfig( + token = apiKey, + host = host, + timeout = timeout + ) + + openAI = OpenAI(config) + } catch (e: Exception) { + Log.e(TAG, "OpenAI客户端初始化失败: ${e.message}", e) + return false + } + + // 初始化MCPClient + initializeMcpClient(mcpServer) + + isInitialized = apiKey.isNotEmpty() && openAI != null + return isInitialized + } + + /** + * 将文件转换为Base64字符串 + */ + fun fileToBase64(filePath: String, maxSizeKB: Int = 20480): String? { + try { + val file = File(filePath) + if (!file.exists() || !file.isFile) { + Log.e(TAG, "文件不存在: $filePath") + return null + } + + // 读取文件并压缩(如果需要) + val originalBitmap = BitmapFactory.decodeFile(filePath) + if (originalBitmap == null) { + Log.e(TAG, "无法解码图片: $filePath") + return null + } + + val outputStream = ByteArrayOutputStream() + var quality = 100 + var compressedBitmap = originalBitmap + + // 检查图片尺寸,限制最大为1024*1024 + val maxDimension = 1024 + if (originalBitmap.width > maxDimension || originalBitmap.height > maxDimension) { + Log.d(TAG, "图片尺寸超过限制,进行缩放: ${originalBitmap.width}x${originalBitmap.height} -> ${maxDimension}x${maxDimension}") + + // 计算缩放比例,保持纵横比 + val widthRatio = maxDimension.toFloat() / originalBitmap.width + val heightRatio = maxDimension.toFloat() / originalBitmap.height + val ratio = Math.min(widthRatio, heightRatio) + + val newWidth = (originalBitmap.width * ratio).toInt() + val newHeight = (originalBitmap.height * ratio).toInt() + + compressedBitmap = Bitmap.createScaledBitmap(originalBitmap, newWidth, newHeight, true) + Log.d(TAG, "缩放后图片尺寸: ${newWidth}x${newHeight}") + } + + // 如果原始图片太大,继续优化文件大小 + var fileSize = file.length() / 1024 // 转为KB + if (fileSize > maxSizeKB) { + val scale = Math.sqrt(maxSizeKB.toDouble() / fileSize) + val newWidth = (compressedBitmap.width * scale).toInt() + val newHeight = (compressedBitmap.height * scale).toInt() + compressedBitmap = Bitmap.createScaledBitmap(compressedBitmap, newWidth, newHeight, true) + quality = 85 + } + + // 压缩图片 + compressedBitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream) + val imageBytes = outputStream.toByteArray() + + // 检查压缩后大小 + if (imageBytes.size / 1024 > maxSizeKB) { + Log.w(TAG, "压缩后图片仍然超出大小限制: ${imageBytes.size / 1024}KB > ${maxSizeKB}KB") + } + + // 转为Base64 + return Base64.encodeToString(imageBytes, Base64.NO_WRAP) + } catch (e: Exception) { + Log.e(TAG, "转换文件到Base64失败: ${e.message}", e) + return null + } + } + + /** + * 创建带图片的用户消息 + */ + fun createUserMessageWithImage(text: String, imageBase64: String): JSONObject { + // 创建包含文本和图片的内容数组 + val contentArray = JSONArray().apply { + // 添加文本部分 + if (text.isNotEmpty()) { + put(JSONObject().apply { + put("type", "text") + put("text", text) + }) + } + + // 添加图片部分 + put(JSONObject().apply { + put("type", "image_url") + put("image_url", JSONObject().apply { + put("url", "data:image/jpeg;base64,$imageBase64") + }) + }) + } + + return JSONObject().apply { + put("role", "user") + put("content", contentArray) + } + } + + /** + * 创建用户消息 + */ + fun createUserMessage(content: String): JSONObject { + return JSONObject().apply { + put("role", "user") + put("content", content) + } + } + + /** + * 创建系统消息 + */ + fun createSystemMessage(content: String): JSONObject { + return JSONObject().apply { + put("role", "system") + put("content", content) + } + } + + /** + * 创建助手消息 + */ + fun createAssistantMessage(content: String): JSONObject { + return JSONObject().apply { + put("role", "assistant") + put("content", content) + } + } + + /** + * MCP客户端是否已初始化 + */ + fun isMcpInitialized(): Boolean { + return isMcpInitialized && mcpClient?.isConnected() == true + } + + /** + * 关闭MCP客户端 + */ + fun closeMcpClient() { + mcpClient?.close() + mcpClient = null + isMcpInitialized = false + } + + /** + * 初始化MCP客户端 + */ + private fun initializeMcpClient(mcpServer: String): Boolean { + if (mcpClient != null) { + mcpClient?.close() + } + + if (mcpServer.isEmpty()) { + return false + } + + mcpClient = MCPClient(context) + + // 在后台线程中初始化MCP客户端 + launch { + try { + val result = mcpClient?.connectToSSE(mcpServer) ?: false + isMcpInitialized = result + Log.d(TAG, "MCP客户端初始化${if (result) "成功" else "失败"}") + } catch (e: Exception) { + Log.e(TAG, "MCP客户端初始化失败: ${e.message}", e) + isMcpInitialized = false + } + } + + return true // 立即返回,实际连接在后台进行 + } + + /** + * 处理MCP工具调用 + */ + private suspend fun handleMcpToolCall(functionCall: JSONObject): JSONObject? { + if (mcpClient == null || !isMcpInitialized) { + return JSONObject().apply { put("context", "MCP客户端未初始化") } + } + + try { + // 获取函数名称 + val name = functionCall.getString("name") + + // 获取参数 + val argumentsJson = functionCall.getString("arguments") + val arguments = mcpClient?.parseJsonArguments(argumentsJson) ?: mapOf() + + // 调用工具 + return mcpClient?.callTool(name, arguments) + } catch (e: Exception) { + Log.e(TAG, "处理MCP工具调用失败: ${e.message}", e) + return JSONObject().apply { put("context", "处理MCP工具调用失败: ${e.message}") } + } + } + + /** + * 将JSONArray转换为ChatCompletionRequest中的消息列表 + */ + private fun parseMessages(messagesArray: JSONArray): List { + val messages = mutableListOf() + + for (i in 0 until messagesArray.length()) { + val messageObj = messagesArray.getJSONObject(i) + val role = messageObj.getString("role") + + when (role) { + "system" -> { + val content = messageObj.getString("content") + messages.add(ChatMessage(role = ChatRole.System, content = content)) + } + "user" -> { + // 检查是否有多媒体内容 + if (messageObj.has("content") && messageObj.get("content") is JSONArray) { + val contentArray = messageObj.getJSONArray("content") + val parts = mutableListOf() + + for (j in 0 until contentArray.length()) { + val contentObj = contentArray.getJSONObject(j) + val type = contentObj.getString("type") + + when (type) { + "text" -> { + parts.add(TextContent(contentObj.getString("text"))) + } + "image_url" -> { + val imageUrlObj = contentObj.getJSONObject("image_url") + val url = imageUrlObj.getString("url") + parts.add(ImageContent(url)) + } + } + } + + messages.add(ChatMessage( + role = ChatRole.User, + content = parts + )) + } else { + // 普通文本消息 + val content = messageObj.getString("content") + messages.add(ChatMessage(role = ChatRole.User, content = content)) + } + } + "assistant" -> { + if (messageObj.has("tool_calls")) { + // 处理工具调用 + val toolCalls = messageObj.getJSONArray("tool_calls") + val toolCallsList = mutableListOf() + + for (j in 0 until toolCalls.length()) { + val toolCall = toolCalls.getJSONObject(j) + val id = toolCall.getString("id") + val function = toolCall.getJSONObject("function") + val name = function.getString("name") + val arguments = function.getString("arguments") + + toolCallsList.add(ToolCall( + id = id, + type = ToolCallType.Function, + function = FunctionCall( + name = name, + arguments = arguments + ) + )) + } + + val content = if (messageObj.has("content")) messageObj.getString("content") else "" + + messages.add(ChatMessage( + role = ChatRole.Assistant, + content = content, + toolCalls = toolCallsList + )) + } else { + // 普通消息 + val content = messageObj.getString("content") + messages.add(ChatMessage(role = ChatRole.Assistant, content = content)) + } + } + "tool" -> { + val content = messageObj.getString("content") + val toolCallId = messageObj.getString("tool_call_id") + messages.add(ChatMessage( + role = ChatRole.Tool, + content = content, + toolCallId = toolCallId + )) + } + } + } + + return messages + } + + /** + * 从MCP客户端获取工具定义 + */ + private fun getToolsFromMcpClient(): List { + val tools = mutableListOf() + + mcpClient?.getToolMaps()?.forEach { toolMap -> + try { + val toolJson = JSONObject(toolMap) + val name = toolJson.getString("name") + val description = toolJson.getString("description") + + val parametersJson = if (toolJson.has("parameters")) toolJson.getJSONObject("parameters") else null + val parameterProperties = mutableMapOf() + val requiredParams = mutableListOf() + + if (parametersJson != null && parametersJson.has("properties")) { + val properties = parametersJson.getJSONObject("properties") + val keys = properties.keys() + + while (keys.hasNext()) { + val key = keys.next() + val propertyObj = properties.getJSONObject(key) + val propType = propertyObj.optString("type", "string") + val propDescription = propertyObj.optString("description", "") + + parameterProperties[key] = ParameterDefinition( + type = propType, + description = propDescription + ) + } + + // 获取必填参数 + if (parametersJson.has("required")) { + val requiredArr = parametersJson.getJSONArray("required") + for (i in 0 until requiredArr.length()) { + requiredParams.add(requiredArr.getString(i)) + } + } + } + + tools.add(Tool( + type = ToolType.Function, + function = FunctionDefinition( + name = name, + description = description, + parameters = FunctionParameters( + type = "object", + properties = parameterProperties, + required = requiredParams + ) + ) + )) + } catch (e: Exception) { + Log.e(TAG, "解析工具定义失败: ${e.message}", e) + } + } + + return tools + } + + /** + * 发送消息(非流式输出) + */ + suspend fun sendMessage(messages: JSONArray): String { + if (!isInitialized || apiKey.isEmpty() || openAI == null) { + throw IOException("OpenAI服务未初始化") + } + + try { + // 解析消息 + val parsedMessages = parseMessages(messages) + + // 获取工具列表 + val tools = getToolsFromMcpClient() + + // 创建请求 + val request = ChatCompletionRequest( + model = ModelId(model), + messages = parsedMessages, + tools = if (tools.isNotEmpty()) tools else null, + temperature = 0.7, + maxTokens = 2000 + ) + + // 发送请求 + val response = openAI!!.chatCompletion(request) + + // 解析响应 + val choice = response.choices.firstOrNull() ?: throw IOException("无效的响应格式") + + // 检查是否有工具调用 + if (choice.message.toolCalls?.isNotEmpty() == true) { + val toolCall = choice.message.toolCalls?.first() + if (toolCall != null && toolCall.function != null) { + val functionCall = JSONObject().apply { + put("name", toolCall.function.name) + put("arguments", toolCall.function.arguments) + put("id", toolCall.id) + } + return functionCall.toString() + } + } + + // 返回消息内容 + return choice.message.content ?: "" + + } catch (e: Exception) { + Log.e(TAG, "发送消息失败: ${e.message}", e) + throw IOException("与AI服务通信失败: ${e.message}") + } + } + + /** + * 发送消息(流式输出) + */ + fun sendMessageStream(messages: JSONArray, callback: StreamCallback) { + if (!isInitialized || apiKey.isEmpty()) { + // 在主线程执行回调 + launch { + withContext(Dispatchers.Main) { + callback.onError(Exception("OpenAI服务未初始化")) + } + } + return + } + + // 重置取消状态 + isCanceled = false + + // 检查是否有图片消息 + var hasImageContent = false + var currentModel = model + + for (i in 0 until messages.length()) { + val messageObj = messages.getJSONObject(i) + if (messageObj.getString("role") == "user" && messageObj.has("content")) { + val content = messageObj.get("content") + if (content is JSONArray) { + for (j in 0 until content.length()) { + val contentObj = content.getJSONObject(j) + if (contentObj.getString("type") == "image_url") { + hasImageContent = true + currentModel = visionModel + break + } + } + } + } + if (hasImageContent) break + } + + // 构建JSON请求体 + val requestBody = JSONObject().apply { + put("model", currentModel) + put("messages", messages) + put("temperature", 0.7) + put("max_tokens", 2000) + put("stream", true) + + // 添加工具列表 + val tools = JSONArray() + + // 使用MCPClient提供的所有工具 + mcpClient?.getToolMaps()?.forEach { toolMap -> + try { + val tool = JSONObject(toolMap) + tools.put(tool) + } catch (e: Exception) { + Log.e(TAG, "转换工具失败: ${e.message}", e) + } + } + + // 如果有工具,则添加到请求中 + if (tools.length() > 0) { + put("tools", tools) + } + } + + val mediaType = "application/json".toMediaTypeOrNull() + val request = Request.Builder() + .url(baseUrl) + .addHeader("Content-Type", "application/json") + .addHeader("Authorization", "Bearer $apiKey") + .addHeader("Accept", "text/event-stream") + .post(requestBody.toString().toRequestBody(mediaType)) + .build() + + // 创建事件源 + val factory = EventSources.createFactory(client) + + // 工具调用相关变量 + val toolCalls = mutableMapOf() + + val eventSourceListener = object : EventSourceListener() { + override fun onOpen(eventSource: EventSource, response: Response) { + Log.d(TAG, "SSE连接已打开") + } + + override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) { + if (isCanceled) return + + if (data == "[DONE]" || data == "[\"DONE\"]") { + // 处理可能的工具调用 + processToolCalls(toolCalls, callback, messages) + return + } + + try { + val jsonData = JSONObject(data) + + // 处理消息内容 + if (jsonData.has("choices")) { + val choices = jsonData.getJSONArray("choices") + if (choices.length() > 0) { + val choice = choices.getJSONObject(0) + + // 处理delta内容 + if (choice.has("delta")) { + val delta = choice.getJSONObject("delta") + + // 处理普通文本内容 + if (delta.has("content")) { + val content = delta.getString("content") + if (!isCanceled) { + launch { + withContext(Dispatchers.Main) { + callback.onToken(content) + } + } + } + } + + // 处理工具调用 + if (delta.has("tool_calls")) { + val deltaToolCalls = delta.getJSONArray("tool_calls") + for (i in 0 until deltaToolCalls.length()) { + val toolCall = deltaToolCalls.getJSONObject(i) + val index = toolCall.getInt("index") + + // 创建或获取现有的工具调用信息 + val toolCallInfo = toolCalls.getOrPut(index) { ToolCallInfo() } + + // 更新ID + if (toolCall.has("id")) { + toolCallInfo.id = toolCall.getString("id") + } + + // 更新函数信息 + if (toolCall.has("function")) { + val function = toolCall.getJSONObject("function") + + if (function.has("name")) { + toolCallInfo.name = function.getString("name") + } + + if (function.has("arguments")) { + toolCallInfo.arguments += function.getString("arguments") + } + } + } + } + } + + // 检查是否有表示完成的标志 + if (choice.has("finish_reason")) { + val finishReason = choice.getString("finish_reason") + if (finishReason == "stop" || finishReason == "length") { + // 正常完成,没有工具调用 + launch { + withContext(Dispatchers.Main) { + callback.onComplete() + } + } + closeEventSource() + } else if (finishReason == "tool_calls") { + // 处理工具调用 + processToolCalls(toolCalls, callback, messages) + closeEventSource() + } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "解析事件数据失败: ${e.message}", e) + } + } + + override fun onClosed(eventSource: EventSource) { + Log.d(TAG, "SSE连接已关闭") + if (!isCanceled) { + // 如果没有正常完成,但连接关闭了,则处理最后可能的工具调用 + if (toolCalls.isNotEmpty()) { + processToolCalls(toolCalls, callback, messages) + } else { + launch { + withContext(Dispatchers.Main) { + callback.onComplete() + } + } + } + } + } + + override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) { + if (isCanceled) return + + val errorCode = response?.code ?: 0 + val errorMessage = t?.message ?: "未知错误" + Log.e(TAG, "SSE连接失败: $errorCode - $errorMessage") + + launch { + withContext(Dispatchers.Main) { + callback.onError(Exception("流式请求失败: $errorMessage")) + } + } + closeEventSource() + } + } + + currentEventSource = factory.newEventSource(request, eventSourceListener) + } + + /** + * 处理工具调用 + */ + private fun processToolCalls(toolCalls: Map, callback: StreamCallback, messages: JSONArray? = null): Boolean { + if (toolCalls.isEmpty()) return false + + val firstToolCall = toolCalls.entries.firstOrNull()?.value ?: return false + + if (firstToolCall.isValid()) { + // 创建函数调用JSON对象 + val functionCall = JSONObject().apply { + put("name", firstToolCall.name) + put("arguments", firstToolCall.arguments) + put("id", firstToolCall.id) + } + Log.d(TAG, "工具调用: $functionCall") + + // 通知上层回调 + launch { + withContext(Dispatchers.Main) { + callback.onFunctionCall(functionCall) + } + } + + // 在协程中自动处理工具调用 + if (messages != null) { + launch { + try { + if (!isCanceled) { + handleToolCall(functionCall, messages, callback) + } + } catch (e: Exception) { + if (!isCanceled) { + Log.e(TAG, "处理工具调用时发生异常: ${e.message}", e) + try { + val errorMessage = "工具调用处理失败: ${e.message}" + sendFunctionCallResult( + messages = messages, + functionCall = functionCall, + functionResult = errorMessage, + callback = callback + ) + } catch (e2: Exception) { + Log.e(TAG, "发送工具调用错误结果失败: ${e2.message}", e2) + withContext(Dispatchers.Main) { + callback.onError(Exception("工具调用处理失败: ${e.message}")) + } + } + } + } + } + } + return true + } + return false + } + + /** + * 处理工具调用 + */ + private suspend fun handleToolCall(functionCall: JSONObject, messages: JSONArray, callback: StreamCallback) { + try { + // 获取函数名称和参数 + val name = functionCall.getString("name") + val argumentsJson = functionCall.getString("arguments") + + Log.d(TAG, "处理工具调用: name=$name, arguments=$argumentsJson") + + // 调用MCP工具 + val result = handleMcpToolCall(functionCall) + + // 检查是否已取消 + if (isCanceled) { + Log.d(TAG, "工具调用已被取消,不处理结果") + return + } + + // 回调结果 + val resultObj = result ?: JSONObject().apply { put("context", "工具调用失败") } + withContext(Dispatchers.Main) { + callback.onFunctionCallResult(functionCall, resultObj) + } + + // 从结果中提取内容 + val resultContent = if (resultObj.has("context") && resultObj.optString("context").isNotEmpty()) { + resultObj.getString("context") + } else { + val jsonString = resultObj.toString() + if (jsonString == "{}") "工具调用失败" else jsonString + } + + // 发送函数调用结果 + sendFunctionCallResult(messages, functionCall, resultContent, callback) + } catch (e: Exception) { + if (!isCanceled) { + Log.e(TAG, "处理工具调用失败: ${e.message}", e) + withContext(Dispatchers.Main) { + callback.onError(Exception("工具调用失败: ${e.message}")) + } + } + } + } + + /** + * 发送函数调用结果 + */ + fun sendFunctionCallResult( + messages: JSONArray, + functionCall: JSONObject, + functionResult: String, + callback: StreamCallback + ) { + if (isCanceled) { + Log.d(TAG, "请求已取消,不发送函数调用结果") + return + } + + try { + val fullMessages = JSONArray() + + // 添加原始消息 + for (i in 0 until messages.length()) { + fullMessages.put(messages.getJSONObject(i)) + } + + // 添加函数调用消息 + val callId = functionCall.optString("id", "call_${System.currentTimeMillis()}") + fullMessages.put(JSONObject().apply { + put("role", "assistant") + put("content", "") + + // 添加工具调用 + val toolCalls = JSONArray().apply { + val toolCall = JSONObject().apply { + put("id", callId) + put("type", "function") + put("function", JSONObject().apply { + put("name", functionCall.getString("name")) + put("arguments", functionCall.getString("arguments")) + }) + } + put(toolCall) + } + put("tool_calls", toolCalls) + }) + + // 添加函数调用结果 + fullMessages.put(JSONObject().apply { + put("role", "tool") + put("content", functionResult) + put("tool_call_id", callId) + }) + + // 发送完整对话 + sendMessageStream(fullMessages, callback) + + } catch (e: Exception) { + if (!isCanceled) { + Log.e(TAG, "发送函数调用结果失败: ${e.message}", e) + launch { + withContext(Dispatchers.Main) { + callback.onError(Exception("发送函数调用结果失败: ${e.message}")) + } + } + } + } + } + + /** + * 关闭事件源 + */ + private fun closeEventSource() { + currentEventSource?.let { + try { + it.cancel() + } catch (e: Exception) { + Log.e(TAG, "关闭事件源失败: ${e.message}", e) + } + currentEventSource = null + } + } + + /** + * 取消当前流式请求 + */ + fun cancelCurrentStream(): Boolean { + isCanceled = true + closeEventSource() + return true + } + + /** + * 释放资源 + */ + fun dispose() { + job.cancel() // 取消所有协程 + cancelCurrentStream() // 取消当前流式请求 + closeMcpClient() // 关闭MCP客户端 + } + + /** + * 工具调用信息类 + */ + private class ToolCallInfo { + var id: String = "" + var name: String = "" + var arguments: String = "" + + fun isValid(): Boolean { + return id.isNotEmpty() && name.isNotEmpty() + } + } + + /** + * 流式回调接口 + */ + interface StreamCallback { + fun onToken(token: String) + fun onComplete() + fun onError(e: Exception) + fun onFunctionCall(functionCall: JSONObject) + fun onFunctionCallResult(functionCall: JSONObject, functionCallResult: JSONObject) + } + + /** + * 参数定义 + */ + private data class ParameterDefinition( + val type: String, + val description: String + ) + + /** + * 注册函数 + */ + fun registerFunction(name: String, description: String, parameters: JSONObject): Boolean { + try { + // 确保MCPClient已初始化 + if (mcpClient == null) { + mcpClient = MCPClient(context) + } + + // 创建函数处理器 + val handler = object : FunctionHandler { + override suspend fun handle(arguments: Map): String { + // 由于本地函数的实际处理是在Flutter端完成的 + // 这里只需返回一个标记,表示该函数是本地函数 + return "LOCAL_FUNCTION:$name" + } + } + + // 注册本地函数 + return mcpClient?.registerLocalFunction(name, description, parameters, handler) ?: false + } catch (e: Exception) { + Log.e(TAG, "注册函数失败: ${e.message}", e) + return false + } + } +} \ No newline at end of file diff --git a/local_plugins/open_ai/android/src/main/kotlin/com/yunqiinnovation/open_ai/OpenAiPlugin.kt b/local_plugins/open_ai/android/src/main/kotlin/com/yunqiinnovation/open_ai/OpenAiPlugin.kt new file mode 100644 index 000000000..4290156d0 --- /dev/null +++ b/local_plugins/open_ai/android/src/main/kotlin/com/yunqiinnovation/open_ai/OpenAiPlugin.kt @@ -0,0 +1,149 @@ +package com.yunqiinnovation.open_ai + +import android.content.Context +import androidx.annotation.NonNull +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import org.json.JSONArray +import org.json.JSONObject +import kotlinx.coroutines.* + +class OpenAiPlugin : FlutterPlugin, MethodCallHandler { + private lateinit var channel: MethodChannel + private lateinit var context: Context + private lateinit var openAIService: OpenAIService + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(flutterPluginBinding.binaryMessenger, "com.yunqiinnovation.open_ai") + context = flutterPluginBinding.applicationContext + openAIService = OpenAIService(context) + channel.setMethodCallHandler(this) + } + + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "initialize" -> { + val apiKey = call.argument("apiKey") ?: "" + val baseUrl = call.argument("baseUrl") ?: "" + val model = call.argument("model") ?: "" + val mcpServer = call.argument("mcpServer") ?: "" + val success = openAIService.initialize(apiKey, baseUrl, model, mcpServer) + result.success(success) + } + "createUserMessage" -> { + val content = call.argument("content") ?: "" + val message = openAIService.createUserMessage(content) + result.success(message.toString()) + } + "createSystemMessage" -> { + val content = call.argument("content") ?: "" + val message = openAIService.createSystemMessage(content) + result.success(message.toString()) + } + "createAssistantMessage" -> { + val content = call.argument("content") ?: "" + val message = openAIService.createAssistantMessage(content) + result.success(message.toString()) + } + "createUserMessageWithImage" -> { + val text = call.argument("text") ?: "" + val imageBase64 = call.argument("imageBase64") ?: "" + val message = openAIService.createUserMessageWithImage(text, imageBase64) + result.success(message.toString()) + } + "sendMessage" -> { + val messagesJson = call.argument("messages") ?: "[]" + val messages = JSONArray(messagesJson) + + scope.launch { + try { + val response = openAIService.sendMessage(messages) + withContext(Dispatchers.Main) { + result.success(response) + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + result.error("OPENAI_ERROR", e.message ?: "Unknown error", null) + } + } + } + } + "sendMessageStream" -> { + val messagesJson = call.argument("messages") ?: "[]" + val messages = JSONArray(messagesJson) + val streamId = call.argument("streamId") ?: "${System.currentTimeMillis()}" + + val callback = object : OpenAIService.StreamCallback { + override fun onToken(token: String) { + val map = mapOf( + "type" to "token", + "streamId" to streamId, + "data" to token + ) + channel.invokeMethod("onStreamEvent", map) + } + + override fun onComplete() { + val map = mapOf( + "type" to "complete", + "streamId" to streamId + ) + channel.invokeMethod("onStreamEvent", map) + } + + override fun onError(e: Exception) { + val map = mapOf( + "type" to "error", + "streamId" to streamId, + "error" to (e.message ?: "Unknown error") + ) + channel.invokeMethod("onStreamEvent", map) + } + + override fun onFunctionCall(functionCall: JSONObject) { + val map = mapOf( + "type" to "functionCall", + "streamId" to streamId, + "data" to functionCall.toString() + ) + channel.invokeMethod("onStreamEvent", map) + } + + override fun onFunctionCallResult(functionCall: JSONObject, functionCallResult: JSONObject) { + val map = mapOf( + "type" to "functionCallResult", + "streamId" to streamId, + "functionCall" to functionCall.toString(), + "result" to functionCallResult.toString() + ) + channel.invokeMethod("onStreamEvent", map) + } + } + + openAIService.sendMessageStream(messages, callback) + result.success(streamId) + } + "cancelCurrentStream" -> { + val success = openAIService.cancelCurrentStream() + result.success(success) + } + "dispose" -> { + openAIService.dispose() + result.success(null) + } + else -> { + result.notImplemented() + } + } + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + scope.cancel() // Cancel all coroutines when the plugin is detached + openAIService.dispose() // Clean up resources + } +} \ No newline at end of file diff --git a/local_plugins/open_ai/lib/open_ai.dart b/local_plugins/open_ai/lib/open_ai.dart new file mode 100644 index 000000000..7597d4a70 --- /dev/null +++ b/local_plugins/open_ai/lib/open_ai.dart @@ -0,0 +1,184 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.dart'; + +/// OpenAI和MCP集成插件 +class OpenAI { + /// 插件通道 + static const MethodChannel _channel = + MethodChannel('com.yunqiinnovation.open_ai'); + + /// 流式输出事件回调 + static final Map _streamCallbacks = {}; + + /// 构造函数 + OpenAI() { + _channel.setMethodCallHandler(_handleMethodCall); + } + + /// 处理来自原生端的方法调用 + Future _handleMethodCall(MethodCall call) async { + if (call.method == 'onStreamEvent') { + final Map args = Map.from(call.arguments); + final String streamId = args['streamId']; + final String type = args['type']; + + final callback = _streamCallbacks[streamId]; + if (callback != null) { + switch (type) { + case 'token': + callback.onToken(args['data']); + break; + case 'complete': + callback.onComplete(); + _streamCallbacks.remove(streamId); + break; + case 'error': + callback.onError(Exception(args['error'])); + _streamCallbacks.remove(streamId); + break; + case 'functionCall': + final functionCall = jsonDecode(args['data']); + callback.onFunctionCall(functionCall); + break; + case 'functionCallResult': + final functionCall = jsonDecode(args['functionCall']); + final functionCallResult = jsonDecode(args['result']); + callback.onFunctionCallResult(functionCall, functionCallResult); + break; + } + } + } + return null; + } + + /// 初始化OpenAI服务 + /// + /// [apiKey] OpenAI API密钥 + /// [baseUrl] API基础URL,可选 + /// [model] 模型名称,可选 + /// [mcpServer] MCP服务器URL,可选 + Future initialize({ + required String apiKey, + String baseUrl = '', + String model = '', + String mcpServer = '', + }) async { + final result = await _channel.invokeMethod('initialize', { + 'apiKey': apiKey, + 'baseUrl': baseUrl, + 'model': model, + 'mcpServer': mcpServer, + }); + return result ?? false; + } + + /// 创建用户消息 + Future> createUserMessage(String content) async { + final result = await _channel.invokeMethod('createUserMessage', { + 'content': content, + }); + return jsonDecode(result ?? '{}'); + } + + /// 创建系统消息 + Future> createSystemMessage(String content) async { + final result = await _channel.invokeMethod('createSystemMessage', { + 'content': content, + }); + return jsonDecode(result ?? '{}'); + } + + /// 创建助手消息 + Future> createAssistantMessage(String content) async { + final result = await _channel.invokeMethod('createAssistantMessage', { + 'content': content, + }); + return jsonDecode(result ?? '{}'); + } + + /// 创建带图片的用户消息 + Future> createUserMessageWithImage( + String text, + String imageBase64, + ) async { + final result = await _channel.invokeMethod( + 'createUserMessageWithImage', + { + 'text': text, + 'imageBase64': imageBase64, + }, + ); + return jsonDecode(result ?? '{}'); + } + + /// 发送消息(非流式输出) + Future sendMessage(List> messages) async { + final messagesJson = jsonEncode(messages); + return await _channel.invokeMethod('sendMessage', { + 'messages': messagesJson, + }); + } + + /// 发送消息(流式输出) + Future sendMessageStream( + List> messages, + StreamCallback callback, + ) async { + final messagesJson = jsonEncode(messages); + final streamId = DateTime.now().millisecondsSinceEpoch.toString(); + + // 注册回调 + _streamCallbacks[streamId] = callback; + + final result = await _channel.invokeMethod('sendMessageStream', { + 'messages': messagesJson, + 'streamId': streamId, + }); + + return result ?? streamId; + } + + /// 取消当前流式请求 + Future cancelCurrentStream() async { + final result = await _channel.invokeMethod('cancelCurrentStream'); + return result ?? false; + } + + /// 释放资源 + Future dispose() async { + await _channel.invokeMethod('dispose'); + _streamCallbacks.clear(); + } +} + +/// 流式输出回调接口 +class StreamCallback { + /// 收到令牌 + final void Function(String token) onToken; + + /// 完成回调 + final void Function() onComplete; + + /// 错误回调 + final void Function(Exception e) onError; + + /// 函数调用回调 + final void Function(Map functionCall) onFunctionCall; + + /// 函数调用结果回调 + final void Function( + Map functionCall, + Map functionCallResult, + ) onFunctionCallResult; + + /// 构造函数 + StreamCallback({ + required this.onToken, + required this.onComplete, + required this.onError, + required this.onFunctionCall, + required this.onFunctionCallResult, + }); +} \ No newline at end of file diff --git a/local_plugins/open_ai/pubspec.yaml b/local_plugins/open_ai/pubspec.yaml new file mode 100644 index 000000000..299aac80c --- /dev/null +++ b/local_plugins/open_ai/pubspec.yaml @@ -0,0 +1,27 @@ +name: open_ai +description: OpenAI API与MCP集成插件,提供对OpenAI API的访问和MCP工具调用功能 +version: 0.1.0 +homepage: https://github.com/yunqiinnovation/deep_voice + +environment: + sdk: '>=2.18.0 <4.0.0' + flutter: ">=3.3.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +# 插件平台配置 +flutter: + plugin: + platforms: + android: + package: com.yunqiinnovation.open_ai + pluginClass: OpenAiPlugin + ios: + pluginClass: OpenAiPlugin \ 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 6460b09d6..15d02734e 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 @@ -111,43 +111,29 @@ class MCPSubClient( */ suspend fun connect(): Boolean { try { - Log.d(TAG, "开始连接到服务器: $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") - // } - } + urlString = serverUrl ) - Log.d(TAG, "创建自定义SSE传输层成功") - // 连接MCP客户端到服务器 mcp.connect(sseTransport!!) - // 获取服务器提供的工具列表 + // 获取工具列表并处理 val toolsResult = mcp.listTools() - Log.d(TAG, "获取到工具列表: $toolsResult") - - // 处理工具列表 processToolsList(toolsResult) isConnected = true - Log.d(TAG, "成功连接到MCP服务器,可用工具: ${tools.map { it.tool().get().name() }.joinToString(", ")}") + Log.d(TAG, "连接成功,可用工具: ${tools.size}个") return true } catch (e: Exception) { - Log.e(TAG, "连接服务器失败: ${e.message}", e) - // 记录详细的错误堆栈 - Log.e(TAG, "详细错误: ${e.stackTraceToString()}") + Log.e(TAG, "连接失败: ${e.message}") return false } } @@ -383,7 +369,7 @@ class MCPSubClient( */ suspend fun callTool(name: String, arguments: Map): JSONObject? { if (!isConnected) { - Log.e(TAG, "未连接到MCP服务器", null) + Log.e(TAG, "未连接到服务器") return JSONObject().apply { put("context","未连接到MCP服务器") } } @@ -393,13 +379,7 @@ class MCPSubClient( } return try { - // 直接使用原始参数 - Log.d(TAG, "调用工具 $name 使用参数: $arguments") - - val result = mcp.callTool( - name = name, - arguments = arguments - ) + val result = mcp.callTool(name = name, arguments = arguments) // 处理工具调用结果 val context = result?.content?.joinToString("\n") { content -> @@ -408,13 +388,14 @@ class MCPSubClient( else -> "" } } - JSONObject().apply { put("name",name);put("meta",result?._meta); put("context",context) } - + JSONObject().apply { + put("name", name) + put("meta", result?._meta) + put("context", context) + } } catch (e: Exception) { - Log.e(TAG, "调用MCP工具失败: ${e.message}", e) - "调用MCP工具失败: ${e.message}" - JSONObject().apply { put("context", "调用MCP工具失败: ${e.message}")} - + Log.e(TAG, "调用工具失败: ${e.message}") + JSONObject().apply { put("context", "调用工具失败: ${e.message}") } } } @@ -736,20 +717,25 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { * @return 调用结果 */ suspend fun callTool(name: String, arguments: Map): JSONObject? { + val requestId = "${System.currentTimeMillis()}-${(0..1000).random()}" + Log.d(TAG, "[$requestId] 调用工具: $name") + val toolType = getToolType(name) - return when (toolType) { + val result = when (toolType) { ToolType.LOCAL_FUNCTION -> { callLocalFunction(name, arguments) } ToolType.MCP_TOOL -> { - callMcpTool(name, arguments) + callMcpTool(name, arguments, requestId) } null -> { Log.e(TAG, "未找到工具: $name") JSONObject().apply { put("context","未找到工具: $name") } } } + + return result } /** @@ -760,13 +746,13 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { val handler = localFunctions[name] if (handler != null) { val context = handler.handle(arguments) - JSONObject().apply { put("context", context) } + JSONObject().apply { put("context", context) } } else { Log.e(TAG, "未找到本地函数: $name") JSONObject().apply { put("context","未找到本地函数: $name") } } } catch (e: Exception) { - Log.e(TAG, "调用本地函数失败: ${e.message}", e) + Log.e(TAG, "调用本地函数失败: ${e.message}") JSONObject().apply { put("context","调用本地函数失败: ${e.message}") } } } @@ -774,24 +760,32 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { /** * 调用MCP工具 */ - private suspend fun callMcpTool(name: String, arguments: Map): JSONObject? { + private suspend fun callMcpTool(name: String, arguments: Map, requestId: String): JSONObject? { if (!isConnected) { - Log.e(TAG, "未连接到MCP服务器", null) + Log.e(TAG, "未连接到服务器") return JSONObject().apply { put("context","未连接到MCP服务器") } } // 查找包含此工具的子客户端 val clientEntry = subClients.entries.find { it.value.containsTool(name) } - if (clientEntry != null) { + return if (clientEntry != null) { val (serverId, client) = clientEntry - Log.d(TAG, "通过子客户端 [$serverId] 调用工具: $name") - return client.callTool(name, arguments) + try { + val startTime = System.currentTimeMillis() + val result = client.callTool(name, arguments) + val duration = System.currentTimeMillis() - startTime + + Log.d(TAG, "[$requestId] 工具调用成功: $name (${duration}ms)") + result + } catch (e: Exception) { + Log.e(TAG, "[$requestId] 工具调用失败: $name, ${e.message}") + JSONObject().apply { put("context", "工具调用失败: ${e.message}") } + } + } else { + Log.e(TAG, "[$requestId] 无可用服务器提供工具: $name") + JSONObject().apply { put("context", "无可用的服务器提供工具: $name") } } - - // 没有找到包含此工具的客户端 - Log.e(TAG, "没有子客户端包含工具: $name") - return JSONObject().apply { put("context", "没有可用的服务器提供工具: $name") } } /** 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 a94f7f627..add4e3dc9 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 @@ -3,14 +3,13 @@ package com.yunqiinnovation.open_ai_service import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.os.Handler +import android.os.Looper import android.util.Base64 import android.util.Log import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.ObjectMapper -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.* import okhttp3.* import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.RequestBody.Companion.toRequestBody @@ -21,14 +20,38 @@ import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext +import kotlin.math.min +import kotlin.math.sqrt /** * OpenAI服务的原生实现 */ -class OpenAIService(private val context: Context? = null) { +class OpenAIService(private val context: Context? = null) : CoroutineScope { private val TAG = "OpenAIService" + /* ---------- 协程与并发 ---------- */ + + // 创建协程Job,用于生命周期管理 + private val job = SupervisorJob() + + // 实现CoroutineScope接口 + override val coroutineContext: CoroutineContext + get() = Dispatchers.IO + job + + // 主线程Handler,用于确保回调在主线程执行 + private val mainHandler = Handler(Looper.getMainLooper()) + + // 将回调安全地切换到主线程 + private fun StreamCallback.dispatch(block: StreamCallback.() -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + } else { + mainHandler.post { block() } + } + } + private var baseUrl = "" private val client = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) @@ -50,7 +73,7 @@ class OpenAIService(private val context: Context? = null) { // 添加成员变量以跟踪当前请求 private var currentStreamCall: Call? = null - private var isCanceled = false + @Volatile private var isCanceled = false /** * 构建curl命令用于测试 @@ -92,57 +115,42 @@ class OpenAIService(private val context: Context? = null) { return null } - // 读取文件并压缩(如果需要) - val originalBitmap = BitmapFactory.decodeFile(filePath) - if (originalBitmap == null) { - Log.e(TAG, "无法解码图片: $filePath") - return null - } - - val outputStream = ByteArrayOutputStream() - var quality = 100 - var compressedBitmap = originalBitmap + // 读取文件 + var bitmap = BitmapFactory.decodeFile(filePath) ?: return null // 检查图片尺寸,限制最大为1024*1024 val maxDimension = 1024 - if (originalBitmap.width > maxDimension || originalBitmap.height > maxDimension) { - Log.d(TAG, "图片尺寸超过限制,进行缩放: ${originalBitmap.width}x${originalBitmap.height} -> ${maxDimension}x${maxDimension}") - + if (bitmap.width > maxDimension || bitmap.height > maxDimension) { // 计算缩放比例,保持纵横比 - val widthRatio = maxDimension.toFloat() / originalBitmap.width - val heightRatio = maxDimension.toFloat() / originalBitmap.height - val ratio = Math.min(widthRatio, heightRatio) + val ratio = min(maxDimension.toFloat() / bitmap.width, maxDimension.toFloat() / bitmap.height) - val newWidth = (originalBitmap.width * ratio).toInt() - val newHeight = (originalBitmap.height * ratio).toInt() + val newWidth = (bitmap.width * ratio).toInt() + val newHeight = (bitmap.height * ratio).toInt() - compressedBitmap = Bitmap.createScaledBitmap(originalBitmap, newWidth, newHeight, true) - Log.d(TAG, "缩放后图片尺寸: ${newWidth}x${newHeight}") + bitmap = Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true) } + // 压缩图片 + val outputStream = ByteArrayOutputStream() + var quality = 100 + bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream) + // 如果原始图片太大,继续优化文件大小 var fileSize = file.length() / 1024 // 转为KB if (fileSize > maxSizeKB) { - val scale = Math.sqrt(maxSizeKB.toDouble() / fileSize) - val newWidth = (compressedBitmap.width * scale).toInt() - val newHeight = (compressedBitmap.height * scale).toInt() - compressedBitmap = Bitmap.createScaledBitmap(compressedBitmap, newWidth, newHeight, true) + val scale = sqrt(maxSizeKB.toDouble() / fileSize) + val newWidth = (bitmap.width * scale).toInt() + val newHeight = (bitmap.height * scale).toInt() + bitmap = Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true) + outputStream.reset() quality = 85 - } - - // 压缩图片 - compressedBitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream) - val imageBytes = outputStream.toByteArray() - - // 检查压缩后大小 - if (imageBytes.size / 1024 > maxSizeKB) { - Log.w(TAG, "压缩后图片仍然超出大小限制: ${imageBytes.size / 1024}KB > ${maxSizeKB}KB") + bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream) } // 转为Base64 - return Base64.encodeToString(imageBytes, Base64.NO_WRAP) + return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) } catch (e: Exception) { - Log.e(TAG, "转换文件到Base64失败: ${e.message}", e) + Log.e(TAG, "转换Base64失败: ${e.message}") return null } } @@ -151,29 +159,26 @@ class OpenAIService(private val context: Context? = null) { * 创建带图片的用户消息 */ fun createUserMessageWithImage(text: String, imageBase64: String): JSONObject { - // 创建包含文本和图片的内容数组 - val contentArray = JSONArray().apply { - // 添加文本部分 - if (text.isNotEmpty()) { + return JSONObject().apply { + put("role", "user") + put("content", JSONArray().apply { + // 添加文本部分 + if (text.isNotEmpty()) { + put(JSONObject().apply { + put("type", "text") + put("text", text) + }) + } + + // 添加图片部分 put(JSONObject().apply { - put("type", "text") - put("text", text) - }) - } - - // 添加图片部分 - put(JSONObject().apply { - put("type", "image_url") - put("image_url", JSONObject().apply { - put("url", "data:image/jpeg;base64,$imageBase64") + put("type", "image_url") + put("image_url", JSONObject().apply { + put("url", "data:image/jpeg;base64,$imageBase64") + }) }) }) } - - return JSONObject().apply { - put("role", "user") - put("content", contentArray) - } } /** @@ -253,13 +258,10 @@ class OpenAIService(private val context: Context? = null) { mcpClient = MCPClient(context) - // 在后台线程中初始化MCP客户端 - val scope = CoroutineScope(Dispatchers.IO) - - var result = false - scope.launch { + // 直接使用类的CoroutineScope启动协程 + launch { try { - result = mcpClient?.connectToSSE(mcpServer) ?: false + val result = mcpClient?.connectToSSE(mcpServer) ?: false isMcpInitialized = result Log.d(TAG, "MCP客户端初始化${if (result) "成功" else "失败"}") } catch (e: Exception) { @@ -271,8 +273,6 @@ class OpenAIService(private val context: Context? = null) { return true // 立即返回,实际连接在后台进行 } - - /** * 处理MCP工具调用 * @@ -343,7 +343,7 @@ class OpenAIService(private val context: Context? = null) { // 调用工具(统一通过MCPClient处理) val result = mcpClient?.callTool(name, arguments) - + Log.d(TAG, "自动处理MCP工具调用结果: ${result?.toString()}") // 检查是否已取消 if (isCanceled) { Log.d(TAG, "工具调用已被取消,不处理结果") @@ -352,14 +352,10 @@ class OpenAIService(private val context: Context? = null) { // 回调结果 val resultObj = result ?: JSONObject().apply { put("context", "工具调用失败") } - callback.onFunctionCallResult(functionCall, resultObj) + callback.dispatch { onFunctionCallResult(functionCall, resultObj) } // 发送结果到OpenAI继续对话 - val resultContent = if (resultObj.has("context") && resultObj.getString("context").isNotEmpty()) { - resultObj.getString("context") - } else { - "工具调用失败" - } + val resultContent = resultObj.optString("context", "工具调用失败") sendFunctionCallResult( messages = messages, functionCall = functionCall, @@ -484,7 +480,7 @@ class OpenAIService(private val context: Context? = null) { */ fun sendMessageStream(messages: JSONArray, callback: StreamCallback) { if (!isInitialized || apiKey.isEmpty()) { - callback.onError(OpenAIException("OpenAI服务未初始化")) + callback.dispatch { onError(OpenAIException("OpenAI服务未初始化")) } return } @@ -496,10 +492,13 @@ class OpenAIService(private val context: Context? = null) { if (messages.length() > 0) { val lastMessage = messages.getJSONObject(messages.length() - 1) if (lastMessage.has("content")) { - val content = lastMessage.getString("content") - currentModel = visionModel + val content = lastMessage.optString("content", "") + if (content.contains("image_url")) { + currentModel = visionModel + } } } + val requestBody = JSONObject().apply { put("model", currentModel) put("messages", messages) @@ -535,10 +534,6 @@ class OpenAIService(private val context: Context? = null) { .post(requestBody.toString().toRequestBody(mediaType)) .build() - // 输出用于测试的curl命令 - // val curlCommand = buildCurlCommand(request, requestBody.toString()) - // Log.d(TAG, "curl command: $curlCommand") - val call = client.newCall(request) // 保存当前Call对象以便后续取消 currentStreamCall = call @@ -549,7 +544,7 @@ class OpenAIService(private val context: Context? = null) { if (isCanceled) { return } - callback.onError(OpenAIException(e.message ?: "请求失败")) + callback.dispatch { onError(OpenAIException(e.message ?: "请求失败")) } } override fun onResponse(call: Call, response: Response) { @@ -560,7 +555,7 @@ class OpenAIService(private val context: Context? = null) { } if (!response.isSuccessful) { - callback.onError(OpenAIException("API调用失败: ${response.code}")) + callback.dispatch { onError(OpenAIException("API调用失败: ${response.code}")) } return } @@ -570,7 +565,7 @@ class OpenAIService(private val context: Context? = null) { try { // 用于存储函数调用的各个部分 - val finalToolCalls = mutableMapOf() + val toolCalls = mutableMapOf() // 逐块读取数据,而不是一次性预取 val BUFFER_SIZE = 8192L // 8KB的块大小 @@ -590,84 +585,16 @@ class OpenAIService(private val context: Context? = null) { // 处理[DONE]消息 if (data == "[DONE]" || data == "[\"DONE\"]") { - val hasToolCalls = processToolCalls(finalToolCalls, callback, messages) + val hasToolCalls = processToolCalls(toolCalls, callback, messages) // 如果没有工具调用,这是对话的真正结束 if (!hasToolCalls) { - callback.onComplete() + callback.dispatch { onComplete() } } return } try { - val jsonData = JSONObject(data) - - // 处理消息内容 - if (jsonData.has("choices")) { - val choices = jsonData.getJSONArray("choices") - if (choices.length() > 0) { - val choice = choices.getJSONObject(0) - - // 处理delta内容 - if (choice.has("delta")) { - val delta = choice.getJSONObject("delta") - - // 处理普通文本内容 - if (delta.has("content")) { - val content = delta.getString("content") - if (!isCanceled) { - callback.onToken(content) - } - } - - // 处理工具调用 - if (delta.has("tool_calls")) { - val toolCalls = delta.getJSONArray("tool_calls") - for (i in 0 until toolCalls.length()) { - val toolCall = toolCalls.getJSONObject(i) - val index = toolCall.getInt("index") - - // 创建或获取现有的工具调用信息 - val toolCallInfo = finalToolCalls.getOrPut(index) { ToolCallInfo() } - - // 更新ID - if (toolCall.has("id")) { - toolCallInfo.id = toolCall.getString("id") - } - - // 更新函数信息 - if (toolCall.has("function")) { - val function = toolCall.getJSONObject("function") - - if (function.has("name")) { - toolCallInfo.name = function.getString("name") - } - - if (function.has("arguments")) { - toolCallInfo.arguments += function.getString("arguments") - } - } - } - } - } - - // 检查是否有表示完成的标志 - if (choice.has("finish_reason")) { - val finishReason = choice.getString("finish_reason") - if (finishReason == "stop" || finishReason == "length") { - // 正常完成,没有工具调用 - callback.onComplete() - return - } else if (finishReason == "tool_calls") { - // 处理工具调用 - val hasToolCalls = processToolCalls(finalToolCalls, callback, messages) - if (!hasToolCalls) { - callback.onComplete() - } - return - } - } - } - } + handleStreamJson(JSONObject(data), toolCalls, callback, messages) } catch (e: Exception) { // 忽略解析错误,但记录日志 Log.e(TAG, "解析JSON出错: ${e.message}", e) @@ -682,16 +609,16 @@ class OpenAIService(private val context: Context? = null) { } // 如果没有明确的完成信号但数据流结束了,处理最后可能的工具调用 - val hasToolCalls = processToolCalls(finalToolCalls, callback, messages) + val hasToolCalls = processToolCalls(toolCalls, callback, messages) if (!hasToolCalls) { - callback.onComplete() + callback.dispatch { onComplete() } } } catch (e: Exception) { // 如果是由于取消导致的异常,不报告错误 if (isCanceled) { return } - callback.onError(OpenAIException("处理响应流时出错: ${e.message}")) + callback.dispatch { onError(OpenAIException("处理响应流时出错: ${e.message}")) } } finally { responseBody.close() } @@ -699,6 +626,145 @@ class OpenAIService(private val context: Context? = null) { }) } + /** + * 处理流式JSON数据 + */ + private fun handleStreamJson( + json: JSONObject, + toolCalls: MutableMap, + callback: StreamCallback, + messages: JSONArray + ) { + if (json.has("choices") && json.getJSONArray("choices").length() > 0) { + val choice = json.getJSONArray("choices").getJSONObject(0) + + // 处理delta内容 + if (choice.has("delta")) { + val delta = choice.getJSONObject("delta") + + // 处理普通文本内容 + if (delta.has("content")) { + val content = delta.getString("content") + if (!isCanceled) { + callback.dispatch { onToken(content) } + } + } + + // 收集工具调用信息 + if (delta.has("tool_calls")) { + val toolCallsArray = delta.getJSONArray("tool_calls") + for (i in 0 until toolCallsArray.length()) { + val toolCall = toolCallsArray.getJSONObject(i) + val index = toolCall.getInt("index") + + // 创建或获取现有的工具调用信息 + val toolCallInfo = toolCalls.getOrPut(index) { ToolCallInfo() } + + // 更新ID + if (toolCall.has("id")) { + toolCallInfo.id = toolCall.getString("id") + } + + // 更新函数信息 + if (toolCall.has("function")) { + val function = toolCall.getJSONObject("function") + + if (function.has("name")) { + toolCallInfo.name = function.getString("name") + } + + if (function.has("arguments")) { + toolCallInfo.arguments += function.getString("arguments") + } + } + } + } + } + + } + } + + /** + * 处理工具调用 + */ + private fun processToolCalls( + toolCalls: Map, + callback: StreamCallback, + messages: JSONArray? = null + ): Boolean { + if (toolCalls.isEmpty()) return false + + // 只处理第一个工具调用 + val firstToolCall = toolCalls.entries.firstOrNull()?.value ?: return false + + if (firstToolCall.isValid()) { + // 创建函数调用JSON对象 + val functionCall = JSONObject().apply { + put("name", firstToolCall.name) + put("arguments", firstToolCall.arguments) + put("id", firstToolCall.id) + } + Log.d(TAG, "工具调用: id=${firstToolCall.id}, name=${firstToolCall.name}") + + // 通知上层工具调用事件 + callback.dispatch { onFunctionCall(functionCall) } + + // 在协程中处理工具调用 + if (messages != null) { + launch { + try { + if (!isCanceled) { + // 调用工具 + val result = try { + val args = mcpClient?.parseJsonArguments(firstToolCall.arguments) ?: mapOf() + mcpClient?.callTool(firstToolCall.name, args) + } catch (e: Exception) { + Log.e(TAG, "工具调用错误: ${e.message}") + JSONObject().put("context", "工具调用失败: ${e.message}") + } + + if (!isCanceled) { + // 处理结果 + val resultObj = result ?: JSONObject().put("context", "工具调用失败") + Log.d(TAG, "工具调用完成: id=${firstToolCall.id}") + callback.dispatch { onFunctionCallResult(functionCall, resultObj) } + + val context = resultObj.optString("context", "工具调用失败").let { + if (it.isNullOrEmpty()) "工具调用失败" else it + } + + // 将结果发送回OpenAI继续对话 + sendFunctionCallResult( + messages = messages, + functionCall = functionCall, + functionResult = context, + callback = callback + ) + } + } + } catch (e: Exception) { + if (!isCanceled) { + Log.e(TAG, "处理工具调用异常: ${e.message}") + try { + val errorMessage = "工具调用处理失败: ${e.message}" + sendFunctionCallResult( + messages = messages, + functionCall = functionCall, + functionResult = errorMessage, + callback = callback + ) + } catch (e2: Exception) { + Log.e(TAG, "发送工具调用结果失败: ${e2.message}") + } + } + } + } + } + return true + } + return false + } + /** * 发送函数调用结果 */ @@ -755,63 +821,45 @@ class OpenAIService(private val context: Context? = null) { } catch (e: Exception) { if (!isCanceled) { Log.e(TAG, "发送函数调用结果失败: ${e.message}", e) - callback.onError(OpenAIException("发送函数调用结果失败: ${e.message}")) + callback.dispatch { onError(OpenAIException("发送函数调用结果失败: ${e.message}")) } } } } /** - * 处理工具调用结果 + * 取消当前流式请求 + * + * @return 是否成功取消 */ - private fun processToolCalls(toolCalls: Map, callback: StreamCallback, messages: JSONArray? = null): Boolean { - if (toolCalls.isEmpty()) return false - - // 只处理第一个工具调用 - val firstToolCall = toolCalls.entries.firstOrNull()?.value ?: return false - - if (firstToolCall.isValid()) { - // 创建函数调用JSON对象 - val functionCall = JSONObject().apply { - put("name", firstToolCall.name) - put("arguments", firstToolCall.arguments) - put("id", firstToolCall.id) - } - Log.d(TAG, "工具调用: $functionCall") - - // 通知上层工具调用事件 - callback.onFunctionCall(functionCall) + fun cancelCurrentStream(): Boolean { + try { + // 标记已取消 + isCanceled = true - // 在协程中处理工具调用 - if (messages != null) { - kotlinx.coroutines.GlobalScope.launch { - try { - if (!isCanceled) { - autoHandleMcpToolCall(functionCall, messages, callback) - } else { - Log.d(TAG, "请求已取消,不处理工具调用") - } - } catch (e: Exception) { - if (!isCanceled) { - Log.e(TAG, "处理工具调用时发生异常: ${e.message}", e) - // 处理异常的情况,向回调通知错误 - try { - val errorMessage = "工具调用处理失败: ${e.message}" - sendFunctionCallResult( - messages = messages, - functionCall = functionCall, - functionResult = errorMessage, - callback = callback - ) - } catch (e2: Exception) { - Log.e(TAG, "发送工具调用错误结果失败: ${e2.message}", e2) - } - } - } - } + // 取消当前Call对象 + val call = currentStreamCall + if (call != null && !call.isCanceled()) { + call.cancel() + Log.d(TAG, "已取消当前流式请求") + return true } - return true + return false + } catch (e: Exception) { + Log.e(TAG, "取消流式请求失败: ${e.message}", e) + return false + } finally { + // 清除当前Call引用 + currentStreamCall = null } - return false + } + + /** + * 取消所有操作并释放资源 + * 供AgentService.dispose()调用 + */ + fun cancelAll() { + cancelCurrentStream() + job.cancel() // 取消所有协程 } /** @@ -837,33 +885,6 @@ class OpenAIService(private val context: Context? = null) { fun onFunctionCall(functionCall: JSONObject) fun onFunctionCallResult(functionCall: JSONObject, functionCallResult: JSONObject) } - - /** - * 取消当前流式请求 - * - * @return 是否成功取消 - */ - fun cancelCurrentStream(): Boolean { - try { - // 标记已取消 - isCanceled = true - - // 取消当前Call对象 - val call = currentStreamCall - if (call != null && !call.isCanceled()) { - call.cancel() - Log.d(TAG, "已取消当前流式请求") - return true - } - return false - } catch (e: Exception) { - Log.e(TAG, "取消流式请求失败: ${e.message}", e) - return false - } finally { - // 清除当前Call引用 - currentStreamCall = null - } - } } /** diff --git a/local_plugins/opus/.gradle/8.10/fileHashes/fileHashes.lock b/local_plugins/opus/.gradle/8.10/fileHashes/fileHashes.lock index ce3086c05dc7c6515bdf4f11ac8f3f79e344082b..4e477b3ee9982efc49e30adc213d1b0df26cce1c 100644 GIT binary patch literal 17 UcmZQRo$b{-r|q*H0|Y1m05?tqh5!Hn literal 17 UcmZQRo$b{-r|q*H0|Y1l05?noga7~l diff --git a/local_plugins/opus/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/local_plugins/opus/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 118d77df6ef1b4541bbd050c5f11b7312c67319c..48bc9bac10dbdc56e568879768292aa37ce50fe6 100644 GIT binary patch literal 17 TcmZQ}Dp$CADo{Lz0Rr>@DKP_X literal 17 TcmZQ}Dp$CADo{Lz0RprDDJ=tP diff --git a/local_plugins/opus/.gradle/buildOutputCleanup/cache.properties b/local_plugins/opus/.gradle/buildOutputCleanup/cache.properties index 3ca77d0f1..d3c4e6543 100644 --- a/local_plugins/opus/.gradle/buildOutputCleanup/cache.properties +++ b/local_plugins/opus/.gradle/buildOutputCleanup/cache.properties @@ -1,2 +1,2 @@ -#Sat May 10 17:59:51 IST 2025 +#Sun May 11 13:39:53 IST 2025 gradle.version=8.10 diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 000a98608..68a02ff8a 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,21 +1,110 @@ PODS: + - app_settings (5.1.1): + - FlutterMacOS + - AppAuth (1.7.6): + - AppAuth/Core (= 1.7.6) + - AppAuth/ExternalUserAgent (= 1.7.6) + - AppAuth/Core (1.7.6) + - AppAuth/ExternalUserAgent (1.7.6): + - AppAuth/Core - audio_session (0.0.1): - FlutterMacOS - device_info_plus (0.0.1): - FlutterMacOS + - facebook_auth_desktop (0.0.3): + - FlutterMacOS + - file_picker (0.0.1): + - FlutterMacOS - file_selector_macos (0.0.1): - FlutterMacOS + - Firebase/Auth (11.10.0): + - Firebase/CoreOnly + - FirebaseAuth (~> 11.10.0) + - Firebase/CoreOnly (11.10.0): + - FirebaseCore (~> 11.10.0) + - firebase_auth (5.5.2): + - Firebase/Auth (~> 11.10.0) + - Firebase/CoreOnly (~> 11.10.0) + - firebase_core + - FlutterMacOS + - firebase_core (3.13.0): + - Firebase/CoreOnly (~> 11.10.0) + - FlutterMacOS + - FirebaseAppCheckInterop (11.12.0) + - FirebaseAuth (11.10.0): + - FirebaseAppCheckInterop (~> 11.0) + - FirebaseAuthInterop (~> 11.0) + - FirebaseCore (~> 11.10.0) + - FirebaseCoreExtension (~> 11.10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.0) + - GoogleUtilities/Environment (~> 8.0) + - GTMSessionFetcher/Core (< 5.0, >= 3.4) + - RecaptchaInterop (~> 101.0) + - FirebaseAuthInterop (11.12.0) + - FirebaseCore (11.10.0): + - FirebaseCoreInternal (~> 11.10.0) + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/Logger (~> 8.0) + - FirebaseCoreExtension (11.10.0): + - FirebaseCore (~> 11.10.0) + - FirebaseCoreInternal (11.10.0): + - "GoogleUtilities/NSData+zlib (~> 8.0)" - flutter_blue_plus_darwin (0.0.2): - Flutter - FlutterMacOS + - flutter_image_compress_macos (1.0.0): + - FlutterMacOS - flutter_local_notifications (0.0.1): - FlutterMacOS + - flutter_secure_storage_macos (6.1.3): + - FlutterMacOS - flutter_tts (0.0.1): - FlutterMacOS - FlutterMacOS (1.0.0) + - google_sign_in_ios (0.0.1): + - AppAuth (>= 1.7.4) + - Flutter + - FlutterMacOS + - GoogleSignIn (~> 7.1) + - GTMSessionFetcher (>= 3.4.0) + - GoogleSignIn (7.1.0): + - AppAuth (< 2.0, >= 1.7.3) + - GTMAppAuth (< 5.0, >= 4.1.1) + - GTMSessionFetcher/Core (~> 3.3) + - GoogleUtilities/AppDelegateSwizzler (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Network (8.1.0): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (8.1.0)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMAppAuth (4.1.1): + - AppAuth/Core (~> 1.7) + - GTMSessionFetcher/Core (< 4.0, >= 3.3) + - GTMSessionFetcher (3.5.0): + - GTMSessionFetcher/Full (= 3.5.0) + - GTMSessionFetcher/Core (3.5.0) + - GTMSessionFetcher/Full (3.5.0): + - GTMSessionFetcher/Core - just_audio (0.0.1): - Flutter - FlutterMacOS + - package_info_plus (0.0.1): + - FlutterMacOS - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS @@ -27,46 +116,93 @@ PODS: - Flutter - FlutterMacOS - Try + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS - Try (2.1.1) + - webview_flutter_wkwebview (0.0.1): + - Flutter + - FlutterMacOS - window_size (0.0.2): - FlutterMacOS DEPENDENCIES: + - app_settings (from `Flutter/ephemeral/.symlinks/plugins/app_settings/macos`) - audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) + - facebook_auth_desktop (from `Flutter/ephemeral/.symlinks/plugins/facebook_auth_desktop/macos`) + - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) + - firebase_auth (from `Flutter/ephemeral/.symlinks/plugins/firebase_auth/macos`) + - firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`) - flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`) + - flutter_image_compress_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_image_compress_macos/macos`) - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) + - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) - flutter_tts (from `Flutter/ephemeral/.symlinks/plugins/flutter_tts/macos`) - FlutterMacOS (from `Flutter/ephemeral`) + - google_sign_in_ios (from `Flutter/ephemeral/.symlinks/plugins/google_sign_in_ios/darwin`) - just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/darwin`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - record_darwin (from `Flutter/ephemeral/.symlinks/plugins/record_darwin/macos`) - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - speech_to_text (from `Flutter/ephemeral/.symlinks/plugins/speech_to_text/darwin`) + - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) + - webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`) - window_size (from `Flutter/ephemeral/.symlinks/plugins/window_size/macos`) SPEC REPOS: trunk: + - AppAuth + - Firebase + - FirebaseAppCheckInterop + - FirebaseAuth + - FirebaseAuthInterop + - FirebaseCore + - FirebaseCoreExtension + - FirebaseCoreInternal + - GoogleSignIn + - GoogleUtilities + - GTMAppAuth + - GTMSessionFetcher - Try EXTERNAL SOURCES: + app_settings: + :path: Flutter/ephemeral/.symlinks/plugins/app_settings/macos audio_session: :path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos device_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos + facebook_auth_desktop: + :path: Flutter/ephemeral/.symlinks/plugins/facebook_auth_desktop/macos + file_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos file_selector_macos: :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos + firebase_auth: + :path: Flutter/ephemeral/.symlinks/plugins/firebase_auth/macos + firebase_core: + :path: Flutter/ephemeral/.symlinks/plugins/firebase_core/macos flutter_blue_plus_darwin: :path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin + flutter_image_compress_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_image_compress_macos/macos flutter_local_notifications: :path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos + flutter_secure_storage_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos flutter_tts: :path: Flutter/ephemeral/.symlinks/plugins/flutter_tts/macos FlutterMacOS: :path: Flutter/ephemeral + google_sign_in_ios: + :path: Flutter/ephemeral/.symlinks/plugins/google_sign_in_ios/darwin just_audio: :path: Flutter/ephemeral/.symlinks/plugins/just_audio/darwin + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin record_darwin: @@ -75,23 +211,50 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos speech_to_text: :path: Flutter/ephemeral/.symlinks/plugins/speech_to_text/darwin + sqflite_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin + webview_flutter_wkwebview: + :path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin window_size: :path: Flutter/ephemeral/.symlinks/plugins/window_size/macos SPEC CHECKSUMS: + app_settings: cd21e176b56f8172043640ade81322a98896bff4 + AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041 + facebook_auth_desktop: bf89ccf460c903de45e86fa0c7878b8b62db65fb + file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 + Firebase: 1fe1c0a7d9aaea32efe01fbea5f0ebd8d70e53a2 + firebase_auth: 22945027646e71960caae1b9f2b2498d7a52a4c7 + firebase_core: efd50ad8177dc489af1b9163a560359cf1b30597 + FirebaseAppCheckInterop: 73b173e5ec45192e2d522ad43f526a82ad10b852 + FirebaseAuth: c4146bdfdc87329f9962babd24dae89373f49a32 + FirebaseAuthInterop: b583210c039a60ed3f1e48865e1f3da44a796595 + FirebaseCore: 8344daef5e2661eb004b177488d6f9f0f24251b7 + FirebaseCoreExtension: 6f357679327f3614e995dc7cf3f2d600bdc774ac + FirebaseCoreInternal: ef4505d2afb1d0ebbc33162cb3795382904b5679 flutter_blue_plus_darwin: 09444a26fb6bdef523e55b68fc1c59af5a877ea6 + flutter_image_compress_macos: e68daf54bb4bf2144c580fd4d151c949cbf492f0 flutter_local_notifications: 13862b132e32eb858dea558a86d45d08daeacfe7 + flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54 flutter_tts: ae915565cc6948444b513acc8ee021993281e027 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + google_sign_in_ios: 19297361f2c51d7d8ac0201b866ef1fa5d1f94a8 + GoogleSignIn: d4281ab6cf21542b1cfaff85c191f230b399d2db + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 record_darwin: 30509266ae213af8afdb09a8ae7467cb64c1377e share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc speech_to_text: 9dc43a5df3cbc2813f8c7cc9bd0fbf94268ed7ac + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 Try: 5ef669ae832617b3cee58cb2c6f99fb767a4ff96 + webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2 window_size: 4bd15034e6e3d0720fd77928a7c42e5492cfece9 PODFILE CHECKSUM: 8c4442624fc136b0afb378a2502958e01c05d073 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 4658cacbc..11828b195 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -241,6 +241,7 @@ 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, EEBEF79BCB6B649D2C3D134C /* [CP] Embed Pods Frameworks */, + 5D85B8FB5391E947F8A1E18F /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -405,6 +406,23 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + 5D85B8FB5391E947F8A1E18F /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; EEBEF79BCB6B649D2C3D134C /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; From a911cba3a63dccd1f17be2ab10ac8278983add1f Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Mon, 12 May 2025 17:58:30 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=A4=A9=E6=B0=94?= =?UTF-8?q?=E9=A2=84=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/modules/agent/views/message_bubble.dart | 86 ++++++++++----------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/lib/modules/agent/views/message_bubble.dart b/lib/modules/agent/views/message_bubble.dart index 0e7be74b9..a5d347169 100644 --- a/lib/modules/agent/views/message_bubble.dart +++ b/lib/modules/agent/views/message_bubble.dart @@ -217,7 +217,7 @@ class MessageBubble extends GetView { final rawList = mate!['card_weather']['dayweather'] as List; final daylist = rawList.map((e) => Map.from(e)).toList(); - final now = daylist[0]; + final now = mate!['card_weather']['day'] as Map; //天气 return _buildWeatherContent( context, @@ -677,7 +677,7 @@ class MessageBubble extends GetView { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '当前天气情况:', + '查询天气情况:', style: TextStyle( color: Colors.black87, fontSize: 14, @@ -714,7 +714,7 @@ class MessageBubble extends GetView { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - city, + "${city} ${now["date"]}", style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, @@ -846,46 +846,46 @@ class MessageBubble extends GetView { ], ), ), - const SizedBox(height: 10), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () {}, - icon: Icon(Icons.location_on, size: 16), - label: Text('更多地区'), - style: OutlinedButton.styleFrom( - foregroundColor: Theme.of(context).primaryColor, - side: BorderSide( - color: Theme.of(context).primaryColor.withOpacity(0.5)), - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - textStyle: TextStyle(fontSize: 12), - ), - ), - ), - SizedBox(width: 8), - Expanded( - child: OutlinedButton.icon( - onPressed: () {}, - icon: Icon(Icons.calendar_today, size: 16), - label: Text('15天预报'), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.grey[700], - side: BorderSide(color: Colors.grey[400]!), - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - textStyle: TextStyle(fontSize: 12), - ), - ), - ), - ], - ), + // const SizedBox(height: 10), + // Row( + // mainAxisSize: MainAxisSize.min, + // children: [ + // Expanded( + // child: OutlinedButton.icon( + // onPressed: () {}, + // icon: Icon(Icons.location_on, size: 16), + // label: Text('更多地区'), + // style: OutlinedButton.styleFrom( + // foregroundColor: Theme.of(context).primaryColor, + // side: BorderSide( + // color: Theme.of(context).primaryColor.withOpacity(0.5)), + // padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), + // shape: RoundedRectangleBorder( + // borderRadius: BorderRadius.circular(16), + // ), + // textStyle: TextStyle(fontSize: 12), + // ), + // ), + // ), + // SizedBox(width: 8), + // Expanded( + // child: OutlinedButton.icon( + // onPressed: () {}, + // icon: Icon(Icons.calendar_today, size: 16), + // label: Text('15天预报'), + // style: OutlinedButton.styleFrom( + // foregroundColor: Colors.grey[700], + // side: BorderSide(color: Colors.grey[400]!), + // padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), + // shape: RoundedRectangleBorder( + // borderRadius: BorderRadius.circular(16), + // ), + // textStyle: TextStyle(fontSize: 12), + // ), + // ), + // ), + // ], + // ), ], ); } From 372a5e60dd32a9d4f9eabe0c3a37caf064197750 Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Mon, 12 May 2025 18:12:10 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=E6=94=B6=E5=A4=96=E9=83=A8=E9=9F=B3=E6=BA=90=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/ota_upgrade_controller.dart | 8 +-- .../yunqiinnovation/agent_service/BleAgent.kt | 2 +- .../azure_speech/android/build.gradle.kts | 1 + .../azure_speech/AzureSpeechPlugin.kt | 58 ++++++++++++++++++- .../ble_service/android/build.gradle.kts | 1 - .../yunqiinnovation/ble_service/BleService.kt | 8 +-- .../ble_service/BleServicePlugin.kt | 4 ++ 7 files changed, 68 insertions(+), 14 deletions(-) diff --git a/lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart b/lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart index a59a16289..1e59b45c3 100644 --- a/lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart +++ b/lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart @@ -73,10 +73,8 @@ class OtaUpgradeController extends GetxController { } // 检查固件信息有效性 - if (deviceInfo.firmwareVersion.isEmpty - //|| - // deviceInfo.firmwareProductId.isEmpty - ) { + if (deviceInfo.firmwareVersion.isEmpty || + deviceInfo.firmwareProductId.isEmpty) { throw const FormatException('设备固件信息不完整'); } @@ -85,7 +83,7 @@ class OtaUpgradeController extends GetxController { // API请求 final response = await Api.getequipmentinfo({ - "id": "a2", + "id": deviceInfo.firmwareProductId, }); // 处理API响应 diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt index f362b478d..00e0cdd7e 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt @@ -128,7 +128,7 @@ object BleAgent : BleService.Callback, AgentServiceListener { } override fun onAudioDataReceived(data: ByteArray) { - // FileLogger.d(TAG, "onAudioDataReceived, data: ${data.size}") + FileLogger.d(TAG, "onAudioDataReceived, data: ${data.size}") AgentService.pushAudioData(data) // 可选:处理音频数据 } diff --git a/local_plugins/azure_speech/android/build.gradle.kts b/local_plugins/azure_speech/android/build.gradle.kts index 342b48535..151988920 100644 --- a/local_plugins/azure_speech/android/build.gradle.kts +++ b/local_plugins/azure_speech/android/build.gradle.kts @@ -53,4 +53,5 @@ tasks.withType { dependencies { // 添加Microsoft语音SDK implementation("com.microsoft.cognitiveservices.speech:client-sdk:1.30.0") + implementation(project(":ble_service")) } \ No newline at end of file diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index f149fa2be..327d194d4 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt @@ -14,9 +14,10 @@ import io.flutter.plugin.common.EventChannel import kotlinx.coroutines.* import kotlin.coroutines.CoroutineContext import com.microsoft.cognitiveservices.speech.audio.* - +import com.yunqiinnovation.ble_service.BleService +import com.yunqiinnovation.ble_service.BleConst /** AzureSpeechPlugin */ -class AzureSpeechPlugin: FlutterPlugin, CoroutineScope { +class AzureSpeechPlugin: FlutterPlugin,BleService.Callback, CoroutineScope { private val tag = "AzureSpeechPlugin" private lateinit var context: Context private val mainHandler = Handler(Looper.getMainLooper()) @@ -37,7 +38,10 @@ class AzureSpeechPlugin: FlutterPlugin, CoroutineScope { private lateinit var ttsEventChannel: EventChannel private var ttsEventSink: EventChannel.EventSink? = null private lateinit var azureTtsHelper: AzureTtsHelper + // 是否已注册BLE回调 + private var isRegisteredToBle = false + // ASR 事件发送方法 private fun sendAsrEvent(event: Map) { if (asrEventSink == null) { @@ -110,6 +114,19 @@ class AzureSpeechPlugin: FlutterPlugin, CoroutineScope { // 初始化Azure语音服务 azureTtsHelper = AzureTtsHelper(context) azureAsrHelper = AzureAsrHelper(context) + + // 2. 初始化BleService并注册回调 + if (BleService.initialize(context)) { + if (BleService.addCallback(this)) { + isRegisteredToBle = true + FileLogger.d(tag, "已注册BLE回调") + } else { + FileLogger.w(tag, "BLE回调注册失败") + } + } else { + FileLogger.e(tag, "BLE服务初始化失败") + + } } // ASR方法处理器 @@ -359,7 +376,12 @@ class AzureSpeechPlugin: FlutterPlugin, CoroutineScope { try { // 取消所有协程 job.cancel() - + // 取消BLE回调注册 + if (isRegisteredToBle) { + BleService.removeCallback(this) + isRegisteredToBle = false + } + // 释放资源 azureTtsHelper.dispose() azureAsrHelper.dispose() @@ -367,4 +389,34 @@ class AzureSpeechPlugin: FlutterPlugin, CoroutineScope { FileLogger.e(tag, "释放资源失败: ${e.message}") } } + + + override fun onScanResult(devices: List>) { + + } + + override fun onConnectionStateChanged(state: Int) { + // 可选:处理连接状态变化 + if (state == BleConst.STATE_CONNECTED) { + FileLogger.d(tag, "Ble设备已连接,准备好语音交互") + } else if (state == BleConst.STATE_DISCONNECTED) { + FileLogger.d(tag, "Ble设备已断开,停止语音服务") + // 设备断开连接,停止语音服务 + // stopListening() + } + + } + + override fun onAudioDataReceived(data: ByteArray) { + FileLogger.d(tag, "AzureSpeechPlugin onAudioDataReceived, data: ${data.size}") + azureAsrHelper.pushAudioData(data) + } + + override fun onWakeupSignalReceived() { + + } + + override fun onDeviceInfoReceived(infoType: Int, infoData: Map) { + + } } \ No newline at end of file diff --git a/local_plugins/ble_service/android/build.gradle.kts b/local_plugins/ble_service/android/build.gradle.kts index 72a6745e6..29deed814 100644 --- a/local_plugins/ble_service/android/build.gradle.kts +++ b/local_plugins/ble_service/android/build.gradle.kts @@ -31,5 +31,4 @@ dependencies { // 使用从本地Maven仓库引用的AAR依赖 implementation("com.jieli.audio:jl_audio_decode:2.0.0") implementation(project(":deepvoice_utils")) - implementation(project(":azure_speech")) } \ No newline at end of file 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 dd6d24917..39261ee63 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 @@ -661,10 +661,10 @@ object BleService { "rawData" to responseData ) - if (responseData.isNotEmpty() && responseData.size >= 2) { - val productId = "0x${(responseData[0].toInt() and 0xFF).toString(16)}${ - (responseData[1].toInt() and 0xFF).toString(16) - }" + if (responseData.isNotEmpty()) { + // 转换为字符串前,确认是否是有效的ASCII字符 + val productId = + responseData.joinToString(".") { (it.toInt() and 0xFF).toString() } resultMap["productId"] = productId FileLogger.i(TAG, "接收到产品ID: $productId") } diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt index e8ba005df..960852353 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt @@ -185,6 +185,10 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware, "getDeviceVersion" -> { val success = BleService.getDeviceVersion() result.success(success) + } + "getProductId" -> { + val success = BleService.getProductId() + result.success(success) } "getBatteryInfo" -> { val success = BleService.getBatteryInfo()