From 78ea4618cf489e1cc5085d3af37156611bb1b564 Mon Sep 17 00:00:00 2001 From: wolfplus Date: Thu, 10 Apr 2025 19:13:53 +0100 Subject: [PATCH 1/4] add --- .cursorrules | 65 ++- android/app/build.gradle.kts | 1 + .../VoiceInteractionHandler copy.kt.bak | 456 ---------------- android/build.gradle.kts | 1 + android/settings.gradle.kts | 4 + lib/core/bindings/initial_binding.dart | 5 +- lib/core/utils/jieli_constants.dart | 89 ++++ lib/data/services/function_service.dart | 97 ---- .../services/open_ai_service_adapter.dart | 21 +- .../services/voice_interaction_service.dart | 14 +- .../bindings/jieli_test_binding.dart | 11 + .../controllers/jieli_test_controller.dart | 298 +++++++++++ .../jieli_test/views/jieli_test_view.dart | 494 +++++++++++++++++ .../controllers/pairing_controller.dart | 4 +- lib/modules/settings/views/settings_view.dart | 22 + lib/routes/app_pages.dart | 7 + lib/routes/app_routes.dart | 1 + .../azure_speech/android/build.gradle.kts | 42 +- local_plugins/chat_storage_sqlite/README.md | 80 +++ .../android/build.gradle.kts | 60 +++ .../android/settings.gradle.kts | 1 + .../android/src/main/AndroidManifest.xml | 8 + .../ChatStorageSqlitePlugin.kt | 236 +++++++++ .../lib/chat_storage_sqlite.dart | 155 ++++++ .../chat_storage_sqlite/pubspec.yaml | 30 ++ local_plugins/jieli/README.md | 144 +++++ local_plugins/jieli/android/build.gradle.kts | 55 ++ .../jieli/android/settings.gradle.kts | 1 + .../android/src/main/AndroidManifest.xml | 22 + .../com/yunqiinnovation/jieli/BleService.kt | 460 ++++++++++++++++ .../com/yunqiinnovation/jieli/JieliPlugin.kt | 269 ++++++++++ local_plugins/jieli/lib/jieli.dart | 188 +++++++ local_plugins/jieli/pubspec.yaml | 23 + .../android/AndroidManifest.xml | 12 + .../open_ai_service/android/build.gradle.kts | 24 +- .../open_ai_service/MCPClient.kt | 496 ++++++++++++++++++ .../open_ai_service/OpenAIService.kt | 228 ++++++-- .../open_ai_service/OpenAIServicePlugin.kt | 65 ++- .../open_ai_service/SystemFunctionHandler.kt | 422 +++++++++++++++ .../open_ai_service/lib/open_ai_service.dart | 101 ++++ .../volcano_speech/android/build.gradle.kts | 4 +- .../volcano_speech/VolcanoAsrHelper.kt | 39 +- .../volcano_speech/VolcanoTtsHelper.kt | 57 +- pubspec.yaml | 5 + test.json | 58 +- 45 files changed, 4207 insertions(+), 668 deletions(-) delete mode 100644 android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler copy.kt.bak create mode 100644 lib/core/utils/jieli_constants.dart delete mode 100644 lib/data/services/function_service.dart create mode 100644 lib/modules/jieli_test/bindings/jieli_test_binding.dart create mode 100644 lib/modules/jieli_test/controllers/jieli_test_controller.dart create mode 100644 lib/modules/jieli_test/views/jieli_test_view.dart create mode 100644 local_plugins/chat_storage_sqlite/README.md create mode 100644 local_plugins/chat_storage_sqlite/android/build.gradle.kts create mode 100644 local_plugins/chat_storage_sqlite/android/settings.gradle.kts create mode 100644 local_plugins/chat_storage_sqlite/android/src/main/AndroidManifest.xml create mode 100644 local_plugins/chat_storage_sqlite/android/src/main/kotlin/com/yunqiinnovation/chat_storage_sqlite/ChatStorageSqlitePlugin.kt create mode 100644 local_plugins/chat_storage_sqlite/lib/chat_storage_sqlite.dart create mode 100644 local_plugins/chat_storage_sqlite/pubspec.yaml create mode 100644 local_plugins/jieli/README.md create mode 100644 local_plugins/jieli/android/build.gradle.kts create mode 100644 local_plugins/jieli/android/settings.gradle.kts create mode 100644 local_plugins/jieli/android/src/main/AndroidManifest.xml create mode 100644 local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt create mode 100644 local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/JieliPlugin.kt create mode 100644 local_plugins/jieli/lib/jieli.dart create mode 100644 local_plugins/jieli/pubspec.yaml create mode 100644 local_plugins/open_ai_service/android/AndroidManifest.xml create mode 100644 local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt create mode 100644 local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt diff --git a/.cursorrules b/.cursorrules index 99d6aa58c..6e3d3fd0c 100644 --- a/.cursorrules +++ b/.cursorrules @@ -7,4 +7,67 @@ 7. 尽可能避免打印日志,仅在出错时记录日志 8. 请使用较小的代码量改动,最安全的方法进行代码编写和优化 9. 请尽可能使用中文回复问题 -10. 重要:不要过度设计!! \ No newline at end of file +10. 重要:不要过度设计!! +11. 本项目使用 Kotlin DSL 进行开发 + +# 项目架构概述 +DeepSound是一个基于Flutter和GetX构建的多平台语音应用,具有语音合成、语音识别、语音聊天等功能。 + +## 总体架构 +- 采用GetX状态管理框架 +- 遵循模块化设计,各功能模块相对独立 +- 使用依赖注入实现组件解耦 +- 多语言支持,使用GetX国际化方案 + +## 主要目录结构 +- lib/core: 核心组件、工具和通用功能 +- lib/data: 数据模型、服务和提供者 +- lib/modules: 功能模块,按业务划分 +- lib/routes: 路由定义和管理 +- local_plugins: 本地插件,提供原生功能支持 + +# 核心模块说明 +## 语音处理 +- 火山语音服务: 提供语音识别和合成核心能力 +- Azure语音服务: 提供备选的语音识别和合成能力 +- 离线TTS支持: 在线语音合成失败时的备选方案 + +## 聊天功能 +- 基于OpenAI的对话功能 +- 支持语音输入、文字输入和语音输出 +- 支持多种角色和语音类型 + +## 用户系统 +- 支持多种登录方式:Firebase、Google、Facebook +- 用户资料管理和设置 +- 主题切换和语言设置 + +## 蓝牙配对 +- 支持蓝牙设备发现和连接 +- 提供简化的配对流程 +- 设备管理和状态监控 + +## 音频处理 +- 音频录制和播放 +- 支持波形显示 +- 音量控制和音频质量设置 + +# 技术栈 +- Flutter: UI框架 +- GetX: 状态管理和依赖注入 +- Firebase: 用户认证和后端服务 +- WebSocket: 实时通信 +- SQLite: 本地数据存储 +- 原生插件: 语音识别、合成和蓝牙功能 + +# 插件说明 +- jieli: 杰理蓝牙设备连接和控制插件 +- volcano_speech: 火山语音服务插件,提供语音识别和合成 +- azure_speech: 微软Azure语音服务插件 +- open_ai_service: OpenAI服务插件,提供AI对话能力 +- speech_to_text: 通用语音识别插件 + +# 环境配置 +- 环境变量通过.env文件管理 +- 支持不同环境的配置切换 +- 敏感信息不应直接硬编码在代码中 \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8c371408e..009a8926c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -108,6 +108,7 @@ dependencies { implementation(project(":azure_speech")) implementation(project(":open_ai_service")) implementation(project(":volcano_speech")) + implementation(project(":chat_storage_sqlite")) // 添加新创建的SQLite聊天存储插件 // 添加OkHttp依赖 implementation("com.squareup.okhttp3:okhttp:4.9.3") diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler copy.kt.bak b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler copy.kt.bak deleted file mode 100644 index 1a9e2df13..000000000 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler copy.kt.bak +++ /dev/null @@ -1,456 +0,0 @@ -package com.yunqiinnovation.deepsound - -import android.content.Context -import org.json.JSONArray -import org.json.JSONObject -import android.util.Log -import com.yunqiinnovation.deepsound.core.utils.FileLogger -import com.yunqiinnovation.azure_speech.AzureAsrHelper -import com.yunqiinnovation.volcano_speech.VolcanoTtsHelper -import com.yunqiinnovation.open_ai_service.OpenAIService - - -/** - * 语音交互处理器 - 处理语音识别、TTS和AI对话相关逻辑 - */ -class VoiceInteractionHandler( - private val context: Context, - private val azureSpeechKey: String, - private val azureSpeechRegion: String, - private val openaiApiKey: String, - private val openaiBaseUrl: String = "", - private val openaiModel: String = "", - private val volcanoSpeechAppId: String, - private val volcanoSpeechAppToken: String -) { - private val TAG = "VoiceInteractionHandler" - - // Azure服务 - private var azureAsrHelper: AzureAsrHelper? = null - private var volcanoTtsHelper: VolcanoTtsHelper? = null - - // OpenAI服务 - private val openAIService = OpenAIService() - - - // 语音功能处理 - private val voiceFunctionHandler = VoiceFunctionHandler(openAIService, context) - - // 当前用户输入 - private var currentUserInput = "" - - // 状态 - private var isInitialized = false - var isRecognitionActive = false - private set - var isTtsSpeaking = false - private set - var hasSpeechDetected = false - private set - - // 回调 - private var callback: InteractionCallback? = null - - /** - * 初始化 - */ - fun initialize(): Boolean { - if (isInitialized) return true - - try { - // 初始化Azure ASR - azureAsrHelper = AzureAsrHelper(context).apply { - initialize(azureSpeechKey, azureSpeechRegion) - } - - // 初始化Volcano TTS (使用大模型TTS) - volcanoTtsHelper = VolcanoTtsHelper(context).apply { - // 这里需要替换为实际的Volcano SDK初始化参数 - // 暂时使用假参数,实际使用时需要替换为真实值 - initialize(volcanoSpeechAppId, volcanoSpeechAppToken, "volc.bigasr.sauc.duration") - - } - - // 初始化OpenAI服务 - openAIService.initialize(openaiApiKey, openaiBaseUrl, openaiModel) - - // 初始化语音功能处理器 - voiceFunctionHandler.initialize() - - isInitialized = true - return true - } catch (e: Exception) { - FileLogger.e(TAG, "初始化失败: ${e.message}", e) - return false - } - } - - /** - * 设置回调 - */ - fun setCallback(callback: InteractionCallback) { - this.callback = callback - } - - /** - * 开始语音识别 - */ - fun startRecognition() { - if (isRecognitionActive) return - - // 检查录音权限 - if (!checkRecordAudioPermission()) { - callback?.onError("需要录音权限,请在设置中授予权限") - return - } - - isRecognitionActive = true - hasSpeechDetected = false - notifyStateChanged() - - try { - azureAsrHelper?.startContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { - override fun onRecognizing(recognizing: String, detectedLanguage: String) { - if (recognizing.isNotEmpty()) { - hasSpeechDetected = true - stopTts() - notifyStateChanged() - } - } - - override fun onResult(result: String, detectedLanguage: String) { - if (result.isNotEmpty()) { - notifyStateChanged() - - processWithOpenAI(result) - - } - - // 重置状态,继续识别 - hasSpeechDetected = false - } - - override fun onSessionStarted() { - notifyStateChanged() - } - - override fun onSessionStopped() { - isRecognitionActive = false - notifyStateChanged() - } - - override fun onCanceled(reason: String, errorDetails: String) { - isRecognitionActive = false - notifyStateChanged() - } - - override fun onError(error: String) { - isRecognitionActive = false - callback?.onError("语音识别出错") - notifyStateChanged() - } - - override fun onSuccess(message: String) { - // 处理成功事件 - } - }) - } catch (e: Exception) { - isRecognitionActive = false - FileLogger.e(TAG, "启动语音识别失败: ${e.message}", e) - callback?.onError("启动语音识别失败") - notifyStateChanged() - } - } - - /** - * 停止语音识别 - */ - fun stopRecognition() { - if (!isRecognitionActive) return - - FileLogger.d(TAG, "停止语音识别") - - try { - azureAsrHelper?.stopContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { - override fun onResult(result: String, detectedLanguage: String) {} - override fun onRecognizing(recognizing: String, detectedLanguage: String) {} - override fun onSessionStarted() {} - override fun onSessionStopped() { - isRecognitionActive = false - FileLogger.d(TAG, "语音识别会话已停止") - notifyStateChanged() - } - override fun onCanceled(reason: String, errorDetails: String) { - isRecognitionActive = false - FileLogger.d(TAG, "语音识别已取消: $reason") - notifyStateChanged() - } - override fun onError(error: String) { - isRecognitionActive = false - FileLogger.e(TAG, "停止语音识别时出错: $error") - notifyStateChanged() - } - override fun onSuccess(message: String) { - isRecognitionActive = false - FileLogger.d(TAG, "语音识别已停止: $message") - notifyStateChanged() - } - }) - } catch (e: Exception) { - FileLogger.e(TAG, "停止语音识别异常: ${e.message}", e) - // 确保状态一致性 - isRecognitionActive = false - notifyStateChanged() - } - } - - /** - * 使用OpenAI处理语音识别结果 - */ - private fun processWithOpenAI(text: String) { - // 保存当前用户输入,用于后续同步聊天记录 - currentUserInput = text - - Thread { - try { - val messages = JSONArray().apply { - put(openAIService.createUserMessage(text)) - } - - // 创建响应构建器 - val responseBuilder = StringBuilder() - - openAIService.sendMessageStream( - messages = messages, - callback = object : OpenAIService.StreamCallback { - override fun onToken(token: String) { - // 累加响应内容 - responseBuilder.append(token) - } - - override fun onComplete() { - // 处理完整响应 - val response = responseBuilder.toString() - if (response.isNotEmpty()) { - // 播放AI回复 - Log.d(TAG, "AI 回复: $response") - - speakAIResponse(response) - - // 同步聊天记录到Flutter端 - sendChatHistoryUpdate("personal_assistant", text, response) - } - } - - override fun onError(e: Exception) { - FileLogger.e(TAG, "AI流式处理出错: ${e.message}", e) - callback?.onError("AI处理出错") - } - - override fun onFunctionCall(call: JSONObject) { - FileLogger.d(TAG, "收到函数调用请求: ${call.getString("name")}") - - // 使用函数处理器处理函数调用 - val handled = voiceFunctionHandler.handleFunctionCall( - functionCall = call, - messages = messages, - callback = object : VoiceFunctionHandler.FunctionCallCallback { - override fun onTokenReceived(token: String) { - responseBuilder.append(token) - } - - override fun onComplete() { - val response = responseBuilder.toString() - if (response.isNotEmpty()) { - // 播放AI回复 - Log.d(TAG, "AI Function Call 回复: $response") - speakAIResponse(response) - - // 同步聊天记录到Flutter端 - sendChatHistoryUpdate("personal_assistant", text, response) - } - notifyStateChanged() - } - - override fun onError(message: String) { - FileLogger.e(TAG, "函数处理出错: $message") - callback?.onError(message) - } - - override fun onFunctionCall(nestedCall: JSONObject) { - FileLogger.d(TAG, "收到嵌套函数调用: ${nestedCall.getString("name")}") - // 不处理嵌套函数调用,直接返回错误提示 - // speakAIResponse("抱歉,暂不支持嵌套函数调用") - } - - override fun onExitWithMessage(farewell: String) { - // 播放退出消息 - speakAIResponse(farewell) - - // 同步聊天记录 - sendChatHistoryUpdate("personal_assistant", text, farewell) - - // 停止语音识别 - stopRecognition() - } - } - ) - - if (!handled) { - // 如果函数没有被处理,作为普通文本处理 - FileLogger.d(TAG, "函数未处理,作为普通文本处理") - speakAIResponse("我无法处理这个请求") - sendChatHistoryUpdate("personal_assistant", text, "我无法处理这个请求") - } - } - } - ) - - } catch (e: Exception) { - FileLogger.e(TAG, "AI处理出错: ${e.message}", e) - callback?.onError("AI处理出错") - } - }.start() - } - - /** - * 播放TTS - */ - fun playTts(text: String, callback: TtsCallback? = null) { - isTtsSpeaking = true - notifyStateChanged() - - volcanoTtsHelper?.speak(text, object : VolcanoTtsHelper.TTSCallback { - override fun onStart(reqId: String) { - // TTS开始播放事件 - } - - override fun onProgress(reqId: String, progress: Double) { - // TTS播放进度事件 - } - - override fun onComplete(reqId: String) { - isTtsSpeaking = false - notifyStateChanged() - callback?.onComplete() - } - - override fun onError(reqId: String, errorCode: Int, errorMsg: String) { - isTtsSpeaking = false - notifyStateChanged() - callback?.onError(errorMsg) - } - }) - } - - /** - * 停止TTS播放 - */ - fun stopTts() { - if (isTtsSpeaking) { - volcanoTtsHelper?.stop() - isTtsSpeaking = false - notifyStateChanged() - } - } - - /** - * 播放AI回复 - */ - private fun speakAIResponse(text: String) { - isTtsSpeaking = true - notifyStateChanged() - - volcanoTtsHelper?.speak(text, object : VolcanoTtsHelper.TTSCallback { - override fun onStart(reqId: String) { - // TTS开始播放事件 - } - - override fun onProgress(reqId: String, progress: Double) { - // TTS播放进度事件 - } - - override fun onComplete(reqId: String) { - isTtsSpeaking = false - notifyStateChanged() - } - - override fun onError(reqId: String, errorCode: Int, errorMsg: String) { - isTtsSpeaking = false - notifyStateChanged() - } - }) - } - - /** - * 释放资源 - */ - fun dispose() { - // 停止语音识别 - stopRecognition() - - // 停止TTS播放 - stopTts() - - // 释放Azure资源 - azureAsrHelper?.let { - FileLogger.d(TAG, "关闭Azure ASR服务") - it.dispose() - } - - volcanoTtsHelper?.let { - FileLogger.d(TAG, "关闭Volcano TTS服务") - it.release() - } - - FileLogger.d(TAG, "语音交互处理器资源已释放") - } - - /** - * 检查录音权限 - */ - private fun checkRecordAudioPermission(): Boolean { - val permission = android.Manifest.permission.RECORD_AUDIO - val result = context.checkCallingOrSelfPermission(permission) - return result == android.content.pm.PackageManager.PERMISSION_GRANTED - } - - /** - * 通知状态变化 - */ - private fun notifyStateChanged() { - callback?.onStateChanged(isRecognitionActive, isTtsSpeaking, hasSpeechDetected) - } - - /** - * 发送聊天历史更新 - */ - private fun sendChatHistoryUpdate(agentId: String, userMessage: String, assistantMessage: String) { - val intent = android.content.Intent(VoiceInteractionService.ACTION_CHAT_HISTORY_UPDATED).apply { - putExtra("agentId", agentId) - putExtra("userMessage", userMessage) - putExtra("assistantMessage", assistantMessage) - putExtra("timestamp", System.currentTimeMillis()) - } - - // 发送广播 - context.sendBroadcast(intent) - } - - /** - * 交互回调接口 - */ - interface InteractionCallback { - fun onStateChanged(isRecognitionActive: Boolean, isTtsSpeaking: Boolean, hasSpeechDetected: Boolean) - fun onError(message: String) - fun onPromptRequest(message: String) - } - - /** - * TTS回调接口 - */ - interface TtsCallback { - fun onComplete() - fun onError(error: String) - } -} \ No newline at end of file diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 5421f73bd..ca0ce6220 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,5 +1,6 @@ allprojects { repositories { + mavenLocal() google() mavenCentral() maven { url = uri("https://storage.googleapis.com/download.flutter.io") } diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index e332da5f9..74e47be3f 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -16,6 +16,7 @@ pluginManagement { includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") repositories { + mavenLocal() google() mavenCentral() gradlePluginPortal() @@ -25,6 +26,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") id("com.android.application") version "8.7.0" apply false + id("com.android.library") version "8.7.0" apply false id("org.jetbrains.kotlin.android") version "2.1.10" apply false } @@ -32,8 +34,10 @@ include(":app") include(":azure_speech") include(":open_ai_service") include(":volcano_speech") +include(":chat_storage_sqlite") // 设置azure_speech项目的路径 project(":azure_speech").projectDir = file("../local_plugins/azure_speech/android") project(":open_ai_service").projectDir = file("../local_plugins/open_ai_service/android") project(":volcano_speech").projectDir = file("../local_plugins/volcano_speech/android") +project(":chat_storage_sqlite").projectDir = file("../local_plugins/chat_storage_sqlite/android") diff --git a/lib/core/bindings/initial_binding.dart b/lib/core/bindings/initial_binding.dart index 748589f63..9a7a3abec 100644 --- a/lib/core/bindings/initial_binding.dart +++ b/lib/core/bindings/initial_binding.dart @@ -9,7 +9,6 @@ import '../../data/services/bluetooth_media_button_service.dart'; import '../../core/utils/logger.dart'; import '../../data/services/voice_interaction_service.dart'; import '../../data/services/open_ai_service_adapter.dart'; - /// 初始绑定,用于管理全局依赖 class InitialBinding extends Bindings { @override @@ -48,11 +47,15 @@ class InitialBinding extends Bindings { Get.lazyPut( () => BluetoothMediaButtonService(), fenix: true); + + // 语音交互服务 Get.lazyPut(() => VoiceInteractionService(), fenix: true); + // 杰理蓝牙服务 + // // 语言管理器(需要最先初始化) // Get.put(LoginService(), permanent: true); // await LoginService().FirebaseInit(); diff --git a/lib/core/utils/jieli_constants.dart b/lib/core/utils/jieli_constants.dart new file mode 100644 index 000000000..55f8cb802 --- /dev/null +++ b/lib/core/utils/jieli_constants.dart @@ -0,0 +1,89 @@ +/// 杰里(Jieli)设备相关常量定义 +/// 用于标准化系统属性类型和功能码 + +/// 系统属性类型常量 +class JieliAttrType { + // 公共属性 + static const int VOLUME = 1; // 音量 + static const int PLAYING_TIME = 2; // 播放时间 + static const int BATTERY_LEVEL = 3; // 电池电量 + static const int WORK_MODE = 4; // 工作模式 + static const int PLAYBACK_MODE = 5; // 播放模式(循环模式) + static const int EQ_MODE = 6; // EQ模式 + static const int LANGUAGE = 7; // 语言设置 + static const int CONTRAST = 8; // 对比度 + static const int LED_ONOFF = 9; // LED开关 + static const int POWER_OFF_TIME = 10; // 关机时间 + static const int LIGHT_ONOFF = 11; // 背光开关 + static const int BLETYPE_SETTING = 12; // BLE类型设置 + static const int TIME_OUT = 13; // 超时设置 + static const int DEVICE_NAME = 14; // 设备名称 + static const int ALARM_ENABLE = 15; // 闹钟开关 + static const int DEV_MAC = 16; // 设备MAC地址 + + // 音乐相关属性 + static const int MUSIC_STATUS = 20; // 音乐状态 + static const int MUSIC_CURR_DEVICE = 21; // 当前音乐设备 + static const int MUSIC_TOTAL_TIME = 22; // 音乐总时长 + static const int MUSIC_CURR_PLAY_TIME = 23; // 当前播放时间 + static const int MUSIC_NUM = 24; // 音乐数量 + static const int MUSIC_DEV_STATUS = 25; // 设备存储状态 + static const int MUSIC_BREAK_POINT = 26; // 断点信息 + static const int MUSIC_CURR_FILENUM = 27; // 当前文件编号 + static const int MUSIC_TOTAL_FILENUM = 28; // 总文件数 + static const int MEDIA_TIMESTAMP = 29; // 媒体时间戳 + + // 模式相关 + static const int CUR_MODE = 30; // 当前模式 + + // TWS相关 + static const int TWS_CONN_STATUS = 60; // TWS连接状态 + static const int LEFT_CHN_BAT = 61; // 左耳电量 + static const int RIGHT_CHN_BAT = 62; // 右耳电量 + static const int TWS_STATUS = 63; // TWS状态 +} + +/// 功能码 +class JieliFunctionCode { + static const int COMMON = 0; // 公共功能 + static const int MUSIC = 1; // 音乐功能 + static const int RADIO = 2; // 收音机功能 + static const int LINEIN = 3; // 线路输入功能 + static const int RTC = 4; // 时钟功能 + static const int BT = 5; // 蓝牙功能 + static const int PC = 6; // PC功能 + static const int RECORD = 7; // 录音功能 + static const int UDISK = 8; // U盘功能 + static const int TALK = 9; // 通话功能 + static const int PHOTO = 10; // 拍照功能 + static const int VIDEO = 11; // 视频功能 +} + +/// 音乐播放状态 +class JieliMusicStatus { + static const int STOP = 0; // 停止状态 + static const int PLAY = 1; // 播放状态 + static const int PAUSE = 2; // 暂停状态 + static const int FAST_FORWARD = 3; // 快进状态 + static const int FAST_BACKWARD = 4; // 快退状态 +} + +/// 循环播放模式 +class JieliPlaybackMode { + static const int ALL = 0; // 全部循环 + static const int FOLDER = 1; // 文件夹循环 + static const int ONE = 2; // 单曲循环 + static const int RANDOM = 3; // 随机播放 + static const int BROWSE = 4; // 浏览播放 +} + +/// EQ模式 +class JieliEQMode { + static const int NORMAL = 0; // 正常 + static const int ROCK = 1; // 摇滚 + static const int POP = 2; // 流行 + static const int CLASSIC = 3; // 经典 + static const int JAZZ = 4; // 爵士 + static const int COUNTRY = 5; // 乡村 + static const int CUSTOM = 6; // 自定义 +} \ No newline at end of file diff --git a/lib/data/services/function_service.dart b/lib/data/services/function_service.dart deleted file mode 100644 index b1e3a1e4d..000000000 --- a/lib/data/services/function_service.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'dart:convert'; - -/// 函数服务 -/// 仅实现各种功能函数,不包含AI交互逻辑 -class FunctionService { - /// 天气查询工具定义 - /// 返回一个可直接用于tools参数的天气查询工具定义 - static Map getWeatherToolDefinition() { - return { - 'type': 'function', - 'function': { - 'name': 'get_current_weather', - 'description': '获取指定地点的当前天气信息', - 'parameters': { - 'type': 'object', - 'properties': { - 'location': { - 'type': 'string', - 'description': '地理位置,例如:北京市', - }, - 'unit': { - 'type': 'string', - 'description': '温度单位', - 'enum': ['celsius', 'fahrenheit'], - }, - }, - 'required': ['location'], - }, - }, - }; - } - - /// 模拟天气API实现 - /// 返回一个模拟的天气信息,实际应用中应该调用真实的天气API - static Future> getWeatherInfo(Map args) async { - final location = args['location'] ?? '未知地点'; - final unit = args['unit'] ?? 'celsius'; - - // 在实际应用中,这里应该调用真实的天气API - return { - 'location': location, - 'temperature': unit == 'celsius' ? '25°C' : '77°F', - 'conditions': '晴转多云', - 'humidity': '55%', - 'wind': '西南风 3级', - 'date': DateTime.now().toString().split(' ')[0], - }; - } - - /// 解析函数调用参数 - /// 从AI响应中提取函数调用参数 - static Map parseFunctionCallArguments(Map functionCallResponse) { - if (functionCallResponse['tool_calls'] == null || - functionCallResponse['tool_calls'].isEmpty) { - throw FunctionServiceException('No tool calls found in response'); - } - - final toolCall = functionCallResponse['tool_calls'][0]; - if (toolCall['function'] == null || toolCall['function']['arguments'] == null) { - throw FunctionServiceException('Invalid function call format'); - } - - try { - return json.decode(toolCall['function']['arguments']); - } catch (e) { - throw FunctionServiceException('Failed to parse function arguments: $e'); - } - } - - /// 获取函数调用ID和名称 - /// 用于构建工具响应消息 - static Map getFunctionCallInfo(Map functionCallResponse) { - if (functionCallResponse['tool_calls'] == null || - functionCallResponse['tool_calls'].isEmpty) { - throw FunctionServiceException('No tool calls found in response'); - } - - final toolCall = functionCallResponse['tool_calls'][0]; - if (toolCall['id'] == null || toolCall['function'] == null || toolCall['function']['name'] == null) { - throw FunctionServiceException('Invalid function call format'); - } - - return { - 'id': toolCall['id'], - 'name': toolCall['function']['name'], - }; - } -} - -/// 函数调用服务异常类 -class FunctionServiceException implements Exception { - final String message; - FunctionServiceException(this.message); - - @override - String toString() => message; -} \ No newline at end of file diff --git a/lib/data/services/open_ai_service_adapter.dart b/lib/data/services/open_ai_service_adapter.dart index 2c0da53ac..7eb337605 100644 --- a/lib/data/services/open_ai_service_adapter.dart +++ b/lib/data/services/open_ai_service_adapter.dart @@ -41,25 +41,14 @@ class OpenAIServiceAdapter implements AiService { _isProcessingStream = false; break; case OpenAIEventType.error: - if (event.content is String) { - _tokenStreamController.addError(event.content as String); - } else { - _tokenStreamController.addError('未知错误: ${event.content}'); - } + _tokenStreamController.addError('未知错误: ${event.content}'); + _isProcessingStream = false; break; case OpenAIEventType.functionCall: - try { - if (event.content is Map) { - _tokenStreamController.addError('收到函数调用,该流仅支持文本响应'); - } else { - _tokenStreamController.addError('收到未知格式的函数调用'); - printError(info: '函数调用格式错误: ${event.content}'); - } - } catch (e) { - printError(info: '处理函数调用事件出错: $e'); - _tokenStreamController.addError('处理函数调用失败: $e'); - } + _tokenStreamController.addError('收到函数调用,该流仅支持文本响应'); + + _isProcessingStream = false; break; } diff --git a/lib/data/services/voice_interaction_service.dart b/lib/data/services/voice_interaction_service.dart index cce506a58..57e8636c5 100644 --- a/lib/data/services/voice_interaction_service.dart +++ b/lib/data/services/voice_interaction_service.dart @@ -42,6 +42,8 @@ class VoiceInteractionService extends GetxService { late String _openaiApiKey; late String _openaiBaseUrl; late String _openaiModel; + late String _volcanoSpeechAppId; + late String _volcanoSpeechAppToken; // 聊天历史服务 late final ChatHistoryService _chatHistoryService; @@ -62,6 +64,8 @@ class VoiceInteractionService extends GetxService { _openaiApiKey = dotenv.env['OPENAI_API_KEY'] ?? ''; _openaiBaseUrl = dotenv.env['OPENAI_BASE_URL'] ?? ''; _openaiModel = dotenv.env['OPENAI_MODEL'] ?? ''; + _volcanoSpeechAppId = dotenv.env['VOLCANO_SPEECH_APP_ID'] ?? ''; + _volcanoSpeechAppToken = dotenv.env['VOLCANO_SPEECH_APP_TOKEN'] ?? ''; if (_azureSpeechKey.isEmpty || _azureSpeechRegion.isEmpty) { Logger.warning('未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION'); @@ -111,11 +115,7 @@ class VoiceInteractionService extends GetxService { Logger.info('收到进入翻译模式事件,正在导航到翻译界面'); _navigateToTranslation(); - final translationModeEvent = GenericVoiceInteractionEvent( - type: 'enter_translation_mode', - timestamp: DateTime.now().millisecondsSinceEpoch, - ); - _eventStreamController.add(translationModeEvent); + break; } } @@ -124,7 +124,7 @@ class VoiceInteractionService extends GetxService { void _navigateToTranslation() { try { // 使用GetX导航到翻译页面 - Get.toNamed(Routes.translation); + Get.offNamedUntil(Routes.translation, (route) => route.settings.name == Routes.home); Logger.info('已导航到翻译界面'); } catch (e) { Logger.error('导航到翻译界面失败: $e'); @@ -222,6 +222,8 @@ class VoiceInteractionService extends GetxService { 'openai_api_key': _openaiApiKey, 'openai_base_url': _openaiBaseUrl, 'openai_model': _openaiModel, + 'volcano_speech_app_id': _volcanoSpeechAppId, + 'volcano_speech_app_token': _volcanoSpeechAppToken, }) ?? false; if (result) { diff --git a/lib/modules/jieli_test/bindings/jieli_test_binding.dart b/lib/modules/jieli_test/bindings/jieli_test_binding.dart new file mode 100644 index 000000000..9df61ef94 --- /dev/null +++ b/lib/modules/jieli_test/bindings/jieli_test_binding.dart @@ -0,0 +1,11 @@ +import 'package:get/get.dart'; +import '../controllers/jieli_test_controller.dart'; + +class JieliTestBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut( + () => JieliTestController(), + ); + } +} \ No newline at end of file diff --git a/lib/modules/jieli_test/controllers/jieli_test_controller.dart b/lib/modules/jieli_test/controllers/jieli_test_controller.dart new file mode 100644 index 000000000..8afa86cf4 --- /dev/null +++ b/lib/modules/jieli_test/controllers/jieli_test_controller.dart @@ -0,0 +1,298 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'package:get/get.dart'; +import 'package:logger/logger.dart'; +import 'package:jieli/jieli.dart'; + +/// 杰理测试控制器 +class JieliTestController extends GetxController { + static final Logger logger = Logger(); + + // Jieli插件实例 + late Jieli jieli; + + // 状态变量 + var isScanning = false.obs; + var deviceInfo = '未获取'.obs; + var connectStatus = '未连接'.obs; + var currentDevice = Rx(null); + var scannedDevices = RxList([]); + var wakeupReceived = false.obs; + var audioDataSize = 0.obs; + var receivedFrames = 0.obs; + var audioDataReceiving = false.obs; + + // 事件订阅 + StreamSubscription? _scanSubscription; + StreamSubscription? _connectionSubscription; + StreamSubscription? _wakeupSubscription; + StreamSubscription? _audioSubscription; + + @override + void onInit() { + super.onInit(); + logger.d('JieliTestController 初始化'); + _initializeJieli(); + } + + @override + void onClose() { + logger.d('JieliTestController 关闭'); + _cancelAllSubscriptions(); + _disposeJieli(); + super.onClose(); + } + + /// 取消所有订阅 + void _cancelAllSubscriptions() { + _scanSubscription?.cancel(); + _connectionSubscription?.cancel(); + _wakeupSubscription?.cancel(); + _audioSubscription?.cancel(); + } + + /// 初始化杰理SDK + void _initializeJieli() { + try { + logger.d('初始化Jieli插件'); + jieli = Jieli(); + _setupEventListeners(); + } catch (e) { + logger.e('初始化Jieli插件错误', error: e); + } + } + + /// 设置事件监听器 + void _setupEventListeners() { + // 监听扫描结果 + _scanSubscription = jieli.scanResults.listen((devices) { + // logger.d('扫描到设备: ${devices.length}个'); + scannedDevices.assignAll(devices); + }); + + // 监听连接状态 + _connectionSubscription = jieli.connectionState.listen((state) { + logger.d('连接状态变化: $state'); + switch (state) { + case ConnectionState.connected: + connectStatus.value = '已连接'; + break; + case ConnectionState.disconnected: + connectStatus.value = '已断开'; + currentDevice.value = null; + break; + case ConnectionState.connecting: + connectStatus.value = '连接中...'; + break; + case ConnectionState.disconnecting: + connectStatus.value = '断开中...'; + break; + case ConnectionState.error: + connectStatus.value = '连接错误'; + break; + } + }); + + // 监听唤醒信号 + _wakeupSubscription = jieli.wakeupStream.listen((wakeup) { + logger.d('收到唤醒信号'); + wakeupReceived.value = true; + Get.snackbar('收到唤醒信号', '耳机发送了唤醒信号'); + }); + + // 监听音频数据 + _audioSubscription = jieli.audioDataStream.listen((data) { + // 更新音频数据统计 + audioDataSize.value += data.length; + receivedFrames.value++; + audioDataReceiving.value = true; + + // 每100帧更新一次日志,避免日志过多 + if (receivedFrames.value % 100 == 0) { + logger.d('已接收音频数据: ${audioDataSize.value} 字节, ${receivedFrames.value} 帧'); + } + }); + } + + /// 检查蓝牙状态 + Future checkBluetoothStatus() async { + try { + final isEnabled = await jieli.isBluetoothEnabled(); + if (!isEnabled) { + logger.d('蓝牙未启用,请求启用蓝牙'); + return await jieli.requestBluetoothEnable(); + } + return true; + } catch (e) { + logger.e('检查蓝牙状态错误', error: e); + return false; + } + } + + /// 开始扫描设备 + Future startScan() async { + if (isScanning.value) return; + + final bluetoothReady = await checkBluetoothStatus(); + if (!bluetoothReady) { + Get.snackbar('蓝牙未启用', '请启用蓝牙后再试'); + return; + } + + try { + logger.d('开始扫描设备'); + isScanning.value = true; + + // 清空之前的设备列表 + scannedDevices.clear(); + + // 开始扫描,10秒后自动停止 + final success = await jieli.startScan(scanTimeout: 10000); + + if (!success) { + logger.e('启动扫描失败'); + isScanning.value = false; + Get.snackbar('扫描失败', '启动扫描设备失败,请检查蓝牙权限'); + } + + // 10秒后自动更新UI状态 + Future.delayed(Duration(milliseconds: 10000), () { + isScanning.value = false; + }); + + } catch (e) { + logger.e('扫描设备错误', error: e); + isScanning.value = false; + Get.snackbar('扫描错误', e.toString()); + } + } + + /// 停止扫描 + Future stopScan() async { + if (!isScanning.value) return; + + try { + logger.d('停止扫描'); + await jieli.stopScan(); + isScanning.value = false; + } catch (e) { + logger.e('停止扫描错误', error: e); + } + } + + /// 连接到指定设备 + Future connectToDevice(BleDevice device) async { + try { + // 先断开当前连接 + if (currentDevice.value != null) { + await disconnectDevice(); + } + + // 更新UI状态 + connectStatus.value = '连接中...'; + logger.d('连接设备: ${device.name} (${device.id})'); + + // 重置音频和唤醒相关的状态 + wakeupReceived.value = false; + audioDataSize.value = 0; + receivedFrames.value = 0; + audioDataReceiving.value = false; + + // 连接设备 + final success = await jieli.connect(device.id); + + if (success) { + // 连接成功,更新当前设备 + currentDevice.value = device; + deviceInfo.value = '设备名称: ${device.name}\n设备ID: ${device.id}\nRSSI: ${device.rssi} dBm'; + logger.d('设备连接成功'); + } else { + deviceInfo.value = '连接失败'; + logger.d('设备连接失败'); + } + } catch (e) { + deviceInfo.value = '连接错误: ${e.toString()}'; + logger.e('连接设备错误', error: e); + } + } + + /// 断开设备连接 + Future disconnectDevice() async { + if (currentDevice.value == null) return; + + try { + connectStatus.value = '断开中...'; + logger.d('断开设备连接'); + + final success = await jieli.disconnect(); + + if (success) { + deviceInfo.value = '未连接设备'; + logger.d('设备断开连接成功'); + } else { + logger.d('设备断开连接失败'); + } + + } catch (e) { + logger.e('断开设备错误', error: e); + } + } + + /// 发送开始录音命令 + Future sendStartRecordingCommand() async { + if (currentDevice.value == null) { + Get.snackbar('操作失败', '未连接设备'); + return; + } + + try { + logger.d('发送开始录音命令'); + final success = await jieli.sendStartRecordingCommand(); + + if (success) { + Get.snackbar('命令已发送', '已发送开始录音命令'); + logger.d('开始录音命令发送成功'); + } else { + Get.snackbar('命令发送失败', '发送开始录音命令失败'); + logger.d('开始录音命令发送失败'); + } + } catch (e) { + logger.e('发送开始录音命令错误', error: e); + Get.snackbar('命令错误', e.toString()); + } + } + + /// 发送停止录音命令 + Future sendStopRecordingCommand() async { + if (currentDevice.value == null) { + Get.snackbar('操作失败', '未连接设备'); + return; + } + + try { + logger.d('发送停止录音命令'); + final success = await jieli.sendStopRecordingCommand(); + + if (success) { + Get.snackbar('命令已发送', '已发送停止录音命令'); + logger.d('停止录音命令发送成功'); + } else { + Get.snackbar('命令发送失败', '发送停止录音命令失败'); + logger.d('停止录音命令发送失败'); + } + } catch (e) { + logger.e('发送停止录音命令错误', error: e); + Get.snackbar('命令错误', e.toString()); + } + } + + /// 清理资源 + void _disposeJieli() { + try { + logger.d('释放Jieli插件资源'); + jieli.dispose(); + } catch (e) { + logger.e('释放Jieli插件资源错误', error: e); + } + } +} \ No newline at end of file diff --git a/lib/modules/jieli_test/views/jieli_test_view.dart b/lib/modules/jieli_test/views/jieli_test_view.dart new file mode 100644 index 000000000..aa6316366 --- /dev/null +++ b/lib/modules/jieli_test/views/jieli_test_view.dart @@ -0,0 +1,494 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import '../controllers/jieli_test_controller.dart'; +import 'package:jieli/jieli.dart'; + +class JieliTestView extends GetView { + const JieliTestView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], + appBar: AppBar( + backgroundColor: isDarkMode ? Colors.black : Colors.white, + elevation: 0.5, + title: Text( + '杰理蓝牙测试', + style: TextStyle( + fontSize: 17.sp, + fontWeight: FontWeight.w600, + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + centerTitle: true, + leading: IconButton( + icon: Icon( + Icons.arrow_back_ios, + color: isDarkMode ? Colors.white : Colors.black, + size: 20.sp, + ), + onPressed: () => Get.back(), + ), + ), + body: Padding( + padding: EdgeInsets.all(16.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildStatusCard(isDarkMode), + SizedBox(height: 16.h), + _buildActionButtons(isDarkMode), + SizedBox(height: 16.h), + _buildDeviceInfo(isDarkMode), + SizedBox(height: 16.h), + _buildDevicesList(isDarkMode), + ], + ), + ), + ); + } + + Widget _buildStatusCard(bool isDarkMode) { + return Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey[900] : Colors.white, + borderRadius: BorderRadius.circular(12.r), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + '连接状态: ', + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.white : Colors.black87, + ), + ), + Obx(() => Text( + controller.connectStatus.value, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.bold, + color: _getStatusColor(controller.connectStatus.value), + ), + )), + ], + ), + SizedBox(height: 8.h), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Obx(() => _buildStatusChip( + label: '唤醒信号', + isActive: controller.wakeupReceived.value, + activeColor: Colors.orange, + isDarkMode: isDarkMode, + )), + Obx(() => _buildStatusChip( + label: '音频数据', + isActive: controller.audioDataReceiving.value, + activeColor: Colors.green, + isDarkMode: isDarkMode, + )), + ], + ), + SizedBox(height: 8.h), + Obx(() => controller.audioDataReceiving.value + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '音频数据统计:', + style: TextStyle( + fontSize: 12.sp, + color: isDarkMode ? Colors.white70 : Colors.black54, + ), + ), + SizedBox(height: 4.h), + Text( + '已接收 ${(controller.audioDataSize.value / 1024).toStringAsFixed(2)} KB', + style: TextStyle( + fontSize: 12.sp, + color: isDarkMode ? Colors.white70 : Colors.black54, + ), + ), + Text( + '共 ${controller.receivedFrames.value} 帧', + style: TextStyle( + fontSize: 12.sp, + color: isDarkMode ? Colors.white70 : Colors.black54, + ), + ), + ], + ) + : SizedBox.shrink() + ), + ], + ), + ); + } + + Widget _buildStatusChip({ + required String label, + required bool isActive, + required Color activeColor, + required bool isDarkMode, + }) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h), + decoration: BoxDecoration( + color: isActive + ? activeColor + : (isDarkMode ? Colors.grey[800] : Colors.grey[200]), + borderRadius: BorderRadius.circular(16.r), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8.w, + height: 8.w, + decoration: BoxDecoration( + color: isActive ? Colors.white : Colors.grey, + shape: BoxShape.circle, + ), + ), + SizedBox(width: 6.w), + Text( + label, + style: TextStyle( + fontSize: 12.sp, + color: isActive + ? Colors.white + : (isDarkMode ? Colors.white70 : Colors.black54), + fontWeight: isActive ? FontWeight.bold : FontWeight.normal, + ), + ), + ], + ), + ); + } + + Widget _buildActionButtons(bool isDarkMode) { + return Column( + children: [ + Row( + children: [ + Expanded( + child: Obx(() => ElevatedButton.icon( + onPressed: controller.isScanning.value + ? controller.stopScan + : controller.startScan, + icon: Icon( + controller.isScanning.value ? Icons.stop : Icons.bluetooth_searching, + size: 18.sp, + ), + label: Text( + controller.isScanning.value ? '停止扫描' : '扫描设备', + style: TextStyle(fontSize: 14.sp), + ), + style: ElevatedButton.styleFrom( + backgroundColor: controller.isScanning.value + ? Colors.red[600] + : Colors.blue[600], + padding: EdgeInsets.symmetric(vertical: 12.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r), + ), + ), + )), + ), + ], + ), + SizedBox(height: 8.h), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: controller.sendStartRecordingCommand, + icon: Icon( + Icons.mic, + size: 18.sp, + ), + label: Text( + '开始录音', + style: TextStyle(fontSize: 14.sp), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green[600], + padding: EdgeInsets.symmetric(vertical: 12.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r), + ), + ), + ), + ), + SizedBox(width: 8.w), + Expanded( + child: ElevatedButton.icon( + onPressed: controller.sendStopRecordingCommand, + icon: Icon( + Icons.stop_circle, + size: 18.sp, + ), + label: Text( + '停止录音', + style: TextStyle(fontSize: 14.sp), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red[600], + padding: EdgeInsets.symmetric(vertical: 12.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r), + ), + ), + ), + ), + ], + ), + ], + ); + } + + Widget _buildDeviceInfo(bool isDarkMode) { + return Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey[900] : Colors.white, + borderRadius: BorderRadius.circular(12.r), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '设备信息:', + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.white : Colors.black87, + ), + ), + SizedBox(height: 8.h), + Container( + padding: EdgeInsets.all(8.w), + decoration: BoxDecoration( + color: isDarkMode ? Colors.black : Colors.grey[100], + borderRadius: BorderRadius.circular(8.r), + ), + child: Obx(() => Text( + controller.deviceInfo.value, + style: TextStyle( + fontSize: 13.sp, + color: isDarkMode ? Colors.white70 : Colors.black54, + height: 1.4, + ), + )), + ), + SizedBox(height: 16.h), + Obx(() => controller.currentDevice.value != null + ? ElevatedButton.icon( + onPressed: controller.disconnectDevice, + icon: Icon( + Icons.bluetooth_disabled, + size: 16.sp, + ), + label: Text( + '断开连接', + style: TextStyle(fontSize: 13.sp), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red[600], + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r), + ), + ), + ) + : SizedBox.shrink() + ), + ], + ), + ); + } + + Widget _buildDevicesList(bool isDarkMode) { + return Expanded( + child: Container( + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey[900] : Colors.white, + borderRadius: BorderRadius.circular(12.r), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.all(16.w), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '扫描到的设备', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: isDarkMode ? Colors.white : Colors.black87, + ), + ), + Obx(() => Text( + '${controller.scannedDevices.length}个设备', + style: TextStyle( + fontSize: 12.sp, + color: isDarkMode ? Colors.white70 : Colors.black54, + ), + )), + ], + ), + ), + Divider(height: 1, thickness: 1, color: isDarkMode ? Colors.grey[800] : Colors.grey[200]), + Expanded( + child: Obx(() => controller.scannedDevices.isEmpty + ? Center( + child: controller.isScanning.value + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16.h), + Text( + '正在扫描...', + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.white70 : Colors.black54, + ), + ), + ], + ) + : Text( + '未扫描到设备', + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.white70 : Colors.black54, + ), + ), + ) + : ListView.separated( + padding: EdgeInsets.symmetric(vertical: 8.h), + itemCount: controller.scannedDevices.length, + separatorBuilder: (context, index) => Divider( + height: 1, + thickness: 1, + color: isDarkMode ? Colors.grey[800] : Colors.grey[200], + ), + itemBuilder: (context, index) { + final device = controller.scannedDevices[index]; + return ListTile( + dense: true, + title: Text( + device.name.isEmpty ? '未知设备' : device.name, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w500, + color: isDarkMode ? Colors.white : Colors.black87, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '设备ID: ${device.id}', + style: TextStyle( + fontSize: 11.sp, + color: isDarkMode ? Colors.white70 : Colors.black54, + ), + ), + Text( + 'RSSI: ${device.rssi} dBm', + style: TextStyle( + fontSize: 11.sp, + color: isDarkMode ? Colors.white70 : Colors.black54, + ), + ), + ], + ), + trailing: Obx(() => controller.currentDevice.value?.id == device.id + ? Chip( + label: Text( + '已连接', + style: TextStyle( + fontSize: 12.sp, + color: Colors.white, + ), + ), + backgroundColor: Colors.green, + padding: EdgeInsets.symmetric(horizontal: 8.w), + ) + : ElevatedButton( + onPressed: () => controller.connectToDevice(device), + child: Text( + '连接', + style: TextStyle(fontSize: 12.sp), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green[600], + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6.r), + ), + ), + ), + ), + contentPadding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + isThreeLine: true, + ); + }, + ), + ), + ), + ], + ), + ), + ); + } + + Color _getStatusColor(String status) { + switch (status) { + case '连接中...': + return Colors.orange; + case '已连接': + return Colors.green; + case '连接失败': + return Colors.red; + case '已断开': + return Colors.grey; + default: + return Colors.grey; + } + } +} \ No newline at end of file diff --git a/lib/modules/pairing/controllers/pairing_controller.dart b/lib/modules/pairing/controllers/pairing_controller.dart index 2501e26bd..5c36698c3 100644 --- a/lib/modules/pairing/controllers/pairing_controller.dart +++ b/lib/modules/pairing/controllers/pairing_controller.dart @@ -6,11 +6,12 @@ import '../../../data/services/classic_bluetooth_service.dart'; import '../../../core/utils/logger.dart'; import 'dart:io'; import '../../../data/services/voice_interaction_service.dart'; - /// 简化的配对控制器,仅用于显示已连接经典蓝牙设备和处理基本配对流程 class PairingController extends GetxController { static PairingController get to => Get.find(); + + // 经典蓝牙服务 final ClassicBluetoothService _bluetoothService = Get.find(); @@ -109,6 +110,7 @@ class PairingController extends GetxController { /// 初始化流程 Future _initialize() async { try { + // 初始化语音交互服务 _voiceInteractionService.initialize(); diff --git a/lib/modules/settings/views/settings_view.dart b/lib/modules/settings/views/settings_view.dart index ce4f7866a..fac80cae2 100644 --- a/lib/modules/settings/views/settings_view.dart +++ b/lib/modules/settings/views/settings_view.dart @@ -480,6 +480,28 @@ class SettingsView extends GetView { }, isDarkMode: isDarkMode, ), + Divider( + height: 1, + color: isDarkMode + ? Colors.white.withOpacity(0.1) + : Colors.grey[200]), + // 杰理测试 + _buildSimpleNavigationSetting( + title: '杰理蓝牙测试', + subtitle: '测试杰理蓝牙设备扫描、连接和功能', + icon: Icons.bluetooth, + iconBgColor: isDarkMode + ? Colors.blue[900]!.withOpacity(0.3) + : Colors.blue[100]!, + iconColor: + isDarkMode ? Colors.blue[300]! : Colors.blue[600]!, + titleColor: isDarkMode ? Colors.white : null, + subtitleColor: isDarkMode ? Colors.white70 : null, + onTap: () { + Get.toNamed(Routes.jieliTest); + }, + isDarkMode: isDarkMode, + ), Divider( height: 1, color: isDarkMode diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index 8a2466fda..07e5cc5fb 100644 --- a/lib/routes/app_pages.dart +++ b/lib/routes/app_pages.dart @@ -31,6 +31,8 @@ import '../modules/pairing/bindings/pairing_binding.dart'; import './app_routes.dart'; import '../modules/settings/views/settings_view.dart'; import '../modules/settings/bindings/settings_binding.dart'; +import '../modules/jieli_test/views/jieli_test_view.dart'; +import '../modules/jieli_test/bindings/jieli_test_binding.dart'; abstract class AppPages { static final pages = [ @@ -119,5 +121,10 @@ abstract class AppPages { page: () => const SettingsView(), binding: SettingsBinding(), ), + GetPage( + name: Routes.jieliTest, + page: () => const JieliTestView(), + binding: JieliTestBinding(), + ), ]; } diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index 4488f63db..7d661c0a3 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -24,4 +24,5 @@ abstract class Routes { static const FLUTTER_TTS_TEST = '/flutter_tts_test'; static const FLUTTER_ASR_TEST = '/flutter_asr_test'; static const editProfile = '/edit_profile'; + static const jieliTest = '/jieli_test'; } diff --git a/local_plugins/azure_speech/android/build.gradle.kts b/local_plugins/azure_speech/android/build.gradle.kts index a26b13967..9aabbb990 100644 --- a/local_plugins/azure_speech/android/build.gradle.kts +++ b/local_plugins/azure_speech/android/build.gradle.kts @@ -1,32 +1,11 @@ -import com.android.build.gradle.LibraryExtension - -buildscript { - repositories { - google() - mavenCentral() - } - dependencies { - classpath("com.android.tools.build:gradle:7.3.0") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.10") - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - plugins { id("com.android.library") - kotlin("android") + id("org.jetbrains.kotlin.android") } -// 配置android扩展 -configure { +android { namespace = "com.yunqiinnovation.azure_speech" - compileSdkVersion(33) + compileSdk = 33 defaultConfig { minSdk = 21 @@ -48,6 +27,20 @@ configure { lintOptions { isCheckReleaseBuilds = false } + + // 添加packaging配置,排除冲突文件 + packaging { + resources { + excludes.add("META-INF/DEPENDENCIES") + excludes.add("META-INF/LICENSE") + excludes.add("META-INF/LICENSE.txt") + excludes.add("META-INF/license.txt") + excludes.add("META-INF/NOTICE") + excludes.add("META-INF/NOTICE.txt") + excludes.add("META-INF/notice.txt") + excludes.add("META-INF/*.kotlin_module") + } + } } // 显式设置Kotlin JVM目标版本 @@ -58,7 +51,6 @@ tasks.withType { } dependencies { - // 添加Microsoft语音SDK implementation("com.microsoft.cognitiveservices.speech:client-sdk:1.30.0") } \ No newline at end of file diff --git a/local_plugins/chat_storage_sqlite/README.md b/local_plugins/chat_storage_sqlite/README.md new file mode 100644 index 000000000..8db279af0 --- /dev/null +++ b/local_plugins/chat_storage_sqlite/README.md @@ -0,0 +1,80 @@ +# chat_storage_sqlite + +SQLite存储插件,用于存储聊天记录并支持分页查询。 + +## 功能特点 + +- 使用SQLite数据库存储聊天记录 +- 提供分页查询功能,可指定获取对应页码的记录 +- 支持按会话ID组织和查询聊天记录 +- 提供添加、查询、删除等基本操作 + +## 使用方法 + +### 保存聊天记录 + +```dart +import 'package:chat_storage_sqlite/chat_storage_sqlite.dart'; + +// 保存一条聊天记录 +final result = await ChatStorageSqlite.saveMessage( + sessionId: 'session_123', + message: '你好,这是一条测试消息', + sender: 'user', + metadata: '{"timestamp": 1630000000000}' // 可选的元数据,支持JSON字符串 +); + +if (result['success']) { + print('消息保存成功,ID: ${result['id']}'); +} else { + print('消息保存失败: ${result['error']}'); +} +``` + +### 获取聊天记录(支持分页) + +```dart +// 获取指定会话的聊天记录,支持分页 +final pagedResult = await ChatStorageSqlite.getMessages( + sessionId: 'session_123', + page: 1, // 当前页码,从1开始 + pageSize: 20, // 每页记录数 +); + +// 使用结果 +print('总记录数: ${pagedResult.totalCount}'); +print('总页数: ${pagedResult.totalPages}'); +print('当前页: ${pagedResult.page}'); + +// 遍历消息列表 +for (var message in pagedResult.messages) { + print('ID: ${message.id}'); + print('发送者: ${message.sender}'); + print('内容: ${message.message}'); + print('时间: ${message.timestamp}'); + print('---'); +} +``` + +### 删除聊天记录 + +```dart +// 方式1:删除指定ID的消息 +final deleteResult1 = await ChatStorageSqlite.deleteMessages( + messageIds: [1, 2, 3], +); + +// 方式2:删除整个会话的所有消息 +final deleteResult2 = await ChatStorageSqlite.deleteMessages( + sessionId: 'session_123', +); + +// 方式3:清空整个数据库 +final clearResult = await ChatStorageSqlite.clearDatabase(); +``` + +## 注意事项 + +- 插件会自动创建和管理SQLite数据库,无需手动初始化 +- 所有操作都是异步的,请使用async/await处理返回结果 +- 插件内部已处理异常情况,但建议在使用时添加适当的错误处理 \ No newline at end of file diff --git a/local_plugins/chat_storage_sqlite/android/build.gradle.kts b/local_plugins/chat_storage_sqlite/android/build.gradle.kts new file mode 100644 index 000000000..78e1b31ea --- /dev/null +++ b/local_plugins/chat_storage_sqlite/android/build.gradle.kts @@ -0,0 +1,60 @@ +plugins { + // Android Library 插件 + id("com.android.library") + // Kotlin Android 插件 + id("org.jetbrains.kotlin.android") + id("com.github.johnrengelman.shadow") version "8.1.1" +} + +android { + // 命名空间,对应你插件的包名(需与代码内包名保持一致) + namespace = "com.yunqiinnovation.chat_storage_sqlite" + + // 目标 SDK 版本 + compileSdk = 33 + + defaultConfig { + // 最低 SDK 版本 + minSdk = 21 + targetSdk = 33 + } + + // Java 语言级别兼容配置 + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + // Kotlin 语言级别 + kotlinOptions { + jvmTarget = "11" + } + + // 添加packaging配置,排除冲突文件 + packaging { + resources { + excludes.add("META-INF/DEPENDENCIES") + excludes.add("META-INF/LICENSE") + excludes.add("META-INF/LICENSE.txt") + excludes.add("META-INF/license.txt") + excludes.add("META-INF/NOTICE") + excludes.add("META-INF/NOTICE.txt") + excludes.add("META-INF/notice.txt") + excludes.add("META-INF/*.kotlin_module") + } + } +} + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") + implementation("com.fasterxml.jackson.core:jackson-databind:2.14.2") + implementation("com.squareup.okhttp3:okhttp:4.9.3") + implementation("com.anthropic:anthropic-java:0.8.0") + implementation("io.modelcontextprotocol:kotlin-sdk:0.4.0") +} + +dependencies { + // SQLite相关依赖 + implementation("androidx.sqlite:sqlite:2.3.1") +} \ No newline at end of file diff --git a/local_plugins/chat_storage_sqlite/android/settings.gradle.kts b/local_plugins/chat_storage_sqlite/android/settings.gradle.kts new file mode 100644 index 000000000..613be470e --- /dev/null +++ b/local_plugins/chat_storage_sqlite/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "chat_storage_sqlite" \ No newline at end of file diff --git a/local_plugins/chat_storage_sqlite/android/src/main/AndroidManifest.xml b/local_plugins/chat_storage_sqlite/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..b7fb5f78b --- /dev/null +++ b/local_plugins/chat_storage_sqlite/android/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/local_plugins/chat_storage_sqlite/android/src/main/kotlin/com/yunqiinnovation/chat_storage_sqlite/ChatStorageSqlitePlugin.kt b/local_plugins/chat_storage_sqlite/android/src/main/kotlin/com/yunqiinnovation/chat_storage_sqlite/ChatStorageSqlitePlugin.kt new file mode 100644 index 000000000..d302c4d7d --- /dev/null +++ b/local_plugins/chat_storage_sqlite/android/src/main/kotlin/com/yunqiinnovation/chat_storage_sqlite/ChatStorageSqlitePlugin.kt @@ -0,0 +1,236 @@ +package com.yunqiinnovation.chat_storage_sqlite + +import android.content.Context +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import android.util.Log +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 java.text.SimpleDateFormat +import java.util.* + +// 定义数据库帮助类 +private class ChatDatabaseHelper(context: Context) : + SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { + + companion object { + private const val DATABASE_NAME = "chat_messages.db" + private const val DATABASE_VERSION = 1 + + // 定义消息表 + private const val TABLE_MESSAGES = "messages" + private const val COLUMN_ID = "id" + private const val COLUMN_SESSION_ID = "session_id" + private const val COLUMN_MESSAGE = "message" + private const val COLUMN_SENDER = "sender" + private const val COLUMN_TIMESTAMP = "timestamp" + private const val COLUMN_METADATA = "metadata" + } + + override fun onCreate(db: SQLiteDatabase) { + // 创建消息表 + val createMessagesTable = """ + CREATE TABLE $TABLE_MESSAGES ( + $COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT, + $COLUMN_SESSION_ID TEXT NOT NULL, + $COLUMN_MESSAGE TEXT NOT NULL, + $COLUMN_SENDER TEXT NOT NULL, + $COLUMN_TIMESTAMP TEXT NOT NULL, + $COLUMN_METADATA TEXT + ) + """.trimIndent() + + db.execSQL(createMessagesTable) + + // 创建索引以加快查询速度 + db.execSQL("CREATE INDEX idx_session_id ON $TABLE_MESSAGES ($COLUMN_SESSION_ID)") + db.execSQL("CREATE INDEX idx_timestamp ON $TABLE_MESSAGES ($COLUMN_TIMESTAMP)") + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + // 处理数据库升级逻辑 + if (oldVersion < newVersion) { + // 简单的升级策略 - 删除旧表并创建新表 + // 在实际应用中,应该实现数据迁移而非删除 + db.execSQL("DROP TABLE IF EXISTS $TABLE_MESSAGES") + onCreate(db) + } + } +} + +/** ChatStorageSqlitePlugin */ +class ChatStorageSqlitePlugin: FlutterPlugin, MethodCallHandler { + private lateinit var channel : MethodChannel + private lateinit var context: Context + private lateinit var dbHelper: ChatDatabaseHelper + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + context = flutterPluginBinding.applicationContext + channel = MethodChannel(flutterPluginBinding.binaryMessenger, "chat_storage_sqlite") + channel.setMethodCallHandler(this) + dbHelper = ChatDatabaseHelper(context) + } + + override fun onMethodCall(call: MethodCall, result: Result) { + when (call.method) { + "saveMessage" -> { + try { + val sessionId = call.argument("sessionId") ?: "" + val message = call.argument("message") ?: "" + val sender = call.argument("sender") ?: "" + val metadata = call.argument("metadata") + + val db = dbHelper.writableDatabase + + // 获取当前时间作为时间戳 + val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()) + + // 执行插入操作 + val values = android.content.ContentValues().apply { + put("session_id", sessionId) + put("message", message) + put("sender", sender) + put("timestamp", timestamp) + put("metadata", metadata) + } + + val id = db.insert("messages", null, values) + if (id != -1L) { + result.success(mapOf("success" to true, "id" to id)) + } else { + result.error("DB_ERROR", "插入消息失败", null) + } + } catch (e: Exception) { + result.error("EXCEPTION", "保存消息时发生错误: ${e.message}", null) + } + } + "getMessages" -> { + try { + val sessionId = call.argument("sessionId") ?: "" + val page = call.argument("page") ?: 1 + val pageSize = call.argument("pageSize") ?: 20 + + val db = dbHelper.readableDatabase + val offset = (page - 1) * pageSize + + // 构建查询语句 + val query = """ + SELECT * FROM messages + WHERE session_id = ? + ORDER BY timestamp DESC + LIMIT ? OFFSET ? + """.trimIndent() + + val selectionArgs = arrayOf(sessionId, pageSize.toString(), offset.toString()) + val cursor = db.rawQuery(query, selectionArgs) + + val messages = cursorToJsonArray(cursor) + + // 获取总记录数 + val countQuery = "SELECT COUNT(*) FROM messages WHERE session_id = ?" + val countCursor = db.rawQuery(countQuery, arrayOf(sessionId)) + var totalCount = 0 + if (countCursor.moveToFirst()) { + totalCount = countCursor.getInt(0) + } + countCursor.close() + + // 计算总页数 + val totalPages = if (totalCount % pageSize == 0) { + totalCount / pageSize + } else { + (totalCount / pageSize) + 1 + } + + val response = JSONObject().apply { + put("messages", messages) + put("page", page) + put("pageSize", pageSize) + put("totalCount", totalCount) + put("totalPages", totalPages) + } + + result.success(response.toString()) + } catch (e: Exception) { + result.error("EXCEPTION", "获取消息时发生错误: ${e.message}", null) + } + } + "deleteMessages" -> { + try { + val sessionId = call.argument("sessionId") ?: "" + val messageIds = call.argument>("messageIds") + + val db = dbHelper.writableDatabase + + if (messageIds != null && messageIds.isNotEmpty()) { + // 删除指定ID的消息 + val idsStr = messageIds.joinToString(",") + val deleteQuery = "DELETE FROM messages WHERE id IN ($idsStr)" + db.execSQL(deleteQuery) + result.success(mapOf("success" to true, "count" to messageIds.size)) + } else if (sessionId.isNotEmpty()) { + // 删除会话中的所有消息 + val deleteQuery = "DELETE FROM messages WHERE session_id = ?" + db.execSQL(deleteQuery, arrayOf(sessionId)) + result.success(mapOf("success" to true)) + } else { + result.error("INVALID_ARGS", "必须提供sessionId或messageIds", null) + } + } catch (e: Exception) { + result.error("EXCEPTION", "删除消息时发生错误: ${e.message}", null) + } + } + "clearDatabase" -> { + try { + val db = dbHelper.writableDatabase + db.execSQL("DELETE FROM messages") + result.success(mapOf("success" to true)) + } catch (e: Exception) { + result.error("EXCEPTION", "清空数据库时发生错误: ${e.message}", null) + } + } + else -> { + result.notImplemented() + } + } + } + + private fun cursorToJsonArray(cursor: Cursor): JSONArray { + val jsonArray = JSONArray() + + val idIndex = cursor.getColumnIndex("id") + val sessionIdIndex = cursor.getColumnIndex("session_id") + val messageIndex = cursor.getColumnIndex("message") + val senderIndex = cursor.getColumnIndex("sender") + val timestampIndex = cursor.getColumnIndex("timestamp") + val metadataIndex = cursor.getColumnIndex("metadata") + + while (cursor.moveToNext()) { + val jsonObject = JSONObject().apply { + put("id", cursor.getInt(idIndex)) + put("sessionId", cursor.getString(sessionIdIndex)) + put("message", cursor.getString(messageIndex)) + put("sender", cursor.getString(senderIndex)) + put("timestamp", cursor.getString(timestampIndex)) + + if (metadataIndex != -1 && !cursor.isNull(metadataIndex)) { + put("metadata", cursor.getString(metadataIndex)) + } + } + jsonArray.put(jsonObject) + } + + cursor.close() + return jsonArray + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } +} \ No newline at end of file diff --git a/local_plugins/chat_storage_sqlite/lib/chat_storage_sqlite.dart b/local_plugins/chat_storage_sqlite/lib/chat_storage_sqlite.dart new file mode 100644 index 000000000..f41f473a7 --- /dev/null +++ b/local_plugins/chat_storage_sqlite/lib/chat_storage_sqlite.dart @@ -0,0 +1,155 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.dart'; + +/// 聊天记录数据模型 +class ChatMessage { + final int? id; + final String sessionId; + final String message; + final String sender; + final String timestamp; + final String? metadata; + + ChatMessage({ + this.id, + required this.sessionId, + required this.message, + required this.sender, + required this.timestamp, + this.metadata, + }); + + Map toMap() { + return { + 'id': id, + 'sessionId': sessionId, + 'message': message, + 'sender': sender, + 'timestamp': timestamp, + 'metadata': metadata, + }; + } + + factory ChatMessage.fromMap(Map map) { + return ChatMessage( + id: map['id'], + sessionId: map['sessionId'], + message: map['message'], + sender: map['sender'], + timestamp: map['timestamp'], + metadata: map['metadata'], + ); + } +} + +/// 分页结果数据模型 +class PagedResult { + final List messages; + final int page; + final int pageSize; + final int totalCount; + final int totalPages; + + PagedResult({ + required this.messages, + required this.page, + required this.pageSize, + required this.totalCount, + required this.totalPages, + }); +} + +/// SQLite聊天记录存储插件 +class ChatStorageSqlite { + static const MethodChannel _channel = MethodChannel('chat_storage_sqlite'); + + /// 保存一条聊天记录 + static Future> saveMessage({ + required String sessionId, + required String message, + required String sender, + String? metadata, + }) async { + try { + final result = await _channel.invokeMethod('saveMessage', { + 'sessionId': sessionId, + 'message': message, + 'sender': sender, + 'metadata': metadata, + }); + return Map.from(result); + } catch (e) { + return {'success': false, 'error': e.toString()}; + } + } + + /// 获取指定会话的聊天记录(支持分页) + static Future getMessages({ + required String sessionId, + int page = 1, + int pageSize = 20, + }) async { + try { + final String result = await _channel.invokeMethod('getMessages', { + 'sessionId': sessionId, + 'page': page, + 'pageSize': pageSize, + }); + + final Map data = jsonDecode(result); + final List messagesList = data['messages']; + + List messages = messagesList.map((item) { + return ChatMessage.fromMap(Map.from(item)); + }).toList(); + + return PagedResult( + messages: messages, + page: data['page'], + pageSize: data['pageSize'], + totalCount: data['totalCount'], + totalPages: data['totalPages'], + ); + } catch (e) { + return PagedResult( + messages: [], + page: page, + pageSize: pageSize, + totalCount: 0, + totalPages: 0, + ); + } + } + + /// 删除聊天记录,可按会话ID或消息ID列表删除 + static Future> deleteMessages({ + String? sessionId, + List? messageIds, + }) async { + try { + if (sessionId == null && (messageIds == null || messageIds.isEmpty)) { + return {'success': false, 'error': '必须提供sessionId或messageIds'}; + } + + final result = await _channel.invokeMethod('deleteMessages', { + 'sessionId': sessionId, + 'messageIds': messageIds, + }); + return Map.from(result); + } catch (e) { + return {'success': false, 'error': e.toString()}; + } + } + + /// 清空整个数据库 + static Future> clearDatabase() async { + try { + final result = await _channel.invokeMethod('clearDatabase'); + return Map.from(result); + } catch (e) { + return {'success': false, 'error': e.toString()}; + } + } +} \ No newline at end of file diff --git a/local_plugins/chat_storage_sqlite/pubspec.yaml b/local_plugins/chat_storage_sqlite/pubspec.yaml new file mode 100644 index 000000000..a4d6e1bf1 --- /dev/null +++ b/local_plugins/chat_storage_sqlite/pubspec.yaml @@ -0,0 +1,30 @@ +name: chat_storage_sqlite +description: SQLite存储插件,用于存储聊天记录并支持分页查询 +version: 0.0.1 +homepage: + +environment: + sdk: ">=2.17.0 <3.0.0" + flutter: ">=2.5.0" + +dependencies: + flutter: + sdk: flutter + sqflite: ^2.3.0 + path: ^1.8.3 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +# The following section is specific to Flutter packages. +flutter: + # This section identifies this Flutter project as a plugin project. + plugin: + platforms: + android: + package: com.yunqiinnovation.chat_storage_sqlite + pluginClass: ChatStorageSqlitePlugin + ios: + pluginClass: ChatStorageSqlitePlugin \ No newline at end of file diff --git a/local_plugins/jieli/README.md b/local_plugins/jieli/README.md new file mode 100644 index 000000000..d926109c8 --- /dev/null +++ b/local_plugins/jieli/README.md @@ -0,0 +1,144 @@ +# Jieli 插件 + +一个用于连接杰理蓝牙耳机的Flutter插件,支持Android 12+的BLE通信。 + +## 功能 + +- 扫描支持特定BLE服务的耳机设备 +- 连接设备并发现服务 +- 接收耳机的唤醒通知 +- 向耳机发送控制命令启动录音 +- 接收音频数据流 + +## 技术规范 + +此插件实现了以下BLE特性: + +- **服务UUID**: 0000A100-0000-1000-8000-00805F9B34FB +- **音频数据特征UUID**: 0000A101-0000-1000-8000-00805F9B34FB +- **控制特征UUID**: 0000A102-0000-1000-8000-00805F9B34FB + +## 使用方法 + +### 初始化插件 + +```dart +final jieli = Jieli(); +``` + +### 检查蓝牙状态 + +```dart +bool isEnabled = await jieli.isBluetoothEnabled(); +if (!isEnabled) { + await jieli.requestBluetoothEnable(); +} +``` + +### 扫描设备 + +```dart +// 监听扫描结果 +jieli.scanResults.listen((devices) { + // 处理扫描到的设备列表 + for (var device in devices) { + print('发现设备: ${device.name} (${device.id}), RSSI: ${device.rssi}'); + } +}); + +// 开始扫描,10秒后自动停止 +await jieli.startScan(scanTimeout: 10000); + +// 手动停止扫描 +await jieli.stopScan(); +``` + +### 连接设备 + +```dart +// 监听连接状态 +jieli.connectionState.listen((state) { + switch (state) { + case ConnectionState.connected: + print('设备已连接'); + break; + case ConnectionState.disconnected: + print('设备已断开'); + break; + case ConnectionState.connecting: + print('正在连接...'); + break; + case ConnectionState.disconnecting: + print('正在断开...'); + break; + case ConnectionState.error: + print('连接错误'); + break; + } +}); + +// 连接到设备 +await jieli.connect(deviceId); + +// 断开连接 +await jieli.disconnect(); +``` + +### 接收唤醒信号 + +```dart +// 监听唤醒信号 +jieli.wakeupStream.listen((wakeup) { + print('收到唤醒信号'); + // 通常在收到唤醒后自动发送开始录音命令 +}); +``` + +### 发送录音命令 + +```dart +// 发送开始录音命令 +await jieli.sendStartRecordingCommand(); + +// 发送停止录音命令 +await jieli.sendStopRecordingCommand(); +``` + +### 接收音频数据 + +```dart +// 监听音频数据流 +jieli.audioDataStream.listen((data) { + print('收到音频数据: ${data.length} 字节'); + // 处理音频数据 +}); +``` + +### 资源清理 + +```dart +// 在不再需要插件时释放资源 +await jieli.dispose(); +``` + +## 权限 + +使用此插件需要以下Android权限: + +- Android 12+ (API 31+): `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT` +- Android 11及以下: `BLUETOOTH`, `BLUETOOTH_ADMIN` + +插件会在需要时自动请求权限,但建议在应用清单文件中声明这些权限: + +```xml + + + + + + + +``` \ No newline at end of file diff --git a/local_plugins/jieli/android/build.gradle.kts b/local_plugins/jieli/android/build.gradle.kts new file mode 100644 index 000000000..70b01c20e --- /dev/null +++ b/local_plugins/jieli/android/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + // Android Library 插件 + id("com.android.library") + // Kotlin Android 插件 + id("org.jetbrains.kotlin.android") + id("com.github.johnrengelman.shadow") version "8.1.1" +} + +android { + // 命名空间,对应你插件的包名(需与代码内包名保持一致) + namespace = "com.yunqiinnovation.jieli" + + // 目标 SDK 版本 + compileSdk = 33 + + defaultConfig { + // 最低 SDK 版本 + minSdk = 21 + targetSdk = 33 + } + + // Java 语言级别兼容配置 + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + // Kotlin 语言级别 + kotlinOptions { + jvmTarget = "11" + } + + // 添加packaging配置,排除冲突文件 + packaging { + resources { + excludes.add("META-INF/DEPENDENCIES") + excludes.add("META-INF/LICENSE") + excludes.add("META-INF/LICENSE.txt") + excludes.add("META-INF/license.txt") + excludes.add("META-INF/NOTICE") + excludes.add("META-INF/NOTICE.txt") + excludes.add("META-INF/notice.txt") + excludes.add("META-INF/*.kotlin_module") + } + } +} + +dependencies { + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.10") + implementation("androidx.core:core-ktx:1.7.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0") + implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.4.0") + implementation("androidx.lifecycle:lifecycle-service:2.4.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2") +} \ No newline at end of file diff --git a/local_plugins/jieli/android/settings.gradle.kts b/local_plugins/jieli/android/settings.gradle.kts new file mode 100644 index 000000000..c0323d3bc --- /dev/null +++ b/local_plugins/jieli/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "jieli" \ No newline at end of file diff --git a/local_plugins/jieli/android/src/main/AndroidManifest.xml b/local_plugins/jieli/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..3cf7a945e --- /dev/null +++ b/local_plugins/jieli/android/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt b/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt new file mode 100644 index 000000000..0ba59a6e6 --- /dev/null +++ b/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt @@ -0,0 +1,460 @@ +package com.yunqiinnovation.jieli + +import android.bluetooth.* +import android.bluetooth.le.* +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.os.ParcelUuid +import android.util.Log +import androidx.lifecycle.LifecycleService +import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.* +import java.util.* + +/** + * BLE回调接口,统一处理蓝牙事件 + */ +interface BleCallback { + /** + * 扫描结果回调 + * @param devices 扫描到的设备列表 + */ + fun onScanResult(devices: List>) + + /** + * 连接状态变化回调 + * @param state 连接状态 + */ + fun onConnectionStateChanged(state: Int) + + /** + * 音频数据回调 + * @param data 音频数据字节数组 + */ + fun onAudioDataReceived(data: ByteArray) + + /** + * 唤醒信号回调 + */ + fun onWakeupSignalReceived() +} + +/** + * BLE服务类,提供核心蓝牙功能 + */ +class BleService(private val context: Context) { + companion object { + private const val TAG = "JieliBleCoreService" + + // 服务和特征UUID + private const val AUDIO_SERVICE_UUID = "0000A100-0000-1000-8000-00805F9B34FB" + private const val AUDIO_DATA_CHAR_UUID = "0000A101-0000-1000-8000-00805F9B34FB" + private const val CONTROL_CHAR_UUID = "0000A102-0000-1000-8000-00805F9B34FB" + private const val CLIENT_CHAR_CONFIG_UUID = "00002902-0000-1000-8000-00805f9b34fb" + + // 连接状态常量 + const val STATE_DISCONNECTED = 0 + const val STATE_CONNECTING = 1 + const val STATE_CONNECTED = 2 + const val STATE_DISCONNECTING = 3 + const val STATE_ERROR = 4 + + // 扫描结果更新间隔(毫秒) + private const val SCAN_RESULT_UPDATE_INTERVAL = 500L + } + + // 蓝牙管理器和适配器 + private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter + private val bluetoothLeScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner + + // GATT连接和特征 + private var bluetoothGatt: BluetoothGatt? = null + private var audioDataChar: BluetoothGattCharacteristic? = null + private var controlChar: BluetoothGattCharacteristic? = null + + // 扫描相关 + private val scanHandler = Handler(Looper.getMainLooper()) + private val scanResults = ArrayList() + private var isScanning = false + private var isPendingScanResultUpdate = false + private val scanResultUpdateRunnable = Runnable { + notifyScanResults() + isPendingScanResultUpdate = false + } + + // 连接状态LiveData + val connectionState = MutableLiveData(STATE_DISCONNECTED) + + // 统一回调接口 + var callback: BleCallback? = null + + /** + * 开始扫描BLE设备 + */ + fun startScan(scanTimeout: Long = 5000): Boolean { + if (bluetoothLeScanner == null || isScanning) { + return false + } + + Log.i(TAG, "开始扫描BLE设备...") + scanResults.clear() + + val serviceUuid = UUID.fromString(AUDIO_SERVICE_UUID) + val scanFilter = ScanFilter.Builder() + // .setServiceUuid(ParcelUuid(serviceUuid)) + .build() + val scanFilters = listOf(scanFilter) + + val scanSettings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + + bluetoothLeScanner.startScan(scanFilters, scanSettings, scanCallback) + isScanning = true + + // 设置超时停止扫描 + scanHandler.postDelayed({ + Log.d(TAG, "定时器触发,准备停止扫描 [主线程:${Looper.myLooper() == Looper.getMainLooper()}]") + stopScan() + }, scanTimeout) + + return true + } + + /** + * 停止扫描 + */ + fun stopScan(): Boolean { + Log.i(TAG, "停止扫描BLE设备") + + if (bluetoothLeScanner == null || !isScanning) { + return false + } + + bluetoothLeScanner.stopScan(scanCallback) + scanHandler.removeCallbacksAndMessages(null) + isScanning = false + + // 最后一次发送扫描结果 + notifyScanResults() + + return true + } + + /** + * 扫描回调 + */ + private val scanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + val device = result.device + val deviceName = device.name ?: "未知设备" + val deviceAddress = device.address + val rssi = result.rssi + + // Log.d(TAG, "发现设备: $deviceName [$deviceAddress], RSSI: $rssi") + + // 避免重复添加 + val existingIndex = scanResults.indexOfFirst { it.device.address == deviceAddress } + if (existingIndex >= 0) { + scanResults[existingIndex] = result + } else { + scanResults.add(result) + } + + // 如果没有待处理的更新,安排一个 + if (!isPendingScanResultUpdate) { + isPendingScanResultUpdate = true + scanHandler.postDelayed(scanResultUpdateRunnable, SCAN_RESULT_UPDATE_INTERVAL) + } + } + + override fun onScanFailed(errorCode: Int) { + Log.e(TAG, "扫描失败,错误码: $errorCode") + isScanning = false + } + } + + /** + * 将扫描结果通知回调 + */ + private fun notifyScanResults() { + val devicesJson = scanResults.map { result -> + mapOf( + "id" to result.device.address, + "name" to (result.device.name ?: "未知设备"), + "rssi" to result.rssi + ) + } + + callback?.onScanResult(devicesJson) + } + + /** + * 连接到指定设备 + */ + fun connect(deviceAddress: String) { + if (bluetoothAdapter == null) { + Log.e(TAG, "蓝牙适配器不可用") + updateConnectionState(STATE_ERROR) + return + } + + // 先断开现有连接 + bluetoothGatt?.close() + bluetoothGatt = null + + try { + val device = bluetoothAdapter.getRemoteDevice(deviceAddress) + updateConnectionState(STATE_CONNECTING) + + // 连接GATT服务器 + Log.i(TAG, "正在连接设备: ${device.name ?: "未知设备"} [$deviceAddress]") + bluetoothGatt = device.connectGatt(context, false, gattCallback) + } catch (e: Exception) { + Log.e(TAG, "连接设备时发生错误: ${e.message}") + updateConnectionState(STATE_ERROR) + } + } + + /** + * 断开连接 + */ + fun disconnect() { + bluetoothGatt?.disconnect() + updateConnectionState(STATE_DISCONNECTING) + } + + /** + * 更新连接状态并通知回调 + */ + private fun updateConnectionState(state: Int) { + connectionState.postValue(state) + callback?.onConnectionStateChanged(state) + } + + /** + * GATT回调 + */ + private val gattCallback = object : BluetoothGattCallback() { + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + if (newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) { + Log.i(TAG, "设备已连接,开始发现服务...") + updateConnectionState(STATE_CONNECTED) + + // 开始搜索服务 + gatt.discoverServices() + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + Log.i(TAG, "设备已断开连接") + updateConnectionState(STATE_DISCONNECTED) + + // 清理资源 + gatt.close() + bluetoothGatt = null + audioDataChar = null + controlChar = null + } else if (status != BluetoothGatt.GATT_SUCCESS) { + Log.e(TAG, "连接失败,status=$status") + updateConnectionState(STATE_ERROR) + + gatt.close() + bluetoothGatt = null + } + } + + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + if (status == BluetoothGatt.GATT_SUCCESS) { + Log.i(TAG, "服务发现成功") + + // 查找目标服务 + val audioService = gatt.getService(UUID.fromString(AUDIO_SERVICE_UUID)) + if (audioService != null) { + Log.i(TAG, "找到音频服务") + + // 获取音频数据和控制特征 + audioDataChar = audioService.getCharacteristic(UUID.fromString(AUDIO_DATA_CHAR_UUID)) + controlChar = audioService.getCharacteristic(UUID.fromString(CONTROL_CHAR_UUID)) + + if (audioDataChar != null && controlChar != null) { + Log.i(TAG, "找到所需特征") + + // 注册特征通知 + setupNotifications(gatt) + } else { + Log.e(TAG, "未找到所需特征") + updateConnectionState(STATE_ERROR) + } + } else { + Log.e(TAG, "未找到音频服务") + updateConnectionState(STATE_ERROR) + } + } else { + Log.e(TAG, "服务发现失败,status=$status") + updateConnectionState(STATE_ERROR) + } + } + + override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { + val characteristicUuid = characteristic.uuid.toString() + + when (characteristicUuid) { + AUDIO_DATA_CHAR_UUID -> { + // 收到音频数据 + val audioData = characteristic.value + Log.d(TAG, "收到音频数据帧,大小: ${audioData.size} 字节") + + callback?.onAudioDataReceived(audioData) + } + CONTROL_CHAR_UUID -> { + // 收到唤醒信号 + val value = characteristic.value + Log.i(TAG, "收到控制通知: ${value.contentToString()}") + + // 假设0x01表示唤醒信号 + if (value.isNotEmpty() && value[0].toInt() == 0x01) { + Log.i(TAG, "收到唤醒信号") + + callback?.onWakeupSignalReceived() + + // 收到唤醒后自动发送开始录音命令 + sendStartRecordingCommand() + } + } + } + } + + override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { + if (characteristic.uuid.toString() == CONTROL_CHAR_UUID) { + if (status == BluetoothGatt.GATT_SUCCESS) { + Log.i(TAG, "命令发送成功: ${characteristic.value.contentToString()}") + } else { + Log.e(TAG, "命令发送失败,status=$status") + } + } + } + + override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { + if (status == BluetoothGatt.GATT_SUCCESS) { + Log.i(TAG, "描述符写入成功: ${descriptor.uuid}") + + // 如果是音频数据特征的描述符,再订阅控制特征 + if (descriptor.characteristic.uuid.toString() == AUDIO_DATA_CHAR_UUID) { + setupControlNotification(gatt) + } + } else { + Log.e(TAG, "描述符写入失败,status=$status") + } + } + } + + /** + * 设置特征通知 + */ + private fun setupNotifications(gatt: BluetoothGatt) { + // 先订阅音频数据通知 + if (audioDataChar?.properties?.and(BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) { + gatt.setCharacteristicNotification(audioDataChar, true) + + val descriptor = audioDataChar?.getDescriptor(UUID.fromString(CLIENT_CHAR_CONFIG_UUID)) + descriptor?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + + if (descriptor != null) { + gatt.writeDescriptor(descriptor) + Log.i(TAG, "设置音频数据特征通知") + } + } else { + Log.e(TAG, "音频数据特征不支持通知") + setupControlNotification(gatt) + } + } + + /** + * 设置控制特征通知 + */ + private fun setupControlNotification(gatt: BluetoothGatt) { + if (controlChar?.properties?.and(BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) { + gatt.setCharacteristicNotification(controlChar, true) + + val descriptor = controlChar?.getDescriptor(UUID.fromString(CLIENT_CHAR_CONFIG_UUID)) + descriptor?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + + if (descriptor != null) { + gatt.writeDescriptor(descriptor) + Log.i(TAG, "设置控制特征通知") + } + } else { + Log.e(TAG, "控制特征不支持通知") + } + } + + /** + * 发送开始录音命令(向控制特征写入0x01) + */ + fun sendStartRecordingCommand(): Boolean { + if (bluetoothGatt == null || controlChar == null || connectionState.value != STATE_CONNECTED) { + Log.e(TAG, "无法发送开始录音命令,设备未连接") + return false + } + + try { + val commandData = byteArrayOf(0x01) + controlChar?.value = commandData + controlChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + + val success = bluetoothGatt?.writeCharacteristic(controlChar) ?: false + + if (success) { + Log.i(TAG, "开始录音命令已发送") + } else { + Log.e(TAG, "开始录音命令发送失败") + } + + return success + } catch (e: Exception) { + Log.e(TAG, "发送开始录音命令时发生异常: ${e.message}") + return false + } + } + + /** + * 发送停止录音命令(向控制特征写入0x02) + */ + fun sendStopRecordingCommand(): Boolean { + if (bluetoothGatt == null || controlChar == null || connectionState.value != STATE_CONNECTED) { + Log.e(TAG, "无法发送停止录音命令,设备未连接") + return false + } + + try { + val commandData = byteArrayOf(0x02) + controlChar?.value = commandData + controlChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + + val success = bluetoothGatt?.writeCharacteristic(controlChar) ?: false + + if (success) { + Log.i(TAG, "停止录音命令已发送") + } else { + Log.e(TAG, "停止录音命令发送失败") + } + + return success + } catch (e: Exception) { + Log.e(TAG, "发送停止录音命令时发生异常: ${e.message}") + return false + } + } + + /** + * 清理资源 + */ + fun cleanup() { + stopScan() + scanHandler.removeCallbacks(scanResultUpdateRunnable) + bluetoothGatt?.close() + bluetoothGatt = null + audioDataChar = null + controlChar = null + } +} \ No newline at end of file diff --git a/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/JieliPlugin.kt b/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/JieliPlugin.kt new file mode 100644 index 000000000..17cc5d21f --- /dev/null +++ b/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/JieliPlugin.kt @@ -0,0 +1,269 @@ +package com.yunqiinnovation.jieli + +import android.app.Activity +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.annotation.NonNull +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.EventChannel +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 kotlinx.coroutines.* + +/** JieliPlugin */ +class JieliPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, EventChannel.StreamHandler { + /// 方法通道 + private lateinit var methodChannel: MethodChannel + /// 统一事件通道 + private lateinit var eventChannel: EventChannel + + private lateinit var context: Context + private var activity: Activity? = null + + /// BLE服务实例 + private lateinit var bleService: BleService + + // 协程作用域 + private val pluginScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // 事件接收器 + private var eventSink: EventChannel.EventSink? = null + + // 事件类型常量 + companion object { + private const val EVENT_TYPE_AUDIO_DATA = "audio_data" + private const val EVENT_TYPE_CONNECTION_STATE = "connection_state" + private const val EVENT_TYPE_SCAN_RESULT = "scan_result" + private const val EVENT_TYPE_WAKEUP_SIGNAL = "wakeup_signal" + } + + // 蓝牙相关请求码 + private val REQUEST_ENABLE_BT = 1 + private val REQUEST_PERMISSION_BLE = 2 + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + val binaryMessenger = flutterPluginBinding.binaryMessenger + + // 设置方法通道 + methodChannel = MethodChannel(binaryMessenger, "com.yunqiinnovation.jieli") + methodChannel.setMethodCallHandler(this) + + // 设置统一事件通道 + eventChannel = EventChannel(binaryMessenger, "com.yunqiinnovation.jieli/events") + eventChannel.setStreamHandler(this) + + context = flutterPluginBinding.applicationContext + + // 初始化BLE服务 + bleService = BleService(context) + + // 设置回调 + setupBleCallbacks() + } + + /** + * 设置BLE服务回调 + */ + private fun setupBleCallbacks() { + // 设置统一回调接口 + bleService.callback = object : BleCallback { + override fun onScanResult(devices: List>) { + pluginScope.launch(Dispatchers.Main) { + sendEvent(EVENT_TYPE_SCAN_RESULT, devices) + } + } + + override fun onConnectionStateChanged(state: Int) { + pluginScope.launch(Dispatchers.Main) { + sendEvent(EVENT_TYPE_CONNECTION_STATE, state) + } + } + + override fun onAudioDataReceived(data: ByteArray) { + pluginScope.launch(Dispatchers.Main) { + sendEvent(EVENT_TYPE_AUDIO_DATA, data) + } + } + + override fun onWakeupSignalReceived() { + pluginScope.launch(Dispatchers.Main) { + sendEvent(EVENT_TYPE_WAKEUP_SIGNAL, true) + } + } + } + } + + /** + * 发送事件到Flutter端 + */ + private fun sendEvent(eventType: String, data: Any?) { + eventSink?.success(mapOf( + "type" to eventType, + "data" to data + )) + } + + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "startScan" -> { + val scanTimeout = call.argument("scanTimeout") ?: 10000 + + if (!hasRequiredPermissions()) { + requestBlePermissions() + result.error("PERMISSION_DENIED", "需要蓝牙权限才能扫描设备", null) + return + } + + val success = bleService.startScan(scanTimeout.toLong()) + result.success(success) + } + "stopScan" -> { + val success = bleService.stopScan() + result.success(success) + } + "connect" -> { + val deviceId = call.argument("deviceId") + if (deviceId == null) { + result.error("INVALID_ARGUMENT", "设备ID不能为空", null) + return + } + + if (!hasRequiredPermissions()) { + requestBlePermissions() + result.error("PERMISSION_DENIED", "需要蓝牙权限才能连接设备", null) + return + } + + bleService.connect(deviceId) + result.success(true) + } + "disconnect" -> { + bleService.disconnect() + result.success(true) + } + "sendStartRecordingCommand" -> { + val success = bleService.sendStartRecordingCommand() + result.success(success) + } + "sendStopRecordingCommand" -> { + val success = bleService.sendStopRecordingCommand() + result.success(success) + } + "isBluetoothEnabled" -> { + val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + val bluetoothAdapter = bluetoothManager.adapter + result.success(bluetoothAdapter?.isEnabled == true) + } + "requestBluetoothEnable" -> { + if (activity == null) { + result.error("ACTIVITY_NOT_AVAILABLE", "Activity不可用,无法请求打开蓝牙", null) + return + } + + val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + val bluetoothAdapter = bluetoothManager.adapter + + if (bluetoothAdapter == null) { + result.error("BLUETOOTH_UNAVAILABLE", "设备不支持蓝牙", null) + return + } + + if (bluetoothAdapter.isEnabled) { + result.success(true) + return + } + + // 请求打开蓝牙 + val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) + activity?.startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT) + result.success(true) + } + else -> { + result.notImplemented() + } + } + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + methodChannel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + + bleService.cleanup() + pluginScope.cancel() + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + } + + override fun onDetachedFromActivityForConfigChanges() { + activity = null + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activity = binding.activity + } + + override fun onDetachedFromActivity() { + activity = null + } + + /// EventChannel.StreamHandler接口实现 + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + eventSink = events + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + + /// 检查是否拥有所需权限 + private fun hasRequiredPermissions(): Boolean { + return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + hasPermission(android.Manifest.permission.BLUETOOTH_SCAN) && + hasPermission(android.Manifest.permission.BLUETOOTH_CONNECT) + } else { + hasPermission(android.Manifest.permission.BLUETOOTH) && + hasPermission(android.Manifest.permission.BLUETOOTH_ADMIN) + } + } + + /// 检查单个权限 + private fun hasPermission(permission: String): Boolean { + return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + } + + /// 请求BLE权限 + private fun requestBlePermissions() { + activity?.let { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + ActivityCompat.requestPermissions( + it, + arrayOf( + android.Manifest.permission.BLUETOOTH_SCAN, + android.Manifest.permission.BLUETOOTH_CONNECT + ), + REQUEST_PERMISSION_BLE + ) + } else { + ActivityCompat.requestPermissions( + it, + arrayOf( + android.Manifest.permission.BLUETOOTH, + android.Manifest.permission.BLUETOOTH_ADMIN + ), + REQUEST_PERMISSION_BLE + ) + } + } + } +} \ No newline at end of file diff --git a/local_plugins/jieli/lib/jieli.dart b/local_plugins/jieli/lib/jieli.dart new file mode 100644 index 000000000..78ec090bb --- /dev/null +++ b/local_plugins/jieli/lib/jieli.dart @@ -0,0 +1,188 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; + +/// 设备类,代表一个BLE设备 +class BleDevice { + final String id; + final String name; + final int rssi; + + BleDevice({required this.id, required this.name, required this.rssi}); + + factory BleDevice.fromJson(Map json) { + return BleDevice( + id: json['id'] as String, + name: json['name'] as String? ?? 'Unknown Device', + rssi: json['rssi'] as int? ?? 0, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'rssi': rssi, + }; + } + + @override + String toString() => 'BleDevice(id: $id, name: $name, rssi: $rssi)'; +} + +/// 连接状态 +enum ConnectionState { + disconnected, + connecting, + connected, + disconnecting, + error +} + +/// 事件类型 +class EventType { + static const String audioData = 'audio_data'; + static const String connectionState = 'connection_state'; + static const String scanResult = 'scan_result'; + static const String wakeupSignal = 'wakeup_signal'; +} + +/// 杰理BLE插件主类 +class Jieli { + static const MethodChannel _methodChannel = MethodChannel('com.yunqiinnovation.jieli'); + static const EventChannel _eventChannel = EventChannel('com.yunqiinnovation.jieli/events'); + + /// 扫描结果流 + static final StreamController> _scanController = + StreamController>.broadcast(); + Stream> get scanResults => _scanController.stream; + + /// 连接状态变化流 + static final StreamController _connectionController = + StreamController.broadcast(); + Stream get connectionState => _connectionController.stream; + + /// 音频数据流 + static final StreamController _audioController = + StreamController.broadcast(); + Stream get audioDataStream => _audioController.stream; + + /// 唤醒信号流 + static final StreamController _wakeupController = + StreamController.broadcast(); + Stream get wakeupStream => _wakeupController.stream; + + /// 事件流订阅 + StreamSubscription? _eventSubscription; + + /// 构造函数,初始化平台通道和事件监听 + Jieli() { + _setupEventListener(); + } + + /// 设置事件监听器 + void _setupEventListener() { + _eventSubscription = _eventChannel.receiveBroadcastStream().listen(_handleEvent); + } + + /// 处理来自原生平台的事件 + void _handleEvent(dynamic event) { + if (event is! Map) return; + + final eventData = Map.from(event); + final String eventType = eventData['type'] as String; + final dynamic data = eventData['data']; + + switch (eventType) { + case EventType.scanResult: + if (data is List) { + final deviceList = data + .map((item) => BleDevice.fromJson(Map.from(item))) + .toList(); + _scanController.add(deviceList); + } + break; + case EventType.connectionState: + if (data is int && data >= 0 && data < ConnectionState.values.length) { + _connectionController.add(ConnectionState.values[data]); + } + break; + case EventType.audioData: + if (data is Uint8List) { + _audioController.add(data); + } + break; + case EventType.wakeupSignal: + if (data is bool) { + _wakeupController.add(data); + } + break; + } + } + + /// 开始扫描BLE设备 + Future startScan({int scanTimeout = 10000}) async { + final result = await _methodChannel.invokeMethod( + 'startScan', + {'scanTimeout': scanTimeout}, + ); + return result ?? false; + } + + /// 停止扫描BLE设备 + Future stopScan() async { + final result = await _methodChannel.invokeMethod('stopScan'); + return result ?? false; + } + + /// 连接到指定设备 + Future connect(String deviceId) async { + final result = await _methodChannel.invokeMethod( + 'connect', + {'deviceId': deviceId}, + ); + return result ?? false; + } + + /// 断开连接 + Future disconnect() async { + final result = await _methodChannel.invokeMethod('disconnect'); + return result ?? false; + } + + /// 发送开始录音命令 + Future sendStartRecordingCommand() async { + final result = await _methodChannel.invokeMethod('sendStartRecordingCommand'); + return result ?? false; + } + + /// 发送停止录音命令 + Future sendStopRecordingCommand() async { + final result = await _methodChannel.invokeMethod('sendStopRecordingCommand'); + return result ?? false; + } + + /// 关闭插件,释放资源 + Future dispose() async { + await stopScan(); + await disconnect(); + _eventSubscription?.cancel(); + _scanController.close(); + _connectionController.close(); + _audioController.close(); + _wakeupController.close(); + } + + /// 检查蓝牙是否已启用 + Future isBluetoothEnabled() async { + final result = await _methodChannel.invokeMethod('isBluetoothEnabled'); + return result ?? false; + } + + /// 请求打开蓝牙 + Future requestBluetoothEnable() async { + final result = await _methodChannel.invokeMethod('requestBluetoothEnable'); + return result ?? false; + } +} \ No newline at end of file diff --git a/local_plugins/jieli/pubspec.yaml b/local_plugins/jieli/pubspec.yaml new file mode 100644 index 000000000..ab230e685 --- /dev/null +++ b/local_plugins/jieli/pubspec.yaml @@ -0,0 +1,23 @@ +name: jieli +description: A Flutter plugin for connecting with Jieli BLE earphones +version: 0.0.1 +homepage: + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=2.0.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + plugin: + platforms: + android: + package: com.yunqiinnovation.jieli + pluginClass: JieliPlugin \ No newline at end of file diff --git a/local_plugins/open_ai_service/android/AndroidManifest.xml b/local_plugins/open_ai_service/android/AndroidManifest.xml new file mode 100644 index 000000000..1d834e578 --- /dev/null +++ b/local_plugins/open_ai_service/android/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/local_plugins/open_ai_service/android/build.gradle.kts b/local_plugins/open_ai_service/android/build.gradle.kts index 4f7b2f643..57889671a 100644 --- a/local_plugins/open_ai_service/android/build.gradle.kts +++ b/local_plugins/open_ai_service/android/build.gradle.kts @@ -3,6 +3,7 @@ plugins { id("com.android.library") // Kotlin Android 插件 id("org.jetbrains.kotlin.android") + id("com.github.johnrengelman.shadow") version "8.1.1" } android { @@ -28,10 +29,27 @@ android { kotlinOptions { jvmTarget = "11" } + + // 添加packaging配置,排除冲突文件 + packaging { + resources { + excludes.add("META-INF/DEPENDENCIES") + excludes.add("META-INF/LICENSE") + excludes.add("META-INF/LICENSE.txt") + excludes.add("META-INF/license.txt") + excludes.add("META-INF/NOTICE") + excludes.add("META-INF/NOTICE.txt") + excludes.add("META-INF/notice.txt") + excludes.add("META-INF/*.kotlin_module") + } + } } dependencies { - - implementation("com.squareup.okhttp3:okhttp:4.10.0") - + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") + implementation("com.fasterxml.jackson.core:jackson-databind:2.14.2") + implementation("com.squareup.okhttp3:okhttp:4.9.3") + implementation("com.anthropic:anthropic-java:0.8.0") + implementation("io.modelcontextprotocol:kotlin-sdk:0.4.0") } \ 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 new file mode 100644 index 000000000..fef91ab05 --- /dev/null +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt @@ -0,0 +1,496 @@ +package com.yunqiinnovation.open_ai_service + +import android.content.Context +import android.util.Log +import com.anthropic.core.JsonValue +import com.anthropic.models.messages.Tool +import com.anthropic.models.messages.ToolUnion +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import io.ktor.client.HttpClient +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.TextContent +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.client.WebSocketClientTransport +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonObject +import org.json.JSONObject +import kotlin.jvm.optionals.getOrNull + +/** + * 工具类型枚举 + */ +enum class ToolType { + LOCAL_FUNCTION, // 本地函数 + MCP_TOOL // MCP工具 +} + +/** + * 函数处理器接口 + */ +interface FunctionHandler { + /** + * 处理函数调用 + * @param arguments 函数参数,Map格式 + * @return 函数执行结果,字符串 + */ + suspend fun handle(arguments: Map): String +} + +/** + * MCP客户端,用于连接Model Context Protocol服务器和管理本地函数 + */ +class MCPClient(private val context: Context? = null) : AutoCloseable { + private val TAG = "MCPClient" + + // 初始化MCP客户端 + private val mcp: Client = Client( + clientInfo = Implementation( + name = "deep-voice-mcp-client", + version = "1.0.0" + ) + ) + + // 服务器提供的工具列表 + private var tools: List = emptyList() + + // 工具Map列表,用于OpenAI请求 + private val toolMaps = mutableListOf>() + + // 本地函数Map,函数名 -> 处理器 + private val localFunctions = mutableMapOf() + + // 本地函数定义Map,函数名 -> 定义 + private val localFunctionDefs = mutableMapOf() + + // 是否已连接 + private var isConnected = false + + // 系统功能处理器 + private val systemFunctionHandler = SystemFunctionHandler(context!!) + + init { + // 如果提供了Context,自动注册语音功能处理器 + context?.let { initializeVoiceFunctions(it) } + } + + /** + * 初始化语音功能处理器 + */ + private fun initializeVoiceFunctions(context: Context) { + try { + // 使用SystemFunctionHandler注册所有功能 + systemFunctionHandler.registerAllFunctions(this) + + Log.d(TAG, "语音功能初始化完成") + } catch (e: Exception) { + Log.e(TAG, "语音功能初始化失败: ${e.message}", e) + } + } + + /** + * 将JsonObject转换为JsonValue + */ + private fun JsonObject.toJsonValue(): JsonValue { + val mapper = ObjectMapper() + val node = mapper.readTree(this.toString()) + return JsonValue.fromJsonNode(node) + } + + /** + * 连接到MCP服务器(通过WebSocket) + */ + suspend fun connectToWebSocket(serverUrl: String): Boolean { + try { + // 创建WebSocket传输层 + val transport = WebSocketClientTransport( + client = HttpClient(), + urlString = serverUrl + ) + + // 连接MCP客户端到服务器 + mcp.connect(transport) + + // 获取服务器提供的工具列表 + val toolsResult = mcp.listTools() + Log.d(TAG, "获取到工具列表: $toolsResult") + + // 处理工具列表 + processToolsList(toolsResult) + + isConnected = true + Log.d(TAG, "已连接到WebSocket服务器,可用工具: ${tools.map { it.tool().get().name() }.joinToString(", ")}") + return true + } catch (e: Exception) { + Log.e(TAG, "连接WebSocket服务器失败: ${e.message}", e) + return false + } + } + + /** + * 处理工具列表 + */ + private fun processToolsList(toolsResult: io.modelcontextprotocol.kotlin.sdk.ListToolsResult?) { + tools = toolsResult?.tools?.map { tool -> + // 确保输入模式有效 + val inputType = tool.inputSchema?.type ?: "object" + val inputProperties = tool.inputSchema?.properties ?: emptyMap() + val inputRequired = tool.inputSchema?.required ?: emptyList() + + ToolUnion.ofTool( + Tool.builder() + .name(tool.name) + .description(tool.description ?: "") + .inputSchema( + Tool.InputSchema.builder() + .type(JsonValue.from(inputType)) + .properties(if (inputProperties is JsonObject) inputProperties.toJsonValue() else JsonValue.from(emptyMap())) + .putAdditionalProperty("required", JsonValue.from(inputRequired)) + .build() + ) + .build() + ) + } ?: emptyList() + + // 更新用于OpenAI的工具映射 + updateToolMaps() + } + + + /** + * 注册本地函数 + * @param name 函数名称 + * @param description 函数描述 + * @param parameters 函数参数定义(Map或JSONObject) + * @param handler 函数处理器 + * @return 是否注册成功 + */ + fun registerLocalFunction( + name: String, + description: String, + parameters: Any, + handler: FunctionHandler + ): Boolean { + try { + // 将参数统一转换为Map格式 + val parametersMap: Map = when (parameters) { + is Map<*, *> -> { + @Suppress("UNCHECKED_CAST") + parameters as Map + } + is JSONObject -> { + val mapper = ObjectMapper() + mapper.readValue( + parameters.toString(), + object : TypeReference>() {} + ) + } + else -> { + Log.e(TAG, "参数类型不支持: ${parameters.javaClass.name}") + return false + } + } + + // 检查参数是否包含必要字段 + if (!parametersMap.containsKey("type") || (parametersMap["type"] != "object")) { + Log.e(TAG, "参数必须是object类型") + return false + } + + // 注册函数处理器 + localFunctions[name] = handler + + // 创建函数定义 + val functionDef = mapOf( + "name" to name, + "description" to description, + "parameters" to parametersMap + ) + + // 保存函数定义 + localFunctionDefs[name] = ObjectMapper().writeValueAsString(functionDef) + + // 更新工具映射 + updateToolMaps() + + Log.d(TAG, "注册本地函数成功: $name") + return true + } catch (e: Exception) { + Log.e(TAG, "注册本地函数失败: ${e.message}", e) + return false + } + } + + /** + * 取消注册本地函数 + * @param name 函数名称 + * @return 是否取消成功 + */ + fun unregisterLocalFunction(name: String): Boolean { + val removed = localFunctions.remove(name) != null + if (removed) { + localFunctionDefs.remove(name) + updateToolMaps() + Log.d(TAG, "取消注册本地函数成功: $name") + } + return removed + } + + /** + * 更新工具Map列表,用于OpenAI请求 + */ + private fun updateToolMaps() { + toolMaps.clear() + + // 首先添加本地函数 + localFunctionDefs.forEach { (name, functionDefJson) -> + try { + val mapper = ObjectMapper() + val functionDef = mapper.readValue(functionDefJson, object : TypeReference>() {}) + val toolMap = mapOf( + "type" to "function", + "function" to functionDef + ) + toolMaps.add(toolMap) + } catch (e: Exception) { + Log.e(TAG, "解析本地函数定义失败: $name, ${e.message}", e) + } + } + + // 然后添加MCP工具 + tools.forEach { toolUnion -> + try { + val tool = toolUnion.tool().get() + val parametersMap = mutableMapOf() + + // 提取工具类型及参数 + try { + val inputSchema = tool.inputSchema() + // 解析JsonValue + val mapper = ObjectMapper() + val inputSchemaStr = inputSchema.toString() + val inputSchemaJson = mapper.readTree(inputSchemaStr) + val typeValue = inputSchemaJson.path("type") + parametersMap["type"] = if (typeValue.isTextual) typeValue.asText() else "object" + + // 提取properties字段 + val propertiesMap = mutableMapOf() + val propertiesNode = inputSchemaJson.path("properties") + if (propertiesNode.isObject) { + propertiesNode.fieldNames().forEach { fieldName -> + val fieldNode = propertiesNode.get(fieldName) + propertiesMap[fieldName] = convertJsonNodeToMap(fieldNode) + } + } + parametersMap["properties"] = propertiesMap + + // 提取required字段 + val requiredList = mutableListOf() + val requiredNode = inputSchemaJson.path("required") + if (requiredNode.isArray) { + for (i in 0 until requiredNode.size()) { + if (requiredNode.get(i).isTextual) { + requiredList.add(requiredNode.get(i).asText()) + } + } + } + parametersMap["required"] = requiredList + } catch (e: Exception) { + Log.e(TAG, "解析工具参数失败: ${e.message}", e) + // 设置默认参数 + parametersMap["type"] = "object" + parametersMap["properties"] = emptyMap() + parametersMap["required"] = emptyList() + } + + // 创建工具Map + val toolMap = mapOf( + "type" to "function", + "function" to mapOf( + "name" to tool.name(), + "description" to (tool.description() ?: ""), + "parameters" to parametersMap + ) + ) + + toolMaps.add(toolMap) + } catch (e: Exception) { + Log.e(TAG, "转换工具到Map失败: ${e.message}", e) + } + } + } + + /** + * 将JsonNode转换为Map + */ + private fun convertJsonNodeToMap(node: JsonNode): Any { + return when { + node.isTextual() -> node.asText() + node.isNumber() -> node.asDouble() + node.isBoolean() -> node.asBoolean() + node.isObject() -> { + val result = mutableMapOf() + node.fieldNames().forEach { fieldName -> + result[fieldName] = convertJsonNodeToMap(node.get(fieldName)) + } + result + } + node.isArray() -> { + val result = mutableListOf() + for (i in 0 until node.size()) { + result.add(convertJsonNodeToMap(node.get(i))) + } + result + } + node.isNull() -> "null" + else -> node.toString() + } + } + + /** + * 获取工具Map列表,用于OpenAI请求 + */ + fun getToolMaps(): List> { + return toolMaps + } + + /** + * 获取工具类型 + * @param name 工具名称 + * @return 工具类型,如果不存在则返回null + */ + fun getToolType(name: String): ToolType? { + if (localFunctions.containsKey(name)) { + return ToolType.LOCAL_FUNCTION + } + + if (tools.any { it.tool().get().name() == name }) { + return ToolType.MCP_TOOL + } + + return null + } + + /** + * 调用工具(统一入口) + * @param name 工具名称 + * @param arguments 参数 + * @return 调用结果 + */ + suspend fun callTool(name: String, arguments: Map): String? { + val toolType = getToolType(name) + + return when (toolType) { + ToolType.LOCAL_FUNCTION -> { + callLocalFunction(name, arguments) + } + ToolType.MCP_TOOL -> { + callMcpTool(name, arguments) + } + null -> { + Log.e(TAG, "未找到工具: $name") + null + } + } + } + + /** + * 调用本地函数 + */ + private suspend fun callLocalFunction(name: String, arguments: Map): String? { + return try { + val handler = localFunctions[name] + if (handler != null) { + handler.handle(arguments) + } else { + Log.e(TAG, "未找到本地函数: $name") + null + } + } catch (e: Exception) { + Log.e(TAG, "调用本地函数失败: ${e.message}", e) + "调用本地函数失败: ${e.message}" + } + } + + /** + * 调用MCP工具 + */ + private suspend fun callMcpTool(name: String, arguments: Map): String? { + if (!isConnected) { + Log.e(TAG, "未连接到MCP服务器", null) + return "未连接到MCP服务器" + } + + return try { + val result = mcp.callTool( + name = name, + arguments = arguments + ) + + // 处理工具调用结果 + result?.content?.joinToString("\n") { content -> + when (content) { + is TextContent -> content.text ?: "" + else -> "" + } + } + } catch (e: Exception) { + Log.e(TAG, "调用MCP工具失败: ${e.message}", e) + "调用MCP工具失败: ${e.message}" + } + } + + /** + * 获取可用的工具列表 + */ + fun getTools(): List { + return tools + } + + /** + * 检查工具是否存在 + */ + fun hasToolWithName(name: String): Boolean { + return localFunctions.containsKey(name) || toolMaps.any { + val functionMap = it["function"] as? Map + functionMap?.get("name") == name + } + } + + /** + * 从字符串解析JSON参数 + */ + fun parseJsonArguments(json: String): Map { + return try { + ObjectMapper().readValue(json, object : TypeReference>() {}) + } catch (e: Exception) { + Log.e(TAG, "解析JSON参数失败: ${e.message}", e) + emptyMap() + } + } + + /** + * 检查是否已连接到MCP服务器 + */ + fun isConnected(): Boolean { + return isConnected + } + + /** + * 关闭连接 + */ + override fun close() { + runBlocking { + try { + mcp.close() + isConnected = false + tools = emptyList() + toolMaps.clear() + Log.d(TAG, "已关闭MCP服务器连接", null) + } catch (e: Exception) { + Log.e(TAG, "关闭MCP服务器连接失败: ${e.message}", e) + } + } + } +} \ No newline at end of file diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt index 907be93a2..9e67d15f8 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 @@ -1,6 +1,13 @@ package com.yunqiinnovation.open_ai_service +import android.content.Context 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 okhttp3.* import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.RequestBody.Companion.toRequestBody @@ -9,11 +16,13 @@ import org.json.JSONObject import java.io.IOException import java.util.concurrent.TimeUnit + /** * OpenAI服务的原生实现 */ -class OpenAIService() { +class OpenAIService(private val context: Context? = null) { private val TAG = "OpenAIService" + private var baseUrl = "" private val client = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) @@ -25,8 +34,12 @@ class OpenAIService() { private var isInitialized = false private var model: String = "" // 默认模型 - // 用于存储注册的函数 - private val registeredFunctions = mutableListOf() + // MCP客户端 + private var mcpClient: MCPClient? = null + private var isMcpInitialized = false + + // 是否自动处理MCP工具调用 + private var autoHandleMcpTools = true /** * 构建curl命令用于测试 @@ -88,40 +101,151 @@ class OpenAIService() { if (model.isNotEmpty()) { this.model = model } + + // 初始化MCPClient + if (mcpClient == null) { + mcpClient = MCPClient(context) + } + isInitialized = apiKey.isNotEmpty() return isInitialized } + /** + * MCP客户端是否已初始化 + */ + fun isMcpInitialized(): Boolean { + return isMcpInitialized && mcpClient?.isConnected() == true + } + + /** + * 关闭MCP客户端 + */ + fun closeMcpClient() { + mcpClient?.close() + mcpClient = null + isMcpInitialized = false + } + + /** + * 初始化MCP客户端(WebSocket方式) + */ + fun initializeMcpClient(serverUrl: String): Boolean { + if (mcpClient != null) { + mcpClient?.close() + } + + mcpClient = MCPClient(context) + + // 在后台线程中初始化MCP客户端 + val scope = CoroutineScope(Dispatchers.IO) + + var result = false + scope.launch { + try { + result = mcpClient?.connectToWebSocket(serverUrl) ?: 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工具调用 + * + * @param functionCall 函数调用JSON对象 + * @return 处理结果,如果处理失败则返回null + */ + suspend fun handleMcpToolCall(functionCall: JSONObject): String? { + if (mcpClient == null || !isMcpInitialized) { + return "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 "处理MCP工具调用失败: ${e.message}" + } + } + /** * 注册函数 */ fun registerFunction(name: String, description: String, parameters: JSONObject): Boolean { try { - val function = JSONObject().apply { - put("name", name) - put("description", description) - put("parameters", parameters) - } - - // 检查是否已存在相同名称的函数 - val existingIndex = registeredFunctions.indexOfFirst { - it.getString("name") == name + // 确保MCPClient已初始化 + if (mcpClient == null) { + mcpClient = MCPClient() } - if (existingIndex >= 0) { - // 如果已存在,则替换 - registeredFunctions[existingIndex] = function - } else { - // 如果不存在,则添加 - registeredFunctions.add(function) + // 创建函数处理器 + val handler = object : FunctionHandler { + override suspend fun handle(arguments: Map): String { + // 由于本地函数的实际处理是在Flutter端完成的 + // 这里只需返回一个标记,表示该函数是本地函数 + return "LOCAL_FUNCTION:$name" + } } - return true + // 直接委托给MCPClient处理 + return mcpClient?.registerLocalFunction(name, description, parameters, handler) ?: false } catch (e: Exception) { + Log.e(TAG, "注册函数失败: ${e.message}", e) return false } } + /** + * 自动处理MCP工具调用 + */ + private suspend fun autoHandleMcpToolCall(functionCall: JSONObject, messages: JSONArray, callback: StreamCallback) { + try { + // 获取函数名称 + val name = functionCall.getString("name") + + // 获取参数 + val argumentsJson = functionCall.getString("arguments") + val arguments = mcpClient?.parseJsonArguments(argumentsJson) ?: mapOf() + + // 调用工具(统一通过MCPClient处理) + val result = mcpClient?.callTool(name, arguments) + + // 不论是本地函数还是MCP工具,都直接将结果发送回OpenAI + sendFunctionCallResult( + messages = messages, + functionCall = functionCall, + functionResult = result ?: "工具调用失败", + callback = callback + ) + } catch (e: Exception) { + Log.e(TAG, "自动处理工具调用失败: ${e.message}", e) + // 失败时返回错误给回调函数 + val errorMessage = "工具调用失败: ${e.message}" + sendFunctionCallResult( + messages = messages, + functionCall = functionCall, + functionResult = errorMessage, + callback = callback + ) + } + } + /** * 发送消息(非流式输出) */ @@ -138,16 +262,21 @@ class OpenAIService() { put("max_tokens", 2000) put("stream", false) - // 如果有注册的函数,则添加到请求中 - if (registeredFunctions.isNotEmpty()) { - val tools = JSONArray() - for (function in registeredFunctions) { - val tool = JSONObject().apply { - put("type", "function") - put("function", function) - } + // 添加工具列表 + 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) } } @@ -231,16 +360,21 @@ class OpenAIService() { put("max_tokens", 2000) put("stream", true) - // 如果有注册的函数,则添加到请求中 - if (registeredFunctions.isNotEmpty()) { - val tools = JSONArray() - for (function in registeredFunctions) { - val tool = JSONObject().apply { - put("type", "function") - put("function", function) - } + // 添加工具列表 + 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) } } @@ -289,7 +423,7 @@ class OpenAIService() { // 处理[DONE]消息 if (data == "[DONE]" || data == "[\"DONE\"]") { - processToolCalls(finalToolCalls, callback) + processToolCalls(finalToolCalls, callback, messages) callback.onComplete() break } @@ -403,12 +537,6 @@ class OpenAIService() { put("tool_call_id", functionCall.optString("id", "call_${System.currentTimeMillis()}")) }) - // 添加一个带有content的assistant消息,确保API请求不会因为缺少content而失败 - fullMessages.put(JSONObject().apply { - put("role", "assistant") - put("content", "") // 空内容,让模型生成新的回复 - }) - // 发送完整对话 sendMessageStream(fullMessages, callback) @@ -418,9 +546,9 @@ class OpenAIService() { } /** - * 处理工具调用(处理完整的函数调用并回调) + * 处理工具调用结果 */ - private fun processToolCalls(toolCalls: Map, callback: StreamCallback) { + private fun processToolCalls(toolCalls: Map, callback: StreamCallback, messages: JSONArray? = null) { if (toolCalls.isEmpty()) return // 只处理第一个工具调用 @@ -434,8 +562,16 @@ class OpenAIService() { put("id", firstToolCall.id) } - // 回调 - callback.onFunctionCall(functionCall) + // 在协程中处理工具调用 + if (messages != null) { + kotlinx.coroutines.GlobalScope.launch { + try { + autoHandleMcpToolCall(functionCall, messages, callback) + } catch (e: Exception) { + Log.e(TAG, "工具调用处理失败: ${e.message}", e) + } + } + } } } diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt index 70af36c13..1e1bb9446 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt @@ -15,6 +15,10 @@ import org.json.JSONArray import org.json.JSONObject import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** OpenAIServicePlugin */ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { @@ -34,7 +38,7 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { private lateinit var context: Context /// OpenAI服务实例 - private val openAIService = OpenAIService() + private lateinit var openAIService: OpenAIService /// 事件接收器(用于流式输出) private var eventSink: EventSink? = null @@ -46,6 +50,9 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { // 保存上下文 context = flutterPluginBinding.applicationContext + // 初始化OpenAI服务 + openAIService = OpenAIService(context) + // 初始化方法通道 methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, methodChannelName) methodChannel.setMethodCallHandler(this) @@ -66,6 +73,7 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { result.success(initialized) } + "registerFunction" -> { val name = call.argument("name") ?: "" val description = call.argument("description") ?: "" @@ -256,6 +264,11 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { result.success(message.toMap()) } + "initializeMcpClient" -> initializeMcpClient(call, result) + "isMcpInitialized" -> isMcpInitialized(call, result) + "closeMcpClient" -> closeMcpClient(call, result) + "handleMcpToolCall" -> handleMcpToolCall(call, result) + else -> { result.notImplemented() } @@ -314,4 +327,54 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { } return list } + + private fun initializeMcpClient(call: MethodCall, result: Result) { + val serverUrl = call.argument("serverUrl") + if (serverUrl == null) { + result.error("INVALID_ARGUMENT", "Missing serverUrl", null) + return + } + + val success = openAIService.initializeMcpClient(serverUrl) + result.success(success) + } + + private fun isMcpInitialized(call: MethodCall, result: Result) { + result.success(openAIService.isMcpInitialized()) + } + + private fun closeMcpClient(call: MethodCall, result: Result) { + openAIService.closeMcpClient() + result.success(true) + } + + private fun handleMcpToolCall(call: MethodCall, result: Result) { + val functionCall = call.argument("functionCall") + if (functionCall == null) { + result.error("INVALID_ARGUMENT", "Missing functionCall", null) + return + } + + try { + val jsonFunctionCall = JSONObject(functionCall) + + // 使用协程处理异步操作 + CoroutineScope(Dispatchers.IO).launch { + try { + val toolResult = openAIService.handleMcpToolCall(jsonFunctionCall) + + // 切换到主线程返回结果 + withContext(Dispatchers.Main) { + result.success(toolResult ?: "无法处理MCP工具调用") + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + result.error("MCP_TOOL_ERROR", "处理MCP工具调用失败: ${e.message}", null) + } + } + } + } catch (e: Exception) { + result.error("INVALID_ARGUMENT", "解析functionCall失败: ${e.message}", null) + } + } } \ No newline at end of file diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt new file mode 100644 index 000000000..f2e7cbcdb --- /dev/null +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt @@ -0,0 +1,422 @@ +package com.yunqiinnovation.open_ai_service + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.ContactsContract +import android.telephony.SmsManager +import android.util.Log +import org.json.JSONObject + +/** + * 系统功能处理器 - 负责调用系统API执行各种功能以及注册系统功能 + */ +class SystemFunctionHandler(private val context: Context) { + companion object { + private const val TAG = "SystemFunctionHandler" + const val ACTION_EXIT_INTERACTION = "com.yunqiinnovation.deepsound.action.EXIT_INTERACTION" + } + + /** + * 在MCPClient中注册所有系统功能 + * @param client MCPClient实例 + */ + fun registerAllFunctions(client: MCPClient) { + try { + // 注册退出交互函数 + registerExitInteractionFunction(client) + + // 注册翻译模式函数 + registerTranslationModeFunction(client) + + // 注册发送短信函数 + registerSendTextMessageFunction(client) + + // 注册拨打电话函数 + registerMakePhoneCallFunction(client) + + // 注册打开应用函数 + registerOpenAppFunction(client) + + Log.d(TAG, "所有系统功能注册完成") + } catch (e: Exception) { + Log.e(TAG, "注册系统功能失败: ${e.message}", e) + } + } + + + /** + * 注册退出交互函数 + */ + private fun registerExitInteractionFunction(client: MCPClient) { + client.registerLocalFunction( + "exit_interaction", + "退出当前语音交互", + JSONObject(""" + { + "type": "object", + "properties": {}, + "required": [] + } + """), + object : FunctionHandler { + override suspend fun handle(arguments: Map): String { + // 调用发送广播的函数 + sendExitInteractionBroadcast() + + return "{\"result\": \"已退出语音交互\"}" + } + } + ) + Log.d(TAG, "已注册退出交互功能") + } + + /** + * 注册翻译模式函数 + */ + private fun registerTranslationModeFunction(client: MCPClient) { + client.registerLocalFunction( + "enter_translation_mode", + "进入实时翻译模式", + JSONObject(""" + { + "type": "object", + "properties": {}, + "required": [] + } + """), + object : FunctionHandler { + override suspend fun handle(arguments: Map): String { + val result = enterTranslationMode() + return "{\"result\": \"${result.message}\"}" + } + } + ) + Log.d(TAG, "已注册翻译模式功能") + } + + /** + * 注册发送短信函数 + */ + private fun registerSendTextMessageFunction(client: MCPClient) { + client.registerLocalFunction( + "send_text_message", + "发送短信", + JSONObject(""" + { + "type": "object", + "properties": { + "contact": { + "type": "string", + "description": "联系人姓名或电话号码" + }, + "message": { + "type": "string", + "description": "短信内容" + } + }, + "required": ["contact", "message"] + } + """), + object : FunctionHandler { + override suspend fun handle(arguments: Map): String { + val contact = arguments["contact"] as? String ?: "" + val message = arguments["message"] as? String ?: "" + val result = sendTextMessage(contact, message) + return "{\"result\": \"${result.message}\"}" + } + } + ) + Log.d(TAG, "已注册发送短信功能") + } + + /** + * 注册拨打电话函数 + */ + private fun registerMakePhoneCallFunction(client: MCPClient) { + client.registerLocalFunction( + "make_phone_call", + "拨打电话", + JSONObject(""" + { + "type": "object", + "properties": { + "contact": { + "type": "string", + "description": "联系人姓名或电话号码" + } + }, + "required": ["contact"] + } + """), + object : FunctionHandler { + override suspend fun handle(arguments: Map): String { + val contact = arguments["contact"] as? String ?: "" + val result = makePhoneCall(contact) + return "{\"result\": \"${result.message}\"}" + } + } + ) + Log.d(TAG, "已注册拨打电话功能") + } + + /** + * 注册打开应用函数 + */ + private fun registerOpenAppFunction(client: MCPClient) { + client.registerLocalFunction( + "open_app", + "打开应用", + JSONObject(""" + { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "应用名称" + } + }, + "required": ["app_name"] + } + """), + object : FunctionHandler { + override suspend fun handle(arguments: Map): String { + val appName = arguments["app_name"] as? String ?: "" + val result = openApp(appName) + return "{\"result\": \"${result.message}\"}" + } + } + ) + Log.d(TAG, "已注册打开应用功能") + } + + + /** + * 发送退出交互广播 + */ + private fun sendExitInteractionBroadcast() { + // 发送广播通知VoiceInteractionHandler停止语音识别 + val intent = Intent(ACTION_EXIT_INTERACTION).apply { + // 明确指定接收广播的包名 + setPackage(context.packageName) + putExtra("timestamp", System.currentTimeMillis()) + // 添加FLAG_RECEIVER_FOREGROUND确保及时处理 + addFlags(Intent.FLAG_RECEIVER_FOREGROUND) + } + Log.d(TAG, "准备发送停止交互广播,包名: ${context.packageName}") + context.sendBroadcast(intent) + Log.d(TAG, "已发送停止交互广播") + } + + + + /** + * 启动翻译模式 + * @return 操作结果 + */ + fun enterTranslationMode(): Result { + Log.d(TAG, "进入翻译模式") + // 此处应添加实际的翻译模式实现 + // 可能需要启动一个专门的翻译服务或活动 + + // 广播通知APP的其他部分进入翻译模式 + val intent = Intent("com.yunqiinnovation.deepsound.ACTION_ENTER_TRANSLATION_MODE").apply { + setPackage(context.packageName) + + putExtra("timestamp", System.currentTimeMillis()) + // 添加FLAG_RECEIVER_FOREGROUND确保及时处理 + addFlags(Intent.FLAG_RECEIVER_FOREGROUND) + } + + // 发送广播 + context.sendBroadcast(intent) + Log.d(TAG, "已发送广播: ENTER_TRANSLATION_MODE") + + return Result( + success = true, + message = "已进入实时翻译模式" + ) + } + + /** + * 发送短信 + * @param contact 联系人(名称或电话号码) + * @param message 短信内容 + * @return 操作结果 + */ + fun sendTextMessage(contact: String, message: String): Result { + Log.d(TAG, "发送短信给 $contact: $message") + + try { + val phoneNumber = getPhoneNumberForContact(contact) + if (phoneNumber.isEmpty()) { + return Result( + success = false, + message = "未找到联系人信息: $contact" + ) + } + + // 使用SmsManager发送短信 + val smsManager = SmsManager.getDefault() + smsManager.sendTextMessage(phoneNumber, null, message, null, null) + + return Result( + success = true, + message = "已向${contact}发送短信: $message" + ) + } catch (e: Exception) { + Log.e(TAG, "发送短信失败: ${e.message}", e) + return Result( + success = false, + message = "发送短信失败: ${e.message}" + ) + } + } + + /** + * 拨打电话 + * @param contact 联系人(名称或电话号码) + * @return 操作结果 + */ + fun makePhoneCall(contact: String): Result { + Log.d(TAG, "拨打电话给 $contact") + + try { + val phoneNumber = getPhoneNumberForContact(contact) + if (phoneNumber.isEmpty()) { + return Result( + success = false, + message = "未找到联系人信息: $contact" + ) + } + + // 创建拨号意图 + val intent = Intent(Intent.ACTION_CALL) + intent.data = Uri.parse("tel:$phoneNumber") + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) + + return Result( + success = true, + message = "正在拨打${contact}的电话" + ) + } catch (e: Exception) { + Log.e(TAG, "拨打电话失败: ${e.message}", e) + return Result( + success = false, + message = "拨打电话失败: ${e.message}" + ) + } + } + + /** + * 打开应用程序 + * @param appName 应用名称 + * @return 操作结果 + */ + fun openApp(appName: String): Result { + Log.d(TAG, "打开应用: $appName") + + try { + // 查找应用的包名 + val packageManager = context.packageManager + val intent = Intent(Intent.ACTION_MAIN, null) + intent.addCategory(Intent.CATEGORY_LAUNCHER) + + val apps = packageManager.queryIntentActivities(intent, 0) + + for (resolveInfo in apps) { + val appLabel = resolveInfo.loadLabel(packageManager).toString() + if (appLabel.equals(appName, ignoreCase = true) || + appLabel.contains(appName, ignoreCase = true)) { + + // 找到匹配的应用 + val packageName = resolveInfo.activityInfo.packageName + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + + if (launchIntent != null) { + launchIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(launchIntent) + + return Result( + success = true, + message = "正在打开${appName}应用" + ) + } + } + } + + return Result( + success = false, + message = "未找到应用: $appName" + ) + } catch (e: Exception) { + Log.e(TAG, "打开应用失败: ${e.message}", e) + return Result( + success = false, + message = "打开应用失败: ${e.message}" + ) + } + } + + /** + * 根据联系人名称获取电话号码 + * @param contact 联系人名称或电话号码 + * @return 电话号码 + */ + private fun getPhoneNumberForContact(contact: String): String { + // 如果输入的是电话号码(简单判断,包含数字和特殊符号) + if (contact.matches(Regex("^[+\\d\\s\\-()]*\$")) && contact.any { it.isDigit() }) { + // 移除非数字字符 + return contact.replace(Regex("[^+\\d]"), "") + } + + // 从联系人数据库查询 + try { + val contentResolver = context.contentResolver + val uri = Uri.withAppendedPath( + ContactsContract.Contacts.CONTENT_FILTER_URI, + Uri.encode(contact) + ) + + val projection = arrayOf( + ContactsContract.Contacts._ID, + ContactsContract.Contacts.DISPLAY_NAME, + ContactsContract.Contacts.HAS_PHONE_NUMBER + ) + + contentResolver.query(uri, projection, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val hasPhoneNumber = cursor.getInt(cursor.getColumnIndexOrThrow(ContactsContract.Contacts.HAS_PHONE_NUMBER)) + + if (hasPhoneNumber > 0) { + val contactId = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID)) + val phoneUri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI + val phoneProjection = arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER) + val phoneSelection = "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?" + val phoneSelectionArgs = arrayOf(contactId) + + contentResolver.query(phoneUri, phoneProjection, phoneSelection, phoneSelectionArgs, null)?.use { phoneCursor -> + if (phoneCursor.moveToFirst()) { + return phoneCursor.getString(phoneCursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER)) + } + } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "查询联系人信息失败: ${e.message}", e) + } + + return "" + } + + /** + * 操作结果数据类 + */ + data class Result( + val success: Boolean, + val message: String + ) +} \ No newline at end of file diff --git a/local_plugins/open_ai_service/lib/open_ai_service.dart b/local_plugins/open_ai_service/lib/open_ai_service.dart index 7c6fe8024..8666f0100 100644 --- a/local_plugins/open_ai_service/lib/open_ai_service.dart +++ b/local_plugins/open_ai_service/lib/open_ai_service.dart @@ -264,4 +264,105 @@ class OpenAIService { throw OpenAIException('解析函数调用失败: $e'); } } + + /// 初始化MCP客户端 + /// + /// [serverUrl] MCP服务器WebSocket地址 + /// + /// 返回一个布尔值,表示请求是否已开始初始化(实际初始化过程是异步的) + Future initializeMcpClient(String serverUrl) async { + try { + final result = await _channel.invokeMethod( + 'initializeMcpClient', + {'serverUrl': serverUrl}, + ); + + return result ?? false; + } catch (e) { + throw OpenAIException('初始化MCP客户端失败: $e'); + } + } + + /// 检查MCP客户端是否已初始化 + Future isMcpInitialized() async { + try { + final result = await _channel.invokeMethod('isMcpInitialized'); + return result ?? false; + } catch (e) { + throw OpenAIException('检查MCP客户端状态失败: $e'); + } + } + + /// 关闭MCP客户端 + Future closeMcpClient() async { + try { + final result = await _channel.invokeMethod('closeMcpClient'); + return result ?? false; + } catch (e) { + throw OpenAIException('关闭MCP客户端失败: $e'); + } + } + + /// 处理MCP工具调用 + /// + /// [functionCall] 函数调用信息,必须包含name和arguments字段 + /// + /// 返回工具调用结果 + Future handleMcpToolCall(Map functionCall) async { + try { + final result = await _channel.invokeMethod( + 'handleMcpToolCall', + {'functionCall': jsonEncode(functionCall)}, + ); + + return result ?? "处理MCP工具调用失败: 结果为空"; + } catch (e) { + throw OpenAIException('处理MCP工具调用失败: $e'); + } + } + + /// 处理函数调用(自动处理本地函数和MCP工具) + /// + /// 这是一个辅助方法,用于简化函数调用处理流程: + /// 1. MCP工具会被自动处理,不会通过onFunctionCall回调返回 + /// 2. 只有本地函数才需要手动处理 + /// 3. 提供统一的处理接口 + /// + /// 参数: + /// [functionCall] 函数调用信息 + /// [messages] 消息上下文 + /// [localFunctionHandler] 本地函数处理器,接收函数名和参数,返回处理结果 + /// + /// 返回值: + /// 布尔值表示是否成功处理(已将结果发送回OpenAI) + Future handleFunctionCall({ + required Map functionCall, + required List> messages, + required Future Function(String name, Map arguments) localFunctionHandler, + }) async { + final name = functionCall['name'] as String; + final argumentsJson = functionCall['arguments'] as String; + + // 解析参数 + Map arguments; + try { + arguments = json.decode(argumentsJson) as Map; + } catch (e) { + arguments = {}; + } + + try { + // 调用本地函数处理器 + final result = await localFunctionHandler(name, arguments); + + // 发送结果回OpenAI + return sendFunctionCallResult( + messages: messages, + functionCall: functionCall, + functionResult: result, + ); + } catch (e) { + throw OpenAIException('处理函数调用失败: $e'); + } + } } \ No newline at end of file diff --git a/local_plugins/volcano_speech/android/build.gradle.kts b/local_plugins/volcano_speech/android/build.gradle.kts index 86b03fd0e..713a21a59 100644 --- a/local_plugins/volcano_speech/android/build.gradle.kts +++ b/local_plugins/volcano_speech/android/build.gradle.kts @@ -7,7 +7,7 @@ buildscript { } dependencies { classpath("com.android.tools.build:gradle:7.3.0") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.10") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.0") } } @@ -21,7 +21,7 @@ allprojects { plugins { id("com.android.library") - kotlin("android") + id("org.jetbrains.kotlin.android") } // 配置android扩展 diff --git a/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoAsrHelper.kt b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoAsrHelper.kt index 614474d1a..d9b63819a 100644 --- a/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoAsrHelper.kt +++ b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoAsrHelper.kt @@ -58,7 +58,7 @@ class VolcanoAsrHelper(private val context: Context) { /** * 初始化语音识别引擎 */ - fun initialize(appId: String, token: String, resourceId: String): Boolean { + fun initialize(appId: String, token: String, resourceId: String = "volc.bigasr.sauc.duration"): Boolean { if (isInitialized) { FileLogger.i(TAG, "引擎已经初始化") return true @@ -66,7 +66,12 @@ class VolcanoAsrHelper(private val context: Context) { try { // 准备环境 - SpeechEngineGenerator.PrepareEnvironment(context, null) + try { + SpeechEngineGenerator.PrepareEnvironment(context, null) + } catch (e: Exception) { + FileLogger.w(TAG, "准备环境时发生异常: ${e.message},尝试继续初始化") + // 即使PrepareEnvironment失败仍继续执行,因为某些环境下可能找不到特定类 + } // 创建引擎 engine = SpeechEngineGenerator.getInstance() @@ -134,7 +139,17 @@ class VolcanoAsrHelper(private val context: Context) { return true } else { - FileLogger.e(TAG, "引擎初始化失败: $result") + // 错误码说明: + // -1:通用错误 + // -2:无效参数 + // -3:无效状态 + // -4:无效引擎 + // -5:操作失败 + // -6:缺少配置 + // -7:参数无效 + // -8:超时 + // -9:资源不足 + FileLogger.e(TAG, "引擎初始化失败: $result, 可能原因: ${getErrorDescription(result)}") return false } } catch (e: Exception) { @@ -526,4 +541,22 @@ class VolcanoAsrHelper(private val context: Context) { } } } + + /** + * 获取错误描述 + */ + private fun getErrorDescription(errorCode: Int?): String { + return when (errorCode) { + -1 -> "通用错误" + -2 -> "无效参数,检查appId、token、resourceId等参数是否正确" + -3 -> "无效状态,引擎状态异常" + -4 -> "无效引擎句柄" + -5 -> "操作失败,可能网络连接问题" + -6 -> "缺少配置参数" + -7 -> "参数设置无效,请检查appId、token等参数是否有效" + -8 -> "操作超时" + -9 -> "资源不足" + else -> "未知错误: $errorCode" + } + } } diff --git a/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoTtsHelper.kt b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoTtsHelper.kt index e24a1ceb1..4642272d4 100644 --- a/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoTtsHelper.kt +++ b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoTtsHelper.kt @@ -29,7 +29,7 @@ class VolcanoTtsHelper(private val context: Context) { private var currentReqId = "" // 配置参数 - private var voice = "zh_female_yuxi" + private var voice = "zh_male_M392_conversation_wvae_bigtts" private var ttsText = "" private var isContinuous = false @@ -51,10 +51,15 @@ class VolcanoTtsHelper(private val context: Context) { FileLogger.i(TAG, "引擎已经初始化") return true } - + FileLogger.d(TAG, "初始化语音合成引擎: $appId, $token, $resourceId") try { // 准备环境 - SpeechEngineGenerator.PrepareEnvironment(context, null) + try { + SpeechEngineGenerator.PrepareEnvironment(context, null) + } catch (e: Exception) { + FileLogger.w(TAG, "准备环境时发生异常: ${e.message},尝试继续初始化") + // 即使PrepareEnvironment失败仍继续执行,因为某些环境下可能找不到特定类 + } // 创建引擎 engine = SpeechEngineGenerator.getInstance() @@ -72,15 +77,15 @@ class VolcanoTtsHelper(private val context: Context) { engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ENGINE_NAME_STRING, SpeechEngineDefines.TTS_ENGINE) // 设置日志级别 - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_LOG_LEVEL_STRING, SpeechEngineDefines.LOG_LEVEL_WARN) - + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_LOG_LEVEL_STRING, SpeechEngineDefines.LOG_LEVEL_DEBUG) // 设置用户ID和设备ID (使用静态值,实际项目中应替换为真实值) engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_UID_STRING, "user_id") engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_DEVICE_ID_STRING, "device_id") // 设置授权信息 - 大模型版本不需要Bearer前缀 + FileLogger.d(TAG, "设置授权信息: $appId, $token, $resourceId") engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_ID_STRING, appId) - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_TOKEN_STRING, token) + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_TOKEN_STRING, "Bearer;$token") // 设置资源ID - 大模型必需 engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_RESOURCE_ID_STRING, resourceId) @@ -93,14 +98,19 @@ class VolcanoTtsHelper(private val context: Context) { // 设置在线请求资源配置 engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_ADDRESS_STRING, "wss://openspeech.bytedance.com") - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_URI_STRING, "/api/v3/sauc/bigmodel") - + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_URI_STRING, "/api/v1/tts/ws_binary") + //【必需配置】语音合成服务所用集群 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_CLUSTER_STRING, "volcano_tts") // 设置发音人 engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_ONLINE_STRING, voice) // 设置播放进度回调 engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_WITH_FRONTEND_INT, 1) + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_AUDIO_STREAM_TYPE_INT, SpeechEngineDefines.AUDIO_STREAM_TYPE_MEDIA); + + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_SCENARIO_STRING, SpeechEngineDefines.TTS_SCENARIO_TYPE_NORMAL) + // 初始化引擎 val result = engine?.initEngine(engineHandler) isInitialized = result == SpeechEngineDefines.ERR_NO_ERROR @@ -118,7 +128,17 @@ class VolcanoTtsHelper(private val context: Context) { return true } else { - FileLogger.e(TAG, "引擎初始化失败: $result") + // 错误码说明: + // -1:通用错误 + // -2:无效参数 + // -3:无效状态 + // -4:无效引擎 + // -5:操作失败 + // -6:缺少配置 + // -7:参数无效 + // -8:超时 + // -9:资源不足 + FileLogger.e(TAG, "引擎初始化失败: $result, 可能原因: ${getErrorDescription(result)}") return false } } catch (e: Exception) { @@ -174,6 +194,7 @@ class VolcanoTtsHelper(private val context: Context) { * 开始合成并播放 */ fun speak(text: String, callback: TTSCallback? = null): Boolean { + FileLogger.d(TAG, "开始合成并播放: $text") if (!isInitialized) { FileLogger.e(TAG, "引擎未初始化") return false @@ -396,4 +417,22 @@ class VolcanoTtsHelper(private val context: Context) { } } } + + /** + * 获取错误描述 + */ + private fun getErrorDescription(errorCode: Int?): String { + return when (errorCode) { + -1 -> "通用错误" + -2 -> "无效参数,检查appId、token、resourceId等参数是否正确" + -3 -> "无效状态,引擎状态异常" + -4 -> "无效引擎句柄" + -5 -> "操作失败,可能网络连接问题" + -6 -> "缺少配置参数" + -7 -> "参数设置无效,请检查appId、token等参数是否有效" + -8 -> "操作超时" + -9 -> "资源不足" + else -> "未知错误: $errorCode" + } + } } diff --git a/pubspec.yaml b/pubspec.yaml index 41fa9edf0..b2962bec9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -66,6 +66,11 @@ dependencies: path: local_plugins/open_ai_service volcano_speech: path: local_plugins/volcano_speech + jieli: + path: local_plugins/jieli + chat_storage_sqlite: + path: local_plugins/chat_storage_sqlite + dev_dependencies: flutter_test: diff --git a/test.json b/test.json index 703948620..473daebb6 100644 --- a/test.json +++ b/test.json @@ -1 +1,57 @@ -curl -v -X POST -H 'Content-Type: application/json' -H 'Authorization: Bearer 168deb3d-fd0c-4912-b9f1-aaee5c6743e6' -H 'Accept: text/event-stream' -d '{"model":"bot-20250405211523-l7c9r","messages":[{"role":"system","content":" 你是一个智能语音助手,能够简洁明了地回答用户的问题。\n时刻关心用户的情绪和需求,主动提供鼓励和温暖。\n\n语言风格活泼、亲切,能够幽默地互动,陪伴用户,缓解压力,增添生活乐趣。\n\n请始终以用户为中心,保持回应的高效性、准确性和温暖体贴,成为用户真正的灵魂伴侣。\n \n 当用户说\"退出\"、\"再见\"、\"结束对话\"等类似意图时,你应该使用exit_interaction函数来结束对话,\n 并在结束前说一句友好的告别语,例如\"再见,有需要随时找我\"。"},{"role":"system","content":" 你是一个智能语音助手,能够简洁明了地回答用户的问题。\n时刻关心用户的情绪和需求,主动提供鼓励和温暖。\n\n语言风格活泼、亲切,能够幽默地互动,陪伴用户,缓解压力,增添生活乐趣。\n\n请始终以用户为中心,保持回应的高效性、准确性和温暖体贴,成为用户真正的灵魂伴侣。\n \n 当用户说\"退出\"、\"再见\"、\"结束对话\"等类似意图时,你应该使用exit_interaction函数来结束对话,\n 并在结束前说一句友好的告别语,例如\"再见,有需要随时找我\"。"},{"role":"user","content":"退下吧。"},{"role":"assistant","content":"","tool_calls":[{"id":"call_8k680azmfc4thqrrnwpwqxah","type":"function","function":{"name":"exit_interaction","arguments":" {}"}}]},{"role":"tool","content":"{\"result\": \"已退出语音交互\"}","tool_call_id":"call_8k680azmfc4thqrrnwpwqxah"}],"temperature":0.7,"max_tokens":2000,"stream":true,"tools":[{"type":"function","function":{"name":"exit_interaction","description":"退出当前语音交互","parameters":{"type":"object","properties":{},"required":[]}}}]}' 'https://ark.cn-beijing.volces.com/api/v3/bots/chat/completions' \ No newline at end of file +curl -v -X POST -H 'Content-Type: application/json' -H 'Authorization: Bearer 168deb3d-fd0c-4912-b9f1-aaee5c6743e6' -H 'Accept: text/event-stream' -d '{ + "model": "bot-20250405211523-l7c9r", + "messages": [ + { + "role": "system", + "content": " 你是一个智能语音助手,能够简洁明了地回答用户的问题。\n时刻关心用户的情绪和需求,主动提供鼓励和温暖。\n\n语言风格活泼、亲切,能够幽默地互动,陪伴用户,缓解压力,增添生活乐趣。\n\n请始终以用户为中心,保持回应的高效性、准确性和温暖体贴,成为用户真正的灵魂伴侣。\n \n 当用户说\"退出\"、\"再见\"、\"结束对话\"等类似意图时,你应该使用exit_interaction函数来结束对话,\n 并在结束前说一句友好的告别语,例如\"再见,有需要随时找我\"。" + }, + { + "role": "system", + "content": " 你是一个智能语音助手,能够简洁明了地回答用户的问题。\n时刻关心用户的情绪和需求,主动提供鼓励和温暖。\n\n语言风格活泼、亲切,能够幽默地互动,陪伴用户,缓解压力,增添生活乐趣。\n\n请始终以用户为中心,保持回应的高效性、准确性和温暖体贴,成为用户真正的灵魂伴侣。\n \n 当用户说\"退出\"、\"再见\"、\"结束对话\"等类似意图时,你应该使用exit_interaction函数来结束对话,\n 并在结束前说一句友好的告别语,例如\"再见,有需要随时找我\"。" + }, + { + "role": "user", + "content": "退下吧。" + }, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_8k680azmfc4thqrrnwpwqxah", + "type": "function", + "function": { + "name": "exit_interaction", + "arguments": " {}" + } + } + ] + }, + { + "role": "tool", + "content": "{\"result\": \"已退出语音交互\"}", + "tool_call_id": "call_8k680azmfc4thqrrnwpwqxah" + } + ], + "temperature": 0.7, + "max_tokens": 2000, + "stream": true, + "tools": [ + { + "type": "function", + "function": { + "name": "exit_interaction", + "description": "退出当前语音交互", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + } + ] +}' 'https: //ark.cn-beijing.volces.com/api/v3/bots/chat/completions' +{ + "code": 0, + "message": "Error during WebSocket handshake: Unexpected response code: 400", + "request_log":"{\"ab_test\":{},\"base\":{\"has_upload\":false,\"is_pending\":true,\"load_flags\":18,\"load_state\":0,\"method\":\"GET\",\"net_error\":-320,\"network_isolation_key\":\"wss://openspeech.bytedance.com wss://openspeech.bytedance.com\",\"origin_url\":\"wss://openspeech.bytedance.com/api/v3/sauc/bigmodel\",\"redirect_times\":0,\"status\":\"FAILED\",\"traffic_annotation\":77012883},\"dns\":{\"address_list\":[\"163.181.50.229:443\",\"163.181.50.223:443\",\"163.181.50.221:443\",\"163.181.50.224:443\",\"163.181.50.226:443\",\"163.181.50.222:443\",\"163.181.50.228:443\",\"163.181.50.227:443\"],\"async_batch_httpdns\":0,\"cache_source\":6,\"cache_stale_reason\":0,\"from_stale_cache\":0,\"hijacked_error\":0,\"is_auth\":false,\"nameservers\":[],\"net_type\":0,\"preload_batch_httpdns\":0,\"queried_with_cip\":\"\",\"race_dns_stale_cache\":0,\"skip_prefer_ip_error\":0,\"source\":2},\"event\":{\"engine_startup\":1744019237.421243,\"network_change\":0.0},\"header\":{\"X-Tt-Logid\":\"2025040717474815E3294F926C737834FA\",\"server-timing\":\"inner; dur=5, cdn-cache;desc=MISS,edge;dur=653,origin;dur=14\",\"x-tt-trace-host\":\"012d6b6cae97bb056dd2540a62f7f6e52ccfb433febe931be5ddf1ac674ca77c57b7dee570790477696b7b5c17bbdd77bf841100de6d411ef8ccc26681b9799e4aee97897e0681b3a8b304853d1848fcbd9fec458fdd375cddc4a7873cdbcef3a0\",\"x-tt-trace-id\":\"00-0fa6216304269466036ad7b0703104d2-0fa6216304269466-01\",\"x-tt-trace-tag\":\"id=03;cdn-cache=miss;type=dyn\"},\"ifconfig\":{\"ipv4_reachable\":true,\"ipv6_reachable\":false},\"multinetwork\":{},\"nqe\":{\"bs_sys_state\":false,\"downlink_throughput\":2658,\"effective_net_type\":5,\"group_rtt_estimate\":[],\"http_rtt\":116,\"pending_requests\":\"1\",\"real_net_type\":2,\"tcp_rtt\":46,\"total_requests\":\"6\"},\"other\":{\"core_ver\":\"87.0.4273.1\",\"gateway\":\"0.0.0.0\",\"is_background\":false,\"is_internal_retry\":false,\"is_main_process\":true,\"libcore\":\"cronet\",\"net_thread_priority\":0,\"quic_commit\":\"6ea2111b/2023-03-16\",\"resume_time\":\"-1\",\"retry_attempts\":0,\"ttnet_build_timestamp\":\"1689596445\",\"ttnet_version\":\"4.1.127.2-tob\",\"version\":\"534436af/2023-07-17\"},\"response\":{\"cached\":false,\"code\":400,\"connection_info\":1,\"network_accessed\":true,\"received_bytes\":0,\"sent_bytes\":0,\"via_proxy\":false},\"session\":{},\"socket\":{\"address_family\":1,\"cip\":\"\",\"connection_attemps\":[],\"happy_eyeballs_result\":2,\"remote\":\"163.181.50.229:443\",\"socket_reused\":false},\"socketpool\":{\"assign_type\":0,\"consume_time\":0,\"is_backup_job\":false,\"pending_reason\":0,\"pending_time\":0,\"reuse_type\":0,\"socket_idle_time\":0},\"ssl\":{\"cert_status\":0,\"connection_status\":6296321,\"handshake_type\":1,\"is_issued_by_known_root\":true,\"server_cert_common_name\":\"*.bytedance.com\"},\"store_idc\":{\"init\":1},\"timing\":{\"detailed_duration\":{\"before_stream\":0,\"body_recv\":-1,\"cdn-cache\":\"miss\",\"connect_delay\":0,\"create_stream\":98,\"dispatch\":0,\"dns\":0,\"edge\":653,\"header_recv\":-1,\"inner\":5,\"origin\":9,\"proxy\":0,\"rtt\":43,\"security_callback\":0,\"send\":0,\"ssl\":48,\"tcp\":49,\"ttfb\":710},\"request\":{\"create_stream_finish\":1744019267.633028,\"create_stream_start\":1744019267.534112,\"duration\":812,\"http_transaction_start\":1744019267.534081,\"request_sent_time\":1744019267.633497,\"response_recv_time\":1744019268.344466,\"start_time\":1744019267.533685}},\"tnc\":{\"data\":{\"canary\":\"\",\"cold_start\":1,\"config\":\"\",\"config_updated\":0,\"local_probe_version\":\"\",\"rules\":\"\",\"source\":0,\"update_time\":\"-1\"},\"probe\":{\"cmd\":\"0\",\"get_time\":\"-1\",\"send_time\":\"-1\",\"state\":1,\"version\":\"0\"},\"request\":{\"probe_version\":\"0\",\"source\":0,\"start_time\":\"-1\",\"state\":1}},\"t \ No newline at end of file From 4decfc566bb1f93b696245d28c470a4a6181f6cf Mon Sep 17 00:00:00 2001 From: wolfplus Date: Fri, 11 Apr 2025 15:07:04 +0100 Subject: [PATCH 2/4] add --- .cursorrules | 14 +- android/app/build.gradle.kts | 3 +- android/settings.gradle.kts | 6 +- lib/core/widgets/message_bubble.dart | 2 +- lib/data/services/chat_history_service.dart | 105 ++++-- .../services/open_ai_service_adapter.dart | 99 +++--- .../android_voice_interaction_service.dart | 15 +- .../ios_voice_interaction_service.dart | 8 +- .../services/voice_interaction_service.dart | 13 +- .../chat/controllers/chat_controller.dart | 300 ++++++++++++------ lib/modules/chat/models/message_model.dart | 20 +- lib/modules/chat/views/chat_view.dart | 2 +- .../splash/controllers/splash_controller.dart | 2 +- local_plugins/azure_speech/LICENSE | 0 .../azure_speech/ios/Classes/.gitkeep | 0 .../azure_speech/ios/azure_speech.podspec | 24 ++ local_plugins/chat_storage/LICENSE | 0 .../README.md | 12 +- .../android/build.gradle.kts | 2 +- .../chat_storage/android/settings.gradle.kts | 1 + .../android/src/main/AndroidManifest.xml | 2 +- .../chat_storage/ChatStoragePlugin.kt} | 6 +- .../chat_storage/ios/Classes/.gitkeep | 0 .../chat_storage/ios/chat_storage.podspec | 23 ++ .../lib/chat_storage.dart} | 5 +- .../pubspec.yaml | 12 +- .../android/settings.gradle.kts | 1 - .../com/yunqiinnovation/jieli/BleService.kt | 2 +- local_plugins/open_ai_service/LICENSE | 0 .../open_ai_service/OpenAIService.kt | 141 ++++---- .../open_ai_service/OpenAIServicePlugin.kt | 169 +++++----- .../open_ai_service/SystemFunctionHandler.kt | 4 +- .../open_ai_service/ios/Classes/.gitkeep | 0 .../ios/Classes/OpenAIService.swift | 296 ++++++++++------- .../ios/open_ai_service.podspec | 23 ++ local_plugins/volcano_speech/LICENSE | 0 .../volcano_speech/ios/Classes/.gitkeep | 0 .../volcano_speech/ios/volcano_speech.podspec | 23 ++ pubspec.yaml | 4 +- test.json | 204 +++++++++--- 40 files changed, 991 insertions(+), 552 deletions(-) create mode 100644 local_plugins/azure_speech/LICENSE create mode 100644 local_plugins/azure_speech/ios/Classes/.gitkeep create mode 100644 local_plugins/azure_speech/ios/azure_speech.podspec create mode 100644 local_plugins/chat_storage/LICENSE rename local_plugins/{chat_storage_sqlite => chat_storage}/README.md (82%) rename local_plugins/{chat_storage_sqlite => chat_storage}/android/build.gradle.kts (96%) create mode 100644 local_plugins/chat_storage/android/settings.gradle.kts rename local_plugins/{chat_storage_sqlite => chat_storage}/android/src/main/AndroidManifest.xml (82%) rename local_plugins/{chat_storage_sqlite/android/src/main/kotlin/com/yunqiinnovation/chat_storage_sqlite/ChatStorageSqlitePlugin.kt => chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt} (98%) create mode 100644 local_plugins/chat_storage/ios/Classes/.gitkeep create mode 100644 local_plugins/chat_storage/ios/chat_storage.podspec rename local_plugins/{chat_storage_sqlite/lib/chat_storage_sqlite.dart => chat_storage/lib/chat_storage.dart} (98%) rename local_plugins/{chat_storage_sqlite => chat_storage}/pubspec.yaml (61%) delete mode 100644 local_plugins/chat_storage_sqlite/android/settings.gradle.kts create mode 100644 local_plugins/open_ai_service/LICENSE create mode 100644 local_plugins/open_ai_service/ios/Classes/.gitkeep create mode 100644 local_plugins/open_ai_service/ios/open_ai_service.podspec create mode 100644 local_plugins/volcano_speech/LICENSE create mode 100644 local_plugins/volcano_speech/ios/Classes/.gitkeep create mode 100644 local_plugins/volcano_speech/ios/volcano_speech.podspec diff --git a/.cursorrules b/.cursorrules index 6e3d3fd0c..a81ae52e0 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1,10 +1,10 @@ -1. 不要主动运行程序, 如flutter run -2. 不要改动android, iOS项目的配置信息,包括SDK, NDK版本等 -3. 不要使用print, 而是使用logger -4. 尽可能使用GetX提供的各种功能, 而不是使用原生的StatefulWidget -5. 不要增加重试逻辑,出现失败直接返回错误 -6. 尽可能避免使用try catch -7. 尽可能避免打印日志,仅在出错时记录日志 +1. 请严格遵循 Effective Dart 的风格指南来编写代码 +2. 不要主动运行程序, 如flutter run +3. 不要改动android, iOS项目的配置信息,包括SDK, NDK, JAVA版本等 +4. 不要使用print, 而是使用logger, 尽可能避免打印日志,仅在出错时记录日志 +5. 尽可能使用GetX提供的各种功能, 而不是使用原生的StatefulWidget +6. 不要增加重试逻辑,出现失败直接返回错误 +7. 尽可能避免使用try catch 8. 请使用较小的代码量改动,最安全的方法进行代码编写和优化 9. 请尽可能使用中文回复问题 10. 重要:不要过度设计!! diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 009a8926c..e388c263b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -108,7 +108,8 @@ dependencies { implementation(project(":azure_speech")) implementation(project(":open_ai_service")) implementation(project(":volcano_speech")) - implementation(project(":chat_storage_sqlite")) // 添加新创建的SQLite聊天存储插件 + implementation(project(":chat_storage")) // 添加新创建的SQLite聊天存储插件 + implementation(project(":jieli")) // 添加新创建的SQLite聊天存储插件 // 添加OkHttp依赖 implementation("com.squareup.okhttp3:okhttp:4.9.3") diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 74e47be3f..bf3d50c8a 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -34,10 +34,12 @@ include(":app") include(":azure_speech") include(":open_ai_service") include(":volcano_speech") -include(":chat_storage_sqlite") +include(":chat_storage") +include(":jieli") // 设置azure_speech项目的路径 project(":azure_speech").projectDir = file("../local_plugins/azure_speech/android") project(":open_ai_service").projectDir = file("../local_plugins/open_ai_service/android") project(":volcano_speech").projectDir = file("../local_plugins/volcano_speech/android") -project(":chat_storage_sqlite").projectDir = file("../local_plugins/chat_storage_sqlite/android") +project(":chat_storage").projectDir = file("../local_plugins/chat_storage/android") +project(":jieli").projectDir = file("../local_plugins/jieli/android") diff --git a/lib/core/widgets/message_bubble.dart b/lib/core/widgets/message_bubble.dart index 7b125ea7c..0ddbd836e 100644 --- a/lib/core/widgets/message_bubble.dart +++ b/lib/core/widgets/message_bubble.dart @@ -52,7 +52,7 @@ class MessageBubble extends StatelessWidget { ), // 加载指示器 - if (message.isLoading) + if (message.isIntermediate) Padding( padding: EdgeInsets.only(top: message.content.isNotEmpty ? 8.h : 0), child: _buildLoadingIndicator(), diff --git a/lib/data/services/chat_history_service.dart b/lib/data/services/chat_history_service.dart index 8211075fc..8749c5714 100644 --- a/lib/data/services/chat_history_service.dart +++ b/lib/data/services/chat_history_service.dart @@ -1,16 +1,13 @@ import 'dart:convert'; import 'package:get/get.dart'; -import 'package:get_storage/get_storage.dart'; import '../../modules/chat/models/message_model.dart'; import '../../core/utils/logger.dart'; +import 'package:chat_storage/chat_storage.dart'; /// 聊天记录管理服务,负责保存和加载聊天记录 class ChatHistoryService extends GetxService { - final GetStorage _storage = GetStorage(); - - // 存储键名 - static const String _historyKeyPrefix = 'chat_history_'; - static const int _maxHistoryCount = 10; // 每个对话最多保存的消息数 + // 最大历史记录数量 + static const int _maxHistoryCount = 200; // 每个对话最多保存的消息数 @override void onInit() { @@ -18,50 +15,95 @@ class ChatHistoryService extends GetxService { Logger.info('ChatHistoryService 已初始化'); } - /// 生成历史记录存储键 - String _getHistoryKey(String agentId) => '$_historyKeyPrefix$agentId'; - /// 保存聊天记录 /// /// [agentId] 代理ID /// [messages] 消息列表 Future saveHistory(String agentId, List messages) async { try { - // 过滤掉加载中的消息 - final filteredMessages = messages.where((m) => !m.isLoading).toList(); + if (messages.isEmpty) return; - // 如果消息超过最大数量,只保留最新的消息 - final messagesToSave = filteredMessages.length > _maxHistoryCount - ? filteredMessages.sublist(filteredMessages.length - _maxHistoryCount) - : filteredMessages; + // 获取最新的消息 + final latestMessage = messages.last; - // 转换为JSON - final jsonList = messagesToSave.map((m) => m.toJson()).toList(); - final jsonString = jsonEncode(jsonList); + // 如果是加载中的消息则跳过 + if (latestMessage.isIntermediate) return; - // 保存到存储 - await _storage.write(_getHistoryKey(agentId), jsonString); - // Logger.info('已保存聊天记录,代理: $agentId,消息数: ${messagesToSave.length}'); - + // 直接插入最新的消息到数据库 + await ChatStorage.saveMessage( + sessionId: agentId, + message: latestMessage.content, + sender: latestMessage.role, + ); + + // 检查并清理过多的历史记录 + await _cleanupOldMessages(agentId); } catch (e) { Logger.error('保存聊天记录失败', e); } } + /// 清理旧消息,保持每个会话的消息数量不超过最大限制 + Future _cleanupOldMessages(String agentId) async { + try { + final pagedResult = await ChatStorage.getMessages( + sessionId: agentId, + page: 1, + pageSize: 1, // 只获取总数信息 + ); + + // 如果消息总数超过最大限制,删除最早的消息 + if (pagedResult.totalCount > _maxHistoryCount) { + final messagesToDelete = pagedResult.totalCount - _maxHistoryCount; + // 获取最早的N条消息ID + final oldestMessages = await ChatStorage.getMessages( + sessionId: agentId, + page: 1, + pageSize: messagesToDelete, + ); + + // 构建要删除的消息ID列表 + final List messageIds = oldestMessages.messages + .where((m) => m.id != null) + .map((m) => m.id!) + .toList(); + + // 删除这些消息 + if (messageIds.isNotEmpty) { + await ChatStorage.deleteMessages(messageIds: messageIds); + } + } + } catch (e) { + Logger.error('清理旧消息失败', e); + } + } + /// 加载聊天记录 /// /// [agentId] 代理ID /// 返回消息列表,如果没有找到或解析错误则返回空列表 - List loadHistory(String agentId) { + Future> loadHistory(String agentId) async { try { - final jsonString = _storage.read(_getHistoryKey(agentId)); + final pagedResult = await ChatStorage.getMessages( + sessionId: agentId, + page: 1, + pageSize: _maxHistoryCount, + ); - if (jsonString == null) { + if (pagedResult.messages.isEmpty) { return []; } - final jsonList = jsonDecode(jsonString) as List; - final messages = jsonList.map((json) => Message.fromJson(json)).toList(); + // 将ChatStorage.ChatMessage转换为Message + final messages = pagedResult.messages.map((chatMessage) { + return Message( + role: chatMessage.sender, + content: chatMessage.message, + timestamp: DateTime.parse(chatMessage.timestamp), // 直接使用chatMessage的timestamp + // isLoading 默认false + // isLoading: metadata['isLoading'] ?? false, + ); + }).toList(); return messages; } catch (e) { @@ -73,7 +115,7 @@ class ChatHistoryService extends GetxService { /// 清除特定代理的聊天记录 Future clearHistory(String agentId) async { try { - await _storage.remove(_getHistoryKey(agentId)); + await ChatStorage.deleteMessages(sessionId: agentId); Logger.info('已清除聊天记录,代理: $agentId'); } catch (e) { Logger.error('清除聊天记录失败', e); @@ -83,12 +125,7 @@ class ChatHistoryService extends GetxService { /// 清除所有聊天记录 Future clearAllHistory() async { try { - final keys = _storage.getKeys(); - for (final key in keys) { - if (key.startsWith(_historyKeyPrefix)) { - await _storage.remove(key); - } - } + await ChatStorage.clearDatabase(); Logger.info('已清除所有聊天记录'); } catch (e) { Logger.error('清除所有聊天记录失败', e); diff --git a/lib/data/services/open_ai_service_adapter.dart b/lib/data/services/open_ai_service_adapter.dart index 7eb337605..b93ce6cf0 100644 --- a/lib/data/services/open_ai_service_adapter.dart +++ b/lib/data/services/open_ai_service_adapter.dart @@ -4,12 +4,22 @@ import 'package:open_ai_service/open_ai_service.dart'; import 'package:get/get.dart'; import 'ai_service.dart'; +/// 定义流事件类型,用于区分不同类型的事件 +enum StreamEventType { token, complete, error } + +/// 流事件包装类 +class StreamEvent { + final StreamEventType type; + final String? content; + + StreamEvent(this.type, {this.content}); +} + /// OpenAI服务适配器 - 连接AiService接口与OpenAIService插件 class OpenAIServiceAdapter implements AiService { final OpenAIService _openAIService = OpenAIService(); StreamSubscription? _eventSubscription; - final StreamController _tokenStreamController = StreamController.broadcast(); - bool _isProcessingStream = false; + final StreamController _tokenStreamController = StreamController.broadcast(); /// 构造函数 OpenAIServiceAdapter() { @@ -26,46 +36,51 @@ class OpenAIServiceAdapter implements AiService { // 设置事件处理 _eventSubscription = _openAIService.eventStream.listen( (event) { - if (!_isProcessingStream) return; - try { switch (event.type) { case OpenAIEventType.token: if (event.content is String) { - _tokenStreamController.add(event.content as String); + _tokenStreamController.add(StreamEvent( + StreamEventType.token, + content: event.content as String + )); } else { printInfo(info: '收到非字符串类型的token: ${event.content}'); } break; case OpenAIEventType.complete: - _isProcessingStream = false; + _tokenStreamController.add(StreamEvent(StreamEventType.complete)); break; case OpenAIEventType.error: - _tokenStreamController.addError('未知错误: ${event.content}'); - - _isProcessingStream = false; + _tokenStreamController.add(StreamEvent( + StreamEventType.error, + content: '未知错误: ${event.content}' + )); break; case OpenAIEventType.functionCall: - _tokenStreamController.addError('收到函数调用,该流仅支持文本响应'); - - - _isProcessingStream = false; + _tokenStreamController.add(StreamEvent( + StreamEventType.error, + content: '收到函数调用,该流仅支持文本响应' + )); break; } } catch (e) { printError(info: '处理事件出错: $e'); - _tokenStreamController.addError('处理事件失败: $e'); - _isProcessingStream = false; + _tokenStreamController.add(StreamEvent( + StreamEventType.error, + content: '处理事件失败: $e' + )); } }, onError: (error) { printError(info: '事件流错误: $error'); - _tokenStreamController.addError('事件流错误: $error'); - _isProcessingStream = false; + _tokenStreamController.add(StreamEvent( + StreamEventType.error, + content: '事件流错误: $error' + )); }, onDone: () { printInfo(info: '事件流已关闭'); - _isProcessingStream = false; }, ); } catch (e) { @@ -147,20 +162,28 @@ class OpenAIServiceAdapter implements AiService { // 创建用于接收token的控制器 final localController = StreamController(); - // 标记开始处理流 - _isProcessingStream = true; - // 添加从广播流到本地流的订阅 final subscription = _tokenStreamController.stream.listen( - (token) => localController.add(token), - onError: (error) { - printError(info: '令牌流错误: $error'); - localController.addError(error); - localController.close(); - }, - onDone: () { - printInfo(info: '令牌流已完成'); - localController.close(); + (streamEvent) { + switch (streamEvent.type) { + case StreamEventType.token: + if (streamEvent.content != null && !localController.isClosed) { + localController.add(streamEvent.content!); + } + break; + case StreamEventType.complete: + if (!localController.isClosed) { + localController.close(); + } + break; + case StreamEventType.error: + if (!localController.isClosed) { + printError(info: '令牌流错误: ${streamEvent.content}'); + localController.addError(streamEvent.content ?? '未知错误'); + localController.close(); + } + break; + } } ); @@ -177,17 +200,19 @@ class OpenAIServiceAdapter implements AiService { ); } catch (e) { printError(info: '启动消息流失败: $e'); - localController.addError('启动消息流失败: $e'); - localController.close(); - _isProcessingStream = false; + if (!localController.isClosed) { + localController.addError('启动消息流失败: $e'); + localController.close(); + } throw '启动消息流失败: $e'; } if (!started) { printError(info: '无法启动消息流'); - localController.addError('无法启动消息流'); - localController.close(); - _isProcessingStream = false; + if (!localController.isClosed) { + localController.addError('无法启动消息流'); + localController.close(); + } throw '无法启动消息流'; } @@ -224,7 +249,6 @@ class OpenAIServiceAdapter implements AiService { /// 释放资源 void dispose() { try { - _isProcessingStream = false; _eventSubscription?.cancel(); _tokenStreamController.close(); printInfo(info: 'OpenAIServiceAdapter资源已释放'); @@ -232,5 +256,4 @@ class OpenAIServiceAdapter implements AiService { printError(info: '释放资源时出错: $e'); } } - } \ No newline at end of file diff --git a/lib/data/services/voice_interaction_impl/android_voice_interaction_service.dart b/lib/data/services/voice_interaction_impl/android_voice_interaction_service.dart index 5a3831d79..f4771ffac 100644 --- a/lib/data/services/voice_interaction_impl/android_voice_interaction_service.dart +++ b/lib/data/services/voice_interaction_impl/android_voice_interaction_service.dart @@ -267,12 +267,12 @@ class AndroidVoiceInteractionService extends GetxService { } /// 保存聊天记录 - void _saveChatHistory(String agentId, String userMessage, String assistantMessage, int timestamp) { + Future _saveChatHistory(String agentId, String userMessage, String assistantMessage, int timestamp) async { try { - // 获取ChatHistoryService实例 - final chatHistoryService = Get.find(); + if (userMessage.isEmpty) { + return; + } - // 创建用户消息和助手消息 final userMsg = Message( role: 'user', content: userMessage, @@ -285,14 +285,17 @@ class AndroidVoiceInteractionService extends GetxService { timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp + 1), // 确保助手消息时间戳晚于用户消息 ); + // 获取ChatHistoryService实例 + final chatHistoryService = Get.find(); + // 加载现有历史记录 - final existingMessages = chatHistoryService.loadHistory(agentId); + final existingMessages = await chatHistoryService.loadHistory(agentId); // 添加新消息 existingMessages.addAll([userMsg, assistantMsg]); // 保存更新后的历史记录 - chatHistoryService.saveHistory(agentId, existingMessages); + await chatHistoryService.saveHistory(agentId, existingMessages); } catch (e) { Logger.error('保存聊天记录失败: $e'); } diff --git a/lib/data/services/voice_interaction_impl/ios_voice_interaction_service.dart b/lib/data/services/voice_interaction_impl/ios_voice_interaction_service.dart index 2d129b050..e9c9e1410 100644 --- a/lib/data/services/voice_interaction_impl/ios_voice_interaction_service.dart +++ b/lib/data/services/voice_interaction_impl/ios_voice_interaction_service.dart @@ -250,7 +250,7 @@ class IosVoiceInteractionService extends GetxService { ); // 保存聊天历史 - _saveChatHistory(userMsg, assistantMsg); + await _saveChatHistory(userMsg, assistantMsg); // 发送聊天历史事件 _sendChatHistoryEvent(userMsg.content, assistantMsg.content); @@ -424,16 +424,16 @@ class IosVoiceInteractionService extends GetxService { } // 保存聊天历史 - void _saveChatHistory(Message userMsg, Message assistantMsg) { + Future _saveChatHistory(Message userMsg, Message assistantMsg) async { try { // 加载现有历史记录 - final existingMessages = _chatHistoryService.loadHistory(_agentId); + final existingMessages = await _chatHistoryService.loadHistory(_agentId); // 添加新消息 existingMessages.addAll([userMsg, assistantMsg]); // 保存更新后的历史记录 - _chatHistoryService.saveHistory(_agentId, existingMessages); + await _chatHistoryService.saveHistory(_agentId, existingMessages); Logger.info('保存聊天历史记录成功'); } catch (e) { diff --git a/lib/data/services/voice_interaction_service.dart b/lib/data/services/voice_interaction_service.dart index 57e8636c5..6adafa5c4 100644 --- a/lib/data/services/voice_interaction_service.dart +++ b/lib/data/services/voice_interaction_service.dart @@ -107,7 +107,7 @@ class VoiceInteractionService extends GetxService { _eventStreamController.add(chatHistoryEvent); // 保存聊天历史到ChatHistoryService - _saveChatHistory(agentId, userMessage, assistantMessage, DateTime.now().millisecondsSinceEpoch); + _saveChatHistory(agentId, userMessage, assistantMessage); break; case 'enter_translation_mode': @@ -132,12 +132,9 @@ class VoiceInteractionService extends GetxService { } /// 保存聊天历史 - void _saveChatHistory(String agentId, String userMessage, String assistantMessage, int timestamp) { + Future _saveChatHistory(String agentId, String userMessage, String assistantMessage) async { try { - // 检查参数有效性 - if (userMessage.isEmpty) { - return; - } + final timestamp = DateTime.now().millisecondsSinceEpoch; // 创建用户消息和助手消息 final userMsg = Message( @@ -153,13 +150,13 @@ class VoiceInteractionService extends GetxService { ); // 加载现有历史记录 - final existingMessages = _chatHistoryService.loadHistory(agentId); + final existingMessages = await _chatHistoryService.loadHistory(agentId); // 添加新消息 existingMessages.addAll([userMsg, assistantMsg]); // 保存更新后的历史记录 - _chatHistoryService.saveHistory(agentId, existingMessages); + await _chatHistoryService.saveHistory(agentId, existingMessages); Logger.info('已保存聊天历史: agentId=$agentId'); } catch (e) { diff --git a/lib/modules/chat/controllers/chat_controller.dart b/lib/modules/chat/controllers/chat_controller.dart index 77fa84a9f..99073e715 100644 --- a/lib/modules/chat/controllers/chat_controller.dart +++ b/lib/modules/chat/controllers/chat_controller.dart @@ -2,18 +2,17 @@ import 'dart:async'; import 'package:get/get.dart'; import 'package:flutter/widgets.dart'; import 'package:get_storage/get_storage.dart'; +import 'package:chat_storage/chat_storage.dart'; import '../../../data/services/ai_service.dart'; import '../../../data/services/deapsound_ai_service.dart'; import '../../../data/services/kouzi_ai_service.dart'; import '../../../data/services/speech_impl/voice_clone_tts_service.dart'; import '../models/message_model.dart'; import '../../../core/utils/logger.dart'; -import '../../../data/services/volcano_ai_service.dart'; import '../../../data/services/tts_service.dart'; import '../../../data/providers/agent_provider.dart'; import '../../../data/models/agent.dart'; import '../../../data/services/asr_service.dart'; -import '../../../data/services/chat_history_service.dart'; import '../../../data/models/events/voice_interaction_event.dart'; import '../../../data/services/voice_interaction_service.dart'; import '../../../data/services/open_ai_service_adapter.dart'; @@ -23,7 +22,6 @@ class ChatController extends GetxController { late AiService _aiService; late TtsService _ttsService; final AsrService _asrService = Get.find(); - final ChatHistoryService _historyService = Get.find(); final VoiceInteractionService _voiceInteractionService = Get.find(); @@ -45,6 +43,13 @@ class ChatController extends GetxController { final RxBool isTtsEnabled = true.obs; final RxBool isVoiceRecognitionActive = false.obs; + // 分页加载状态 + final RxInt _currentPage = 1.obs; + final RxInt _totalPages = 1.obs; + final RxBool _isLoadingMore = false.obs; + final RxBool _hasMoreHistory = true.obs; + static const int _pageSize = 20; // 每页加载的消息数量 + // 语音交互模式 bool _isVoiceInteractionMode = false; @@ -53,9 +58,6 @@ class ChatController extends GetxController { final RxBool isListening = false.obs; final RxBool isSpeechDetected = false.obs; - // 临时消息索引,用于更新临时消息 - int _tempMessageIndex = -1; - // 防抖计时器 Timer? _scrollDebounceTimer; @@ -107,14 +109,9 @@ class ChatController extends GetxController { break; default: // 默认使用OpenAIServiceAdapter - try { _aiService = Get.find(); Logger.info('使用OpenAIServiceAdapter'); - } catch (e) { - // 如果找不到OpenAIServiceAdapter,则回退到VolcanoAIService - Logger.info('未找到OpenAIServiceAdapter,回退使用VolcanoAIService: $e'); - _aiService = Get.find(); - } + } // 使用克隆音色语音合成 @@ -136,8 +133,11 @@ class ChatController extends GetxController { // 设置TTS服务 await _ttsService.setVoice(agent.voice); - // 加载聊天历史 - _loadChatHistory(); + // 添加滚动监听器,用于触发加载更多 + scrollController.addListener(_scrollListener); + + // 加载聊天历史 (加载第一页) + await _loadChatHistory(); // 如果没有历史记录,添加欢迎消息 if (messages.isEmpty && agent.welcomeMessage.isNotEmpty) { @@ -213,7 +213,9 @@ class ChatController extends GetxController { messages.add(userMsg); messages.add(assistantMsg); } - _scrollToBottom(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToBottom(); + }); } /// 停止当前交互 @@ -239,6 +241,7 @@ class ChatController extends GetxController { Logger.info('ChatController onClose'); // 清理资源 + scrollController.removeListener(_scrollListener); // 移除监听器 scrollController.dispose(); _aiResponseSubscription?.cancel(); _recognitionSubscription?.cancel(); @@ -255,7 +258,7 @@ class ChatController extends GetxController { // 保存当前聊天记录 if (agent.id != null) { - _historyService.saveHistory(agent.id!, messages.toList()); + // _historyService.saveHistory(agent.id!, messages.toList()); } super.onClose(); @@ -289,11 +292,11 @@ class ChatController extends GetxController { _aiResponseSubscription!.cancel(); _aiResponseSubscription = null; - // 将加载中的消息转换为正常消息,保留已输出的内容 - final index = messages.indexWhere((m) => m.isLoading); + // 将AI中间消息转换为正常消息,保留已输出的内容 + final index = messages.indexWhere((m) => m.isIntermediate && m.role == 'assistant'); if (index != -1) { messages[index] = messages[index].copyWith( - isLoading: false, + isIntermediate: false, ); messages.refresh(); } @@ -323,10 +326,10 @@ class ChatController extends GetxController { // 清空输入框 inputText.value = ''; - // 添加加载中的消息 - final loadingMessage = Message.loading(); - messages.add(loadingMessage); - + // 添加中间状态的AI消息 + final intermediateMessage = Message.intermediate('assistant'); + messages.add(intermediateMessage); + messages.refresh(); WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToBottom(); }); @@ -355,8 +358,8 @@ class ChatController extends GetxController { fullResponse += chunk; - // 更新加载中的消息内容 - _updateLoadingMessage(fullResponse); + // 更新中间状态的消息内容 + _updateIntermediateMessage(fullResponse); // 如果TTS启用,使用流式处理发送到TTS if (isTtsEnabled.value) { @@ -366,21 +369,18 @@ class ChatController extends GetxController { onDone: () { if (_shouldCancelAiResponse) return; - // 完成响应,将isLoading设为false + // 完成响应,将isIntermediate设为false _finalizeResponse(fullResponse); // 如果TTS启用,处理缓冲区中的剩余文本 if (isTtsEnabled.value) { _ttsService.flushStream(); } - - // 保存聊天历史 - _saveChatHistory(); }, onError: (error) { Logger.error('AI响应错误', error); - // 更新加载中的消息为错误消息 + // 更新中间状态的消息为错误消息 _handleResponseError(); }, ); @@ -396,7 +396,7 @@ class ChatController extends GetxController { final List> messageHistory = []; // 只取最近的10条消息作为上下文 - final recentMessages = messages.where((m) => !m.isLoading).toList(); + final recentMessages = messages.where((m) => !m.isIntermediate).toList(); final startIndex = recentMessages.length > 10 ? recentMessages.length - 10 : 0; @@ -411,42 +411,61 @@ class ChatController extends GetxController { return messageHistory; } - // 更新加载中的消息 - void _updateLoadingMessage(String content) { - final index = messages.indexWhere((m) => m.isLoading); + // 更新中间状态的消息 + void _updateIntermediateMessage(String content) { + final index = messages.indexWhere((m) => m.isIntermediate && m.role == 'assistant'); if (index != -1) { messages[index] = messages[index].copyWith( content: content, - isLoading: true, ); messages.refresh(); _scrollToBottom(); } } - // 完成响应 + // 更新或添加最终响应 void _finalizeResponse(String content) { - final index = messages.indexWhere((m) => m.isLoading); + // 查找AI的中间状态消息 + final index = messages.indexWhere((m) => m.isIntermediate && m.role == 'assistant'); if (index != -1) { + // 更新中间状态消息为最终状态 messages[index] = messages[index].copyWith( content: content, - isLoading: false, + isIntermediate: false, + timestamp: DateTime.now(), // 更新时间戳为最终时间 ); - messages.refresh(); - _scrollToBottom(); + // 保存最终的助手消息到数据库 + _saveMessageToDb(messages[index]); + } else { + // 如果没有中间状态消息,直接添加新消息 (理论上不应发生) + final assistantMsg = Message( + role: 'assistant', + content: content, + timestamp: DateTime.now(), + ); + messages.add(assistantMsg); + _saveMessageToDb(assistantMsg); // 保存到数据库 } - + + isLoading.value = false; isSending.value = false; - _aiResponseSubscription = null; + _aiResponseSubscription = null; // 清理订阅 + _shouldCancelAiResponse = false; // 重置取消标志 + + // 响应完成后,重新启动语音识别的自动停止计时器 + if (_isVoiceInteractionMode) { + _startAutoStopTimer(); + } } // 处理响应错误 void _handleResponseError() { - final index = messages.indexWhere((m) => m.isLoading); + // 查找AI的中间状态消息 + final index = messages.indexWhere((m) => m.isIntermediate && m.role == 'assistant'); if (index != -1) { messages[index] = messages[index].copyWith( content: '抱歉,我遇到了一些问题,无法回应您的请求。', - isLoading: false, + isIntermediate: false, ); messages.refresh(); } @@ -479,9 +498,6 @@ class ChatController extends GetxController { isListening.value = true; recognizedText.value = ''; - // 重置临时消息索引 - _tempMessageIndex = -1; - // 启动自动停止计时器 _startAutoStopTimer(); @@ -573,24 +589,23 @@ class ChatController extends GetxController { _interruptCurrentResponse("检测到用户开始讲话,中断当前响应"); } - // 更新或创建临时消息 - if (_tempMessageIndex >= 0 && _tempMessageIndex < messages.length) { - // 更新已有的临时消息 - messages[_tempMessageIndex] = messages[_tempMessageIndex].copyWith( + // 查找或创建用户的中间状态消息 + final intermediateIndex = messages.indexWhere((m) => m.isIntermediate && m.role == 'user'); + + if (intermediateIndex >= 0) { + // 更新已有的中间状态消息 + messages[intermediateIndex] = messages[intermediateIndex].copyWith( content: text, ); messages.refresh(); } else { - // 添加新的临时消息 - final tempMessage = Message( - role: 'user', - content: text, - timestamp: DateTime.now(), - ); - messages.add(tempMessage); - _tempMessageIndex = messages.length - 1; + // 添加新的中间状态消息 + var intermediateMessage = Message.intermediate('user'); + intermediateMessage = intermediateMessage.copyWith(content: text); + messages.add(intermediateMessage); messages.refresh(); } + WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToBottom(); }); @@ -606,14 +621,12 @@ class ChatController extends GetxController { // 更新活动时间 _updateActivityTime(); - // 查找并移除临时消息 - if (_tempMessageIndex >= 0 && _tempMessageIndex < messages.length) { - messages.removeAt(_tempMessageIndex); + // 查找并移除用户的中间状态消息 + final intermediateIndex = messages.indexWhere((m) => m.isIntermediate && m.role == 'user'); + if (intermediateIndex >= 0) { + messages.removeAt(intermediateIndex); } - // 重置临时消息索引 - _tempMessageIndex = -1; - // 发送识别到的文本 sendMessage(text); @@ -640,16 +653,20 @@ class ChatController extends GetxController { // 清理语音识别资源 void _cleanupRecognition() { - // 查找并移除临时消息 - if (_tempMessageIndex >= 0 && _tempMessageIndex < messages.length) { - if (messages[_tempMessageIndex].content.trim().isEmpty) { - messages.removeAt(_tempMessageIndex); + // 查找并移除用户的中间状态消息 + final intermediateIndex = messages.indexWhere((m) => m.isIntermediate && m.role == 'user'); + if (intermediateIndex >= 0) { + if (messages[intermediateIndex].content.trim().isEmpty) { + messages.removeAt(intermediateIndex); + } else { + // 如果中间状态消息有内容,将其转换为正式消息 + messages[intermediateIndex] = messages[intermediateIndex].copyWith( + isIntermediate: false, + ); } + messages.refresh(); } - // 重置临时消息索引 - _tempMessageIndex = -1; - // 停止语音识别 isVoiceRecognitionActive.value = false; isListening.value = false; @@ -671,15 +688,20 @@ class ChatController extends GetxController { recognizedText.value = ''; isSpeechDetected.value = false; - // 查找并移除临时消息 - if (_tempMessageIndex >= 0 && _tempMessageIndex < messages.length) { - if (messages[_tempMessageIndex].content.trim().isEmpty) { - messages.removeAt(_tempMessageIndex); + // 查找并处理用户的中间状态消息 + final intermediateIndex = messages.indexWhere((m) => m.isIntermediate && m.role == 'user'); + if (intermediateIndex >= 0) { + if (messages[intermediateIndex].content.trim().isEmpty) { + // 如果没有内容则移除 + messages.removeAt(intermediateIndex); + } else { + // 有内容则转为正式消息 + messages[intermediateIndex] = messages[intermediateIndex].copyWith( + isIntermediate: false, + ); } + messages.refresh(); } - - // 重置临时消息索引 - _tempMessageIndex = -1; } catch (e) { Logger.error('停止语音识别失败', e); } @@ -688,7 +710,7 @@ class ChatController extends GetxController { // 清空聊天记录 void clearChat() { messages.clear(); - + _ttsService.stop(); // 添加欢迎消息 if (agent.welcomeMessage.isNotEmpty) { messages.add(Message( @@ -697,31 +719,119 @@ class ChatController extends GetxController { timestamp: DateTime.now(), )); } + // 删除数据库中的历史记录 + ChatStorage.deleteMessages(sessionId: agent.id).catchError((e) { + Logger.error('清空数据库聊天记录失败: $e'); + }); + } + + // 滚动监听,用于触发加载更多历史记录 + void _scrollListener() { + // 当滚动到顶部并且有更多历史记录且不在加载中时,加载更多 + if (scrollController.position.pixels == scrollController.position.minScrollExtent && + _hasMoreHistory.value && + !_isLoadingMore.value) { + _loadMoreHistory(); + } + } + + // 加载聊天历史 (首次加载或刷新) + Future _loadChatHistory() async { + isLoading.value = true; + _currentPage.value = 1; // 重置为第一页 + _hasMoreHistory.value = true; // 假设有更多 + + try { + final pagedResult = await ChatStorage.getMessages( + sessionId: agent.id, + page: _currentPage.value, + pageSize: _pageSize, + ); + + _totalPages.value = pagedResult.totalPages; + _hasMoreHistory.value = _currentPage.value < _totalPages.value; - // 删除存储的历史记录 - _historyService.clearHistory(agent.id); + final historyMessages = pagedResult.messages.map((chatMsg) { + return Message( + role: chatMsg.sender, + content: chatMsg.message, + timestamp: DateTime.parse(chatMsg.timestamp), + ); + }).toList(); + + messages.assignAll(historyMessages.reversed); // 最新的在底部,所以要反转 + + // 滚动到底部 (如果是首次加载) + WidgetsBinding.instance.addPostFrameCallback((_) { + if (scrollController.hasClients) { + scrollController.jumpTo(scrollController.position.maxScrollExtent); + } + }); + + } catch (e) { + Logger.error('加载聊天历史失败', e); + } finally { + isLoading.value = false; + } } - // 加载聊天历史 - void _loadChatHistory() { - // 从历史服务加载 - final history = _historyService.loadHistory(agent.id); - if (history.isNotEmpty) { - messages.clear(); - messages.addAll(history); + // 加载更多历史记录 + Future _loadMoreHistory() async { + if (_isLoadingMore.value || !_hasMoreHistory.value) return; + + _isLoadingMore.value = true; + _currentPage.value++; + + try { + final pagedResult = await ChatStorage.getMessages( + sessionId: agent.id, + page: _currentPage.value, + pageSize: _pageSize, + ); + + _totalPages.value = pagedResult.totalPages; + _hasMoreHistory.value = _currentPage.value < _totalPages.value; + + final olderMessages = pagedResult.messages.map((chatMsg) { + return Message( + role: chatMsg.sender, + content: chatMsg.message, + timestamp: DateTime.parse(chatMsg.timestamp), + ); + }).toList(); + + // 将旧消息插入到列表顶部 + messages.insertAll(0, olderMessages.reversed); + + } catch (e) { + Logger.error('加载更多聊天历史失败', e); + _currentPage.value--; // 加载失败,恢复页码 + } finally { + _isLoadingMore.value = false; } } - // 保存聊天历史 - void _saveChatHistory() { - _historyService.saveHistory(agent.id, messages); + // 保存单条聊天消息到数据库 + Future _saveMessageToDb(Message message) async { + if (message.isIntermediate) return; // 不保存中间状态的消息 + try { + await ChatStorage.saveMessage( + sessionId: agent.id, + message: message.content, + sender: message.role, + ); + } catch (e) { + Logger.error('保存消息到数据库失败', e); + } } - // 添加消息,供外部调用 + // 添加消息,供外部调用 (如用户发送消息、AI回复) void addMessage(Message message) { messages.add(message); - _saveChatHistory(); - _scrollToBottom(); + _saveMessageToDb(message); // 保存到数据库 + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToBottom(); + }); } void _stopVoiceRecognitionAutomatically() { diff --git a/lib/modules/chat/models/message_model.dart b/lib/modules/chat/models/message_model.dart index 6307989ae..001853834 100644 --- a/lib/modules/chat/models/message_model.dart +++ b/lib/modules/chat/models/message_model.dart @@ -2,13 +2,13 @@ class Message { final String role; // 'user' or 'assistant' final String content; final DateTime timestamp; - final bool isLoading; + final bool isIntermediate; // 中间状态标记,替代isLoading Message({ required this.role, required this.content, required this.timestamp, - this.isLoading = false, + this.isIntermediate = false, // 默认不是中间状态 }); // 从JSON构造函数 @@ -17,7 +17,7 @@ class Message { role: json['role'] as String, content: json['content'] as String, timestamp: DateTime.parse(json['timestamp'] as String), - isLoading: json['isLoading'] as bool? ?? false, + isIntermediate: json['isIntermediate'] as bool? ?? false, ); } @@ -27,17 +27,17 @@ class Message { 'role': role, 'content': content, 'timestamp': timestamp.toIso8601String(), - 'isLoading': isLoading, + 'isIntermediate': isIntermediate, }; } - // 创建一个加载中的消息 - factory Message.loading() { + // 创建一个中间状态的消息 + factory Message.intermediate(String role) { return Message( - role: 'assistant', + role: role, content: '', timestamp: DateTime.now(), - isLoading: true, + isIntermediate: true, ); } @@ -46,13 +46,13 @@ class Message { String? role, String? content, DateTime? timestamp, - bool? isLoading, + bool? isIntermediate, }) { return Message( role: role ?? this.role, content: content ?? this.content, timestamp: timestamp ?? this.timestamp, - isLoading: isLoading ?? this.isLoading, + isIntermediate: isIntermediate ?? this.isIntermediate, ); } } \ No newline at end of file diff --git a/lib/modules/chat/views/chat_view.dart b/lib/modules/chat/views/chat_view.dart index 86d77e3a1..cfd64290d 100644 --- a/lib/modules/chat/views/chat_view.dart +++ b/lib/modules/chat/views/chat_view.dart @@ -312,7 +312,7 @@ class ChatView extends GetView { ), // 加载中动画 - if (message.isLoading) + if (message.isIntermediate) Padding( padding: EdgeInsets.only(top: 8.h), child: Row( diff --git a/lib/modules/splash/controllers/splash_controller.dart b/lib/modules/splash/controllers/splash_controller.dart index b5dd9d823..c9a7c38d1 100644 --- a/lib/modules/splash/controllers/splash_controller.dart +++ b/lib/modules/splash/controllers/splash_controller.dart @@ -34,7 +34,7 @@ class SplashController extends GetxController { // 进度条动画(持续2秒) void _startLoadingAnimation() { - const totalDuration = 2000; // 动画总时长2秒 + const totalDuration = 500; // 动画总时长2秒 const steps = totalDuration ~/ 50; // 40次更新(50ms间隔) final stepValue = 1.0 / steps; diff --git a/local_plugins/azure_speech/LICENSE b/local_plugins/azure_speech/LICENSE new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/azure_speech/ios/Classes/.gitkeep b/local_plugins/azure_speech/ios/Classes/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/azure_speech/ios/azure_speech.podspec b/local_plugins/azure_speech/ios/azure_speech.podspec new file mode 100644 index 000000000..bc179f0c1 --- /dev/null +++ b/local_plugins/azure_speech/ios/azure_speech.podspec @@ -0,0 +1,24 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint azure_speech.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'azure_speech' + s.version = '0.0.1' + s.summary = 'Azure Speech plugin' + s.description = <<-DESC +A Flutter plugin for Azure Speech services. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.dependency 'MicrosoftCognitiveServicesSpeech-iOS' + s.platform = :ios, '12.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end \ No newline at end of file diff --git a/local_plugins/chat_storage/LICENSE b/local_plugins/chat_storage/LICENSE new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/chat_storage_sqlite/README.md b/local_plugins/chat_storage/README.md similarity index 82% rename from local_plugins/chat_storage_sqlite/README.md rename to local_plugins/chat_storage/README.md index 8db279af0..223dfa095 100644 --- a/local_plugins/chat_storage_sqlite/README.md +++ b/local_plugins/chat_storage/README.md @@ -14,10 +14,10 @@ SQLite存储插件,用于存储聊天记录并支持分页查询。 ### 保存聊天记录 ```dart -import 'package:chat_storage_sqlite/chat_storage_sqlite.dart'; +import 'package:chat_storage/chat_storage.dart'; // 保存一条聊天记录 -final result = await ChatStorageSqlite.saveMessage( +final result = await ChatStorage.saveMessage( sessionId: 'session_123', message: '你好,这是一条测试消息', sender: 'user', @@ -35,7 +35,7 @@ if (result['success']) { ```dart // 获取指定会话的聊天记录,支持分页 -final pagedResult = await ChatStorageSqlite.getMessages( +final pagedResult = await ChatStorage.getMessages( sessionId: 'session_123', page: 1, // 当前页码,从1开始 pageSize: 20, // 每页记录数 @@ -60,17 +60,17 @@ for (var message in pagedResult.messages) { ```dart // 方式1:删除指定ID的消息 -final deleteResult1 = await ChatStorageSqlite.deleteMessages( +final deleteResult1 = await ChatStorage.deleteMessages( messageIds: [1, 2, 3], ); // 方式2:删除整个会话的所有消息 -final deleteResult2 = await ChatStorageSqlite.deleteMessages( +final deleteResult2 = await ChatStorage.deleteMessages( sessionId: 'session_123', ); // 方式3:清空整个数据库 -final clearResult = await ChatStorageSqlite.clearDatabase(); +final clearResult = await ChatStorage.clearDatabase(); ``` ## 注意事项 diff --git a/local_plugins/chat_storage_sqlite/android/build.gradle.kts b/local_plugins/chat_storage/android/build.gradle.kts similarity index 96% rename from local_plugins/chat_storage_sqlite/android/build.gradle.kts rename to local_plugins/chat_storage/android/build.gradle.kts index 78e1b31ea..458280df7 100644 --- a/local_plugins/chat_storage_sqlite/android/build.gradle.kts +++ b/local_plugins/chat_storage/android/build.gradle.kts @@ -8,7 +8,7 @@ plugins { android { // 命名空间,对应你插件的包名(需与代码内包名保持一致) - namespace = "com.yunqiinnovation.chat_storage_sqlite" + namespace = "com.yunqiinnovation.chat_storage" // 目标 SDK 版本 compileSdk = 33 diff --git a/local_plugins/chat_storage/android/settings.gradle.kts b/local_plugins/chat_storage/android/settings.gradle.kts new file mode 100644 index 000000000..2a25170b4 --- /dev/null +++ b/local_plugins/chat_storage/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "chat_storage" \ No newline at end of file diff --git a/local_plugins/chat_storage_sqlite/android/src/main/AndroidManifest.xml b/local_plugins/chat_storage/android/src/main/AndroidManifest.xml similarity index 82% rename from local_plugins/chat_storage_sqlite/android/src/main/AndroidManifest.xml rename to local_plugins/chat_storage/android/src/main/AndroidManifest.xml index b7fb5f78b..2ab64f02a 100644 --- a/local_plugins/chat_storage_sqlite/android/src/main/AndroidManifest.xml +++ b/local_plugins/chat_storage/android/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ + package="com.yunqiinnovation.chat_storage"> diff --git a/local_plugins/chat_storage_sqlite/android/src/main/kotlin/com/yunqiinnovation/chat_storage_sqlite/ChatStorageSqlitePlugin.kt b/local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt similarity index 98% rename from local_plugins/chat_storage_sqlite/android/src/main/kotlin/com/yunqiinnovation/chat_storage_sqlite/ChatStorageSqlitePlugin.kt rename to local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt index d302c4d7d..9e4711531 100644 --- a/local_plugins/chat_storage_sqlite/android/src/main/kotlin/com/yunqiinnovation/chat_storage_sqlite/ChatStorageSqlitePlugin.kt +++ b/local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt @@ -1,4 +1,4 @@ -package com.yunqiinnovation.chat_storage_sqlite +package com.yunqiinnovation.chat_storage import android.content.Context import android.database.Cursor @@ -65,14 +65,14 @@ private class ChatDatabaseHelper(context: Context) : } /** ChatStorageSqlitePlugin */ -class ChatStorageSqlitePlugin: FlutterPlugin, MethodCallHandler { +class ChatStoragePlugin: FlutterPlugin, MethodCallHandler { private lateinit var channel : MethodChannel private lateinit var context: Context private lateinit var dbHelper: ChatDatabaseHelper override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { context = flutterPluginBinding.applicationContext - channel = MethodChannel(flutterPluginBinding.binaryMessenger, "chat_storage_sqlite") + channel = MethodChannel(flutterPluginBinding.binaryMessenger, "chat_storage") channel.setMethodCallHandler(this) dbHelper = ChatDatabaseHelper(context) } diff --git a/local_plugins/chat_storage/ios/Classes/.gitkeep b/local_plugins/chat_storage/ios/Classes/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/chat_storage/ios/chat_storage.podspec b/local_plugins/chat_storage/ios/chat_storage.podspec new file mode 100644 index 000000000..b7a2c39b8 --- /dev/null +++ b/local_plugins/chat_storage/ios/chat_storage.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint chat_storage.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'chat_storage' + s.version = '0.0.1' + s.summary = 'Chat Storage plugin' + s.description = <<-DESC +A Flutter plugin for chat storage services. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '12.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end \ No newline at end of file diff --git a/local_plugins/chat_storage_sqlite/lib/chat_storage_sqlite.dart b/local_plugins/chat_storage/lib/chat_storage.dart similarity index 98% rename from local_plugins/chat_storage_sqlite/lib/chat_storage_sqlite.dart rename to local_plugins/chat_storage/lib/chat_storage.dart index f41f473a7..2119dd84b 100644 --- a/local_plugins/chat_storage_sqlite/lib/chat_storage_sqlite.dart +++ b/local_plugins/chat_storage/lib/chat_storage.dart @@ -62,8 +62,8 @@ class PagedResult { } /// SQLite聊天记录存储插件 -class ChatStorageSqlite { - static const MethodChannel _channel = MethodChannel('chat_storage_sqlite'); +class ChatStorage { + static const MethodChannel _channel = MethodChannel('chat_storage'); /// 保存一条聊天记录 static Future> saveMessage({ @@ -77,7 +77,6 @@ class ChatStorageSqlite { 'sessionId': sessionId, 'message': message, 'sender': sender, - 'metadata': metadata, }); return Map.from(result); } catch (e) { diff --git a/local_plugins/chat_storage_sqlite/pubspec.yaml b/local_plugins/chat_storage/pubspec.yaml similarity index 61% rename from local_plugins/chat_storage_sqlite/pubspec.yaml rename to local_plugins/chat_storage/pubspec.yaml index a4d6e1bf1..0a68a4a25 100644 --- a/local_plugins/chat_storage_sqlite/pubspec.yaml +++ b/local_plugins/chat_storage/pubspec.yaml @@ -1,7 +1,7 @@ -name: chat_storage_sqlite -description: SQLite存储插件,用于存储聊天记录并支持分页查询 +name: chat_storage +description: A Flutter plugin for storing chat messages using SQLite. version: 0.0.1 -homepage: +# homepage: environment: sdk: ">=2.17.0 <3.0.0" @@ -24,7 +24,7 @@ flutter: plugin: platforms: android: - package: com.yunqiinnovation.chat_storage_sqlite - pluginClass: ChatStorageSqlitePlugin + package: com.yunqiinnovation.chat_storage + pluginClass: ChatStoragePlugin ios: - pluginClass: ChatStorageSqlitePlugin \ No newline at end of file + pluginClass: ChatStoragePlugin \ No newline at end of file diff --git a/local_plugins/chat_storage_sqlite/android/settings.gradle.kts b/local_plugins/chat_storage_sqlite/android/settings.gradle.kts deleted file mode 100644 index 613be470e..000000000 --- a/local_plugins/chat_storage_sqlite/android/settings.gradle.kts +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = "chat_storage_sqlite" \ No newline at end of file diff --git a/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt b/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt index 0ba59a6e6..dec530b53 100644 --- a/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt +++ b/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt @@ -45,7 +45,7 @@ interface BleCallback { */ class BleService(private val context: Context) { companion object { - private const val TAG = "JieliBleCoreService" + private const val TAG = "JieliBleService" // 服务和特征UUID private const val AUDIO_SERVICE_UUID = "0000A100-0000-1000-8000-00805F9B34FB" diff --git a/local_plugins/open_ai_service/LICENSE b/local_plugins/open_ai_service/LICENSE new file mode 100644 index 000000000..e69de29bb 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 9e67d15f8..9ca318840 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 @@ -11,6 +11,7 @@ import kotlinx.coroutines.withContext import okhttp3.* import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.RequestBody.Companion.toRequestBody +import okio.Buffer import org.json.JSONArray import org.json.JSONObject import java.io.IOException @@ -389,8 +390,8 @@ class OpenAIService(private val context: Context? = null) { .build() // 输出用于测试的curl命令 - // val curlCommand = buildCurlCommand(request, requestBody.toString()) - // Log.d(TAG, "curl command: $curlCommand") + val curlCommand = buildCurlCommand(request, requestBody.toString()) + Log.d(TAG, "curl command: $curlCommand") client.newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { @@ -406,81 +407,95 @@ class OpenAIService(private val context: Context? = null) { val responseBody = response.body ?: return val source = responseBody.source() + val buffer = Buffer() try { - // 预取数据到缓冲区 - source.request(Long.MAX_VALUE) - val bufferedSource = source.buffer - // 用于存储函数调用的各个部分 val finalToolCalls = mutableMapOf() + val lineBuffer = StringBuilder() - while (!bufferedSource.exhausted()) { - val line = bufferedSource.readUtf8Line()?.trim() ?: continue - if (line.isEmpty()) continue - if (line.startsWith("data:")) { - val data = line.substring(5).trim() - - // 处理[DONE]消息 - if (data == "[DONE]" || data == "[\"DONE\"]") { - processToolCalls(finalToolCalls, callback, messages) - callback.onComplete() - break - } + // 逐块读取数据,而不是一次性预取 + val BUFFER_SIZE = 8192L // 8KB的块大小 + + while (!Thread.currentThread().isInterrupted) { + // 读取一小块数据 + val bytesRead = source.read(buffer, BUFFER_SIZE) + if (bytesRead == -1L) break // 流结束 + + // 处理接收到的数据 + while (true) { + val line = buffer.readUtf8Line() ?: break + if (line.trim().isEmpty()) continue - try { - val jsonData = JSONObject(data) + if (line.startsWith("data:")) { + val data = line.substring(5).trim() + Log.d("OpenAIService", "$data") - // 处理消息内容 - if (jsonData.has("choices")) { - val choices = jsonData.getJSONArray("choices") - if (choices.length() > 0) { - val choice = choices.getJSONObject(0) - - if (choice.has("delta")) { - val delta = choice.getJSONObject("delta") - - // 处理普通文本内容 - if (delta.has("content")) { - val content = delta.getString("content") - callback.onToken(content) - } + // 处理[DONE]消息 + if (data == "[DONE]" || data == "[\"DONE\"]") { + Log.d(TAG, "收到[DONE]消息") + val hasToolCalls = processToolCalls(finalToolCalls, callback, messages) + // 只有在没有工具调用时才认为对话真正完成 + if (!hasToolCalls) { + callback.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) - // 处理工具调用(函数调用) - 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 (choice.has("delta")) { + val delta = choice.getJSONObject("delta") + + // 处理普通文本内容 + if (delta.has("content")) { + val content = delta.getString("content") + 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() } - if (function.has("name")) { - toolCallInfo.name = function.getString("name") + // 更新ID + if (toolCall.has("id")) { + toolCallInfo.id = toolCall.getString("id") } - if (function.has("arguments")) { - toolCallInfo.arguments += function.getString("arguments") + // 更新函数信息 + 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") + } } } } } } } + } catch (e: Exception) { + // 忽略解析错误 + Log.e(TAG, "解析JSON出错: ${e.message}") } - } catch (e: Exception) { - // 忽略解析错误 - Log.e(TAG, "解析JSON出错: ${e.message}") } } } @@ -548,11 +563,11 @@ class OpenAIService(private val context: Context? = null) { /** * 处理工具调用结果 */ - private fun processToolCalls(toolCalls: Map, callback: StreamCallback, messages: JSONArray? = null) { - if (toolCalls.isEmpty()) return + private fun processToolCalls(toolCalls: Map, callback: StreamCallback, messages: JSONArray? = null): Boolean { + if (toolCalls.isEmpty()) return false // 只处理第一个工具调用 - val firstToolCall = toolCalls.entries.firstOrNull()?.value ?: return + val firstToolCall = toolCalls.entries.firstOrNull()?.value ?: return false if (firstToolCall.isValid()) { // 创建函数调用JSON对象 @@ -572,7 +587,9 @@ class OpenAIService(private val context: Context? = null) { } } } + return true } + return false } /** diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt index 1e1bb9446..091af559c 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt @@ -1,6 +1,8 @@ package com.yunqiinnovation.open_ai_service import android.content.Context +import android.os.Handler +import android.os.Looper import android.util.Log import androidx.annotation.NonNull import io.flutter.embedding.engine.plugins.FlutterPlugin @@ -43,8 +45,11 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { /// 事件接收器(用于流式输出) private var eventSink: EventSink? = null - /// 执行器(用于后台线程) + /// 执行器(用于真正需要在后台线程处理的任务) private val executor = Executors.newSingleThreadExecutor() + + /// 主线程Handler + private val mainHandler = Handler(Looper.getMainLooper()) override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { // 保存上下文 @@ -98,17 +103,17 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { messages.put(JSONObject(message)) } - // 在后台线程执行请求 + // 对于需要在后台执行的同步阻塞操作,仍然使用executor executor.execute { try { val response = openAIService.sendMessage(messages) // 在主线程返回结果 - android.os.Handler(android.os.Looper.getMainLooper()).post { + mainHandler.post { result.success(response) } } catch (e: Exception) { // 在主线程返回错误 - android.os.Handler(android.os.Looper.getMainLooper()).post { + mainHandler.post { result.error("OPENAI_ERROR", e.message, null) } } @@ -130,53 +135,47 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { messages.put(JSONObject(message)) } - // 在后台线程执行请求 - executor.execute { - try { - openAIService.sendMessageStream( - messages = messages, - callback = object : OpenAIService.StreamCallback { - override fun onToken(token: String) { - // 发送token事件 - android.os.Handler(android.os.Looper.getMainLooper()).post { - eventSink?.success(mapOf("type" to "token", "content" to token)) - } + // 不再在后台线程包装OkHttp的异步调用 + try { + openAIService.sendMessageStream( + messages = messages, + callback = object : OpenAIService.StreamCallback { + override fun onToken(token: String) { + // 发送token事件 + mainHandler.post { + eventSink?.success(mapOf("type" to "token", "content" to token)) } - - override fun onComplete() { - // 发送完成事件 - android.os.Handler(android.os.Looper.getMainLooper()).post { - eventSink?.success(mapOf("type" to "complete")) - } + } + + override fun onComplete() { + // 发送完成事件 + mainHandler.post { + eventSink?.success(mapOf("type" to "complete")) } - - override fun onError(e: Exception) { - // 发送错误事件 - android.os.Handler(android.os.Looper.getMainLooper()).post { - eventSink?.success(mapOf("type" to "error", "content" to e.message)) - } + } + + override fun onError(e: Exception) { + // 发送错误事件 + mainHandler.post { + eventSink?.success(mapOf("type" to "error", "content" to e.message)) } - - override fun onFunctionCall(functionCall: JSONObject) { - // 发送函数调用事件 - android.os.Handler(android.os.Looper.getMainLooper()).post { - val functionCallMap = functionCall.toMap() - eventSink?.success(mapOf("type" to "functionCall", "content" to functionCallMap)) - } + } + + override fun onFunctionCall(functionCall: JSONObject) { + // 发送函数调用事件 + mainHandler.post { + val functionCallMap = functionCall.toMap() + eventSink?.success(mapOf("type" to "functionCall", "content" to functionCallMap)) } } - ) - - // 请求已开始 - android.os.Handler(android.os.Looper.getMainLooper()).post { - result.success(true) } - } catch (e: Exception) { - // 在主线程返回错误 - android.os.Handler(android.os.Looper.getMainLooper()).post { - result.error("OPENAI_ERROR", e.message, null) - } - } + ) + + // 请求已开始,直接返回结果 + result.success(true) + } catch (e: Exception) { + // 直接在当前线程处理错误 + result.error("OPENAI_ERROR", e.message, null) } } @@ -200,55 +199,49 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { // 转换函数调用 val functionCall = JSONObject(functionCallRaw) - // 在后台线程执行请求 - executor.execute { - try { - openAIService.sendFunctionCallResult( - messages = messages, - functionCall = functionCall, - functionResult = functionResult, - callback = object : OpenAIService.StreamCallback { - override fun onToken(token: String) { - // 发送token事件 - android.os.Handler(android.os.Looper.getMainLooper()).post { - eventSink?.success(mapOf("type" to "token", "content" to token)) - } + // 不再在后台线程包装OkHttp的异步调用 + try { + openAIService.sendFunctionCallResult( + messages = messages, + functionCall = functionCall, + functionResult = functionResult, + callback = object : OpenAIService.StreamCallback { + override fun onToken(token: String) { + // 发送token事件 + mainHandler.post { + eventSink?.success(mapOf("type" to "token", "content" to token)) } - - override fun onComplete() { - // 发送完成事件 - android.os.Handler(android.os.Looper.getMainLooper()).post { - eventSink?.success(mapOf("type" to "complete")) - } + } + + override fun onComplete() { + // 发送完成事件 + mainHandler.post { + eventSink?.success(mapOf("type" to "complete")) } - - override fun onError(e: Exception) { - // 发送错误事件 - android.os.Handler(android.os.Looper.getMainLooper()).post { - eventSink?.success(mapOf("type" to "error", "content" to e.message)) - } + } + + override fun onError(e: Exception) { + // 发送错误事件 + mainHandler.post { + eventSink?.success(mapOf("type" to "error", "content" to e.message)) } - - override fun onFunctionCall(nestedFunctionCall: JSONObject) { - // 发送函数调用事件 - android.os.Handler(android.os.Looper.getMainLooper()).post { - val functionCallMap = nestedFunctionCall.toMap() - eventSink?.success(mapOf("type" to "functionCall", "content" to functionCallMap)) - } + } + + override fun onFunctionCall(nestedFunctionCall: JSONObject) { + // 发送函数调用事件 + mainHandler.post { + val functionCallMap = nestedFunctionCall.toMap() + eventSink?.success(mapOf("type" to "functionCall", "content" to functionCallMap)) } } - ) - - // 请求已开始 - android.os.Handler(android.os.Looper.getMainLooper()).post { - result.success(true) } - } catch (e: Exception) { - // 在主线程返回错误 - android.os.Handler(android.os.Looper.getMainLooper()).post { - result.error("OPENAI_ERROR", e.message, null) - } - } + ) + + // 请求已开始,直接返回结果 + result.success(true) + } catch (e: Exception) { + // 直接在当前线程处理错误 + result.error("OPENAI_ERROR", e.message, null) } } diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt index f2e7cbcdb..875895d65 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt @@ -78,7 +78,7 @@ class SystemFunctionHandler(private val context: Context) { private fun registerTranslationModeFunction(client: MCPClient) { client.registerLocalFunction( "enter_translation_mode", - "进入实时翻译模式", + "用户请求进入实时翻译模式时,启动实时翻译功能", JSONObject(""" { "type": "object", @@ -167,7 +167,7 @@ class SystemFunctionHandler(private val context: Context) { private fun registerOpenAppFunction(client: MCPClient) { client.registerLocalFunction( "open_app", - "打开应用", + "当用户请求打开特定应用时,启动相应应用", JSONObject(""" { "type": "object", diff --git a/local_plugins/open_ai_service/ios/Classes/.gitkeep b/local_plugins/open_ai_service/ios/Classes/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift b/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift index 671affd73..5df99e126 100644 --- a/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift +++ b/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift @@ -237,30 +237,47 @@ public class OpenAIService { } /// 发送消息(流式输出) - public func sendMessageStream(messages: [[String: Any]], systemPrompt: String, callback: @escaping StreamCallback) { + public func sendMessageStream( + messages: [[String: Any]], + systemPrompt: String, + callback: @escaping StreamCallback + ) { guard isInitialized, !apiKey.isEmpty else { callback.onError(OpenAIError("OpenAI服务未初始化")) return } - // 构建完整消息,添加系统提示 - var fullMessages: [[String: Any]] = [ - ["role": "system", "content": systemPrompt] - ] - fullMessages.append(contentsOf: messages) - // 构建请求体 - var requestDict: [String: Any] = [ + var requestBody: [String: Any] = [ "model": model, - "messages": fullMessages, "temperature": 0.7, "max_tokens": 2000, "stream": true ] - // 如果有注册的函数,添加到请求中 - if !registeredFunctions.isEmpty { + // 构建完整消息数组,添加系统提示 + var fullMessages: [[String: Any]] = [ + ["role": "system", "content": systemPrompt] + ] + fullMessages.append(contentsOf: messages) + requestBody["messages"] = fullMessages + + // 添加工具列表 + if let toolMaps = mcpClient?.getToolMaps(), !toolMaps.isEmpty { + var tools: [[String: Any]] = [] + + for toolMap in toolMaps { + if let tool = toolMap as? [String: Any] { + tools.append(tool) + } + } + + if !tools.isEmpty { + requestBody["tools"] = tools + } + } else if !registeredFunctions.isEmpty { var tools: [[String: Any]] = [] + for function in registeredFunctions { let tool: [String: Any] = [ "type": "function", @@ -268,11 +285,14 @@ public class OpenAIService { ] tools.append(tool) } - requestDict["tools"] = tools + + if !tools.isEmpty { + requestBody["tools"] = tools + } } - // 将请求数据转换为JSON数据 - guard let jsonData = try? JSONSerialization.data(withJSONObject: requestDict) else { + // 转换为JSON数据 + guard let jsonData = try? JSONSerialization.data(withJSONObject: requestBody) else { callback.onError(OpenAIError("无法序列化请求数据")) return } @@ -290,102 +310,10 @@ public class OpenAIService { request.addValue("text/event-stream", forHTTPHeaderField: "Accept") request.httpBody = jsonData - // 用于存储函数调用的各个部分 - var finalToolCalls: [Int: ToolCallInfo] = [:] - - // 创建数据任务 - let task = session.dataTask(with: request) { data, response, error in - if let error = error { - callback.onError(OpenAIError("请求失败: \(error.localizedDescription)")) - return - } - - guard let httpResponse = response as? HTTPURLResponse else { - callback.onError(OpenAIError("无效的HTTP响应")) - return - } - - guard httpResponse.statusCode == 200 else { - callback.onError(OpenAIError("API调用失败: \(httpResponse.statusCode)")) - return - } - - guard let data = data else { - callback.onError(OpenAIError("响应数据为空")) - return - } - - // 处理SSE数据流 - if let text = String(data: data, encoding: .utf8) { - let lines = text.components(separatedBy: "\n") - - for line in lines { - if line.isEmpty { continue } - - if line.hasPrefix("data: ") { - let dataContent = line.dropFirst(6) - - // 处理[DONE]消息 - if dataContent == "[DONE]" { - self.processToolCalls(finalToolCalls, callback: callback) - callback.onComplete() - break - } - - // 解析JSON数据 - do { - if let data = dataContent.data(using: .utf8), - let jsonData = try JSONSerialization.jsonObject(with: data) as? [String: Any] { - - // 处理消息内容 - if let choices = jsonData["choices"] as? [[String: Any]], !choices.isEmpty, - let choice = choices.first { - - if let delta = choice["delta"] as? [String: Any] { - // 处理普通文本内容 - if let content = delta["content"] as? String { - callback.onToken(content) - } - - // 处理工具调用(函数调用) - if let toolCalls = delta["tool_calls"] as? [[String: Any]] { - for toolCall in toolCalls { - if let index = toolCall["index"] as? Int { - // 创建或获取现有的工具调用信息 - let toolCallInfo = finalToolCalls[index] ?? ToolCallInfo() - - // 更新ID - if let id = toolCall["id"] as? String { - toolCallInfo.id = id - } - - // 更新函数信息 - if let function = toolCall["function"] as? [String: Any] { - if let name = function["name"] as? String { - toolCallInfo.name = name - } - - if let arguments = function["arguments"] as? String { - toolCallInfo.arguments += arguments - } - } - - finalToolCalls[index] = toolCallInfo - } - } - } - } - } - } - } catch { - NSLog("解析JSON出错: \(error.localizedDescription)") - // 忽略解析错误,继续处理其他行 - } - } - } - } - } - + // 创建流式会话任务 + let delegate = SSEStreamDelegate(callback: callback) + let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + let task = session.dataTask(with: request) task.resume() } @@ -447,12 +375,147 @@ public class OpenAIService { } } + /// SSE流委托实现 + private class SSEStreamDelegate: NSObject, URLSessionDataDelegate { + let callback: StreamCallback + private var buffer = Data() + private var finalToolCalls: [Int: ToolCallInfo] = [:] + + init(callback: @escaping StreamCallback) { + self.callback = callback + super.init() + } + + // 接收数据流 + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + buffer.append(data) + + // 处理可能包含多行的数据 + processBuffer() + } + + // 处理缓冲区数据 + private func processBuffer() { + // 按行分割 + while let newlineIndex = buffer.firstIndex(of: 10) { // 10是换行符的ASCII码 + let lineData = buffer.prefix(upTo: newlineIndex) + buffer.removeSubrange(0...newlineIndex) // 移除已处理的行,包括换行符 + + // 解析行数据 + if let line = String(data: lineData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) { + processLine(line) + } + } + } + + // 处理单行数据 + private func processLine(_ line: String) { + guard !line.isEmpty else { return } + + if line.hasPrefix("data: ") { + let dataContent = line.dropFirst(6) + + // 处理[DONE]消息 + if dataContent == "[DONE]" { + let hasToolCalls = self.processToolCalls() + // 只有在没有工具调用时才认为对话真正完成 + if !hasToolCalls { + callback.onComplete() + } + return + } + + // 解析JSON数据 + do { + if let data = dataContent.data(using: .utf8), + let jsonData = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + + // 处理消息内容 + if let choices = jsonData["choices"] as? [[String: Any]], !choices.isEmpty, + let choice = choices.first { + + if let delta = choice["delta"] as? [String: Any] { + // 处理普通文本内容 + if let content = delta["content"] as? String { + callback.onToken(content) + } + + // 处理工具调用(函数调用) + if let toolCalls = delta["tool_calls"] as? [[String: Any]] { + for toolCall in toolCalls { + if let index = toolCall["index"] as? Int { + // 创建或获取现有的工具调用信息 + let toolCallInfo = finalToolCalls[index] ?? ToolCallInfo() + + // 更新ID + if let id = toolCall["id"] as? String { + toolCallInfo.id = id + } + + // 更新函数信息 + if let function = toolCall["function"] as? [String: Any] { + if let name = function["name"] as? String { + toolCallInfo.name = name + } + + if let arguments = function["arguments"] as? String { + toolCallInfo.arguments += arguments + } + } + + finalToolCalls[index] = toolCallInfo + } + } + } + } + } + } + } catch { + NSLog("解析JSON出错: \(error.localizedDescription)") + // 忽略解析错误,继续处理其他行 + } + } + } + + // 处理工具调用 + private func processToolCalls() -> Bool { + if finalToolCalls.isEmpty { return false } + + // 只处理第一个工具调用 + guard let firstToolCall = finalToolCalls.values.first, firstToolCall.isValid else { return false } + + // 创建函数调用字典 + let functionCall: [String: Any] = [ + "name": firstToolCall.name, + "arguments": firstToolCall.arguments, + "id": firstToolCall.id + ] + + // 回调 + callback.onFunctionCall(functionCall) + return true + } + + // 处理完成 + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + if let error = error { + callback.onError(OpenAIError("请求失败: \(error.localizedDescription)")) + } + } + } + + /// 流式输出回调协议 + public typealias StreamCallback = (onToken: (String) -> Void, + onComplete: () -> Void, + onError: (Error) -> Void, + onFunctionCall: ([String: Any]) -> Void) + /// 处理工具调用(函数调用)并回调 - private func processToolCalls(_ toolCalls: [Int: ToolCallInfo], callback: StreamCallback) { - if toolCalls.isEmpty { return } + private func processToolCalls(_ toolCalls: [Int: ToolCallInfo], callback: StreamCallback) -> Bool { + if toolCalls.isEmpty { return false } // 只处理第一个工具调用 - guard let firstToolCall = toolCalls.values.first, firstToolCall.isValid else { return } + guard let firstToolCall = toolCalls.values.first, firstToolCall.isValid else { return false } // 创建函数调用字典 let functionCall: [String: Any] = [ @@ -463,11 +526,6 @@ public class OpenAIService { // 回调 callback.onFunctionCall(functionCall) + return true } - - /// 流式输出回调协议 - public typealias StreamCallback = (onToken: (String) -> Void, - onComplete: () -> Void, - onError: (Error) -> Void, - onFunctionCall: ([String: Any]) -> Void) } \ No newline at end of file diff --git a/local_plugins/open_ai_service/ios/open_ai_service.podspec b/local_plugins/open_ai_service/ios/open_ai_service.podspec new file mode 100644 index 000000000..d06d07fed --- /dev/null +++ b/local_plugins/open_ai_service/ios/open_ai_service.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint open_ai_service.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'open_ai_service' + s.version = '0.0.1' + s.summary = 'OpenAI Service plugin' + s.description = <<-DESC +A Flutter plugin for OpenAI services. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '12.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end \ No newline at end of file diff --git a/local_plugins/volcano_speech/LICENSE b/local_plugins/volcano_speech/LICENSE new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/volcano_speech/ios/Classes/.gitkeep b/local_plugins/volcano_speech/ios/Classes/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/volcano_speech/ios/volcano_speech.podspec b/local_plugins/volcano_speech/ios/volcano_speech.podspec new file mode 100644 index 000000000..9a70baed1 --- /dev/null +++ b/local_plugins/volcano_speech/ios/volcano_speech.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint volcano_speech.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'volcano_speech' + s.version = '0.0.1' + s.summary = 'Volcano Speech plugin' + s.description = <<-DESC +A Flutter plugin for Volcano Speech services. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '12.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index b2962bec9..b08d2746e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,8 +68,8 @@ dependencies: path: local_plugins/volcano_speech jieli: path: local_plugins/jieli - chat_storage_sqlite: - path: local_plugins/chat_storage_sqlite + chat_storage: + path: local_plugins/chat_storage dev_dependencies: diff --git a/test.json b/test.json index 473daebb6..2411527e2 100644 --- a/test.json +++ b/test.json @@ -1,57 +1,163 @@ -curl -v -X POST -H 'Content-Type: application/json' -H 'Authorization: Bearer 168deb3d-fd0c-4912-b9f1-aaee5c6743e6' -H 'Accept: text/event-stream' -d '{ - "model": "bot-20250405211523-l7c9r", - "messages": [ - { - "role": "system", - "content": " 你是一个智能语音助手,能够简洁明了地回答用户的问题。\n时刻关心用户的情绪和需求,主动提供鼓励和温暖。\n\n语言风格活泼、亲切,能够幽默地互动,陪伴用户,缓解压力,增添生活乐趣。\n\n请始终以用户为中心,保持回应的高效性、准确性和温暖体贴,成为用户真正的灵魂伴侣。\n \n 当用户说\"退出\"、\"再见\"、\"结束对话\"等类似意图时,你应该使用exit_interaction函数来结束对话,\n 并在结束前说一句友好的告别语,例如\"再见,有需要随时找我\"。" - }, +curl 'https://ark.cn-beijing.volces.com/api/v3/bots/chat/completions' \ +-H "Authorization: Bearer 168deb3d-fd0c-4912-b9f1-aaee5c6743e6" \ +-H 'Content-Type: application/json' \ +-d '{ + "model": "bot-20250405211523-l7c9r", + "stream": true, + "stream_options": {"include_usage": true}, + "messages": [ { "role": "system", - "content": " 你是一个智能语音助手,能够简洁明了地回答用户的问题。\n时刻关心用户的情绪和需求,主动提供鼓励和温暖。\n\n语言风格活泼、亲切,能够幽默地互动,陪伴用户,缓解压力,增添生活乐趣。\n\n请始终以用户为中心,保持回应的高效性、准确性和温暖体贴,成为用户真正的灵魂伴侣。\n \n 当用户说\"退出\"、\"再见\"、\"结束对话\"等类似意图时,你应该使用exit_interaction函数来结束对话,\n 并在结束前说一句友好的告别语,例如\"再见,有需要随时找我\"。" + "content": "You are a helpful assistant." }, { "role": "user", - "content": "退下吧。" - }, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_8k680azmfc4thqrrnwpwqxah", - "type": "function", - "function": { - "name": "exit_interaction", - "arguments": " {}" - } - } - ] - }, - { - "role": "tool", - "content": "{\"result\": \"已退出语音交互\"}", - "tool_call_id": "call_8k680azmfc4thqrrnwpwqxah" + "content": "Hello!" } - ], - "temperature": 0.7, - "max_tokens": 2000, - "stream": true, - "tools": [ - { - "type": "function", - "function": { - "name": "exit_interaction", - "description": "退出当前语音交互", - "parameters": { - "type": "object", - "properties": {}, - "required": [] - } + ] +}' + + + + +curl 'https://ark.cn-beijing.volces.com/api/v3/bots/chat/completions' \ +-H 'Content-Type: application/json' \ +-H 'Authorization: Bearer 168deb3d-fd0c-4912-b9f1-aaee5c6743e6' \ +-d '{ + "model": "bot-20250405211523-l7c9r", + "stream": true, + "messages": [ + { + "role": "assistant", + "content": "你好呀!今天想和我聊些什么呢😄 是生活趣事、学习心得,还是其他好玩的话题?" + }, + { + "role": "assistant", + "content": "" + }, + { + "role": "assistant", + "content": "你好呀!今天过得怎么样?有什么需要我帮忙的吗?或者只是想聊聊天?😊" + }, + { + "role": "assistant", + "content": "明白啦!有什么需要我帮忙的吗?无论是查资料、聊天解闷,还是帮你处理手机上的任务,我随时待命哦~ 😊" + }, + { + "role": "assistant", + "content": "好的!随时准备陪你测试,有什么需要尽管说哦~ 😊 你希望测试什么功能呢?" + }, + { + "role": "assistant", + "content": "哎呀,听起来你有点小困扰呢!可能是因为网络波动、系统更新或者我偶尔“打瞌睡”了(开玩笑啦~)。不过别担心,我会努力保持最佳状态陪伴你!如果有什么具体问题,随时告诉我,我一定帮你解决得妥妥的!😊\n\n(或者你想测试什么功能?我随时待命哦!)" + }, + { + "role": "assistant", + "content": "嘿嘿,我是你的贴心语音助理小语呀!随时为你提供帮助、陪你聊天、解答问题,还能给你加油打气哦~有什么需要尽管告诉我吧! 😊" + }, + { + "role": "assistant", + "content": "我是小语呀,那个随时准备和你畅聊、给你排忧解难、陪你度过各种时光的手机全能语音助理😜 怎么啦,是不是刚才没记住呀🧐" + }, + { + "role": "assistant", + "content": "" + }, + { + "role": "user", + "content": "你好呀,你是谁呀?" + } + ], + "temperature": 0.7, + "max_tokens": 2000, + "stream": true, + "tools": [ + { + "type": "function", + "function": { + "name": "exit_interaction", + "description": "退出当前语音交互", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "enter_translation_mode", + "description": "用户请求进入实时翻译模式时,启动实时翻译功能", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "send_text_message", + "description": "发送短信", + "parameters": { + "type": "object", + "properties": { + "contact": { + "type": "string", + "description": "联系人姓名或电话号码" + }, + "message": { + "type": "string", + "description": "短信内容" } + }, + "required": [ + "contact", + "message" + ] } - ] -}' 'https: //ark.cn-beijing.volces.com/api/v3/bots/chat/completions' -{ - "code": 0, - "message": "Error during WebSocket handshake: Unexpected response code: 400", - "request_log":"{\"ab_test\":{},\"base\":{\"has_upload\":false,\"is_pending\":true,\"load_flags\":18,\"load_state\":0,\"method\":\"GET\",\"net_error\":-320,\"network_isolation_key\":\"wss://openspeech.bytedance.com wss://openspeech.bytedance.com\",\"origin_url\":\"wss://openspeech.bytedance.com/api/v3/sauc/bigmodel\",\"redirect_times\":0,\"status\":\"FAILED\",\"traffic_annotation\":77012883},\"dns\":{\"address_list\":[\"163.181.50.229:443\",\"163.181.50.223:443\",\"163.181.50.221:443\",\"163.181.50.224:443\",\"163.181.50.226:443\",\"163.181.50.222:443\",\"163.181.50.228:443\",\"163.181.50.227:443\"],\"async_batch_httpdns\":0,\"cache_source\":6,\"cache_stale_reason\":0,\"from_stale_cache\":0,\"hijacked_error\":0,\"is_auth\":false,\"nameservers\":[],\"net_type\":0,\"preload_batch_httpdns\":0,\"queried_with_cip\":\"\",\"race_dns_stale_cache\":0,\"skip_prefer_ip_error\":0,\"source\":2},\"event\":{\"engine_startup\":1744019237.421243,\"network_change\":0.0},\"header\":{\"X-Tt-Logid\":\"2025040717474815E3294F926C737834FA\",\"server-timing\":\"inner; dur=5, cdn-cache;desc=MISS,edge;dur=653,origin;dur=14\",\"x-tt-trace-host\":\"012d6b6cae97bb056dd2540a62f7f6e52ccfb433febe931be5ddf1ac674ca77c57b7dee570790477696b7b5c17bbdd77bf841100de6d411ef8ccc26681b9799e4aee97897e0681b3a8b304853d1848fcbd9fec458fdd375cddc4a7873cdbcef3a0\",\"x-tt-trace-id\":\"00-0fa6216304269466036ad7b0703104d2-0fa6216304269466-01\",\"x-tt-trace-tag\":\"id=03;cdn-cache=miss;type=dyn\"},\"ifconfig\":{\"ipv4_reachable\":true,\"ipv6_reachable\":false},\"multinetwork\":{},\"nqe\":{\"bs_sys_state\":false,\"downlink_throughput\":2658,\"effective_net_type\":5,\"group_rtt_estimate\":[],\"http_rtt\":116,\"pending_requests\":\"1\",\"real_net_type\":2,\"tcp_rtt\":46,\"total_requests\":\"6\"},\"other\":{\"core_ver\":\"87.0.4273.1\",\"gateway\":\"0.0.0.0\",\"is_background\":false,\"is_internal_retry\":false,\"is_main_process\":true,\"libcore\":\"cronet\",\"net_thread_priority\":0,\"quic_commit\":\"6ea2111b/2023-03-16\",\"resume_time\":\"-1\",\"retry_attempts\":0,\"ttnet_build_timestamp\":\"1689596445\",\"ttnet_version\":\"4.1.127.2-tob\",\"version\":\"534436af/2023-07-17\"},\"response\":{\"cached\":false,\"code\":400,\"connection_info\":1,\"network_accessed\":true,\"received_bytes\":0,\"sent_bytes\":0,\"via_proxy\":false},\"session\":{},\"socket\":{\"address_family\":1,\"cip\":\"\",\"connection_attemps\":[],\"happy_eyeballs_result\":2,\"remote\":\"163.181.50.229:443\",\"socket_reused\":false},\"socketpool\":{\"assign_type\":0,\"consume_time\":0,\"is_backup_job\":false,\"pending_reason\":0,\"pending_time\":0,\"reuse_type\":0,\"socket_idle_time\":0},\"ssl\":{\"cert_status\":0,\"connection_status\":6296321,\"handshake_type\":1,\"is_issued_by_known_root\":true,\"server_cert_common_name\":\"*.bytedance.com\"},\"store_idc\":{\"init\":1},\"timing\":{\"detailed_duration\":{\"before_stream\":0,\"body_recv\":-1,\"cdn-cache\":\"miss\",\"connect_delay\":0,\"create_stream\":98,\"dispatch\":0,\"dns\":0,\"edge\":653,\"header_recv\":-1,\"inner\":5,\"origin\":9,\"proxy\":0,\"rtt\":43,\"security_callback\":0,\"send\":0,\"ssl\":48,\"tcp\":49,\"ttfb\":710},\"request\":{\"create_stream_finish\":1744019267.633028,\"create_stream_start\":1744019267.534112,\"duration\":812,\"http_transaction_start\":1744019267.534081,\"request_sent_time\":1744019267.633497,\"response_recv_time\":1744019268.344466,\"start_time\":1744019267.533685}},\"tnc\":{\"data\":{\"canary\":\"\",\"cold_start\":1,\"config\":\"\",\"config_updated\":0,\"local_probe_version\":\"\",\"rules\":\"\",\"source\":0,\"update_time\":\"-1\"},\"probe\":{\"cmd\":\"0\",\"get_time\":\"-1\",\"send_time\":\"-1\",\"state\":1,\"version\":\"0\"},\"request\":{\"probe_version\":\"0\",\"source\":0,\"start_time\":\"-1\",\"state\":1}},\"t \ No newline at end of file + } + }, + { + "type": "function", + "function": { + "name": "make_phone_call", + "description": "拨打电话", + "parameters": { + "type": "object", + "properties": { + "contact": { + "type": "string", + "description": "联系人姓名或电话号码" + } + }, + "required": [ + "contact" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "open_app", + "description": "当用户请求打开特定应用时,启动相应应用", + "parameters": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "应用名称" + } + }, + "required": [ + "app_name" + ] + } + } + } + ] +}' + From c9ca2b4b38ed817c24c6bf19f9894d10d90ae89e Mon Sep 17 00:00:00 2001 From: wolfplus Date: Sat, 12 Apr 2025 21:17:34 +0100 Subject: [PATCH 3/4] add --- .mcp.json | 12 + .../yunqiinnovation/deepsound/MainActivity.kt | 6 +- .../deepsound/VoiceInteractionHandler.kt | 5 +- .../deepsound/VoiceInteractionService.kt | 6 +- android/build.gradle.kts | 2 + .../services/open_ai_service_adapter.dart | 24 + .../services/voice_interaction_service.dart | 32 +- .../controllers/jieli_test_controller.dart | 105 ++- .../jieli_test/views/jieli_test_view.dart | 74 ++ .../com/yunqiinnovation/jieli/BleService.kt | 269 ++++--- .../com/yunqiinnovation/jieli/JieliPlugin.kt | 25 +- local_plugins/jieli/lib/jieli.dart | 84 ++ .../open_ai_service/android/build.gradle.kts | 5 + .../open_ai_service/MCPClient.kt | 747 +++++++++++++----- .../open_ai_service/OpenAIService.kt | 10 +- .../open_ai_service/OpenAIServicePlugin.kt | 3 +- .../mcp/CustomSseClientTransport.kt | 357 +++++++++ .../open_ai_service/lib/open_ai_service.dart | 3 + pubspec.yaml | 1 + test.json | 14 +- 20 files changed, 1460 insertions(+), 324 deletions(-) create mode 100644 .mcp.json create mode 100644 local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/mcp/CustomSseClientTransport.kt diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..a344e9c9a --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + + "amap-amap-sse": { + "url": "https://mcp.amap.com/sse?key=e5fdc9605eabdeb5626f18f5721f343d" + }, + "web-search": { + "url": "http://mcp.ideapsound.com:8000/sse" + } + + } + } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt index 1c4717f11..ffc3d256a 100644 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt @@ -172,6 +172,7 @@ class MainActivity: FlutterActivity() { var openaiModel: String? = null var volcanoSpeechAppId: String = "" var volcanoSpeechAppToken: String = "" + var mcpServer: String = "" // 安全存储相关常量 private const val SECURE_PREFS_FILENAME = "deep_voice_secure_prefs" @@ -184,6 +185,7 @@ class MainActivity: FlutterActivity() { private const val KEY_MCP_SERVER_ENDPOINT = "mcp_server_endpoint" private const val KEY_VOLCANO_SPEECH_APP_ID = "volcano_speech_app_id" private const val KEY_VOLCANO_SPEECH_APP_TOKEN = "volcano_speech_app_token" + private const val KEY_MCP_SERVER = "mcp_server" // 会话管理 private const val KEY_SESSION_ID = "session_id" @@ -224,6 +226,7 @@ class MainActivity: FlutterActivity() { .putString(KEY_OPENAI_MODEL, openaiModel) .putString(KEY_VOLCANO_SPEECH_APP_ID, volcanoSpeechAppId) .putString(KEY_VOLCANO_SPEECH_APP_TOKEN, volcanoSpeechAppToken) + .putString(KEY_MCP_SERVER, mcpServer) .apply() FileLogger.d("MainActivity", "密钥已安全保存到加密存储中") @@ -272,6 +275,7 @@ class MainActivity: FlutterActivity() { openaiModel = sharedPreferences.getString(KEY_OPENAI_MODEL, null) volcanoSpeechAppId = sharedPreferences.getString(KEY_VOLCANO_SPEECH_APP_ID, "") ?: "" volcanoSpeechAppToken = sharedPreferences.getString(KEY_VOLCANO_SPEECH_APP_TOKEN, "") ?: "" + mcpServer = sharedPreferences.getString(KEY_MCP_SERVER, "") ?: "" FileLogger.d("MainActivity", "已从加密存储加载密钥") // 检查是否成功获取所有必要密钥 @@ -520,7 +524,7 @@ class MainActivity: FlutterActivity() { openaiModel = call.argument("openai_model") volcanoSpeechAppId = call.argument("volcano_speech_app_id") ?: "" volcanoSpeechAppToken = call.argument("volcano_speech_app_token") ?: "" - + mcpServer = call.argument("mcp_config") ?: "" // 保存密钥到安全存储 saveKeysToSecureStorage(applicationContext) diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt index b885117c0..cf6bf560a 100644 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt @@ -25,7 +25,8 @@ class VoiceInteractionHandler( private val openaiBaseUrl: String = "", private val openaiModel: String = "", private val volcanoSpeechAppId: String, - private val volcanoSpeechAppToken: String + private val volcanoSpeechAppToken: String, + private val mcpServer: String ) { private val TAG = "VoiceInteractionHandler" @@ -80,7 +81,7 @@ class VoiceInteractionHandler( } // 初始化OpenAI服务 - openAIService.initialize(openaiApiKey, openaiBaseUrl, openaiModel) + openAIService.initialize(openaiApiKey, openaiBaseUrl, openaiModel, mcpServer) // 注册广播接收器 try { diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt index dbe64c79e..0497458d2 100644 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt @@ -147,6 +147,7 @@ class VoiceInteractionService : Service() { var openaiModel = MainActivity.openaiModel ?: "" // OpenAI模型 var volcanoSpeechAppId = MainActivity.volcanoSpeechAppId ?: "" var volcanoSpeechAppToken = MainActivity.volcanoSpeechAppToken ?: "" + var mcpServer = MainActivity.mcpServer ?: "" // 如果静态变量中没有配置,尝试从加密存储中加载 if (subscriptionKey.isEmpty() || serviceRegion.isEmpty() || openaiKey.isEmpty()) { @@ -164,7 +165,7 @@ class VoiceInteractionService : Service() { openaiModel = MainActivity.openaiModel ?: "" volcanoSpeechAppId = MainActivity.volcanoSpeechAppId ?: "" volcanoSpeechAppToken = MainActivity.volcanoSpeechAppToken ?: "" - + mcpServer = MainActivity.mcpServer ?: "" FileLogger.d(TAG, "已从加密存储加载配置信息") } else { FileLogger.e(TAG, "无法从加密存储加载配置信息") @@ -175,7 +176,8 @@ class VoiceInteractionService : Service() { voiceInteractionHandler = VoiceInteractionHandler(applicationContext, subscriptionKey, serviceRegion, openaiKey, openaiBaseUrl, openaiModel, - volcanoSpeechAppId, volcanoSpeechAppToken) + volcanoSpeechAppId, volcanoSpeechAppToken, + mcpServer) // 初始化回调 voiceInteractionHandler.setCallback(object : VoiceInteractionHandler.InteractionCallback { diff --git a/android/build.gradle.kts b/android/build.gradle.kts index ca0ce6220..88678470b 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -8,6 +8,8 @@ allprojects { maven { url = uri("https://artifact.bytedance.com/repository/Volcengine/") } + maven { url = uri("https://maven.pkg.jetbrains.space/public/p/ktor/eap") } + } } diff --git a/lib/data/services/open_ai_service_adapter.dart b/lib/data/services/open_ai_service_adapter.dart index b93ce6cf0..0e018524d 100644 --- a/lib/data/services/open_ai_service_adapter.dart +++ b/lib/data/services/open_ai_service_adapter.dart @@ -1,8 +1,12 @@ import 'dart:async'; +import 'dart:io'; +import 'dart:convert'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:open_ai_service/open_ai_service.dart'; import 'package:get/get.dart'; import 'ai_service.dart'; +import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; /// 定义流事件类型,用于区分不同类型的事件 enum StreamEventType { token, complete, error } @@ -105,11 +109,31 @@ class OpenAIServiceAdapter implements AiService { return false; } + // 读取.mcp.json文件 + String mcpConfig = ''; + try { + // 从Flutter资源包中加载.mcp.json + mcpConfig = await rootBundle.loadString('.mcp.json'); + printInfo(info: '成功从资源包加载.mcp.json配置文件'); + + // 验证JSON格式 + final jsonData = jsonDecode(mcpConfig); + if (jsonData is Map && jsonData.containsKey('mcpServers')) { + printInfo(info: '解析到有效的mcpServers配置'); + } else { + printInfo(info: '.mcp.json内容格式不正确,期望包含mcpServers字段'); + } + } catch (e) { + printError(info: '加载.mcp.json资源文件时出错: $e'); + + } + // 初始化OpenAI服务 final result = await _openAIService.initialize( apiKey: apiKey, baseUrl: baseUrl, model: model, + mcpServer: mcpConfig, ); if (result) { diff --git a/lib/data/services/voice_interaction_service.dart b/lib/data/services/voice_interaction_service.dart index 6adafa5c4..1c6e330dd 100644 --- a/lib/data/services/voice_interaction_service.dart +++ b/lib/data/services/voice_interaction_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; @@ -44,6 +45,9 @@ class VoiceInteractionService extends GetxService { late String _openaiModel; late String _volcanoSpeechAppId; late String _volcanoSpeechAppToken; + // MCP配置信息 + String? _mcpConfig; + // 聊天历史服务 late final ChatHistoryService _chatHistoryService; @@ -71,6 +75,21 @@ class VoiceInteractionService extends GetxService { Logger.warning('未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION'); } + // 加载MCP配置 + _loadMcpConfig(); + } + + /// 加载MCP配置 + void _loadMcpConfig() { + try { + rootBundle.loadString('.mcp.json').then((config) { + _mcpConfig = config; + Logger.info('成功加载MCP配置, 配置内容: $_mcpConfig'); + }); + } catch (e) { + Logger.warning('加载MCP配置失败: $e'); + _mcpConfig = null; + } } /// 处理来自原生层的事件 @@ -212,8 +231,8 @@ class VoiceInteractionService extends GetxService { try { Logger.info('启动语音交互服务...'); - // 使用已加载的配置信息 - final result = await _channel.invokeMethod('startService', { + // 构建参数 + final Map params = { 'azure_speech_key': _azureSpeechKey, 'azure_speech_region': _azureSpeechRegion, 'openai_api_key': _openaiApiKey, @@ -221,7 +240,14 @@ class VoiceInteractionService extends GetxService { 'openai_model': _openaiModel, 'volcano_speech_app_id': _volcanoSpeechAppId, 'volcano_speech_app_token': _volcanoSpeechAppToken, - }) ?? false; + }; + + // 如果有MCP配置,添加到参数中 + if (_mcpConfig != null) { + params['mcp_config'] = _mcpConfig; + } + + final result = await _channel.invokeMethod('startService', params) ?? false; if (result) { _isServiceRunning.value = true; diff --git a/lib/modules/jieli_test/controllers/jieli_test_controller.dart b/lib/modules/jieli_test/controllers/jieli_test_controller.dart index 8afa86cf4..b6d896836 100644 --- a/lib/modules/jieli_test/controllers/jieli_test_controller.dart +++ b/lib/modules/jieli_test/controllers/jieli_test_controller.dart @@ -1,13 +1,11 @@ import 'dart:async'; import 'dart:typed_data'; +import 'package:flutter/material.dart' hide ConnectionState; import 'package:get/get.dart'; -import 'package:logger/logger.dart'; import 'package:jieli/jieli.dart'; /// 杰理测试控制器 class JieliTestController extends GetxController { - static final Logger logger = Logger(); - // Jieli插件实例 late Jieli jieli; @@ -21,23 +19,23 @@ class JieliTestController extends GetxController { var audioDataSize = 0.obs; var receivedFrames = 0.obs; var audioDataReceiving = false.obs; + var volumeCommandResult = ''.obs; // 事件订阅 StreamSubscription? _scanSubscription; StreamSubscription? _connectionSubscription; StreamSubscription? _wakeupSubscription; StreamSubscription? _audioSubscription; + StreamSubscription? _commandResultSubscription; @override void onInit() { super.onInit(); - logger.d('JieliTestController 初始化'); _initializeJieli(); } @override void onClose() { - logger.d('JieliTestController 关闭'); _cancelAllSubscriptions(); _disposeJieli(); super.onClose(); @@ -49,16 +47,16 @@ class JieliTestController extends GetxController { _connectionSubscription?.cancel(); _wakeupSubscription?.cancel(); _audioSubscription?.cancel(); + _commandResultSubscription?.cancel(); } /// 初始化杰理SDK void _initializeJieli() { try { - logger.d('初始化Jieli插件'); jieli = Jieli(); _setupEventListeners(); } catch (e) { - logger.e('初始化Jieli插件错误', error: e); + // 初始化错误处理 } } @@ -66,13 +64,11 @@ class JieliTestController extends GetxController { void _setupEventListeners() { // 监听扫描结果 _scanSubscription = jieli.scanResults.listen((devices) { - // logger.d('扫描到设备: ${devices.length}个'); scannedDevices.assignAll(devices); }); // 监听连接状态 _connectionSubscription = jieli.connectionState.listen((state) { - logger.d('连接状态变化: $state'); switch (state) { case ConnectionState.connected: connectStatus.value = '已连接'; @@ -95,7 +91,6 @@ class JieliTestController extends GetxController { // 监听唤醒信号 _wakeupSubscription = jieli.wakeupStream.listen((wakeup) { - logger.d('收到唤醒信号'); wakeupReceived.value = true; Get.snackbar('收到唤醒信号', '耳机发送了唤醒信号'); }); @@ -106,10 +101,21 @@ class JieliTestController extends GetxController { audioDataSize.value += data.length; receivedFrames.value++; audioDataReceiving.value = true; - - // 每100帧更新一次日志,避免日志过多 - if (receivedFrames.value % 100 == 0) { - logger.d('已接收音频数据: ${audioDataSize.value} 字节, ${receivedFrames.value} 帧'); + }); + + // 监听命令结果 + _commandResultSubscription = jieli.commandResultStream.listen((result) { + // 处理音量调整命令结果 + if (result.commandType == Jieli.commandTypeVolume) { + volumeCommandResult.value = result.success ? '音量调整成功' : '音量调整失败'; + Get.snackbar( + result.success ? '音量调整成功' : '音量调整失败', + result.success ? '设备已更新音量' : '设备未能更新音量', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: result.success ? Colors.green.withOpacity(0.7) : Colors.red.withOpacity(0.7), + colorText: Colors.white, + duration: Duration(seconds: 2), + ); } }); } @@ -119,12 +125,10 @@ class JieliTestController extends GetxController { try { final isEnabled = await jieli.isBluetoothEnabled(); if (!isEnabled) { - logger.d('蓝牙未启用,请求启用蓝牙'); return await jieli.requestBluetoothEnable(); } return true; } catch (e) { - logger.e('检查蓝牙状态错误', error: e); return false; } } @@ -140,7 +144,6 @@ class JieliTestController extends GetxController { } try { - logger.d('开始扫描设备'); isScanning.value = true; // 清空之前的设备列表 @@ -150,7 +153,6 @@ class JieliTestController extends GetxController { final success = await jieli.startScan(scanTimeout: 10000); if (!success) { - logger.e('启动扫描失败'); isScanning.value = false; Get.snackbar('扫描失败', '启动扫描设备失败,请检查蓝牙权限'); } @@ -161,7 +163,6 @@ class JieliTestController extends GetxController { }); } catch (e) { - logger.e('扫描设备错误', error: e); isScanning.value = false; Get.snackbar('扫描错误', e.toString()); } @@ -172,11 +173,10 @@ class JieliTestController extends GetxController { if (!isScanning.value) return; try { - logger.d('停止扫描'); await jieli.stopScan(); isScanning.value = false; } catch (e) { - logger.e('停止扫描错误', error: e); + // 停止扫描错误处理 } } @@ -190,7 +190,6 @@ class JieliTestController extends GetxController { // 更新UI状态 connectStatus.value = '连接中...'; - logger.d('连接设备: ${device.name} (${device.id})'); // 重置音频和唤醒相关的状态 wakeupReceived.value = false; @@ -205,14 +204,11 @@ class JieliTestController extends GetxController { // 连接成功,更新当前设备 currentDevice.value = device; deviceInfo.value = '设备名称: ${device.name}\n设备ID: ${device.id}\nRSSI: ${device.rssi} dBm'; - logger.d('设备连接成功'); } else { deviceInfo.value = '连接失败'; - logger.d('设备连接失败'); } } catch (e) { deviceInfo.value = '连接错误: ${e.toString()}'; - logger.e('连接设备错误', error: e); } } @@ -222,19 +218,15 @@ class JieliTestController extends GetxController { try { connectStatus.value = '断开中...'; - logger.d('断开设备连接'); final success = await jieli.disconnect(); if (success) { deviceInfo.value = '未连接设备'; - logger.d('设备断开连接成功'); - } else { - logger.d('设备断开连接失败'); } } catch (e) { - logger.e('断开设备错误', error: e); + // 断开连接错误处理 } } @@ -246,18 +238,14 @@ class JieliTestController extends GetxController { } try { - logger.d('发送开始录音命令'); final success = await jieli.sendStartRecordingCommand(); if (success) { Get.snackbar('命令已发送', '已发送开始录音命令'); - logger.d('开始录音命令发送成功'); } else { Get.snackbar('命令发送失败', '发送开始录音命令失败'); - logger.d('开始录音命令发送失败'); } } catch (e) { - logger.e('发送开始录音命令错误', error: e); Get.snackbar('命令错误', e.toString()); } } @@ -270,29 +258,66 @@ class JieliTestController extends GetxController { } try { - logger.d('发送停止录音命令'); final success = await jieli.sendStopRecordingCommand(); if (success) { Get.snackbar('命令已发送', '已发送停止录音命令'); - logger.d('停止录音命令发送成功'); } else { Get.snackbar('命令发送失败', '发送停止录音命令失败'); - logger.d('停止录音命令发送失败'); } } catch (e) { - logger.e('发送停止录音命令错误', error: e); Get.snackbar('命令错误', e.toString()); } } + /// 增加音量 + Future increaseVolume() async { + if (currentDevice.value == null) { + Get.snackbar('操作失败', '未连接设备'); + return; + } + + try { + volumeCommandResult.value = '发送中...'; + final success = await jieli.increaseVolume(); + + if (!success) { + Get.snackbar('命令发送失败', '发送增加音量命令失败'); + volumeCommandResult.value = '发送失败'; + } + } catch (e) { + Get.snackbar('命令错误', e.toString()); + volumeCommandResult.value = '发送出错'; + } + } + + /// 减小音量 + Future decreaseVolume() async { + if (currentDevice.value == null) { + Get.snackbar('操作失败', '未连接设备'); + return; + } + + try { + volumeCommandResult.value = '发送中...'; + final success = await jieli.decreaseVolume(); + + if (!success) { + Get.snackbar('命令发送失败', '发送减小音量命令失败'); + volumeCommandResult.value = '发送失败'; + } + } catch (e) { + Get.snackbar('命令错误', e.toString()); + volumeCommandResult.value = '发送出错'; + } + } + /// 清理资源 void _disposeJieli() { try { - logger.d('释放Jieli插件资源'); jieli.dispose(); } catch (e) { - logger.e('释放Jieli插件资源错误', error: e); + // 释放资源错误处理 } } } \ No newline at end of file diff --git a/lib/modules/jieli_test/views/jieli_test_view.dart b/lib/modules/jieli_test/views/jieli_test_view.dart index aa6316366..46409651b 100644 --- a/lib/modules/jieli_test/views/jieli_test_view.dart +++ b/lib/modules/jieli_test/views/jieli_test_view.dart @@ -260,6 +260,80 @@ class JieliTestView extends GetView { ), ], ), + SizedBox(height: 8.h), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: controller.increaseVolume, + icon: Icon( + Icons.volume_up, + size: 18.sp, + ), + label: Text( + '增加音量', + style: TextStyle(fontSize: 14.sp), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue[600], + padding: EdgeInsets.symmetric(vertical: 12.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r), + ), + ), + ), + ), + SizedBox(width: 8.w), + Expanded( + child: ElevatedButton.icon( + onPressed: controller.decreaseVolume, + icon: Icon( + Icons.volume_down, + size: 18.sp, + ), + label: Text( + '减小音量', + style: TextStyle(fontSize: 14.sp), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange[600], + padding: EdgeInsets.symmetric(vertical: 12.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r), + ), + ), + ), + ), + ], + ), + SizedBox(height: 8.h), + Obx(() => controller.volumeCommandResult.value.isNotEmpty + ? Container( + width: double.infinity, + padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 16.w), + decoration: BoxDecoration( + color: controller.volumeCommandResult.value.contains('成功') + ? Colors.green[100] + : (controller.volumeCommandResult.value == '发送中...' + ? Colors.blue[100] + : Colors.red[100]), + borderRadius: BorderRadius.circular(8.r), + ), + child: Text( + '音量控制: ${controller.volumeCommandResult.value}', + style: TextStyle( + fontSize: 13.sp, + color: controller.volumeCommandResult.value.contains('成功') + ? Colors.green[900] + : (controller.volumeCommandResult.value == '发送中...' + ? Colors.blue[900] + : Colors.red[900]), + ), + textAlign: TextAlign.center, + ), + ) + : SizedBox.shrink() + ), ], ); } diff --git a/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt b/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt index dec530b53..4534aade2 100644 --- a/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt +++ b/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt @@ -12,34 +12,6 @@ import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.* import java.util.* -/** - * BLE回调接口,统一处理蓝牙事件 - */ -interface BleCallback { - /** - * 扫描结果回调 - * @param devices 扫描到的设备列表 - */ - fun onScanResult(devices: List>) - - /** - * 连接状态变化回调 - * @param state 连接状态 - */ - fun onConnectionStateChanged(state: Int) - - /** - * 音频数据回调 - * @param data 音频数据字节数组 - */ - fun onAudioDataReceived(data: ByteArray) - - /** - * 唤醒信号回调 - */ - fun onWakeupSignalReceived() -} - /** * BLE服务类,提供核心蓝牙功能 */ @@ -47,12 +19,11 @@ class BleService(private val context: Context) { companion object { private const val TAG = "JieliBleService" - // 服务和特征UUID - private const val AUDIO_SERVICE_UUID = "0000A100-0000-1000-8000-00805F9B34FB" - private const val AUDIO_DATA_CHAR_UUID = "0000A101-0000-1000-8000-00805F9B34FB" - private const val CONTROL_CHAR_UUID = "0000A102-0000-1000-8000-00805F9B34FB" - private const val CLIENT_CHAR_CONFIG_UUID = "00002902-0000-1000-8000-00805f9b34fb" - + // 服务和特征UUID - 根据协议规范重新定义 + private const val AUDIO_SERVICE_UUID = "0000faa0-0000-1000-8000-00805f9b34fb" // 主服务UUID + private const val NOTIFY_CHAR_UUID = "0000faa2-0000-1000-8000-00805f9b34fb" // 用于接收数据的特征(notify) + private const val WRITE_CHAR_UUID = "0000faa1-0000-1000-8000-00805f9b34fb" // 用于发送命令的特征(write without response) + private const val CLIENT_CHAR_CONFIG_UUID = "00002902-0000-1000-8000-00805f9b34fb" // 标准的客户端特征配置描述符 // 连接状态常量 const val STATE_DISCONNECTED = 0 const val STATE_CONNECTING = 1 @@ -62,6 +33,20 @@ class BleService(private val context: Context) { // 扫描结果更新间隔(毫秒) private const val SCAN_RESULT_UPDATE_INTERVAL = 500L + + // 命令类型常量 + const val COMMAND_VOLUME = 1 + } + + /** + * 回调接口 + */ + interface Callback { + fun onScanResult(devices: List>) + fun onConnectionStateChanged(state: Int) + fun onAudioDataReceived(data: ByteArray) + fun onWakeupSignalReceived() + fun onCommandResult(commandType: Int, success: Boolean) } // 蓝牙管理器和适配器 @@ -71,8 +56,8 @@ class BleService(private val context: Context) { // GATT连接和特征 private var bluetoothGatt: BluetoothGatt? = null - private var audioDataChar: BluetoothGattCharacteristic? = null - private var controlChar: BluetoothGattCharacteristic? = null + private var notifyChar: BluetoothGattCharacteristic? = null + private var writeChar: BluetoothGattCharacteristic? = null // 扫描相关 private val scanHandler = Handler(Looper.getMainLooper()) @@ -88,7 +73,7 @@ class BleService(private val context: Context) { val connectionState = MutableLiveData(STATE_DISCONNECTED) // 统一回调接口 - var callback: BleCallback? = null + var callback: Callback? = null /** * 开始扫描BLE设备 @@ -252,8 +237,8 @@ class BleService(private val context: Context) { // 清理资源 gatt.close() bluetoothGatt = null - audioDataChar = null - controlChar = null + notifyChar = null + writeChar = null } else if (status != BluetoothGatt.GATT_SUCCESS) { Log.e(TAG, "连接失败,status=$status") updateConnectionState(STATE_ERROR) @@ -272,13 +257,16 @@ class BleService(private val context: Context) { if (audioService != null) { Log.i(TAG, "找到音频服务") - // 获取音频数据和控制特征 - audioDataChar = audioService.getCharacteristic(UUID.fromString(AUDIO_DATA_CHAR_UUID)) - controlChar = audioService.getCharacteristic(UUID.fromString(CONTROL_CHAR_UUID)) + // 获取特征 + notifyChar = audioService.getCharacteristic(UUID.fromString(NOTIFY_CHAR_UUID)) + writeChar = audioService.getCharacteristic(UUID.fromString(WRITE_CHAR_UUID)) - if (audioDataChar != null && controlChar != null) { + if (notifyChar != null && writeChar != null) { Log.i(TAG, "找到所需特征") + // 设置特征写入类型为无响应模式 + writeChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + // 注册特征通知 setupNotifications(gatt) } else { @@ -297,22 +285,28 @@ class BleService(private val context: Context) { override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { val characteristicUuid = characteristic.uuid.toString() + Log.d(TAG, "收到特征变化通知: $characteristicUuid") when (characteristicUuid) { - AUDIO_DATA_CHAR_UUID -> { - // 收到音频数据 - val audioData = characteristic.value - Log.d(TAG, "收到音频数据帧,大小: ${audioData.size} 字节") + // NOTIFY_CHAR_UUID -> { + // // 收到音频数据 + // val audioData = characteristic.value + // Log.d(TAG, "收到音频数据帧,大小: ${audioData.size} 字节") - callback?.onAudioDataReceived(audioData) - } - CONTROL_CHAR_UUID -> { - // 收到唤醒信号 + // callback?.onAudioDataReceived(audioData) + // } + NOTIFY_CHAR_UUID -> { + // 处理控制消息 val value = characteristic.value Log.i(TAG, "收到控制通知: ${value.contentToString()}") + // 判断是否是协议格式响应 + if (value.isNotEmpty() && value[0].toInt() and 0xFF == 0xA5) { + // 处理设备返回的协议数据 + processResponseData(value) + } // 假设0x01表示唤醒信号 - if (value.isNotEmpty() && value[0].toInt() == 0x01) { + else if (value.isNotEmpty() && value[0].toInt() == 0x01) { Log.i(TAG, "收到唤醒信号") callback?.onWakeupSignalReceived() @@ -325,7 +319,7 @@ class BleService(private val context: Context) { } override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { - if (characteristic.uuid.toString() == CONTROL_CHAR_UUID) { + if (characteristic.uuid.toString() == WRITE_CHAR_UUID) { if (status == BluetoothGatt.GATT_SUCCESS) { Log.i(TAG, "命令发送成功: ${characteristic.value.contentToString()}") } else { @@ -337,13 +331,10 @@ class BleService(private val context: Context) { override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { if (status == BluetoothGatt.GATT_SUCCESS) { Log.i(TAG, "描述符写入成功: ${descriptor.uuid}") - - // 如果是音频数据特征的描述符,再订阅控制特征 - if (descriptor.characteristic.uuid.toString() == AUDIO_DATA_CHAR_UUID) { - setupControlNotification(gatt) - } + Log.i(TAG, "特征通知设置成功,设备就绪") } else { Log.e(TAG, "描述符写入失败,status=$status") + updateConnectionState(STATE_ERROR) } } } @@ -352,57 +343,92 @@ class BleService(private val context: Context) { * 设置特征通知 */ private fun setupNotifications(gatt: BluetoothGatt) { - // 先订阅音频数据通知 - if (audioDataChar?.properties?.and(BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) { - gatt.setCharacteristicNotification(audioDataChar, true) + // 启用 notify 特征通知 + if (notifyChar?.properties?.and(BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) { + gatt.setCharacteristicNotification(notifyChar, true) - val descriptor = audioDataChar?.getDescriptor(UUID.fromString(CLIENT_CHAR_CONFIG_UUID)) + val descriptor = notifyChar?.getDescriptor(UUID.fromString(CLIENT_CHAR_CONFIG_UUID)) descriptor?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE if (descriptor != null) { gatt.writeDescriptor(descriptor) - Log.i(TAG, "设置音频数据特征通知") + Log.i(TAG, "设置数据通知特征通知") } } else { - Log.e(TAG, "音频数据特征不支持通知") - setupControlNotification(gatt) + Log.e(TAG, "数据通知特征不支持通知") } } /** - * 设置控制特征通知 + * 处理设备返回的数据 + * 协议格式: + * 协议头(0xA5) + 字段类型(1byte) + 数据长度(1byte) + 数据域(N byte) + 校验和(1byte) */ - private fun setupControlNotification(gatt: BluetoothGatt) { - if (controlChar?.properties?.and(BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) { - gatt.setCharacteristicNotification(controlChar, true) - - val descriptor = controlChar?.getDescriptor(UUID.fromString(CLIENT_CHAR_CONFIG_UUID)) - descriptor?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - - if (descriptor != null) { - gatt.writeDescriptor(descriptor) - Log.i(TAG, "设置控制特征通知") + private fun processResponseData(data: ByteArray) { + if (data.size < 4) { + Log.e(TAG, "返回数据格式错误,长度不足") + return + } + + // 判断协议头 + if (data[0].toInt() and 0xFF != 0xA5) { + Log.e(TAG, "返回数据协议头错误: ${data[0].toInt() and 0xFF}") + return + } + + val fieldType = data[1].toInt() and 0xFF + val dataLen = data[2].toInt() and 0xFF + + // 验证长度 + if (data.size != dataLen + 4) { // 协议头+字段类型+数据长度+数据域+校验和 + Log.e(TAG, "返回数据长度不匹配: 应为${dataLen + 4},实际为${data.size}") + return + } + + // 计算校验和 + var checksum = 0 + for (i in 0 until data.size - 1) { + checksum += data[i].toInt() and 0xFF + } + checksum = checksum and 0xFF + + val receivedChecksum = data[data.size - 1].toInt() and 0xFF + + if (checksum != receivedChecksum) { + Log.e(TAG, "校验和错误: 计算值=$checksum, 接收值=$receivedChecksum") + return + } + + // 解析字段类型 + when (fieldType) { + 0x01 -> { + // 音量调整命令的响应 + val result = data[3].toInt() and 0xFF + val success = result == 0 + Log.i(TAG, "音量调整命令响应: ${if (success) "成功" else "失败"}") + callback?.onCommandResult(COMMAND_VOLUME, success) + } + // 其他命令类型可在此处添加 + else -> { + Log.d(TAG, "未知命令类型: ${String.format("0x%02X", fieldType)}") } - } else { - Log.e(TAG, "控制特征不支持通知") } } /** - * 发送开始录音命令(向控制特征写入0x01) + * 发送开始录音命令 */ fun sendStartRecordingCommand(): Boolean { - if (bluetoothGatt == null || controlChar == null || connectionState.value != STATE_CONNECTED) { + if (bluetoothGatt == null || writeChar == null || connectionState.value != STATE_CONNECTED) { Log.e(TAG, "无法发送开始录音命令,设备未连接") return false } try { val commandData = byteArrayOf(0x01) - controlChar?.value = commandData - controlChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + writeChar?.value = commandData - val success = bluetoothGatt?.writeCharacteristic(controlChar) ?: false + val success = bluetoothGatt?.writeCharacteristic(writeChar) ?: false if (success) { Log.i(TAG, "开始录音命令已发送") @@ -418,20 +444,19 @@ class BleService(private val context: Context) { } /** - * 发送停止录音命令(向控制特征写入0x02) + * 发送停止录音命令 */ fun sendStopRecordingCommand(): Boolean { - if (bluetoothGatt == null || controlChar == null || connectionState.value != STATE_CONNECTED) { + if (bluetoothGatt == null || writeChar == null || connectionState.value != STATE_CONNECTED) { Log.e(TAG, "无法发送停止录音命令,设备未连接") return false } try { val commandData = byteArrayOf(0x02) - controlChar?.value = commandData - controlChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + writeChar?.value = commandData - val success = bluetoothGatt?.writeCharacteristic(controlChar) ?: false + val success = bluetoothGatt?.writeCharacteristic(writeChar) ?: false if (success) { Log.i(TAG, "停止录音命令已发送") @@ -446,6 +471,70 @@ class BleService(private val context: Context) { } } + /** + * 发送音量调整命令 + * 协议格式: + * 协议头(0xAA) + 字段类型(0x01) + 数据长度(0x01) + 音量值(0~16) + 校验和 + */ + fun adjustVolume(volume: Int): Boolean { + if (bluetoothGatt == null || writeChar == null || connectionState.value != STATE_CONNECTED) { + Log.e(TAG, "无法发送音量调整命令,设备未连接") + return false + } + + // 限制音量范围为0-16 + val safeVolume = volume.coerceIn(0, 16) + + try { + // 构建命令数据 + val header: Byte = 0xAA.toByte() + val fieldType: Byte = 0x01 + val dataLen: Byte = 0x01 + val volumeData: Byte = safeVolume.toByte() + + // 计算校验和 + val checksum = ((header.toInt() and 0xFF) + + (fieldType.toInt() and 0xFF) + + (dataLen.toInt() and 0xFF) + + (volumeData.toInt() and 0xFF)) and 0xFF + + // 创建完整命令 + val commandData = byteArrayOf(header, fieldType, dataLen, volumeData, checksum.toByte()) + + // 写入特征 + writeChar?.value = commandData + + val success = bluetoothGatt?.writeCharacteristic(writeChar) ?: false + + if (success) { + Log.i(TAG, "音量调整命令已发送: $safeVolume") + } else { + Log.e(TAG, "音量调整命令发送失败") + } + + return success + } catch (e: Exception) { + Log.e(TAG, "发送音量调整命令时发生异常: ${e.message}") + return false + } + } + + /** + * 增加音量 + */ + fun increaseVolume(): Boolean { + // 假设当前音量保存在设备中,我们只需发送增加命令 + return adjustVolume(16) // 发送最大音量命令 + } + + /** + * 减小音量 + */ + fun decreaseVolume(): Boolean { + // 假设当前音量保存在设备中,我们只需发送减小命令 + return adjustVolume(0) // 发送最小音量命令 + } + /** * 清理资源 */ @@ -454,7 +543,7 @@ class BleService(private val context: Context) { scanHandler.removeCallbacks(scanResultUpdateRunnable) bluetoothGatt?.close() bluetoothGatt = null - audioDataChar = null - controlChar = null + notifyChar = null + writeChar = null } } \ No newline at end of file diff --git a/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/JieliPlugin.kt b/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/JieliPlugin.kt index 17cc5d21f..95a267e97 100644 --- a/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/JieliPlugin.kt +++ b/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/JieliPlugin.kt @@ -44,6 +44,7 @@ class JieliPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, EventChannel private const val EVENT_TYPE_CONNECTION_STATE = "connection_state" private const val EVENT_TYPE_SCAN_RESULT = "scan_result" private const val EVENT_TYPE_WAKEUP_SIGNAL = "wakeup_signal" + private const val EVENT_TYPE_COMMAND_RESULT = "command_result" } // 蓝牙相关请求码 @@ -75,7 +76,7 @@ class JieliPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, EventChannel */ private fun setupBleCallbacks() { // 设置统一回调接口 - bleService.callback = object : BleCallback { + bleService.callback = object : BleService.Callback { override fun onScanResult(devices: List>) { pluginScope.launch(Dispatchers.Main) { sendEvent(EVENT_TYPE_SCAN_RESULT, devices) @@ -99,6 +100,15 @@ class JieliPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, EventChannel sendEvent(EVENT_TYPE_WAKEUP_SIGNAL, true) } } + + override fun onCommandResult(commandType: Int, success: Boolean) { + pluginScope.launch(Dispatchers.Main) { + sendEvent(EVENT_TYPE_COMMAND_RESULT, mapOf( + "commandType" to commandType, + "success" to success + )) + } + } } } @@ -187,6 +197,19 @@ class JieliPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, EventChannel activity?.startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT) result.success(true) } + "adjustVolume" -> { + val volume = call.argument("volume") ?: 0 + val success = bleService.adjustVolume(volume) + result.success(success) + } + "increaseVolume" -> { + val success = bleService.increaseVolume() + result.success(success) + } + "decreaseVolume" -> { + val success = bleService.decreaseVolume() + result.success(success) + } else -> { result.notImplemented() } diff --git a/local_plugins/jieli/lib/jieli.dart b/local_plugins/jieli/lib/jieli.dart index 78ec090bb..fa65eca91 100644 --- a/local_plugins/jieli/lib/jieli.dart +++ b/local_plugins/jieli/lib/jieli.dart @@ -46,6 +46,7 @@ class EventType { static const String connectionState = 'connection_state'; static const String scanResult = 'scan_result'; static const String wakeupSignal = 'wakeup_signal'; + static const String commandResult = 'command_result'; } /// 杰理BLE插件主类 @@ -72,10 +73,21 @@ class Jieli { static final StreamController _wakeupController = StreamController.broadcast(); Stream get wakeupStream => _wakeupController.stream; + + /// 命令结果流 + static final StreamController _commandResultController = + StreamController.broadcast(); + Stream get commandResultStream => _commandResultController.stream; + + /// 原始事件流 + Stream>? _eventStream; /// 事件流订阅 StreamSubscription? _eventSubscription; + /// 命令类型常量 + static const int commandTypeVolume = 1; + /// 构造函数,初始化平台通道和事件监听 Jieli() { _setupEventListener(); @@ -118,6 +130,16 @@ class Jieli { _wakeupController.add(data); } break; + case EventType.commandResult: + if (data is Map) { + final commandType = data['commandType'] as int; + final success = data['success'] as bool; + _commandResultController.add(CommandResult( + commandType: commandType, + success: success, + )); + } + break; } } @@ -172,6 +194,7 @@ class Jieli { _connectionController.close(); _audioController.close(); _wakeupController.close(); + _commandResultController.close(); } /// 检查蓝牙是否已启用 @@ -185,4 +208,65 @@ class Jieli { final result = await _methodChannel.invokeMethod('requestBluetoothEnable'); return result ?? false; } + + /// 调整音量 + /// [volume] 音量值,范围0-16 + Future adjustVolume(int volume) async { + try { + final result = await _methodChannel.invokeMethod( + 'adjustVolume', + {'volume': volume}, + ); + return result ?? false; + } catch (e) { + print('调整音量失败: $e'); + return false; + } + } + + /// 增加音量 + Future increaseVolume() async { + try { + final result = await _methodChannel.invokeMethod('increaseVolume'); + return result ?? false; + } catch (e) { + print('增加音量失败: $e'); + return false; + } + } + + /// 减小音量 + Future decreaseVolume() async { + try { + final result = await _methodChannel.invokeMethod('decreaseVolume'); + return result ?? false; + } catch (e) { + print('减小音量失败: $e'); + return false; + } + } + + /// 命令结果回调 + Stream get onCommandResult => commandResultStream + .where((event) => event.commandType == commandTypeVolume) + .map((event) => event); +} + +/// 蓝牙设备模型 +class BluetoothDevice { + // ... existing code ... +} + +/// 命令结果 +class CommandResult { + final int commandType; + final bool success; + + CommandResult({ + required this.commandType, + required this.success, + }); + + @override + String toString() => 'CommandResult{commandType: $commandType, success: $success}'; } \ No newline at end of file diff --git a/local_plugins/open_ai_service/android/build.gradle.kts b/local_plugins/open_ai_service/android/build.gradle.kts index 57889671a..1f079fa6c 100644 --- a/local_plugins/open_ai_service/android/build.gradle.kts +++ b/local_plugins/open_ai_service/android/build.gradle.kts @@ -50,6 +50,11 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") implementation("com.fasterxml.jackson.core:jackson-databind:2.14.2") implementation("com.squareup.okhttp3:okhttp:4.9.3") + implementation("com.squareup.okhttp3:logging-interceptor:4.9.3") implementation("com.anthropic:anthropic-java:0.8.0") implementation("io.modelcontextprotocol:kotlin-sdk:0.4.0") + implementation("io.ktor:ktor-client-core:3.1.2") + implementation("io.ktor:ktor-client-cio:3.1.2") + implementation("io.ktor:ktor-client-okhttp:3.1.2") + implementation("io.ktor:ktor-client-logging:3.1.2") } \ No newline at end of file diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt index fef91ab05..a4625376c 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt @@ -9,14 +9,30 @@ import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper import io.ktor.client.HttpClient +import io.ktor.client.plugins.sse.* import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.ListToolsResult import io.modelcontextprotocol.kotlin.sdk.TextContent import io.modelcontextprotocol.kotlin.sdk.client.Client -import io.modelcontextprotocol.kotlin.sdk.client.WebSocketClientTransport +import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.JsonObject import org.json.JSONObject import kotlin.jvm.optionals.getOrNull +import java.io.File +import io.ktor.client.engine.okhttp.* +import okhttp3.OkHttpClient +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager +import java.security.SecureRandom +import java.util.concurrent.TimeUnit +import okhttp3.logging.HttpLoggingInterceptor +import okhttp3.Interceptor +import io.ktor.client.request.* +import io.ktor.http.* +import com.yunqiinnovation.open_ai_service.mcp.CustomSseClientTransport /** * 工具类型枚举 @@ -39,55 +55,45 @@ interface FunctionHandler { } /** - * MCP客户端,用于连接Model Context Protocol服务器和管理本地函数 + * 单个MCP子客户端 + * 负责连接到一个具体的MCP服务器并管理其工具 */ -class MCPClient(private val context: Context? = null) : AutoCloseable { - private val TAG = "MCPClient" - +class MCPSubClient( + private val serverId: String, + serverUrl: String, + private val customClient: HttpClient? = null +) : AutoCloseable { + private val TAG = "MCPSubClient[$serverId]" + + // 保存服务器URL + private val serverUrl: String = serverUrl + + init { + Log.d(TAG, "MCPSubClient初始化,URL: $serverUrl") + } + // 初始化MCP客户端 private val mcp: Client = Client( clientInfo = Implementation( - name = "deep-voice-mcp-client", + name = "deep-voice-mcp-client-$serverId", version = "1.0.0" ) ) - + // 服务器提供的工具列表 private var tools: List = emptyList() // 工具Map列表,用于OpenAI请求 private val toolMaps = mutableListOf>() - // 本地函数Map,函数名 -> 处理器 - private val localFunctions = mutableMapOf() - - // 本地函数定义Map,函数名 -> 定义 - private val localFunctionDefs = mutableMapOf() - // 是否已连接 private var isConnected = false - // 系统功能处理器 - private val systemFunctionHandler = SystemFunctionHandler(context!!) - - init { - // 如果提供了Context,自动注册语音功能处理器 - context?.let { initializeVoiceFunctions(it) } - } + // SSE传输层,使用自定义的传输层 + private var sseTransport: CustomSseClientTransport? = null - /** - * 初始化语音功能处理器 - */ - private fun initializeVoiceFunctions(context: Context) { - try { - // 使用SystemFunctionHandler注册所有功能 - systemFunctionHandler.registerAllFunctions(this) - - Log.d(TAG, "语音功能初始化完成") - } catch (e: Exception) { - Log.e(TAG, "语音功能初始化失败: ${e.message}", e) - } - } + // 工具名称到工具的映射,用于快速查找 + private val toolMapping = mutableMapOf() /** * 将JsonObject转换为JsonValue @@ -97,20 +103,35 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { val node = mapper.readTree(this.toString()) return JsonValue.fromJsonNode(node) } - + /** - * 连接到MCP服务器(通过WebSocket) + * 连接到MCP服务器(通过SSE) + * @return 是否连接成功 */ - suspend fun connectToWebSocket(serverUrl: String): Boolean { + suspend fun connect(): Boolean { try { - // 创建WebSocket传输层 - val transport = WebSocketClientTransport( - client = HttpClient(), - urlString = serverUrl + Log.d(TAG, "开始连接到服务器: $serverUrl") + + // 使用提供的客户端或创建带SSL配置的客户端 + val client = customClient ?: createSslTrustAllClient() + + // 使用自定义传输层,直接传递完整URL + sseTransport = CustomSseClientTransport( + client = client, + urlString = serverUrl, + requestBuilder = { + // 添加基本请求头 + headers { + append(HttpHeaders.Accept, "text/event-stream") + append(HttpHeaders.CacheControl, "no-cache") + } + } ) + Log.d(TAG, "创建自定义SSE传输层成功") + // 连接MCP客户端到服务器 - mcp.connect(transport) + mcp.connect(sseTransport!!) // 获取服务器提供的工具列表 val toolsResult = mcp.listTools() @@ -120,10 +141,12 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { processToolsList(toolsResult) isConnected = true - Log.d(TAG, "已连接到WebSocket服务器,可用工具: ${tools.map { it.tool().get().name() }.joinToString(", ")}") + Log.d(TAG, "成功连接到MCP服务器,可用工具: ${tools.map { it.tool().get().name() }.joinToString(", ")}") return true } catch (e: Exception) { - Log.e(TAG, "连接WebSocket服务器失败: ${e.message}", e) + Log.e(TAG, "连接服务器失败: ${e.message}", e) + // 记录详细的错误堆栈 + Log.e(TAG, "详细错误: ${e.stackTraceToString()}") return false } } @@ -131,7 +154,7 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { /** * 处理工具列表 */ - private fun processToolsList(toolsResult: io.modelcontextprotocol.kotlin.sdk.ListToolsResult?) { + private fun processToolsList(toolsResult: ListToolsResult?) { tools = toolsResult?.tools?.map { tool -> // 确保输入模式有效 val inputType = tool.inputSchema?.type ?: "object" @@ -153,10 +176,415 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { ) } ?: emptyList() - // 更新用于OpenAI的工具映射 + // 更新工具映射 + tools.forEach { toolUnion -> + val tool = toolUnion.tool().get() + toolMapping[tool.name()] = toolUnion + } + + // 更新工具Map列表 updateToolMaps() } - + + /** + * 更新工具Map列表,用于OpenAI请求 + */ + private fun updateToolMaps() { + toolMaps.clear() + + // 添加MCP工具 + tools.forEach { toolUnion -> + try { + val toolOptional = toolUnion.tool() + if (!toolOptional.isPresent) { + return@forEach + } + + val tool = toolOptional.get() + + // 创建基本参数结构 + val parametersMap = mutableMapOf() + parametersMap["type"] = "object" // 默认值 + parametersMap["properties"] = mutableMapOf() + parametersMap["required"] = emptyList() + + // 从工具的输入模式中提取信息 + try { + // 获取输入模式并转换为字符串 + val schema = tool.inputSchema() + val schemaStr = schema.toString() + Log.d(TAG, "解析工具Schema: $schemaStr") + + // 如果toString()提供了足够的信息,就使用正则表达式解析 + // 这是最可靠的方法,因为它不依赖于API的细节变化 + + // 解析类型 + val typePattern = "type=([^,}]+)".toRegex() + val typeMatch = typePattern.find(schemaStr) + if (typeMatch != null) { + parametersMap["type"] = typeMatch.groupValues[1].trim() + } + + // 尝试从字符串中解析完整的JSON结构 + try { + // 直接从Schema字符串中提取属性结构,手动构建properties映射 + val propertiesMap = mutableMapOf() + + // 匹配属性定义: 形如 fieldName={...} + val propertiesPattern = "properties=\\{([^}]+)\\}".toRegex() + val propertiesMatch = propertiesPattern.find(schemaStr) + + if (propertiesMatch != null) { + // 匹配属性定义: 形如 fieldName={...} + val fieldPattern = "([\\w]+)=\\{([^{}]+(?:\\{[^{}]*\\}[^{}]*)*)\\}".toRegex() + val fieldMatches = fieldPattern.findAll(propertiesMatch.groupValues[1]) + + fieldMatches.forEach { fieldMatch -> + val fieldName = fieldMatch.groupValues[1] + val fieldContent = fieldMatch.groupValues[2] + + // 为属性创建一个Map + val fieldMap = mutableMapOf() + + // 解析字段内容中的键值对 + val keyValuePattern = "(\\w+)=([^,}]+)".toRegex() + val keyValueMatches = keyValuePattern.findAll(fieldContent) + + keyValueMatches.forEach { kvMatch -> + val key = kvMatch.groupValues[1] + val value = kvMatch.groupValues[2].trim() + + // 根据值的类型进行适当处理 + val processedValue: Any = when { + value == "true" -> true + value == "false" -> false + value.matches("\\d+".toRegex()) -> value.toIntOrNull() ?: value + value.matches("\\d+\\.\\d+".toRegex()) -> value.toDoubleOrNull() ?: value + else -> value // 保持为字符串 + } + + fieldMap[key] = processedValue + } + + propertiesMap[fieldName] = fieldMap + } + parametersMap["properties"] = propertiesMap + Log.d(TAG, "成功解析属性: $propertiesMap") + } else { + parametersMap["properties"] = emptyMap() + } + + // 提取required部分 + val requiredPattern = "required=\\[([^\\]]+)\\]".toRegex() + val requiredMatch = requiredPattern.find(schemaStr) + + if (requiredMatch != null) { + val requiredStr = requiredMatch.groupValues[1] + val requiredList = requiredStr.split(",").map { it.trim() } + parametersMap["required"] = requiredList + } else { + parametersMap["required"] = emptyList() + } + } catch (e: Exception) { + Log.e(TAG, "从字符串解析schema细节失败: ${e.message}") + // 使用默认值 + parametersMap["properties"] = emptyMap() + parametersMap["required"] = emptyList() + } + } catch (e: Exception) { + Log.e(TAG, "解析工具输入模式失败: ${e.message}", e) + // 使用默认值继续 + } + + // 创建工具Map + toolMaps.add(mapOf( + "type" to "function", + "function" to mapOf( + "name" to tool.name(), + "description" to (tool.description() ?: ""), + "parameters" to parametersMap + ) + )) + + Log.d(TAG, "工具 ${tool.name()} 成功添加到工具映射") + + } catch (e: Exception) { + Log.e(TAG, "转换工具到Map失败: ${e.message}") + } + } + + Log.d(TAG, "更新工具映射完成,共 ${toolMaps.size} 个工具") + } + + /** + * 将JsonNode转换为Map + */ + private fun convertJsonNodeToMap(node: JsonNode): Any { + return when { + node.isTextual() -> node.asText() + node.isNumber() -> node.asDouble() + node.isBoolean() -> node.asBoolean() + node.isObject() -> { + val result = mutableMapOf() + node.fieldNames().forEach { fieldName -> + result[fieldName] = convertJsonNodeToMap(node.get(fieldName)) + } + result + } + node.isArray() -> { + val result = mutableListOf() + for (i in 0 until node.size()) { + result.add(convertJsonNodeToMap(node.get(i))) + } + result + } + node.isNull() -> "null" + else -> node.toString() + } + } + + /** + * 获取工具Map列表,用于OpenAI请求 + */ + fun getToolMaps(): List> { + return toolMaps + } + + /** + * 获取可用的工具列表 + */ + fun getTools(): List { + return tools + } + + /** + * 检查是否包含指定工具 + */ + fun containsTool(name: String): Boolean { + return toolMapping.containsKey(name) + } + + /** + * 调用工具 + */ + suspend fun callTool(name: String, arguments: Map): String? { + if (!isConnected) { + Log.e(TAG, "未连接到MCP服务器", null) + return "未连接到MCP服务器" + } + + if (!containsTool(name)) { + Log.w(TAG, "此客户端不包含工具: $name") + return null + } + + return try { + val result = mcp.callTool( + name = name, + arguments = arguments + ) + + // 处理工具调用结果 + result?.content?.joinToString("\n") { content -> + when (content) { + is TextContent -> content.text ?: "" + else -> "" + } + } + } catch (e: Exception) { + Log.e(TAG, "调用MCP工具失败: ${e.message}", e) + "调用MCP工具失败: ${e.message}" + } + } + + /** + * 检查是否已连接 + */ + fun isConnected(): Boolean { + return isConnected + } + + /** + * 关闭连接 + */ + override fun close() { + runBlocking { + try { + mcp.close() + sseTransport?.close() + isConnected = false + Log.d(TAG, "已关闭MCP服务器连接") + } catch (e: Exception) { + Log.e(TAG, "关闭MCP服务器连接失败: ${e.message}", e) + } + } + } +} + +/** + * MCP客户端,用于连接Model Context Protocol服务器和管理本地函数 + */ +class MCPClient(private val context: Context? = null) : AutoCloseable { + private val TAG = "MCPClient" + + // 本地函数Map,函数名 -> 处理器 + private val localFunctions = mutableMapOf() + + // 本地函数定义Map,函数名 -> 定义 + private val localFunctionDefs = mutableMapOf() + + // 子客户端列表,每个连接一个MCP服务器 + private val subClients = mutableMapOf() + + // 是否已连接 + private var isConnected = false + + // 系统功能处理器 + private val systemFunctionHandler = SystemFunctionHandler(context!!) + + // 共享的HttpClient,用于所有子客户端 + private val sharedHttpClient by lazy { createSslTrustAllClient() } + + init { + // 如果提供了Context,自动注册语音功能处理器 + context?.let { initializeVoiceFunctions(it) } + } + + /** + * 初始化语音功能处理器 + */ + private fun initializeVoiceFunctions(context: Context) { + try { + // 使用SystemFunctionHandler注册所有功能 + systemFunctionHandler.registerAllFunctions(this) + + Log.d(TAG, "语音功能初始化完成") + } catch (e: Exception) { + Log.e(TAG, "语音功能初始化失败: ${e.message}", e) + } + } + + /** + * 连接到MCP服务器(通过SSE) + * 直接接收完整的JSON配置字符串 + * + * @param mcpConfigJson 包含mcpServers字段的JSON配置字符串 + * @return 是否连接成功 + */ + suspend fun connectToSSE(mcpConfigJson: String): Boolean { + // 清除现有连接 + closeAllConnections() + + val serverMap = mutableMapOf() + var configLoaded = false + + // 尝试解析传入的JSON配置 + try { + if (mcpConfigJson.isNotEmpty()) { + val mapper = ObjectMapper() + try { + val config = mapper.readValue(mcpConfigJson, Map::class.java) + + // 处理mcpServers格式 + @Suppress("UNCHECKED_CAST") + val mcpServers = config["mcpServers"] as? Map> + if (mcpServers != null && mcpServers.isNotEmpty()) { + // 从服务器配置中提取URL + mcpServers.forEach { (serverId, serverConfig) -> + val url = serverConfig["url"] + if (!url.isNullOrEmpty()) { + serverMap[serverId] = url + Log.d(TAG, "从配置中添加服务器 [$serverId]: $url") + } + } + configLoaded = serverMap.isNotEmpty() + Log.d(TAG, "从传入的JSON配置加载了 ${serverMap.size} 个服务器") + } + } catch (e: Exception) { + Log.e(TAG, "解析传入的JSON配置失败: ${e.message}") + } + } + } catch (e: Exception) { + Log.e(TAG, "处理传入的配置失败: ${e.message}") + } + + + // 创建并连接所有子客户端 + val connectionResults = mutableMapOf() + + for ((serverId, serverUrl) in serverMap) { + try { + Log.d(TAG, "为服务器 [$serverId],[$serverUrl] 创建子客户端") + val subClient = MCPSubClient(serverId, serverUrl, sharedHttpClient) + val connected = subClient.connect() + + if (connected) { + subClients[serverId] = subClient + connectionResults[serverId] = true + Log.d(TAG, "子客户端 [$serverId] 连接成功") + } else { + subClient.close() + connectionResults[serverId] = false + Log.w(TAG, "子客户端 [$serverId] 连接失败") + } + } catch (e: Exception) { + Log.e(TAG, "创建子客户端 [$serverId] 失败: ${e.message}", e) + connectionResults[serverId] = false + } + } + + // 检查是否有任何客户端连接成功 + val successCount = connectionResults.count { it.value } + isConnected = successCount > 0 + + if (isConnected) { + Log.d(TAG, "已成功连接 $successCount/${serverMap.size} 个MCP服务器") + + // 记录所有可用工具 + val allToolNames = getAllToolNames() + Log.d(TAG, "所有可用工具: ${allToolNames.joinToString(", ")}") + + return true + } else { + Log.e(TAG, "所有MCP服务器连接均失败") + return false + } + } + + /** + * 关闭所有连接 + */ + private fun closeAllConnections() { + subClients.forEach { (serverId, client) -> + try { + client.close() + Log.d(TAG, "已关闭子客户端 [$serverId]") + } catch (e: Exception) { + Log.e(TAG, "关闭子客户端 [$serverId] 失败: ${e.message}") + } + } + subClients.clear() + } + + /** + * 获取所有工具名称 + */ + private fun getAllToolNames(): List { + val toolNames = mutableSetOf() + + // 添加本地函数 + toolNames.addAll(localFunctions.keys) + + // 添加所有子客户端的工具 + subClients.forEach { (_, client) -> + client.getTools().forEach { toolUnion -> + val tool = toolUnion.tool().get() + toolNames.add(tool.name()) + } + } + + return toolNames.toList() + } /** * 注册本地函数 @@ -211,9 +639,6 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { // 保存函数定义 localFunctionDefs[name] = ObjectMapper().writeValueAsString(functionDef) - // 更新工具映射 - updateToolMaps() - Log.d(TAG, "注册本地函数成功: $name") return true } catch (e: Exception) { @@ -231,128 +656,39 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { val removed = localFunctions.remove(name) != null if (removed) { localFunctionDefs.remove(name) - updateToolMaps() Log.d(TAG, "取消注册本地函数成功: $name") } return removed } /** - * 更新工具Map列表,用于OpenAI请求 + * 获取工具Map列表,用于OpenAI请求 */ - private fun updateToolMaps() { - toolMaps.clear() + fun getToolMaps(): List> { + val allToolMaps = mutableListOf>() // 首先添加本地函数 localFunctionDefs.forEach { (name, functionDefJson) -> try { val mapper = ObjectMapper() val functionDef = mapper.readValue(functionDefJson, object : TypeReference>() {}) - val toolMap = mapOf( + allToolMaps.add(mapOf( "type" to "function", "function" to functionDef - ) - toolMaps.add(toolMap) + )) } catch (e: Exception) { - Log.e(TAG, "解析本地函数定义失败: $name, ${e.message}", e) + Log.e(TAG, "解析本地函数定义失败: $name, ${e.message}") } } - // 然后添加MCP工具 - tools.forEach { toolUnion -> - try { - val tool = toolUnion.tool().get() - val parametersMap = mutableMapOf() - - // 提取工具类型及参数 - try { - val inputSchema = tool.inputSchema() - // 解析JsonValue - val mapper = ObjectMapper() - val inputSchemaStr = inputSchema.toString() - val inputSchemaJson = mapper.readTree(inputSchemaStr) - val typeValue = inputSchemaJson.path("type") - parametersMap["type"] = if (typeValue.isTextual) typeValue.asText() else "object" - - // 提取properties字段 - val propertiesMap = mutableMapOf() - val propertiesNode = inputSchemaJson.path("properties") - if (propertiesNode.isObject) { - propertiesNode.fieldNames().forEach { fieldName -> - val fieldNode = propertiesNode.get(fieldName) - propertiesMap[fieldName] = convertJsonNodeToMap(fieldNode) - } - } - parametersMap["properties"] = propertiesMap - - // 提取required字段 - val requiredList = mutableListOf() - val requiredNode = inputSchemaJson.path("required") - if (requiredNode.isArray) { - for (i in 0 until requiredNode.size()) { - if (requiredNode.get(i).isTextual) { - requiredList.add(requiredNode.get(i).asText()) - } - } - } - parametersMap["required"] = requiredList - } catch (e: Exception) { - Log.e(TAG, "解析工具参数失败: ${e.message}", e) - // 设置默认参数 - parametersMap["type"] = "object" - parametersMap["properties"] = emptyMap() - parametersMap["required"] = emptyList() - } - - // 创建工具Map - val toolMap = mapOf( - "type" to "function", - "function" to mapOf( - "name" to tool.name(), - "description" to (tool.description() ?: ""), - "parameters" to parametersMap - ) - ) - - toolMaps.add(toolMap) - } catch (e: Exception) { - Log.e(TAG, "转换工具到Map失败: ${e.message}", e) - } - } - } - - /** - * 将JsonNode转换为Map - */ - private fun convertJsonNodeToMap(node: JsonNode): Any { - return when { - node.isTextual() -> node.asText() - node.isNumber() -> node.asDouble() - node.isBoolean() -> node.asBoolean() - node.isObject() -> { - val result = mutableMapOf() - node.fieldNames().forEach { fieldName -> - result[fieldName] = convertJsonNodeToMap(node.get(fieldName)) - } - result - } - node.isArray() -> { - val result = mutableListOf() - for (i in 0 until node.size()) { - result.add(convertJsonNodeToMap(node.get(i))) - } - result - } - node.isNull() -> "null" - else -> node.toString() + // 然后添加所有子客户端的工具 + subClients.forEach { (serverId, client) -> + val clientTools = client.getToolMaps() + allToolMaps.addAll(clientTools) + Log.d(TAG, "添加子客户端 [$serverId] 的 ${clientTools.size} 个工具") } - } - - /** - * 获取工具Map列表,用于OpenAI请求 - */ - fun getToolMaps(): List> { - return toolMaps + + return allToolMaps } /** @@ -365,7 +701,7 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { return ToolType.LOCAL_FUNCTION } - if (tools.any { it.tool().get().name() == name }) { + if (subClients.any { it.value.containsTool(name) }) { return ToolType.MCP_TOOL } @@ -422,40 +758,40 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { return "未连接到MCP服务器" } - return try { - val result = mcp.callTool( - name = name, - arguments = arguments - ) - - // 处理工具调用结果 - result?.content?.joinToString("\n") { content -> - when (content) { - is TextContent -> content.text ?: "" - else -> "" - } - } - } catch (e: Exception) { - Log.e(TAG, "调用MCP工具失败: ${e.message}", e) - "调用MCP工具失败: ${e.message}" + // 查找包含此工具的子客户端 + val clientEntry = subClients.entries.find { it.value.containsTool(name) } + + if (clientEntry != null) { + val (serverId, client) = clientEntry + Log.d(TAG, "通过子客户端 [$serverId] 调用工具: $name") + return client.callTool(name, arguments) } + + // 没有找到包含此工具的客户端 + Log.e(TAG, "没有子客户端包含工具: $name") + return "没有可用的服务器提供工具: $name" } /** * 获取可用的工具列表 */ fun getTools(): List { - return tools + val allTools = mutableListOf() + + // 收集所有子客户端的工具 + subClients.forEach { (_, client) -> + allTools.addAll(client.getTools()) + } + + return allTools } /** * 检查工具是否存在 */ fun hasToolWithName(name: String): Boolean { - return localFunctions.containsKey(name) || toolMaps.any { - val functionMap = it["function"] as? Map - functionMap?.get("name") == name - } + return localFunctions.containsKey(name) || + subClients.any { it.value.containsTool(name) } } /** @@ -483,14 +819,71 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { override fun close() { runBlocking { try { - mcp.close() + closeAllConnections() isConnected = false - tools = emptyList() - toolMaps.clear() - Log.d(TAG, "已关闭MCP服务器连接", null) + Log.d(TAG, "已关闭所有MCP服务器连接") } catch (e: Exception) { Log.e(TAG, "关闭MCP服务器连接失败: ${e.message}", e) } } } +} + +/** + * 创建一个信任所有SSL证书的HttpClient + */ +private fun createSslTrustAllClient(): HttpClient { + // 创建信任所有证书的TrustManager + val trustAllCerts = arrayOf( + object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = arrayOf() + } + ) + + // 创建SSL上下文并初始化它 + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, trustAllCerts, SecureRandom()) + + // 创建HTTP日志拦截器 + val loggingInterceptor = HttpLoggingInterceptor { message -> + Log.d("OkHttp", message) + }.apply { + level = HttpLoggingInterceptor.Level.HEADERS + } + + // 创建自定义拦截器显示实际请求URL + val urlLoggingInterceptor = Interceptor { chain -> + val request = chain.request() + Log.d("OkHttp-URL", "实际请求URL: ${request.url}") + Log.d("OkHttp-Headers", "请求头: ${request.headers}") + + val response = chain.proceed(request) + + Log.d("OkHttp-Response", "状态码: ${response.code} - ${response.message}") + Log.d("OkHttp-Response", "响应头: ${response.headers}") + + response + } + + // 创建OkHttpClient并配置信任所有证书 + val okHttpClient = OkHttpClient.Builder() + .sslSocketFactory(sslContext.socketFactory, trustAllCerts[0] as X509TrustManager) + .hostnameVerifier { _, _ -> true } + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .addInterceptor(loggingInterceptor) // 添加HTTP日志拦截器 + .addInterceptor(urlLoggingInterceptor) // 添加URL日志拦截器 + .build() + + // 创建使用OkHttp引擎的HttpClient + return HttpClient(OkHttp) { + engine { + preconfigured = okHttpClient + } + + // 安装SSE插件 + install(SSE) + } } \ No newline at end of file diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt index 9ca318840..b91ec832a 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt @@ -94,7 +94,7 @@ class OpenAIService(private val context: Context? = null) { /** * 初始化OpenAI服务 */ - fun initialize(apiKey: String, baseUrl: String, model: String): Boolean { + fun initialize(apiKey: String, baseUrl: String, model: String, mcpServer: String): Boolean { this.apiKey = apiKey if (baseUrl.isNotEmpty()) { this.baseUrl = baseUrl @@ -107,7 +107,7 @@ class OpenAIService(private val context: Context? = null) { if (mcpClient == null) { mcpClient = MCPClient(context) } - + initializeMcpClient(mcpServer) isInitialized = apiKey.isNotEmpty() return isInitialized } @@ -129,9 +129,9 @@ class OpenAIService(private val context: Context? = null) { } /** - * 初始化MCP客户端(WebSocket方式) + * 初始化MCP客户端 */ - fun initializeMcpClient(serverUrl: String): Boolean { + fun initializeMcpClient(mcpServer: String): Boolean { if (mcpClient != null) { mcpClient?.close() } @@ -144,7 +144,7 @@ class OpenAIService(private val context: Context? = null) { var result = false scope.launch { try { - result = mcpClient?.connectToWebSocket(serverUrl) ?: false + result = mcpClient?.connectToSSE(mcpServer) ?: false isMcpInitialized = result Log.d(TAG, "MCP客户端初始化${if (result) "成功" else "失败"}") } catch (e: Exception) { diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt index 091af559c..03b4d5d9e 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt @@ -73,8 +73,9 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { val apiKey = call.argument("apiKey") ?: "" val baseUrl = call.argument("baseUrl") ?: "" val model = call.argument("model") ?: "" + val mcpServer = call.argument("mcpServer") ?: "" - val initialized = openAIService.initialize(apiKey, baseUrl, model) + val initialized = openAIService.initialize(apiKey, baseUrl, model, mcpServer) result.success(initialized) } diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/mcp/CustomSseClientTransport.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/mcp/CustomSseClientTransport.kt new file mode 100644 index 000000000..4b98b1c0f --- /dev/null +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/mcp/CustomSseClientTransport.kt @@ -0,0 +1,357 @@ +package com.yunqiinnovation.open_ai_service.mcp + +import android.util.Log +import io.ktor.client.* +import io.ktor.client.plugins.sse.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport +import kotlinx.coroutines.* +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.decodeFromString +import kotlin.properties.Delegates +import kotlin.time.Duration +import java.util.concurrent.atomic.AtomicBoolean +import org.json.JSONObject + +/** + * 自定义SSE客户端传输层,修复原始SseClientTransport中的URL拼接问题 + * 解决URL查询参数与路径拼接错误的问题,确保消息端点URL格式正确 + */ +class CustomSseClientTransport( + private val client: HttpClient, + private val urlString: String?, + private val reconnectionTime: Duration? = null, + private val requestBuilder: HttpRequestBuilder.() -> Unit = {}, +) : AbstractTransport() { + private val TAG = "CustomSseClientTransport" + + private val scope by lazy { + CoroutineScope(session.coroutineContext + SupervisorJob()) + } + + // 使用Java标准库的AtomicBoolean替代kotlinx.atomicfu + private val initialized = AtomicBoolean(false) + private var session: ClientSSESession by Delegates.notNull() + private val endpoint = CompletableDeferred() + + private var job: Job? = null + + // 创建JSON解析器,增强灵活性设置 + private val json = Json { + ignoreUnknownKeys = true // 忽略未知字段 + isLenient = true // 宽松解析模式 + coerceInputValues = true // 尝试强制转换类型 + encodeDefaults = true // 编码默认值 + explicitNulls = false // 不要求显式null值 + } + + // 保存基础URL(不包含查询参数)和查询参数 + private var baseUrlWithoutParams: String? = null + private var queryParams: Map = emptyMap() + private var hostPart: String = "" // 添加类级别变量 + private var pathPart: String = "" // 添加类级别变量 + + /** + * 解析URL,分离基础URL、路径和查询参数 + * 返回三元组: (主机部分URL, 路径部分, 查询参数Map) + */ + private fun parseUrl(url: String): Triple> { + return try { + val params = mutableMapOf() + + // 确保URL有协议部分 + var processedUrl = url.trim() + if (!processedUrl.startsWith("http://") && !processedUrl.startsWith("https://")) { + processedUrl = "https://$processedUrl" + Log.d(TAG, "添加默认协议: $processedUrl") + } + + val urlObj = java.net.URL(processedUrl) + + // 解析查询参数 + if (urlObj.query != null) { + urlObj.query.split("&").forEach { param -> + val parts = param.split("=", limit = 2) + if (parts.size == 2) { + params[parts[0]] = parts[1] + } + } + } + + // 构建主机部分URL(协议+主机+端口) + val port = if (urlObj.port == -1) "" else ":${urlObj.port}" + val hostUrl = "${urlObj.protocol}://${urlObj.host}$port" + + // 路径部分 + val path = urlObj.path + + Triple(hostUrl, path, params) + } catch (e: Exception) { + Log.e(TAG, "解析URL失败: $url, ${e.message}") + Triple(url, "", emptyMap()) + } + } + + /** + * 收集SSE事件 + */ + private suspend fun collectEvents() { + job = scope.launch(CoroutineName("CustomSseMcpClientTransport.collect#${hashCode()}")) { + session.incoming.collect { event -> + when (event.event) { + "error" -> { + val e = IllegalStateException("SSE error: ${event.data}") + Log.e(TAG, "SSE错误: ${event.data}") + _onError(e) + throw e + } + + "open" -> { + Log.d(TAG, "SSE连接已打开") + // 连接已打开,等待endpoint事件 + } + + "endpoint" -> { + try { + val eventData = event.data ?: "" + Log.d(TAG, "收到endpoint事件: $eventData") + + // 使用主机部分构建endpoint + val fullEndpoint = if (eventData.startsWith("/")) { + "$hostPart$eventData" + } else { + "$hostPart/$eventData" + } + + Log.d(TAG, "构建的endpoint路径(不含参数): $fullEndpoint") + + // 添加查询参数到endpoint + val endpointWithParams = if (queryParams.isNotEmpty()) { + // 检查endpoint是否已有查询参数 + if (fullEndpoint.contains("?")) { + // 已有查询参数,添加&并附加其他参数 + val queryString = queryParams.entries.joinToString("&") { "${it.key}=${it.value}" } + "$fullEndpoint&$queryString" + } else { + // 没有查询参数,添加?并附加参数 + val queryString = queryParams.entries.joinToString("&") { "${it.key}=${it.value}" } + "$fullEndpoint?$queryString" + } + } else { + fullEndpoint + } + + Log.d(TAG, "最终消息端点: $endpointWithParams") + endpoint.complete(endpointWithParams) + } catch (e: Exception) { + Log.e(TAG, "处理endpoint事件失败: ${e.message}", e) + _onError(e) + close() + error(e) + } + } + + else -> { + try { + // 解析JSON-RPC消息 + val data = event.data + if (data != null) { + Log.d(TAG, "收到事件数据: $data") + try { + // 尝试安全地解析JSON消息 + safeParseMessage(data) + } catch (e: Exception) { + Log.e(TAG, "解析JSON-RPC消息失败: ${e.message}", e) + // 错误已记录,但不中断连接,只发送错误通知 + _onError(e) + } + } + } catch (e: Exception) { + Log.e(TAG, "处理事件失败: ${e.message}", e) + _onError(e) + } + } + } + } + } + } + + /** + * 安全解析JSON-RPC消息 + */ + private suspend fun safeParseMessage(data: String) { + try { + // 先尝试使用标准解析 + val message = json.decodeFromString(data) + _onMessage(message) + } catch (e: Exception) { + // 如果标准解析失败,记录错误并尝试使用备用解析方式 + Log.w(TAG, "标准解析失败,尝试备用解析: ${e.message}") + + try { + // 尝试修复nextCursor缺失问题 + if (e.message?.contains("nextCursor") == true) { + // 尝试手动添加缺失的nextCursor字段 + val jsonObj = JSONObject(data) + + // 只有在解析ListToolsResult时处理 + if (data.contains("\"tools\"")) { + Log.d(TAG, "尝试修复ListToolsResult缺少nextCursor字段的问题") + + // 手动解析result部分并添加nextCursor + val resultJson = try { + if (jsonObj.has("result")) { + val resultObj = jsonObj.getJSONObject("result") + if (!resultObj.has("nextCursor")) { + resultObj.put("nextCursor", "") + jsonObj.put("result", resultObj) + } + jsonObj.toString() + } else { + // 如果没有result字段,可能是其他类型的消息 + data + } + } catch (ex: Exception) { + Log.e(TAG, "手动修复JSON失败: ${ex.message}") + data + } + + // 重新尝试解析修复后的JSON + val fixedMessage = json.decodeFromString(resultJson) + _onMessage(fixedMessage) + return + } + } + + // 通用错误处理 + Log.e(TAG, "无法解析消息,跳过: $data") + } catch (ex: Exception) { + Log.e(TAG, "备用解析也失败: ${ex.message}", ex) + // 不抛出异常,只记录错误 + _onError(e) + } + } + } + + /** + * 启动传输层 + */ + override suspend fun start() { + if (!initialized.compareAndSet(false, true)) { + Log.e(TAG, "传输层已经启动,不能重复启动") + error("CustomSseClientTransport already started!") + } + + // 解析URL和参数 + if (urlString != null) { + // 解析URL,提取主机部分、路径部分和查询参数 + val urlInfo = parseUrl(urlString) + hostPart = urlInfo.first + pathPart = urlInfo.second + queryParams = urlInfo.third + + // 存储不带查询参数的基础URL(主机+路径) + baseUrlWithoutParams = hostPart + pathPart + + Log.d(TAG, "原始URL: $urlString") + Log.d(TAG, "主机部分: $hostPart") + Log.d(TAG, "路径部分: $pathPart") + Log.d(TAG, "查询参数: $queryParams") + Log.d(TAG, "完整基础URL: $baseUrlWithoutParams") + } + + // 创建SSE会话 - 直接使用原始URL,不添加/sse后缀 + session = urlString?.let { + // 完整的SSE连接URL(主机部分+原始路径+查询参数) + val sseConnectUrl = if (queryParams.isNotEmpty()) { + // 如果路径已包含查询参数,就不再添加 + if (pathPart.contains("?")) { + "$hostPart$pathPart" + } else { + val queryString = queryParams.entries.joinToString("&") { "${it.key}=${it.value}" } + "$hostPart$pathPart?$queryString" + } + } else { + "$hostPart$pathPart" + } + + Log.d(TAG, "SSE连接URL: $sseConnectUrl") + + client.sseSession( + urlString = sseConnectUrl, + reconnectionTime = reconnectionTime, + block = requestBuilder, + ) + } ?: client.sseSession( + reconnectionTime = reconnectionTime, + block = requestBuilder, + ) + + // 收集SSE事件 + collectEvents() + + // 等待endpoint就绪 + endpoint.await() + Log.d(TAG, "传输层启动完成,消息端点已就绪") + } + + /** + * 发送消息 + */ + @OptIn(ExperimentalCoroutinesApi::class) + override suspend fun send(message: JSONRPCMessage) { + if (!endpoint.isCompleted) { + Log.e(TAG, "发送失败: 未连接") + error("Not connected") + } + + try { + val messageEndpoint = endpoint.getCompleted() + Log.d(TAG, "发送消息到: $messageEndpoint") + + // 序列化消息 + val jsonString = json.encodeToString(message) + + val response = client.post(messageEndpoint) { + headers.append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(jsonString) + } + + if (!response.status.isSuccess()) { + val text = response.bodyAsText() + Log.e(TAG, "发送消息失败: HTTP ${response.status}, $text") + error("Error POSTing to endpoint (HTTP ${response.status}): $text") + } + } catch (e: Exception) { + Log.e(TAG, "发送消息异常: ${e.message}", e) + _onError(e) + throw e + } + } + + /** + * 关闭传输层 + */ + override suspend fun close() { + if (!initialized.get()) { + Log.e(TAG, "关闭失败: 传输层未初始化") + error("CustomSseClientTransport is not initialized!") + } + + session.cancel() + _onClose() + job?.cancelAndJoin() + Log.d(TAG, "传输层已关闭") + } + + /** + * 检查传输层是否已初始化 + */ + fun isInitialized(): Boolean { + return initialized.get() + } +} \ No newline at end of file diff --git a/local_plugins/open_ai_service/lib/open_ai_service.dart b/local_plugins/open_ai_service/lib/open_ai_service.dart index 8666f0100..df0fa315d 100644 --- a/local_plugins/open_ai_service/lib/open_ai_service.dart +++ b/local_plugins/open_ai_service/lib/open_ai_service.dart @@ -94,10 +94,12 @@ class OpenAIService { /// [apiKey] OpenAI API密钥 /// [baseUrl] 可选,自定义API基础URL /// [model] 可选,自定义使用的模型 + /// [mcpServer] 可选,MCP服务器地址配置 Future initialize({ required String apiKey, String baseUrl = '', String model = '', + String mcpServer = '', }) async { try { final result = await _channel.invokeMethod( @@ -106,6 +108,7 @@ class OpenAIService { 'apiKey': apiKey, 'baseUrl': baseUrl, 'model': model, + 'mcpServer': mcpServer, }, ); diff --git a/pubspec.yaml b/pubspec.yaml index b08d2746e..a076d7687 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -91,3 +91,4 @@ flutter: - assets/images/explore2.jpg - assets/lottie/ - .env + - .mcp.json diff --git a/test.json b/test.json index 2411527e2..208c91d82 100644 --- a/test.json +++ b/test.json @@ -16,9 +16,19 @@ curl 'https://ark.cn-beijing.volces.com/api/v3/bots/chat/completions' \ } ] }' +"map-qq": { + "url": "https://mcp.map.qq.com/sse?key=E5ABZ-RIACZ-DX2XC-7DEU2-AXR2Q-V7BWQ" +}, - - +"amap-amap-sse": { + "url": "https://mcp.amap.com/sse?key=66b3f6199e3561f874a5a9cd9e9ed7f2" +}, + // "map-qq": { + // "url": "https://mcp.map.qq.com?key=E5ABZ-RIACZ-DX2XC-7DEU2-AXR2Q-V7BWQ" + // }, + "web-search": { + "url": "http://mcp.ideapsound.com:8000" + }, curl 'https://ark.cn-beijing.volces.com/api/v3/bots/chat/completions' \ -H 'Content-Type: application/json' \ From d6720d00361a4fd4fc79ba5ecfbae479155aeca3 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Wed, 16 Apr 2025 14:58:59 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E5=92=8Cfacntion=20call=20=E7=9A=84=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E8=A1=A5=E5=85=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle.kts | 11 +- android/app/src/main/AndroidManifest.xml | 4 + .../deepsound/VoiceInteractionHandler.kt | 4 +- android/settings.gradle.kts | 7 +- lib/data/services/deapsound_ai_service.dart | 2 +- lib/data/services/network/nw_api.dart | 2 +- .../services/open_ai_service_adapter.dart | 147 +-- .../chat/controllers/chat_controller.dart | 44 +- .../controllers/profile_controller.dart | 35 +- .../open_ai_service/MCPClient.kt | 27 +- .../open_ai_service/OpenAIService.kt | 15 +- .../open_ai_service/OpenAIServicePlugin.kt | 27 +- .../mcp/CustomSseClientTransport.kt | 2 +- .../open_ai_service/lib/open_ai_service.dart | 130 +- .../.github/ISSUE_TEMPLATE/bug_report.md | 62 + .../.github/ISSUE_TEMPLATE/config.yml | 1 + .../.github/ISSUE_TEMPLATE/feature_request.md | 20 + .../publish_flutter_package/action.yaml | 41 + .../spotify_sdk/.github/dependabot.yml | 42 + .../.github/scripts/verify_pub_score.sh | 16 + .../.github/workflows/pull_request.yml | 38 + .../spotify_sdk/.github/workflows/release.yml | 34 + local_plugins/spotify_sdk/.gitignore | 50 + local_plugins/spotify_sdk/.metadata | 10 + local_plugins/spotify_sdk/CHANGELOG.md | 184 +++ local_plugins/spotify_sdk/CODE_OF_CONDUCT.md | 76 ++ local_plugins/spotify_sdk/CONTRIBUTING.md | 48 + local_plugins/spotify_sdk/LICENSE | 201 +++ local_plugins/spotify_sdk/README.md | 257 ++++ .../spotify_sdk/analysis_options.yaml | 12 + local_plugins/spotify_sdk/android/.gitignore | 8 + .../spotify_sdk/android/build.gradle | 74 ++ .../spotify_sdk/android/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.properties | 7 + .../libs/spotify-app-remote-release-0.8.0.aar | Bin 0 -> 132749 bytes .../spotify_sdk/android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 10 + .../minimalme/spotify_sdk/BaseSpotifyApi.kt | 13 + .../spotify_sdk/SpotifyConnectApi.kt | 25 + .../minimalme/spotify_sdk/SpotifyImagesApi.kt | 35 + .../minimalme/spotify_sdk/SpotifyPlayerApi.kt | 210 +++ .../minimalme/spotify_sdk/SpotifySdkPlugin.kt | 381 ++++++ .../minimalme/spotify_sdk/SpotifyUserApi.kt | 59 + .../subscriptions/CapabilitiesChannel.kt | 23 + .../subscriptions/ConnectionStatusChannel.kt | 27 + .../subscriptions/PlayerContextChannel.kt | 22 + .../subscriptions/PlayerStateChannel.kt | 22 + .../subscriptions/UserStatusChannel.kt | 23 + .../spotify_sdk/bin/android_cleanup.dart | 39 + .../bin/android_module_creator.dart | 96 ++ .../spotify_sdk/bin/android_setup.dart | 91 ++ local_plugins/spotify_sdk/bin/github_api.dart | 100 ++ .../spotify_sdk/bin/precondition_checker.dart | 52 + local_plugins/spotify_sdk/ios/.gitignore | 37 + local_plugins/spotify_sdk/ios/Assets/.gitkeep | 0 .../ios/Classes/ConnectionStatusHandler.swift | 45 + .../ios/Classes/Extension/Dictionary.swift | 13 + .../ios/Classes/PlayerDelegate.swift | 14 + .../PlayerState/PlayerContextHandler.swift | 18 + .../ios/Classes/PlayerState/PlayerState.swift | 85 ++ .../PlayerState/PlayerStateHandler.swift | 22 + .../ios/Classes/SpotifyError.swift | 4 + .../ios/Classes/SpotifySdkConstants.swift | 46 + .../ios/Classes/SpotifySdkPlugin.h | 4 + .../ios/Classes/SpotifySdkPlugin.m | 18 + .../ios/Classes/StatusHandler.swift | 17 + .../ios/Classes/SwiftSpotifySdkPlugin.swift | 416 ++++++ .../spotify_sdk/ios/prepare-iOS-SDK.sh | 10 + .../spotify_sdk/ios/spotify_sdk.podspec | 26 + .../lib/enums/image_dimension_enum.dart | 17 + .../lib/enums/podcast_playback_speed.dart | 23 + .../lib/enums/repeat_mode_enum.dart | 11 + .../extensions/image_dimension_extension.dart | 19 + .../podcast_playback_speed_extension.dart | 21 + .../lib/logging/custom_log_filter.dart | 8 + .../spotify_sdk/lib/models/album.dart | 15 + .../spotify_sdk/lib/models/album.g.dart | 17 + .../spotify_sdk/lib/models/artist.dart | 15 + .../spotify_sdk/lib/models/artist.g.dart | 17 + .../spotify_sdk/lib/models/capabilities.dart | 18 + .../lib/models/capabilities.g.dart | 16 + .../lib/models/connection_status.dart | 31 + .../lib/models/connection_status.g.dart | 23 + .../lib/models/crossfade_state.dart | 19 + .../lib/models/crossfade_state.g.dart | 19 + .../spotify_sdk/lib/models/image_uri.dart | 15 + .../spotify_sdk/lib/models/image_uri.g.dart | 15 + .../spotify_sdk/lib/models/library_state.dart | 24 + .../lib/models/library_state.g.dart | 20 + .../lib/models/player_context.dart | 18 + .../lib/models/player_context.g.dart | 23 + .../lib/models/player_options.dart | 30 + .../lib/models/player_options.g.dart | 25 + .../lib/models/player_restrictions.dart | 33 + .../lib/models/player_restrictions.g.dart | 27 + .../spotify_sdk/lib/models/player_state.dart | 36 + .../lib/models/player_state.g.dart | 29 + .../spotify_sdk/lib/models/track.dart | 43 + .../spotify_sdk/lib/models/track.g.dart | 35 + .../spotify_sdk/lib/models/user_status.dart | 26 + .../spotify_sdk/lib/models/user_status.g.dart | 20 + .../spotify_sdk/lib/platform_channels.dart | 161 +++ .../spotify_sdk/lib/spotify_sdk.dart | 660 ++++++++++ .../spotify_sdk/lib/spotify_sdk_web.dart | 1137 +++++++++++++++++ local_plugins/spotify_sdk/pubspec.yaml | 82 ++ .../spotify_sdk/test/spotify_sdk_test.dart | 28 + pubspec.yaml | 3 +- 107 files changed, 6214 insertions(+), 210 deletions(-) create mode 100644 local_plugins/spotify_sdk/.github/ISSUE_TEMPLATE/bug_report.md create mode 100644 local_plugins/spotify_sdk/.github/ISSUE_TEMPLATE/config.yml create mode 100644 local_plugins/spotify_sdk/.github/ISSUE_TEMPLATE/feature_request.md create mode 100644 local_plugins/spotify_sdk/.github/actions/publish_flutter_package/action.yaml create mode 100644 local_plugins/spotify_sdk/.github/dependabot.yml create mode 100755 local_plugins/spotify_sdk/.github/scripts/verify_pub_score.sh create mode 100644 local_plugins/spotify_sdk/.github/workflows/pull_request.yml create mode 100644 local_plugins/spotify_sdk/.github/workflows/release.yml create mode 100644 local_plugins/spotify_sdk/.gitignore create mode 100644 local_plugins/spotify_sdk/.metadata create mode 100644 local_plugins/spotify_sdk/CHANGELOG.md create mode 100644 local_plugins/spotify_sdk/CODE_OF_CONDUCT.md create mode 100644 local_plugins/spotify_sdk/CONTRIBUTING.md create mode 100644 local_plugins/spotify_sdk/LICENSE create mode 100644 local_plugins/spotify_sdk/README.md create mode 100644 local_plugins/spotify_sdk/analysis_options.yaml create mode 100644 local_plugins/spotify_sdk/android/.gitignore create mode 100644 local_plugins/spotify_sdk/android/build.gradle create mode 100644 local_plugins/spotify_sdk/android/gradle.properties create mode 100644 local_plugins/spotify_sdk/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 local_plugins/spotify_sdk/android/libs/spotify-app-remote-release-0.8.0.aar create mode 100644 local_plugins/spotify_sdk/android/settings.gradle create mode 100644 local_plugins/spotify_sdk/android/src/main/AndroidManifest.xml create mode 100644 local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/BaseSpotifyApi.kt create mode 100644 local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyConnectApi.kt create mode 100644 local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyImagesApi.kt create mode 100644 local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyPlayerApi.kt create mode 100644 local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifySdkPlugin.kt create mode 100644 local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyUserApi.kt create mode 100644 local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/CapabilitiesChannel.kt create mode 100644 local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/ConnectionStatusChannel.kt create mode 100644 local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/PlayerContextChannel.kt create mode 100644 local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/PlayerStateChannel.kt create mode 100644 local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/UserStatusChannel.kt create mode 100644 local_plugins/spotify_sdk/bin/android_cleanup.dart create mode 100644 local_plugins/spotify_sdk/bin/android_module_creator.dart create mode 100644 local_plugins/spotify_sdk/bin/android_setup.dart create mode 100644 local_plugins/spotify_sdk/bin/github_api.dart create mode 100644 local_plugins/spotify_sdk/bin/precondition_checker.dart create mode 100644 local_plugins/spotify_sdk/ios/.gitignore create mode 100644 local_plugins/spotify_sdk/ios/Assets/.gitkeep create mode 100644 local_plugins/spotify_sdk/ios/Classes/ConnectionStatusHandler.swift create mode 100644 local_plugins/spotify_sdk/ios/Classes/Extension/Dictionary.swift create mode 100644 local_plugins/spotify_sdk/ios/Classes/PlayerDelegate.swift create mode 100644 local_plugins/spotify_sdk/ios/Classes/PlayerState/PlayerContextHandler.swift create mode 100644 local_plugins/spotify_sdk/ios/Classes/PlayerState/PlayerState.swift create mode 100644 local_plugins/spotify_sdk/ios/Classes/PlayerState/PlayerStateHandler.swift create mode 100644 local_plugins/spotify_sdk/ios/Classes/SpotifyError.swift create mode 100644 local_plugins/spotify_sdk/ios/Classes/SpotifySdkConstants.swift create mode 100644 local_plugins/spotify_sdk/ios/Classes/SpotifySdkPlugin.h create mode 100644 local_plugins/spotify_sdk/ios/Classes/SpotifySdkPlugin.m create mode 100644 local_plugins/spotify_sdk/ios/Classes/StatusHandler.swift create mode 100644 local_plugins/spotify_sdk/ios/Classes/SwiftSpotifySdkPlugin.swift create mode 100755 local_plugins/spotify_sdk/ios/prepare-iOS-SDK.sh create mode 100644 local_plugins/spotify_sdk/ios/spotify_sdk.podspec create mode 100644 local_plugins/spotify_sdk/lib/enums/image_dimension_enum.dart create mode 100644 local_plugins/spotify_sdk/lib/enums/podcast_playback_speed.dart create mode 100644 local_plugins/spotify_sdk/lib/enums/repeat_mode_enum.dart create mode 100644 local_plugins/spotify_sdk/lib/extensions/image_dimension_extension.dart create mode 100644 local_plugins/spotify_sdk/lib/extensions/podcast_playback_speed_extension.dart create mode 100644 local_plugins/spotify_sdk/lib/logging/custom_log_filter.dart create mode 100644 local_plugins/spotify_sdk/lib/models/album.dart create mode 100644 local_plugins/spotify_sdk/lib/models/album.g.dart create mode 100644 local_plugins/spotify_sdk/lib/models/artist.dart create mode 100644 local_plugins/spotify_sdk/lib/models/artist.g.dart create mode 100644 local_plugins/spotify_sdk/lib/models/capabilities.dart create mode 100644 local_plugins/spotify_sdk/lib/models/capabilities.g.dart create mode 100644 local_plugins/spotify_sdk/lib/models/connection_status.dart create mode 100644 local_plugins/spotify_sdk/lib/models/connection_status.g.dart create mode 100644 local_plugins/spotify_sdk/lib/models/crossfade_state.dart create mode 100644 local_plugins/spotify_sdk/lib/models/crossfade_state.g.dart create mode 100644 local_plugins/spotify_sdk/lib/models/image_uri.dart create mode 100644 local_plugins/spotify_sdk/lib/models/image_uri.g.dart create mode 100644 local_plugins/spotify_sdk/lib/models/library_state.dart create mode 100644 local_plugins/spotify_sdk/lib/models/library_state.g.dart create mode 100644 local_plugins/spotify_sdk/lib/models/player_context.dart create mode 100644 local_plugins/spotify_sdk/lib/models/player_context.g.dart create mode 100644 local_plugins/spotify_sdk/lib/models/player_options.dart create mode 100644 local_plugins/spotify_sdk/lib/models/player_options.g.dart create mode 100644 local_plugins/spotify_sdk/lib/models/player_restrictions.dart create mode 100644 local_plugins/spotify_sdk/lib/models/player_restrictions.g.dart create mode 100644 local_plugins/spotify_sdk/lib/models/player_state.dart create mode 100644 local_plugins/spotify_sdk/lib/models/player_state.g.dart create mode 100644 local_plugins/spotify_sdk/lib/models/track.dart create mode 100644 local_plugins/spotify_sdk/lib/models/track.g.dart create mode 100644 local_plugins/spotify_sdk/lib/models/user_status.dart create mode 100644 local_plugins/spotify_sdk/lib/models/user_status.g.dart create mode 100644 local_plugins/spotify_sdk/lib/platform_channels.dart create mode 100644 local_plugins/spotify_sdk/lib/spotify_sdk.dart create mode 100644 local_plugins/spotify_sdk/lib/spotify_sdk_web.dart create mode 100644 local_plugins/spotify_sdk/pubspec.yaml create mode 100644 local_plugins/spotify_sdk/test/spotify_sdk_test.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e388c263b..883dea602 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -3,6 +3,9 @@ import java.io.FileInputStream repositories { google() mavenCentral() + flatDir { + dirs(project(":spotify_sdk").projectDir.resolve("libs")) + } } plugins { @@ -54,6 +57,12 @@ android { } defaultConfig { + manifestPlaceholders.putAll( + mapOf( + "redirectSchemeName" to "spotify-sdk", + "redirectHostName" to "auth" + ) + ) // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.yunqiinnovation.deepsound" // You can update the following values to match your application needs. @@ -110,6 +119,7 @@ dependencies { implementation(project(":volcano_speech")) implementation(project(":chat_storage")) // 添加新创建的SQLite聊天存储插件 implementation(project(":jieli")) // 添加新创建的SQLite聊天存储插件 + implementation(project(":spotify_sdk")) // 添加OkHttp依赖 implementation("com.squareup.okhttp3:okhttp:4.9.3") @@ -126,7 +136,6 @@ dependencies { // 添加 AndroidX Security 加密 SharedPreferences 依赖 implementation("androidx.security:security-crypto:1.1.0-alpha06") - } flutter { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 23c380fb0..fe1f5f245 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -92,6 +92,10 @@ + + + +