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 000000000..44f39b981 Binary files /dev/null and b/local_plugins/agent_service/android/src/main/res/raw/calling.mp3 differ diff --git a/local_plugins/agent_service/android/src/main/res/raw/stop.mp3 b/local_plugins/agent_service/android/src/main/res/raw/stop.mp3 index 4ab6124b4..783559fbe 100644 Binary files a/local_plugins/agent_service/android/src/main/res/raw/stop.mp3 and b/local_plugins/agent_service/android/src/main/res/raw/stop.mp3 differ diff --git a/local_plugins/agent_service/android/src/main/res/raw/stop3.mp3 b/local_plugins/agent_service/android/src/main/res/raw/stop3.mp3 new file mode 100644 index 000000000..4ab6124b4 Binary files /dev/null and b/local_plugins/agent_service/android/src/main/res/raw/stop3.mp3 differ diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCompanionManager.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCompanionManager.kt index 60a555498..4b6f8da56 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCompanionManager.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCompanionManager.kt @@ -40,6 +40,9 @@ class BleCompanionManager(private val context: Context) { context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) } + // 用于验证的临时MAC地址 + private var pendingMacAddress: String? = null + // 获取CompanionDeviceManager服务 private val companionDeviceManager: CompanionDeviceManager? by lazy { if (Build.VERSION.SDK_INT >= 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 ce3086c05..4e477b3ee 100644 Binary files a/local_plugins/opus/.gradle/8.10/fileHashes/fileHashes.lock and b/local_plugins/opus/.gradle/8.10/fileHashes/fileHashes.lock differ diff --git a/local_plugins/opus/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/local_plugins/opus/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 118d77df6..48bc9bac1 100644 Binary files a/local_plugins/opus/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/local_plugins/opus/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ 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;