From fd5478dbf608b5d304743cc556fe4d70f44b922a Mon Sep 17 00:00:00 2001 From: wolfplus Date: Sat, 5 Apr 2025 13:29:57 +0100 Subject: [PATCH 01/23] init --- android/app/build.gradle.kts | 10 + .../com/example/deep_voice/AzureAsrHelper.kt | 654 --------------- .../example/deep_voice/VolcanoAIService.kt | 344 -------- .../deepsound}/ClassicBluetoothHelper.kt | 0 .../deepsound}/MainActivity.kt | 255 +----- .../deepsound/OpenAIService.kt | 606 ++++++++++++++ .../deepsound/VoiceFunctionHandler.kt | 180 +++++ .../deepsound}/VoiceInteractionService.kt | 210 +++-- .../deepsound}/core/utils/FileLogger.kt | 0 android/settings.gradle.kts | 4 + azure/LICENSE | 21 - azure/README.md | 66 -- azure/ios/Classes/AzureAsrHelper.swift | 757 ------------------ .../AzureSpeechRecognitionPlugin.swift | 18 - azure/ios/Classes/AzureTtsHelper.swift | 427 ---------- .../SwiftAzureSpeechRecognitionPlugin.swift | 259 ------ azure/ios/azure_speech_recognition.podspec | 24 - azure/lib/azure_speech_recognition.dart | 6 - azure/pubspec.yaml | 23 - .../speech_impl/azure_asr_service.dart | 4 +- .../speech_impl/azure_tts_service.dart | 2 +- .../services/voice_interaction_service.dart | 12 +- local_plugins/azure_speech/README.md | 136 ++++ .../azure_speech/android/build.gradle.kts | 65 ++ .../azure_speech/android/settings.gradle.kts | 1 + .../android/src/main/AndroidManifest.xml | 6 + .../azure_speech/AzureAsrHelper.kt | 593 ++++++++++++++ .../azure_speech/AzureSpeechPlugin.kt | 297 +++++++ .../azure_speech}/AzureTtsHelper.kt | 12 +- .../azure_speech/utils/FileLogger.kt | 45 ++ .../ios/Classes/AzureAsrHelper.swift | 461 +++++++++++ .../ios/Classes/AzureSpeechPlugin.swift | 182 +++++ .../ios/Classes/AzureTtsHelper.swift | 330 ++++++++ local_plugins/azure_speech/pubspec.yaml | 32 + pubspec.yaml | 2 + 35 files changed, 3148 insertions(+), 2896 deletions(-) delete mode 100644 android/app/src/main/kotlin/com/example/deep_voice/AzureAsrHelper.kt delete mode 100644 android/app/src/main/kotlin/com/example/deep_voice/VolcanoAIService.kt rename android/app/src/main/kotlin/com/{example/deep_voice => yunqiinnovation/deepsound}/ClassicBluetoothHelper.kt (100%) rename android/app/src/main/kotlin/com/{example/deep_voice => yunqiinnovation/deepsound}/MainActivity.kt (67%) create mode 100644 android/app/src/main/kotlin/com/yunqiinnovation/deepsound/OpenAIService.kt create mode 100644 android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceFunctionHandler.kt rename android/app/src/main/kotlin/com/{example/deep_voice => yunqiinnovation/deepsound}/VoiceInteractionService.kt (78%) rename android/app/src/main/kotlin/com/{example/deep_voice => yunqiinnovation/deepsound}/core/utils/FileLogger.kt (100%) delete mode 100644 azure/LICENSE delete mode 100644 azure/README.md delete mode 100644 azure/ios/Classes/AzureAsrHelper.swift delete mode 100644 azure/ios/Classes/AzureSpeechRecognitionPlugin.swift delete mode 100644 azure/ios/Classes/AzureTtsHelper.swift delete mode 100644 azure/ios/Classes/SwiftAzureSpeechRecognitionPlugin.swift delete mode 100644 azure/ios/azure_speech_recognition.podspec delete mode 100644 azure/lib/azure_speech_recognition.dart delete mode 100644 azure/pubspec.yaml create mode 100644 local_plugins/azure_speech/README.md create mode 100644 local_plugins/azure_speech/android/build.gradle.kts create mode 100644 local_plugins/azure_speech/android/settings.gradle.kts create mode 100644 local_plugins/azure_speech/android/src/main/AndroidManifest.xml create mode 100644 local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt create mode 100644 local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt rename {android/app/src/main/kotlin/com/example/deep_voice => local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech}/AzureTtsHelper.kt (98%) create mode 100644 local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/utils/FileLogger.kt create mode 100644 local_plugins/azure_speech/ios/Classes/AzureAsrHelper.swift create mode 100644 local_plugins/azure_speech/ios/Classes/AzureSpeechPlugin.swift create mode 100644 local_plugins/azure_speech/ios/Classes/AzureTtsHelper.swift create mode 100644 local_plugins/azure_speech/pubspec.yaml diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 542cf24d8..78b816b75 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -34,6 +34,11 @@ android { jvmTarget = JavaVersion.VERSION_11.toString() } + // 添加lint选项 + lintOptions { + isCheckReleaseBuilds = false + } + defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.yunqiinnovation.deepsound" @@ -82,6 +87,11 @@ android { dependencies { + implementation("io.modelcontextprotocol:kotlin-sdk:0.4.0") + + + // 添加本地插件模块依赖 + implementation(project(":azure_speech")) // 添加OkHttp依赖 implementation("com.squareup.okhttp3:okhttp:4.9.3") diff --git a/android/app/src/main/kotlin/com/example/deep_voice/AzureAsrHelper.kt b/android/app/src/main/kotlin/com/example/deep_voice/AzureAsrHelper.kt deleted file mode 100644 index 171adf80e..000000000 --- a/android/app/src/main/kotlin/com/example/deep_voice/AzureAsrHelper.kt +++ /dev/null @@ -1,654 +0,0 @@ -package com.yunqiinnovation.deepsound - -import android.content.Context -import android.media.AudioAttributes -import android.media.AudioFormat -import android.media.AudioRecord -import android.media.MediaRecorder -import android.media.audiofx.AcousticEchoCanceler -import android.media.audiofx.NoiseSuppressor -import android.media.audiofx.AutomaticGainControl -import android.os.Process -import com.yunqiinnovation.deepsound.core.utils.FileLogger -import com.microsoft.cognitiveservices.speech.* -import com.microsoft.cognitiveservices.speech.audio.* -import com.microsoft.cognitiveservices.speech.util.EventHandler -import java.util.concurrent.ExecutionException -import java.util.concurrent.atomic.AtomicBoolean -// 移除WebRTC相关导入 - -class AzureAsrHelper(private val context: Context) { - private var recognizer: SpeechRecognizer? = null - private var speechConfig: SpeechConfig? = null - private val TAG = "AzureAsrHelper" - private var isContinuousRecognitionActive = false - private var currentLanguage = "zh-CN" - private var subscriptionKey = "" - private var serviceRegion = "" - private var isAutoDetectLanguage = false - private var supportedLanguages = arrayOf("zh-CN", "en-US") - - // 是否使用回音消除 - 内部控制常量 - private val useEchoCancellation = true - - // 自定义音频处理相关 - private var customAudioProcessor: CustomAudioProcessor? = null - private var pushStream: PushAudioInputStream? = null - private var audioConfig: AudioConfig? = null - - // 初始化SDK并创建recognizer - fun initialize(subscriptionKey: String, serviceRegion: String, - supportedLanguages: Array = arrayOf("zh-CN", "en-US")): Boolean { - try { - FileLogger.d(TAG, "初始化 Azure 语音服务") - - // 检查配置是否为空 - if (subscriptionKey.isEmpty() || serviceRegion.isEmpty()) { - FileLogger.e(TAG, "Azure 配置信息不完整") - return false - } - - // 释放之前的资源 - dispose() - - this.subscriptionKey = subscriptionKey - this.serviceRegion = serviceRegion - - // 设置语言 - if (supportedLanguages.isNotEmpty()) { - this.supportedLanguages = supportedLanguages - } - - // 根据支持的语言数量决定是否启用自动语言检测 - this.isAutoDetectLanguage = supportedLanguages.size >= 2 - - // 如果只有一种语言,设置为当前语言 - if (!isAutoDetectLanguage && supportedLanguages.isNotEmpty()) { - this.currentLanguage = supportedLanguages[0] - } - - // 创建语音配置 - speechConfig = SpeechConfig.fromSubscription(subscriptionKey, serviceRegion) - - // 设置语言配置 - if (isAutoDetectLanguage) { - // 设置自动语言检测 - speechConfig?.setProperty(PropertyId.SpeechServiceConnection_LanguageIdMode, "Continuous") - } else { - // 设置指定的识别语言 - speechConfig?.speechRecognitionLanguage = currentLanguage - } - - // 创建识别器 - try { - if (useEchoCancellation) { - // 如果使用回音消除,创建自定义音频输入流 - setupCustomAudioProcessing() - - if (isAutoDetectLanguage) { - val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) - recognizer = SpeechRecognizer(speechConfig, autoDetectConfig, audioConfig) - } else { - recognizer = SpeechRecognizer(speechConfig, audioConfig) - } - } else { - // 使用默认麦克风输入 - if (isAutoDetectLanguage) { - val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) - recognizer = SpeechRecognizer(speechConfig, autoDetectConfig) - } else { - recognizer = SpeechRecognizer(speechConfig) - } - } - - FileLogger.d(TAG, "Azure 语音服务初始化成功") - return true - } catch (e: Exception) { - FileLogger.e(TAG, "创建识别器失败: ${e.message}") - stopCustomAudioProcessing() - return false - } - } catch (e: Exception) { - FileLogger.e(TAG, "初始化失败: ${e.message}") - return false - } - } - - // 重置 recognizer - private fun resetRecognizer(): Boolean { - try { - // 释放之前的 recognizer - recognizer?.close() - recognizer = null - - // 停止当前的音频处理 - stopCustomAudioProcessing() - - // 使用现有配置重新创建 recognizer - if (speechConfig != null) { - if (useEchoCancellation) { - // 如果使用回音消除,创建自定义音频输入流 - setupCustomAudioProcessing() - - if (isAutoDetectLanguage) { - val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) - recognizer = SpeechRecognizer(speechConfig, autoDetectConfig, audioConfig) - } else { - recognizer = SpeechRecognizer(speechConfig, audioConfig) - } - } else { - // 使用默认麦克风输入 - if (isAutoDetectLanguage) { - val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) - recognizer = SpeechRecognizer(speechConfig, autoDetectConfig) - } else { - recognizer = SpeechRecognizer(speechConfig) - } - } - return true - } else { - FileLogger.e(TAG, "语音配置未初始化") - return false - } - } catch (e: Exception) { - FileLogger.e(TAG, "重置识别器失败: ${e.message}") - return false - } - } - - // 开始一次性语音识别 - fun recognizeOnce(callback: RecognizeCallback) { - if (speechConfig == null) { - callback.onError("语音服务未初始化") - return - } - - // 重置 recognizer - if (!resetRecognizer()) { - callback.onError("重置识别器失败") - return - } - - try { - // 启动音频处理 - startCustomAudioProcessing() - - // 执行识别 - val result = recognizer?.recognizeOnceAsync()?.get() - - // 停止音频处理 - stopCustomAudioProcessing() - - if (result != null && result.reason == ResultReason.RecognizedSpeech) { - val detectedLanguage = AutoDetectSourceLanguageResult.fromResult(result)?.language - callback.onResult(result.text, detectedLanguage ?: "") - } else { - callback.onError("未能识别语音") - } - } catch (e: Exception) { - // 停止音频处理 - stopCustomAudioProcessing() - callback.onError("识别异常: ${e.message}") - } - } - - // 开始连续语音识别 - fun startContinuousRecognition(callback: ContinuousRecognizeCallback): Boolean { - if (speechConfig == null) { - callback.onError("语音服务未初始化") - return false - } - - // 如果已经在进行连续识别,先停止 - if (isContinuousRecognitionActive) { - stopContinuousRecognition(callback) - } - - // 重置 recognizer - if (!resetRecognizer()) { - callback.onError("重置识别器失败") - return false - } - - try { - // 启动音频处理 - startCustomAudioProcessing() - - // 设置识别事件处理 - // 最终识别结果 - recognizer?.recognized?.addEventListener( - EventHandler { _, event -> - if (event.result.reason == ResultReason.RecognizedSpeech) { - val detectedLanguage = if (isAutoDetectLanguage) { - AutoDetectSourceLanguageResult.fromResult(event.result)?.language ?: "" - } else { - currentLanguage - } - // FileLogger.d(TAG, "最终识别结果: ${event.result.text}") - callback.onResult(event.result.text, detectedLanguage) - } - } - ) - - // 识别中事件 - recognizer?.recognizing?.addEventListener( - EventHandler { _, event -> - if (event.result.reason == ResultReason.RecognizingSpeech) { - val detectedLanguage = if (isAutoDetectLanguage) { - AutoDetectSourceLanguageResult.fromResult(event.result)?.language ?: "" - } else { - currentLanguage - } - // FileLogger.d(TAG, "识别中结果: ${event.result.text}") - callback.onRecognizing(event.result.text, detectedLanguage) - } - } - ) - - // 会话事件 - recognizer?.sessionStarted?.addEventListener( - EventHandler { _, _ -> - isContinuousRecognitionActive = true - callback.onSessionStarted() - } - ) - - recognizer?.sessionStopped?.addEventListener( - EventHandler { _, _ -> - isContinuousRecognitionActive = false - callback.onSessionStopped() - } - ) - - // 取消事件 - recognizer?.canceled?.addEventListener( - EventHandler { _, event -> - val errorDetails = if (event.reason == CancellationReason.Error) event.errorDetails else "" - callback.onCanceled(event.reason.toString(), errorDetails) - isContinuousRecognitionActive = false - } - ) - - // 开始连续识别 - recognizer?.startContinuousRecognitionAsync()?.get() - isContinuousRecognitionActive = true - - return true - } catch (e: Exception) { - callback.onError("开始连续识别失败: ${e.message}") - isContinuousRecognitionActive = false - stopCustomAudioProcessing() - return false - } - } - - // 停止连续语音识别 - fun stopContinuousRecognition(callback: ContinuousRecognizeCallback): Boolean { - if (!isContinuousRecognitionActive || recognizer == null) { - return true - } - - try { - recognizer?.stopContinuousRecognitionAsync() - isContinuousRecognitionActive = false - callback.onSessionStopped() - - // 停止音频处理 - stopCustomAudioProcessing() - - return true - } catch (e: Exception) { - callback.onError("停止连续识别失败: ${e.message}") - isContinuousRecognitionActive = false - stopCustomAudioProcessing() - return false - } - } - - // 检查连续识别是否活跃 - fun isContinuousRecognitionActive(): Boolean { - return isContinuousRecognitionActive - } - - // 设置自定义音频处理 - private fun setupCustomAudioProcessing() { - if (!useEchoCancellation) { - return - } - - try { - // 1. 创建PushAudioInputStream - pushStream = PushAudioInputStream.create() - - // 2. 创建AudioConfig - audioConfig = AudioConfig.fromStreamInput(pushStream) - - // 3. 创建自定义音频处理器 - customAudioProcessor = CustomAudioProcessor(pushStream) - - FileLogger.d(TAG, "自定义音频处理设置完成") - } catch (e: Exception) { - FileLogger.e(TAG, "设置自定义音频处理失败: ${e.message}") - releaseCustomAudioProcessing() - - // 降级处理:如果自定义处理设置失败,尝试使用默认麦克风 - try { - FileLogger.d(TAG, "尝试降级到默认麦克风输入") - audioConfig = AudioConfig.fromDefaultMicrophoneInput() - } catch (e2: Exception) { - FileLogger.e(TAG, "默认麦克风输入设置也失败: ${e2.message}") - audioConfig = null - } - } - } - - // 启动自定义音频处理 - private fun startCustomAudioProcessing() { - if (!useEchoCancellation || customAudioProcessor == null) { - return - } - - try { - customAudioProcessor?.startRecording() - FileLogger.d(TAG, "自定义音频处理已启动") - } catch (e: Exception) { - FileLogger.e(TAG, "启动自定义音频处理失败: ${e.message}") - } - } - - // 停止自定义音频处理 - private fun stopCustomAudioProcessing() { - if (!useEchoCancellation || customAudioProcessor == null) { - return - } - - try { - customAudioProcessor?.stopRecording() - FileLogger.d(TAG, "自定义音频处理已停止") - } catch (e: Exception) { - FileLogger.e(TAG, "停止自定义音频处理失败: ${e.message}") - } - } - - // 释放自定义音频处理资源 - private fun releaseCustomAudioProcessing() { - stopCustomAudioProcessing() - - try { - customAudioProcessor = null - pushStream?.close() - pushStream = null - audioConfig?.close() - audioConfig = null - - FileLogger.d(TAG, "自定义音频处理资源已释放") - } catch (e: Exception) { - FileLogger.e(TAG, "释放自定义音频处理资源时出错: ${e.message}") - } - } - - // 释放所有资源 - fun dispose() { - try { - // 停止和释放音频处理 - releaseCustomAudioProcessing() - - recognizer?.close() - recognizer = null - - speechConfig?.close() - speechConfig = null - - isContinuousRecognitionActive = false - - FileLogger.d(TAG, "语音识别资源已释放") - } catch (e: Exception) { - FileLogger.e(TAG, "释放资源出错: ${e.message}") - } - } - - // 自定义音频处理器 - 使用Android原生回音消除 - private inner class CustomAudioProcessor(private val pushStream: PushAudioInputStream?) { - private val SAMPLE_RATE = 16000 - private val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO - private val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT - private val BUFFER_SIZE = SAMPLE_RATE * 2 // 简化缓冲区大小计算,更稳定 - - private var audioRecord: AudioRecord? = null - private var echoCanceler: AcousticEchoCanceler? = null - private val isRecording = AtomicBoolean(false) - private var recordingThread: Thread? = null - - // 启动录音并处理音频数据 - fun startRecording() { - if (isRecording.get() || pushStream == null) { - return - } - - try { - // 使用Builder模式构建AudioFormat - val audioFormat = AudioFormat.Builder() - .setSampleRate(SAMPLE_RATE) - .setEncoding(AUDIO_FORMAT) - .setChannelMask(CHANNEL_CONFIG) - .build() - - // 使用Builder模式创建AudioRecord实例 - audioRecord = AudioRecord.Builder() - .setAudioSource(MediaRecorder.AudioSource.VOICE_COMMUNICATION) - .setAudioFormat(audioFormat) - .setBufferSizeInBytes(BUFFER_SIZE) - .build() - - // 检查AudioRecord初始化状态 - if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { - FileLogger.e(TAG, "AudioRecord初始化失败,状态: ${audioRecord?.state}") - // 尝试使用DEFAULT音频源重试一次 - audioRecord?.release() - audioRecord = AudioRecord.Builder() - .setAudioSource(MediaRecorder.AudioSource.DEFAULT) - .setAudioFormat(audioFormat) - .setBufferSizeInBytes(BUFFER_SIZE) - .build() - - if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { - FileLogger.e(TAG, "AudioRecord初始化第二次尝试也失败,放弃") - releaseAudioResources() - return - } else { - FileLogger.d(TAG, "使用默认音频源成功初始化AudioRecord") - } - } - - // 启用音频效果(回音消除、噪声抑制等) - enableAudioEffects() - - // 启动录音 - audioRecord?.startRecording() - isRecording.set(true) - - // 创建录音线程 - recordingThread = Thread({ - val buffer = ByteArray(BUFFER_SIZE) - - while (isRecording.get()) { - try { - val readSize = audioRecord?.read(buffer, 0, BUFFER_SIZE) ?: 0 - - if (readSize > 0) { - try { - // 将处理后的音频数据推送到流 - if (readSize == buffer.size) { - // 如果读取的大小等于buffer的大小,直接写入整个buffer - pushStream.write(buffer) - } else { - // 如果只读取了部分数据,创建新的数组只包含有效数据 - val validData = buffer.copyOfRange(0, readSize) - pushStream.write(validData) - } - } catch (e: Exception) { - FileLogger.e(TAG, "写入音频数据失败: ${e.message}") - break - } - } else if (readSize == 0) { - // 读取为0,可能是临时的,等待一下继续尝试 - Thread.sleep(10) - } else { - // 负值表示错误 - FileLogger.e(TAG, "读取音频数据失败,错误码: $readSize") - break - } - } catch (e: Exception) { - FileLogger.e(TAG, "录音线程异常: ${e.message}") - break - } - } - }, "AudioRecordingThread") - - // 设置线程优先级并启动 - recordingThread?.priority = Thread.MAX_PRIORITY - recordingThread?.start() - - FileLogger.d(TAG, "音频录制已启动" + (if(echoCanceler?.enabled == true) ",回音消除已启用" else "")) - } catch (e: Exception) { - FileLogger.e(TAG, "启动音频录制失败: ${e.message}") - releaseAudioResources() - } - } - - // 启用音频效果(回音消除、噪声抑制等) - private fun enableAudioEffects() { - try { - val audioSessionId = audioRecord?.audioSessionId ?: -1 - - if (audioSessionId != -1) { - // 启用回音消除 - if (AcousticEchoCanceler.isAvailable()) { - try { - echoCanceler = AcousticEchoCanceler.create(audioSessionId) - if (echoCanceler != null) { - echoCanceler?.enabled = true - FileLogger.d(TAG, "回音消除已启用,会话ID: $audioSessionId") - } else { - FileLogger.w(TAG, "回音消除器创建返回null") - } - } catch (e: Exception) { - FileLogger.e(TAG, "创建回音消除器时出错: ${e.message}") - } - } else { - FileLogger.d(TAG, "设备不支持回音消除") - } - - // 以下功能暂时不启用,可根据需要取消注释 - /* - // 启用噪声抑制 - if (NoiseSuppressor.isAvailable()) { - try { - val ns = NoiseSuppressor.create(audioSessionId) - ns?.enabled = true - FileLogger.d(TAG, "噪声抑制已启用") - } catch (e: Exception) { - FileLogger.e(TAG, "创建噪声抑制器时出错: ${e.message}") - } - } - - // 启用自动增益控制 - if (AutomaticGainControl.isAvailable()) { - try { - val agc = AutomaticGainControl.create(audioSessionId) - agc?.enabled = true - FileLogger.d(TAG, "自动增益控制已启用") - } catch (e: Exception) { - FileLogger.e(TAG, "创建自动增益控制时出错: ${e.message}") - } - } - */ - } else { - FileLogger.w(TAG, "无效的音频会话ID,无法启用音频效果") - } - } catch (e: Exception) { - FileLogger.e(TAG, "启用音频效果时出错: ${e.message}") - } - } - - // 停止录音 - fun stopRecording() { - if (!isRecording.get()) { - return - } - - isRecording.set(false) - - try { - // 等待录音线程结束 - recordingThread?.join(1000) - - // 释放资源 - releaseAudioResources() - - FileLogger.d(TAG, "音频录制已停止") - } catch (e: Exception) { - FileLogger.e(TAG, "停止音频录制失败: ${e.message}") - } - } - - // 释放音频资源 - private fun releaseAudioResources() { - try { - // 停止录音 - try { - if (audioRecord?.state == AudioRecord.STATE_INITIALIZED) { - audioRecord?.stop() - } - } catch (e: Exception) { - // 忽略可能的IllegalStateException - FileLogger.w(TAG, "停止AudioRecord时出错: ${e.message}") - } - - // 释放回音消除器 - try { - if (echoCanceler != null) { - echoCanceler?.enabled = false - echoCanceler?.release() - echoCanceler = null - } - } catch (e: Exception) { - FileLogger.w(TAG, "释放回音消除器时出错: ${e.message}") - } finally { - echoCanceler = null - } - - // 释放音频记录器 - try { - audioRecord?.release() - } catch (e: Exception) { - FileLogger.w(TAG, "释放AudioRecord时出错: ${e.message}") - } finally { - audioRecord = null - } - - // 重置线程 - recordingThread = null - - } catch (e: Exception) { - FileLogger.e(TAG, "释放音频资源失败: ${e.message}") - } - } - } - - // 一次性识别回调接口 - interface RecognizeCallback { - fun onResult(result: String, detectedLanguage: String = "") - fun onError(error: String) - } - - // 连续识别回调接口 - interface ContinuousRecognizeCallback { - fun onResult(result: String, detectedLanguage: String = "") - fun onRecognizing(recognizing: String, detectedLanguage: String = "") - fun onSessionStarted() - fun onSessionStopped() - fun onCanceled(reason: String, errorDetails: String) - fun onError(error: String) - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/example/deep_voice/VolcanoAIService.kt b/android/app/src/main/kotlin/com/example/deep_voice/VolcanoAIService.kt deleted file mode 100644 index 2fbcd8da8..000000000 --- a/android/app/src/main/kotlin/com/example/deep_voice/VolcanoAIService.kt +++ /dev/null @@ -1,344 +0,0 @@ -package com.yunqiinnovation.deepsound - -import android.util.Log -import okhttp3.* -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.RequestBody.Companion.toRequestBody -import org.json.JSONArray -import org.json.JSONObject -import java.io.IOException -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit - -/** - * 火山AI服务的原生实现 - * - * 参考Flutter端的VolcanoAIService实现,提供同步和异步的API调用方式 - */ -class VolcanoAIService() { - private val TAG = "VolcanoAIService" - private val baseUrl = "https://ark.cn-beijing.volces.com/api/v3" - private val chatEndpoint = "/chat/completions" - private val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build() - - private var apiKey: String = "" - private var isInitialized = false - - /** - * 初始化火山AI服务 - * - * @param apiKey 火山AI API密钥 - * @return 初始化是否成功 - */ - fun initialize(apiKey: String): Boolean { - this.apiKey = apiKey - isInitialized = apiKey.isNotEmpty() - - if (!isInitialized) { - Log.e(TAG, "初始化失败:API key 不能为空") - } else { - Log.d(TAG, "火山AI服务初始化成功") - } - - return isInitialized - } - - /** - * 生成个性化问候语 - * - * @param agentName 代理名称 - * @param systemPrompt 系统提示词 - * @param callback 回调函数,返回生成的问候语 - */ - fun generateGreeting(agentName: String, systemPrompt: String, callback: (String?, Exception?) -> Unit) { - val messages = JSONArray().apply { - put(JSONObject().apply { - put("role", "system") - put("content", systemPrompt) - }) - put(JSONObject().apply { - put("role", "user") - put("content", "请用一句简短的话向我打个招呼,要符合你的身份和性格特点,不要超过18个字。") - }) - } - - sendMessageStream(messages, systemPrompt, object : StreamCallback { - val stringBuilder = StringBuilder() - - override fun onToken(token: String) { - stringBuilder.append(token) - } - - override fun onComplete() { - callback(stringBuilder.toString(), null) - } - - override fun onError(e: Exception) { - callback(null, e) - } - }) - } - - /** - * 发送消息(非流式输出) - * - * @param messages 消息列表 - * @param systemPrompt 系统提示词 - * @return 返回AI的回复 - * @throws VolcanoAIException 如果API调用失败 - */ - @Throws(VolcanoAIException::class) - fun sendMessage(messages: JSONArray, systemPrompt: String): String { - // 检查是否已初始化 - if (!isInitialized || apiKey.isEmpty()) { - throw VolcanoAIException("火山AI服务未初始化或API key为空,请先调用initialize方法") - } - - val fullMessages = JSONArray().apply { - put(JSONObject().apply { - put("role", "system") - put("content", systemPrompt) - }) - for (i in 0 until messages.length()) { - put(messages.getJSONObject(i)) - } - } - - val requestBody = JSONObject().apply { - put("model", "doubao-1-5-lite-32k-250115") - put("messages", fullMessages) - put("temperature", 0.7) - put("max_tokens", 2000) - put("stream", false) - } - - val mediaType = "application/json".toMediaTypeOrNull() - val request = Request.Builder() - .url("$baseUrl$chatEndpoint") - .addHeader("Content-Type", "application/json") - .addHeader("Authorization", "Bearer $apiKey") - .post(requestBody.toString().toRequestBody(mediaType)) - .build() - - try { - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - val errorBody = response.body?.string() ?: "" - val errorMessage = try { - JSONObject(errorBody).getJSONObject("error").getString("message") - } catch (e: Exception) { - "Unknown error occurred" - } - throw VolcanoAIException(errorMessage) - } - - val responseBody = response.body?.string() ?: throw VolcanoAIException("Empty response") - val jsonResponse = JSONObject(responseBody) - - if (jsonResponse.has("choices") && - jsonResponse.getJSONArray("choices").length() > 0 && - jsonResponse.getJSONArray("choices").getJSONObject(0).has("message")) { - return jsonResponse.getJSONArray("choices") - .getJSONObject(0) - .getJSONObject("message") - .getString("content") - } - - throw VolcanoAIException("Invalid response format") - } - } catch (e: Exception) { - if (e is VolcanoAIException) throw e - throw VolcanoAIException("Failed to communicate with AI service: ${e.message}") - } - } - - /** - * 发送消息(流式输出) - * - * @param messages 消息列表 - * @param systemPrompt 系统提示词 - * @param callback 回调函数,用于接收流式输出的结果 - */ - fun sendMessageStream(messages: JSONArray, systemPrompt: String, callback: StreamCallback) { - // 检查是否已初始化 - if (!isInitialized || apiKey.isEmpty()) { - callback.onError(VolcanoAIException("火山AI服务未初始化或API key为空,请先调用initialize方法")) - return - } - - val fullMessages = JSONArray().apply { - put(JSONObject().apply { - put("role", "system") - put("content", systemPrompt) - }) - for (i in 0 until messages.length()) { - put(messages.getJSONObject(i)) - } - } - - val requestBody = JSONObject().apply { - put("model", "doubao-1-5-lite-32k-250115") - put("messages", fullMessages) - put("temperature", 0.7) - put("max_tokens", 2000) - put("stream", true) - } - - val mediaType = "application/json".toMediaTypeOrNull() - val request = Request.Builder() - .url("$baseUrl$chatEndpoint") - .addHeader("Content-Type", "application/json") - .addHeader("Authorization", "Bearer $apiKey") - .addHeader("Accept", "text/event-stream") - .post(requestBody.toString().toRequestBody(mediaType)) - .build() - - client.newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - callback.onError(VolcanoAIException("Failed to communicate with AI service: ${e.message}")) - } - - override fun onResponse(call: Call, response: Response) { - if (!response.isSuccessful) { - val errorBody = response.body?.string() ?: "" - val errorMessage = try { - JSONObject(errorBody).getJSONObject("error").getString("message") - } catch (e: Exception) { - "Unknown error occurred" - } - callback.onError(VolcanoAIException(errorMessage)) - return - } - - val responseBody = response.body ?: return - val source = responseBody.source() - val bufferedSource = source.buffer - - try { - while (!bufferedSource.exhausted()) { - val line = bufferedSource.readUtf8Line() ?: continue - - if (line.isEmpty()) continue - if (line.startsWith("data: ")) { - val data = line.substring(6) - if (data == "[DONE]") { - callback.onComplete() - break - } - - try { - val jsonData = JSONObject(data) - if (jsonData.has("choices") && - jsonData.getJSONArray("choices").length() > 0 && - jsonData.getJSONArray("choices").getJSONObject(0).has("delta") && - jsonData.getJSONArray("choices").getJSONObject(0).getJSONObject("delta").has("content")) { - val content = jsonData.getJSONArray("choices") - .getJSONObject(0) - .getJSONObject("delta") - .getString("content") - callback.onToken(content) - } - } catch (e: Exception) { - // 忽略无效的JSON数据 - continue - } - } - } - } catch (e: Exception) { - callback.onError(VolcanoAIException("Error processing stream: ${e.message}")) - } finally { - response.close() - } - } - }) - } - - /** - * 同步方式发送消息(流式输出) - * - * 注意:此方法会阻塞当前线程,请在后台线程中调用 - * - * @param messages 消息列表 - * @param systemPrompt 系统提示词 - * @return 返回完整的AI回复 - * @throws VolcanoAIException 如果API调用失败 - */ - @Throws(VolcanoAIException::class) - fun sendMessageStreamSync(messages: JSONArray, systemPrompt: String): String { - val result = StringBuilder() - val latch = CountDownLatch(1) - var exception: Exception? = null - - sendMessageStream(messages, systemPrompt, object : StreamCallback { - override fun onToken(token: String) { - result.append(token) - } - - override fun onComplete() { - latch.countDown() - } - - override fun onError(e: Exception) { - exception = e - latch.countDown() - } - }) - - // 等待流式输出完成或出错 - latch.await(60, TimeUnit.SECONDS) - - if (exception != null) { - throw exception as VolcanoAIException - } - - return result.toString() - } - - /** - * 创建用户消息 - */ - fun createUserMessage(content: String): JSONObject { - return JSONObject().apply { - put("role", "user") - put("content", content) - } - } - - /** - * 创建系统消息 - */ - fun createSystemMessage(content: String): JSONObject { - return JSONObject().apply { - put("role", "system") - put("content", content) - } - } - - /** - * 创建助手消息 - */ - fun createAssistantMessage(content: String): JSONObject { - return JSONObject().apply { - put("role", "assistant") - put("content", content) - } - } - - /** - * 流式输出回调接口 - */ - interface StreamCallback { - fun onToken(token: String) - fun onComplete() - fun onError(e: Exception) - } -} - -/** - * 火山AI异常 - */ -class VolcanoAIException(message: String) : Exception(message) \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/example/deep_voice/ClassicBluetoothHelper.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/ClassicBluetoothHelper.kt similarity index 100% rename from android/app/src/main/kotlin/com/example/deep_voice/ClassicBluetoothHelper.kt rename to android/app/src/main/kotlin/com/yunqiinnovation/deepsound/ClassicBluetoothHelper.kt diff --git a/android/app/src/main/kotlin/com/example/deep_voice/MainActivity.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt similarity index 67% rename from android/app/src/main/kotlin/com/example/deep_voice/MainActivity.kt rename to android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt index 49fd8fdf6..df36efb44 100644 --- a/android/app/src/main/kotlin/com/example/deep_voice/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt @@ -23,18 +23,12 @@ import com.yunqiinnovation.deepsound.core.utils.FileLogger class MainActivity: FlutterActivity() { - private val AZURE_ASR_CHANNEL = "com.deep_voice.azure_asr" - private val AZURE_ASR_EVENT_CHANNEL = "com.deep_voice.azure_asr_events" - private val AZURE_TTS_CHANNEL = "com.deep_voice.azure_tts" private val VOICE_INTERACTION_CHANNEL = "com.deep_voice.voice_interaction" private val VOICE_INTERACTION_EVENT_CHANNEL = "com.deep_voice.voice_interaction_events" private val CLASSIC_BLUETOOTH_CHANNEL = "com.deep_voice.classic_bluetooth" private val CLASSIC_BLUETOOTH_EVENT_CHANNEL = "com.deep_voice.classic_bluetooth_events" private val TAG = "MainActivity" - private lateinit var azureAsrHelper: AzureAsrHelper - private lateinit var azureTtsHelper: AzureTtsHelper private lateinit var classicBluetoothHelper: ClassicBluetoothHelper - private var azureAsrEventSink: EventChannel.EventSink? = null private var voiceInteractionEventSink: EventChannel.EventSink? = null private var bluetoothEventSink: EventChannel.EventSink? = null @@ -58,10 +52,6 @@ class MainActivity: FlutterActivity() { val assistantMessage = intent.getStringExtra("assistantMessage") ?: "" val timestamp = intent.getLongExtra("timestamp", System.currentTimeMillis()) - Log.d(TAG, "收到聊天记录更新广播: agentId=$agentId, timestamp=$timestamp") - Log.d(TAG, "用户消息: ${userMessage.take(50)}...") - Log.d(TAG, "助手回复: ${assistantMessage.take(50)}...") - sendChatHistoryEvent(agentId, userMessage, assistantMessage, timestamp) } } @@ -143,13 +133,17 @@ class MainActivity: FlutterActivity() { var azureSpeechKey: String = "" var azureSpeechRegion: String = "" var volcanoAiApiKey: String = "" + var openaiApiKey: String = "" + var openaiBaseUrl: String? = null // 安全存储相关常量 private const val SECURE_PREFS_FILENAME = "deep_voice_secure_prefs" private const val KEY_AZURE_SPEECH_KEY = "azure_speech_key" private const val KEY_AZURE_SPEECH_REGION = "azure_speech_region" private const val KEY_VOLCANO_AI_API_KEY = "volcano_ai_api_key" - + private const val KEY_OPENAI_API_KEY = "openai_api_key" + private const val KEY_OPENAI_BASE_URL = "openai_base_url" + private const val KEY_MCP_SERVER_ENDPOINT = "mcp_server_endpoint" // 会话管理 private const val KEY_SESSION_ID = "session_id" private var currentSessionId = "" @@ -183,6 +177,8 @@ class MainActivity: FlutterActivity() { .putString(KEY_AZURE_SPEECH_KEY, azureSpeechKey) .putString(KEY_AZURE_SPEECH_REGION, azureSpeechRegion) .putString(KEY_VOLCANO_AI_API_KEY, volcanoAiApiKey) + .putString(KEY_OPENAI_API_KEY, openaiApiKey) + .putString(KEY_OPENAI_BASE_URL, openaiBaseUrl) .putString(KEY_SESSION_ID, currentSessionId) .apply() @@ -227,11 +223,13 @@ class MainActivity: FlutterActivity() { azureSpeechKey = sharedPreferences.getString(KEY_AZURE_SPEECH_KEY, "") ?: "" azureSpeechRegion = sharedPreferences.getString(KEY_AZURE_SPEECH_REGION, "") ?: "" volcanoAiApiKey = sharedPreferences.getString(KEY_VOLCANO_AI_API_KEY, "") ?: "" - + openaiApiKey = sharedPreferences.getString(KEY_OPENAI_API_KEY, "") ?: "" + openaiBaseUrl = sharedPreferences.getString(KEY_OPENAI_BASE_URL, null) FileLogger.d("MainActivity", "已从加密存储加载密钥") - // 检查是否成功获取所有密钥 - return azureSpeechKey.isNotEmpty() && azureSpeechRegion.isNotEmpty() && volcanoAiApiKey.isNotEmpty() + // 检查是否成功获取所有必要密钥 + return azureSpeechKey.isNotEmpty() && azureSpeechRegion.isNotEmpty() && + (openaiApiKey.isNotEmpty() || volcanoAiApiKey.isNotEmpty()) } catch (e: Exception) { FileLogger.e("MainActivity", "从加密存储加载密钥失败: ${e.message}") e.printStackTrace() @@ -247,8 +245,6 @@ class MainActivity: FlutterActivity() { FileLogger.init(applicationContext) // 初始化 Azure 语音服务 - azureAsrHelper = AzureAsrHelper(applicationContext) - azureTtsHelper = AzureTtsHelper(applicationContext) classicBluetoothHelper = ClassicBluetoothHelper(applicationContext) // 注册广播接收器 @@ -300,8 +296,7 @@ class MainActivity: FlutterActivity() { setupMethodChannels(flutterEngine) // 初始化 Azure 语音服务 - azureAsrHelper = AzureAsrHelper(this) - azureTtsHelper = AzureTtsHelper(this) + classicBluetoothHelper = ClassicBluetoothHelper(this) Log.d(TAG, "Flutter 引擎配置完成") } @@ -327,20 +322,6 @@ class MainActivity: FlutterActivity() { } ) - // Azure ASR 事件通道 - EventChannel(flutterEngine.dartExecutor.binaryMessenger, AZURE_ASR_EVENT_CHANNEL).setStreamHandler( - object : EventChannel.StreamHandler { - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - Log.d(TAG, "ASR事件通道开始监听") - azureAsrEventSink = events - } - - override fun onCancel(arguments: Any?) { - azureAsrEventSink = null - } - } - ) - // 设置蓝牙事件通道 EventChannel(flutterEngine.dartExecutor.binaryMessenger, CLASSIC_BLUETOOTH_EVENT_CHANNEL).setStreamHandler( object : EventChannel.StreamHandler { @@ -367,195 +348,6 @@ class MainActivity: FlutterActivity() { private fun setupMethodChannels(flutterEngine: FlutterEngine) { Log.d(TAG, "开始设置方法通道") - // 设置 Azure ASR 方法通道 - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, AZURE_ASR_CHANNEL).setMethodCallHandler { call, result -> - when (call.method) { - "initialize" -> { - val subscriptionKey = call.argument("subscriptionKey") - val region = call.argument("region") - val supportedLanguages = call.argument>("supportedLanguages")?.toTypedArray() ?: arrayOf("zh-CN", "en-US") - - if (subscriptionKey == null || region == null) { - result.error("INVALID_ARGUMENTS", "订阅密钥和区域不能为空", null) - return@setMethodCallHandler - } - - try { - val success = azureAsrHelper.initialize(subscriptionKey, region, supportedLanguages) - result.success(success) - } catch (e: Exception) { - result.error("INITIALIZATION_ERROR", e.message, null) - } - } - "recognizeOnce" -> { - - azureAsrHelper.recognizeOnce(object : AzureAsrHelper.RecognizeCallback { - override fun onResult(text: String, detectedLanguage: String) { - result.success(mapOf( - "text" to text, - "detectedLanguage" to detectedLanguage - )) - } - - override fun onError(error: String) { - result.error("RECOGNITION_ERROR", error, null) - } - }) - } - "startContinuousRecognition" -> { - // 确保事件通道已准备好 - if (azureAsrEventSink == null) { - result.error("EVENT_CHANNEL_NOT_READY", "事件通道未准备好,无法开始连续识别", null) - return@setMethodCallHandler - } - - val success = azureAsrHelper.startContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { - override fun onResult(text: String, detectedLanguage: String) { - sendAsrEvent(mapOf( - "type" to "result", - "text" to text, - "detectedLanguage" to detectedLanguage - )) - } - - override fun onRecognizing(recognizing: String, detectedLanguage: String) { - sendAsrEvent(mapOf( - "type" to "recognizing", - "text" to recognizing, - "detectedLanguage" to detectedLanguage - )) - } - - override fun onSessionStarted() { - sendAsrEvent(mapOf("type" to "sessionStarted")) - } - - override fun onSessionStopped() { - sendAsrEvent(mapOf("type" to "sessionStopped")) - } - - override fun onCanceled(reason: String, errorDetails: String) { - sendAsrEvent(mapOf( - "type" to "canceled", - "reason" to reason, - "errorDetails" to errorDetails - )) - } - - override fun onError(error: String) { - sendAsrEvent(mapOf("type" to "error", "message" to error)) - } - }) - result.success(success) - } - "stopContinuousRecognition" -> { - try { - if (!azureAsrHelper.isContinuousRecognitionActive()) { - result.success(true) - return@setMethodCallHandler - } - - val success = azureAsrHelper.stopContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { - override fun onResult(text: String, detectedLanguage: String) {} - override fun onRecognizing(recognizing: String, detectedLanguage: String) {} - override fun onSessionStarted() {} - override fun onSessionStopped() {} - override fun onCanceled(reason: String, errorDetails: String) {} - override fun onError(error: String) { - result.error("STOP_ERROR", error, null) - } - }) - result.success(success) - } catch (e: Exception) { - result.error("STOP_ERROR", e.message, null) - } - } - "isContinuousRecognitionActive" -> { - result.success(azureAsrHelper.isContinuousRecognitionActive()) - } - "dispose" -> { - azureAsrHelper.dispose() - result.success(true) - } - else -> { - result.notImplemented() - } - } - } - - // 设置 Azure TTS 方法通道 - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, AZURE_TTS_CHANNEL).setMethodCallHandler { call, result -> - when (call.method) { - "initialize" -> { - val subscriptionKey = call.argument("subscriptionKey") ?: "" - val region = call.argument("region") ?: "" - val language = call.argument("language") ?: "zh-CN" - - val success = azureTtsHelper.initialize(subscriptionKey, region, language) - result.success(success) - } - "setVoice" -> { - val voiceName = call.argument("voiceName") ?: return@setMethodCallHandler result.error("INVALID_ARGUMENTS", "语音名称不能为空", null) - result.success(azureTtsHelper.setVoice(voiceName)) - } - "setSpeechParams" -> { - val rate = call.argument("rate") ?: 0 - val pitch = call.argument("pitch") ?: 0 - val volume = call.argument("volume") ?: 100 - result.success(azureTtsHelper.setSpeechParams(rate, pitch, volume)) - } - "setAudioOutputType" -> { - val outputTypeStr = call.argument("outputType") ?: "speaker" - val outputType = when (outputTypeStr.lowercase()) { - "speaker" -> AzureTtsHelper.AudioOutputType.SPEAKER - "earpiece" -> AzureTtsHelper.AudioOutputType.EARPIECE - "auto" -> AzureTtsHelper.AudioOutputType.AUTO - else -> AzureTtsHelper.AudioOutputType.SPEAKER - } - result.success(azureTtsHelper.setAudioOutputType(outputType)) - } - "speakText" -> { - val text = call.argument("text") ?: return@setMethodCallHandler result.error("INVALID_ARGUMENTS", "文本不能为空", null) - - azureTtsHelper.speakText(text, object : AzureTtsHelper.TTSCallback { - override fun onSuccess(message: String) { - runOnUiThread { result.success(message) } - } - - override fun onError(error: String) { - runOnUiThread { result.error("SPEAK_ERROR", error, null) } - } - }) - } - "speakSsml" -> { - val ssml = call.argument("ssml") ?: return@setMethodCallHandler result.error("INVALID_ARGUMENTS", "SSML不能为空", null) - - azureTtsHelper.speakSsml(ssml, object : AzureTtsHelper.TTSCallback { - override fun onSuccess(message: String) { - runOnUiThread { result.success(message) } - } - - override fun onError(error: String) { - runOnUiThread { result.error("SPEAK_ERROR", error, null) } - } - }) - } - "stopSpeaking" -> { - result.success(azureTtsHelper.stopSpeaking()) - } - "isSpeaking" -> { - result.success(azureTtsHelper.isSpeaking()) - } - "dispose" -> { - azureTtsHelper.dispose() - result.success(true) - } - else -> { - result.notImplemented() - } - } - } - // 设置语音交互方法通道 MethodChannel(flutterEngine.dartExecutor.binaryMessenger, VOICE_INTERACTION_CHANNEL).setMethodCallHandler { call, result -> when (call.method) { @@ -657,15 +449,6 @@ class MainActivity: FlutterActivity() { } } - // ASR 事件发送方法 - private fun sendAsrEvent(event: Map) { - if (azureAsrEventSink == null) return - - runOnUiThread { - azureAsrEventSink?.success(event) - } - } - /** * 启动语音交互服务(带配置参数) */ @@ -675,12 +458,16 @@ class MainActivity: FlutterActivity() { // 获取配置参数 val key = call.argument("azure_speech_key") ?: "" val region = call.argument("azure_speech_region") ?: "" - val aiKey = call.argument("volcano_ai_api_key") ?: "" + val openaiKey = call.argument("openai_api_key") ?: "" + val baseUrl = call.argument("openai_base_url") - // 设置 Azure Speech 配置 + // 设置 Azure Speech 和 AI 配置 azureSpeechKey = key azureSpeechRegion = region - volcanoAiApiKey = aiKey + openaiApiKey = openaiKey + if (baseUrl != null) { + openaiBaseUrl = baseUrl + } // 保存密钥到安全存储 saveKeysToSecureStorage(applicationContext) @@ -799,8 +586,6 @@ class MainActivity: FlutterActivity() { } // 释放资源 - azureAsrHelper.dispose() - azureTtsHelper.dispose() classicBluetoothHelper.dispose() super.onDestroy() diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/OpenAIService.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/OpenAIService.kt new file mode 100644 index 000000000..39907e6d5 --- /dev/null +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/OpenAIService.kt @@ -0,0 +1,606 @@ +package com.yunqiinnovation.deepsound + +import android.util.Log +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit +import com.yunqiinnovation.deepsound.core.utils.FileLogger + +/** + * OpenAI服务的原生实现 + */ +class OpenAIService() { + private val TAG = "OpenAIService" + private var baseUrl = "https://api.openai.com/v1/chat/completions" + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + private var apiKey: String = "" + private var isInitialized = false + private var model: String = "doubao-1-5-lite-32k-250115" // 默认模型 + + // 用于存储注册的函数 + private val registeredFunctions = mutableListOf() + + + /** + * 初始化OpenAI服务 + */ + fun initialize(apiKey: String, baseUrl: String = ""): Boolean { + this.apiKey = apiKey + if (baseUrl.isNotEmpty()) { + this.baseUrl = baseUrl + } + isInitialized = apiKey.isNotEmpty() + return isInitialized + } + + /** + * 注册函数 + */ + 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 + } + + if (existingIndex >= 0) { + // 如果已存在,则替换 + registeredFunctions[existingIndex] = function + } else { + // 如果不存在,则添加 + registeredFunctions.add(function) + } + + return true + } catch (e: Exception) { + return false + } + } + + /** + * 发送消息(非流式输出) + */ + @Throws(OpenAIException::class) + fun sendMessage(messages: JSONArray, systemPrompt: String): String { + if (!isInitialized || apiKey.isEmpty()) { + throw OpenAIException("OpenAI服务未初始化") + } + + val fullMessages = JSONArray().apply { + put(JSONObject().apply { + put("role", "system") + put("content", systemPrompt) + }) + for (i in 0 until messages.length()) { + put(messages.getJSONObject(i)) + } + } + + val requestBody = JSONObject().apply { + put("model", model) + put("messages", fullMessages) + put("temperature", 0.7) + 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) + } + tools.put(tool) + } + put("tools", tools) + } + } + + val mediaType = "application/json".toMediaTypeOrNull() + val request = Request.Builder() + .url(baseUrl) + .addHeader("Content-Type", "application/json") + .addHeader("Authorization", "Bearer $apiKey") + .post(requestBody.toString().toRequestBody(mediaType)) + .build() + + try { + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw OpenAIException("API调用失败: ${response.code}") + } + + val responseBody = response.body?.string() ?: throw OpenAIException("Empty response") + val jsonResponse = JSONObject(responseBody) + + // 检查是否有函数调用 + if (jsonResponse.has("choices") && + jsonResponse.getJSONArray("choices").length() > 0) { + + val choice = jsonResponse.getJSONArray("choices").getJSONObject(0) + + // 检查是否是函数调用 + if (choice.has("message")) { + val message = choice.getJSONObject("message") + + // 检查是否有工具调用 + if (message.has("tool_calls")) { + val toolCalls = message.getJSONArray("tool_calls") + if (toolCalls.length() > 0) { + val toolCall = toolCalls.getJSONObject(0) + if (toolCall.has("function")) { + val function = toolCall.getJSONObject("function") + val functionCall = JSONObject().apply { + put("name", function.getString("name")) + put("arguments", function.getString("arguments")) + put("id", toolCall.getString("id")) + } + return functionCall.toString() + } + } + } + + // 如果没有工具调用,返回消息内容 + if (message.has("content")) { + return message.getString("content") + } + } + } + + throw OpenAIException("Invalid response format") + } + } catch (e: Exception) { + if (e is OpenAIException) throw e + throw OpenAIException("Failed to communicate with AI service: ${e.message}") + } + } + + /** + * 发送消息(流式输出) + */ + fun sendMessageStream(messages: JSONArray, systemPrompt: String, callback: StreamCallback) { + if (!isInitialized || apiKey.isEmpty()) { + callback.onError(OpenAIException("OpenAI服务未初始化")) + return + } + + val fullMessages = JSONArray().apply { + put(JSONObject().apply { + put("role", "system") + put("content", systemPrompt) + }) + for (i in 0 until messages.length()) { + put(messages.getJSONObject(i)) + } + } + + val requestBody = JSONObject().apply { + put("model", model) + put("messages", fullMessages) + put("temperature", 0.7) + 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) + } + tools.put(tool) + } + put("tools", tools) + } + } + + val mediaType = "application/json".toMediaTypeOrNull() + val request = Request.Builder() + .url(baseUrl) + .addHeader("Content-Type", "application/json") + .addHeader("Authorization", "Bearer $apiKey") + .addHeader("Accept", "text/event-stream") + .post(requestBody.toString().toRequestBody(mediaType)) + .build() + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + callback.onError(OpenAIException(e.message ?: "请求失败")) + } + + override fun onResponse(call: Call, response: Response) { + if (!response.isSuccessful) { + callback.onError(OpenAIException("API调用失败: ${response.code}")) + return + } + + val responseBody = response.body ?: return + val source = responseBody.source() + + try { + // 预取数据到缓冲区 + source.request(Long.MAX_VALUE) + val bufferedSource = source.buffer + + // 用于存储函数调用的各个部分 + val finalToolCalls = mutableMapOf() + + while (!bufferedSource.exhausted()) { + val line = bufferedSource.readUtf8Line() ?: continue + + if (line.isEmpty()) continue + if (line.startsWith("data: ")) { + val data = line.substring(6) + if (data == "[DONE]") { + callback.onComplete() + break + } + + try { + val jsonData = JSONObject(data) + if (jsonData.has("choices") && + jsonData.getJSONArray("choices").length() > 0) { + + val choice = jsonData.getJSONArray("choices").getJSONObject(0) + + // 检查是否有delta + if (choice.has("delta")) { + val delta = choice.getJSONObject("delta") + + // 检查是否有工具调用 + 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.optInt("index", i) + + // 如果是新的工具调用,初始化 + if (!finalToolCalls.containsKey(index)) { + finalToolCalls[index] = ToolCallInfo() + } + + // 获取ID + if (toolCall.has("id")) { + finalToolCalls[index]?.id = toolCall.getString("id") + } + + // 处理函数信息 + if (toolCall.has("function")) { + val function = toolCall.getJSONObject("function") + + if (function.has("name")) { + finalToolCalls[index]?.name = function.getString("name") + } + + if (function.has("arguments")) { + finalToolCalls[index]?.arguments += function.getString("arguments") + } + } + } + continue + } + + // 如果有内容,发送给回调 + if (delta.has("content") && !delta.isNull("content")) { + val content = delta.getString("content") + callback.onToken(content) + } + } + } + } catch (e: Exception) { + // 忽略无效的JSON + continue + } + } + } + + // 处理完整的函数调用 + for ((_, toolCallInfo) in finalToolCalls) { + if (toolCallInfo.name.isNotEmpty()) { + try { + // 创建函数调用对象 + val functionCall = JSONObject().apply { + put("id", toolCallInfo.id) + put("name", toolCallInfo.name) + put("arguments", toolCallInfo.arguments.trim()) + } + + callback.onFunctionCall(functionCall) + } catch (e: Exception) { + // 出错时使用空参数 + val functionCall = JSONObject().apply { + put("id", toolCallInfo.id) + put("name", toolCallInfo.name) + put("arguments", "{}") + } + callback.onFunctionCall(functionCall) + } + } + } + } catch (e: Exception) { + callback.onError(OpenAIException("处理流式响应出错: ${e.message}")) + } finally { + response.close() + } + } + }) + } + + /** + * 发送函数调用结果 + */ + fun sendFunctionCallResult( + messages: JSONArray, + systemPrompt: String, + functionCall: JSONObject, + functionResult: String, + callback: StreamCallback + ) { + if (!isInitialized || apiKey.isEmpty()) { + callback.onError(OpenAIException("OpenAI服务未初始化")) + return + } + + // 构建完整的消息历史 + val fullMessages = JSONArray().apply { + // 添加系统提示 + put(JSONObject().apply { + put("role", "system") + put("content", systemPrompt) + }) + + // 添加历史消息 + for (i in 0 until messages.length()) { + put(messages.getJSONObject(i)) + } + + // 添加函数调用信息 + put(JSONObject().apply { + put("role", "assistant") + put("content", null) + put("tool_calls", JSONArray().apply { + put(JSONObject().apply { + put("id", functionCall.optString("id", "call_${System.currentTimeMillis()}")) + put("type", "function") + put("function", JSONObject().apply { + put("name", functionCall.getString("name")) + put("arguments", functionCall.getString("arguments")) + }) + }) + }) + }) + + // 添加函数返回结果 + put(JSONObject().apply { + put("role", "tool") + put("tool_call_id", functionCall.optString("id", "call_${System.currentTimeMillis()}")) + put("content", functionResult) + }) + } + + // 构建请求 + val requestBody = JSONObject().apply { + put("model", model) + put("messages", fullMessages) + put("temperature", 0.7) + 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) + } + tools.put(tool) + } + put("tools", tools) + } + } + + val mediaType = "application/json".toMediaTypeOrNull() + val request = Request.Builder() + .url(baseUrl) + .addHeader("Content-Type", "application/json") + .addHeader("Authorization", "Bearer $apiKey") + .addHeader("Accept", "text/event-stream") + .post(requestBody.toString().toRequestBody(mediaType)) + .build() + + // 发送请求 + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + callback.onError(OpenAIException(e.message ?: "请求失败")) + } + + override fun onResponse(call: Call, response: Response) { + if (!response.isSuccessful) { + callback.onError(OpenAIException("API调用失败: ${response.code}")) + return + } + + val responseBody = response.body ?: return + val source = responseBody.source() + + try { + // 预取数据到缓冲区 + source.request(Long.MAX_VALUE) + val bufferedSource = source.buffer + + // 用于存储函数调用的各个部分 + val finalToolCalls = mutableMapOf() + + while (!bufferedSource.exhausted()) { + val line = bufferedSource.readUtf8Line() ?: continue + + if (line.isEmpty()) continue + if (line.startsWith("data: ")) { + val data = line.substring(6) + if (data == "[DONE]") { + callback.onComplete() + break + } + + try { + val jsonData = JSONObject(data) + if (jsonData.has("choices") && + jsonData.getJSONArray("choices").length() > 0) { + + val choice = jsonData.getJSONArray("choices").getJSONObject(0) + + // 检查是否有delta + if (choice.has("delta")) { + val delta = choice.getJSONObject("delta") + + // 检查是否有工具调用 + 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.optInt("index", i) + + // 如果是新的工具调用,初始化 + if (!finalToolCalls.containsKey(index)) { + finalToolCalls[index] = ToolCallInfo() + } + + // 获取ID + if (toolCall.has("id")) { + finalToolCalls[index]?.id = toolCall.getString("id") + } + + // 处理函数信息 + if (toolCall.has("function")) { + val function = toolCall.getJSONObject("function") + + if (function.has("name")) { + finalToolCalls[index]?.name = function.getString("name") + } + + if (function.has("arguments")) { + finalToolCalls[index]?.arguments += function.getString("arguments") + } + } + } + continue + } + + // 如果有内容,发送给回调 + if (delta.has("content") && !delta.isNull("content")) { + val content = delta.getString("content") + callback.onToken(content) + } + } + } + } catch (e: Exception) { + // 忽略无效的JSON + continue + } + } + } + + // 处理完整的函数调用 + for ((_, toolCallInfo) in finalToolCalls) { + if (toolCallInfo.name.isNotEmpty()) { + try { + // 创建函数调用对象 + val functionCall = JSONObject().apply { + put("id", toolCallInfo.id) + put("name", toolCallInfo.name) + put("arguments", toolCallInfo.arguments.trim()) + } + + callback.onFunctionCall(functionCall) + } catch (e: Exception) { + // 出错时使用空参数 + val functionCall = JSONObject().apply { + put("id", toolCallInfo.id) + put("name", toolCallInfo.name) + put("arguments", "{}") + } + callback.onFunctionCall(functionCall) + } + } + } + } catch (e: Exception) { + callback.onError(OpenAIException("处理流式响应出错: ${e.message}")) + } finally { + response.close() + } + } + }) + } + + /** + * 创建用户消息 + */ + fun createUserMessage(content: String): JSONObject { + return JSONObject().apply { + put("role", "user") + put("content", content) + } + } + + /** + * 创建系统消息 + */ + fun createSystemMessage(content: String): JSONObject { + return JSONObject().apply { + put("role", "system") + put("content", content) + } + } + + /** + * 创建助手消息 + */ + fun createAssistantMessage(content: String): JSONObject { + return JSONObject().apply { + put("role", "assistant") + put("content", content) + } + } + + /** + * 流式输出回调接口 + */ + interface StreamCallback { + fun onToken(token: String) + fun onComplete() + fun onError(e: Exception) + fun onFunctionCall(functionCall: JSONObject) {} + } + + /** + * 用于存储工具调用信息的辅助类 + */ + private class ToolCallInfo { + var id: String = "" + var name: String = "" + var arguments: String = "" + } +} + +/** + * OpenAI异常 + */ +class OpenAIException(message: String) : Exception(message) \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceFunctionHandler.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceFunctionHandler.kt new file mode 100644 index 000000000..a89441506 --- /dev/null +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceFunctionHandler.kt @@ -0,0 +1,180 @@ +package com.yunqiinnovation.deepsound + +import org.json.JSONArray +import org.json.JSONObject +import com.yunqiinnovation.deepsound.OpenAIService +import com.yunqiinnovation.deepsound.core.utils.FileLogger + +/** + * 语音功能处理器 - 处理AI函数调用 + */ +class VoiceFunctionHandler( + private val openAIService: OpenAIService, + private val systemPrompt: String +) { + companion object { + private const val TAG = "VoiceFunctionHandler" + } + + /** + * 初始化并注册所有可用的函数 + */ + fun initialize() { + try { + // 注册退出交互函数 + registerExitInteractionFunction() + + // 在这里可以注册更多函数 + + } catch (e: Exception) { + FileLogger.e(TAG, "初始化函数处理器失败: ${e.message}", e) + } + } + + /** + * 注册退出交互函数 + */ + private fun registerExitInteractionFunction() { + try { + openAIService.registerFunction( + "exit_interaction", + "退出当前语音交互", + JSONObject(""" + { + "type": "object", + "properties": {}, + "required": [] + } + """) + ) + FileLogger.d(TAG, "退出交互功能已注册") + } catch (e: Exception) { + FileLogger.e(TAG, "注册退出交互函数失败: ${e.message}", e) + } + } + + /** + * 处理函数调用 + * + * @param functionCall 函数调用信息 + * @param messages 消息历史 + * @param callback 回调,处理退出等操作 + * @return 是否已处理函数调用 + */ + fun handleFunctionCall( + functionCall: JSONObject, + messages: JSONArray, + callback: FunctionCallCallback + ): Boolean { + val functionName = functionCall.getString("name") + FileLogger.d(TAG, "处理函数调用: $functionName") + + return when (functionName) { + "exit_interaction" -> { + handleExitInteraction(functionCall, messages, callback) + true + } + else -> { + // 未知函数,返回默认结果 + handleUnknownFunction(functionCall, messages, callback) + false + } + } + } + + /** + * 处理退出交互函数 + */ + private fun handleExitInteraction( + functionCall: JSONObject, + messages: JSONArray, + callback: FunctionCallCallback + ) { + FileLogger.d(TAG, "处理退出交互函数") + + val responseBuilder = StringBuilder() + + openAIService.sendFunctionCallResult( + messages = messages, + systemPrompt = systemPrompt, + functionCall = functionCall, + functionResult = "{\"result\": \"已退出语音交互\"}", + callback = object : OpenAIService.StreamCallback { + override fun onToken(token: String) { + responseBuilder.append(token) + } + + override fun onComplete() { + FileLogger.d(TAG, "handleExitInteraction onComplete: ${responseBuilder.toString()}") + val response = responseBuilder.toString() + if (response.isNotEmpty()) { + callback.onExitWithMessage(response) + } else { + callback.onExitWithMessage("已退出语音交互") + } + } + + override fun onError(e: Exception) { + FileLogger.e(TAG, "处理退出交互函数调用出错: ${e.message}") + callback.onError("退出交互时出错") + } + + override fun onFunctionCall(nestedCall: JSONObject) { + FileLogger.e(TAG, "意外收到嵌套函数调用: ${nestedCall.getString("name")}") + } + } + ) + } + + /** + * 处理未知函数调用 + */ + private fun handleUnknownFunction( + functionCall: JSONObject, + messages: JSONArray, + callback: FunctionCallCallback + ) { + FileLogger.d(TAG, "处理未知函数: ${functionCall.getString("name")}") + + try { + openAIService.sendFunctionCallResult( + messages = messages, + systemPrompt = systemPrompt, + functionCall = functionCall, + functionResult = "{\"result\": \"处理函数调用中\"}", + callback = object : OpenAIService.StreamCallback { + override fun onToken(token: String) { + callback.onTokenReceived(token) + } + + override fun onComplete() { + callback.onComplete() + } + + override fun onError(e: Exception) { + FileLogger.e(TAG, "处理函数调用失败: ${e.message}") + callback.onError("处理函数调用失败: ${e.message}") + } + + override fun onFunctionCall(nestedCall: JSONObject) { + callback.onFunctionCall(nestedCall) + } + } + ) + } catch (e: Exception) { + FileLogger.e(TAG, "处理函数调用失败: ${e.message}") + callback.onError("处理函数调用失败: ${e.message}") + } + } + + /** + * 函数调用回调接口 + */ + interface FunctionCallCallback { + fun onTokenReceived(token: String) + fun onComplete() + fun onError(message: String) + fun onFunctionCall(functionCall: JSONObject) + fun onExitWithMessage(farewell: String) + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/example/deep_voice/VoiceInteractionService.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt similarity index 78% rename from android/app/src/main/kotlin/com/example/deep_voice/VoiceInteractionService.kt rename to android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt index f469c136e..ad20ab6b7 100644 --- a/android/app/src/main/kotlin/com/example/deep_voice/VoiceInteractionService.kt +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt @@ -22,10 +22,15 @@ import android.os.Handler import android.os.Looper import java.util.concurrent.atomic.AtomicBoolean import org.json.JSONArray +import org.json.JSONObject import android.media.MediaPlayer import android.media.AudioAttributes import android.net.Uri import com.yunqiinnovation.deepsound.core.utils.FileLogger +import com.yunqiinnovation.azure_speech.AzureAsrHelper +import com.yunqiinnovation.azure_speech.AzureTtsHelper +import com.yunqiinnovation.deepsound.OpenAIService + /** * 后台语音交互 Service: @@ -78,7 +83,8 @@ class VoiceInteractionService : Service() { private lateinit var audioManager: AudioManager private lateinit var azureAsrHelper: AzureAsrHelper private lateinit var azureTtsHelper: AzureTtsHelper - private lateinit var volcanoAIService: VolcanoAIService + private lateinit var openAIService: OpenAIService + private lateinit var functionHandler: VoiceFunctionHandler // 定时器 private val handler = Handler(Looper.getMainLooper()) @@ -95,7 +101,10 @@ class VoiceInteractionService : Service() { 请保持回答简短、准确,避免过长的解释。 如果用户的问题不清楚,请礼貌地请求澄清。 不要使用复杂的术语,除非用户明确要求。 - 用户用语音和你交互. + 用户用语音和你交互。 + + 当用户说"退出"、"再见"、"结束对话"等类似意图时,你应该使用exit_interaction函数来结束对话, + 并在结束前说一句友好的告别语,例如"再见,有需要随时找我"。 """.trimIndent() // 添加媒体播放器 @@ -157,10 +166,11 @@ class VoiceInteractionService : Service() { // 尝试从静态变量获取配置 var subscriptionKey = MainActivity.azureSpeechKey var serviceRegion = MainActivity.azureSpeechRegion - var volcanoKey = MainActivity.volcanoAiApiKey + var openaiKey = MainActivity.openaiApiKey + var openaiBaseUrl = MainActivity.openaiBaseUrl ?: "" // OpenAI API基本URL // 如果静态变量中没有配置,尝试从加密存储中加载 - if (subscriptionKey.isEmpty() || serviceRegion.isEmpty() || volcanoKey.isEmpty()) { + if (subscriptionKey.isEmpty() || serviceRegion.isEmpty() || openaiKey.isEmpty()) { FileLogger.d(TAG, "静态变量中的配置信息不完整,尝试从加密存储加载") // 从加密存储加载密钥 @@ -170,7 +180,8 @@ class VoiceInteractionService : Service() { // 更新本地变量 subscriptionKey = MainActivity.azureSpeechKey serviceRegion = MainActivity.azureSpeechRegion - volcanoKey = MainActivity.volcanoAiApiKey + openaiKey = MainActivity.openaiApiKey + openaiBaseUrl = MainActivity.openaiBaseUrl ?: "" FileLogger.d(TAG, "已从加密存储加载配置信息") } else { @@ -191,15 +202,29 @@ class VoiceInteractionService : Service() { FileLogger.e(TAG, "Azure配置信息不完整,无法初始化Azure服务") } - // 初始化火山AI服务 - volcanoAIService = VolcanoAIService() + // 初始化OpenAI服务 + openAIService = OpenAIService() - // 初始化火山AI服务 - if (volcanoKey.isNotEmpty()) { - volcanoAIService.initialize(volcanoKey) - FileLogger.d(TAG, "火山AI服务已初始化") + // 初始化OpenAI服务 + if (openaiKey.isNotEmpty()) { + val initialized = if (openaiBaseUrl.isNotEmpty()) { + openAIService.initialize(openaiKey, openaiBaseUrl) + } else { + openAIService.initialize(openaiKey) + } + + if (initialized) { + FileLogger.d(TAG, "OpenAI服务已初始化") + + // 初始化函数处理器 + functionHandler = VoiceFunctionHandler(openAIService, systemPrompt) + functionHandler.initialize() + + } else { + FileLogger.e(TAG, "OpenAI服务初始化失败") + } } else { - FileLogger.e(TAG, "火山AI配置信息不完整,无法初始化火山AI服务") + FileLogger.e(TAG, "OpenAI配置信息不完整,无法初始化OpenAI服务") } } @@ -404,7 +429,7 @@ class VoiceInteractionService : Service() { override fun onResult(result: String, detectedLanguage: String) { if (result.isNotEmpty()) { - processWithVolcanoAI(result) + processWithOpenAI(result) } // 重置状态,继续识别 @@ -483,27 +508,114 @@ class VoiceInteractionService : Service() { } /** - * 使用VolcanoAI处理语音识别结果 + * 使用OpenAI处理语音识别结果 */ - private fun processWithVolcanoAI(text: String) { + private fun processWithOpenAI(text: String) { // 保存当前用户输入,用于后续同步聊天记录 currentUserInput = text Thread { try { val messages = JSONArray().apply { - put(volcanoAIService.createUserMessage(text)) + put(openAIService.createUserMessage(text)) } + + // 创建响应构建器 + val responseBuilder = StringBuilder() - val response = volcanoAIService.sendMessage(messages, systemPrompt) - - // 播放AI回复 - speakAIResponse(response) + openAIService.sendMessageStream( + messages = messages, + systemPrompt = systemPrompt, + 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端 + notifyChatHistoryUpdated("personal_assistant", text, response) + } + } + + override fun onError(e: Exception) { + FileLogger.e(TAG, "AI流式处理出错: ${e.message}", e) + playNotification("AI处理出错") + } + + override fun onFunctionCall(call: JSONObject) { + FileLogger.d(TAG, "收到函数调用请求: ${call.getString("name")}") + + // 使用函数处理器处理函数调用 + val handled = functionHandler.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端 + notifyChatHistoryUpdated("personal_assistant", text, response) + } + updateLastActivityTime() + } + + override fun onError(message: String) { + FileLogger.e(TAG, "函数处理出错: $message") + playNotification(message) + } + + override fun onFunctionCall(nestedCall: JSONObject) { + FileLogger.d(TAG, "收到嵌套函数调用: ${nestedCall.getString("name")}") + // 递归处理嵌套函数调用 + functionHandler.handleFunctionCall( + functionCall = nestedCall, + messages = messages, + callback = this + ) + } + + override fun onExitWithMessage(farewell: String) { + // 播放退出消息 + speakAIResponse(farewell) + + // 同步聊天记录 + notifyChatHistoryUpdated("personal_assistant", text, farewell) + + // 停止语音识别 + stopVoiceRecognition() + } + } + ) + + if (!handled) { + // 如果函数没有被处理,作为普通文本处理 + FileLogger.d(TAG, "函数未处理,作为普通文本处理") + speakAIResponse("我无法处理这个请求") + notifyChatHistoryUpdated("personal_assistant", text, "我无法处理这个请求") + } + } + } + ) - // 同步聊天记录到Flutter端 - notifyChatHistoryUpdated("personal_assistant", text, response) } catch (e: Exception) { - FileLogger.e(TAG, "AI处理出错: ${e.message}") + FileLogger.e(TAG, "AI处理出错: ${e.message}", e) playNotification("AI处理出错") } }.start() @@ -746,38 +858,45 @@ class VoiceInteractionService : Service() { override fun onBind(intent: Intent?): IBinder? = null override fun onDestroy() { - FileLogger.d(TAG, "onDestroy - 语音交互服务正在销毁") - - // 释放音频播放器 - audioPlayer?.release() - audioPlayer = null + super.onDestroy() + FileLogger.d(TAG, "onDestroy - 语音交互服务即将销毁") - // 停止监控 + // 停止服务监控 stopMonitoring() // 停止语音识别 - if (isRecognitionActive) { - stopVoiceRecognition() - } + stopVoiceRecognition() + + // 停止媒体会话 + mediaSession.release() + FileLogger.d(TAG, "媒体会话已释放") // 停止TTS stopCurrentTTS() - // 释放 MediaSession - mediaSession.release() + // 关闭Azure语音服务 + if (::azureAsrHelper.isInitialized) { + FileLogger.d(TAG, "关闭Azure语音服务") + azureAsrHelper.dispose() + } - // 释放Azure服务实例 - FileLogger.d(TAG, "释放Azure服务实例") - azureAsrHelper.dispose() - azureTtsHelper.dispose() + if (::azureTtsHelper.isInitialized) { + FileLogger.d(TAG, "关闭Azure TTS服务") + azureTtsHelper.dispose() + } - // 更新服务状态 - isRunning.set(false) + // 关闭OpenAI服务 + if (::openAIService.isInitialized) { + FileLogger.d(TAG, "关闭OpenAI服务") + } - // 关闭日志系统 - FileLogger.shutdown() + // 关闭音频播放器 + audioPlayer?.release() - super.onDestroy() + // 设置服务状态 + isRunning.set(false) + + FileLogger.d(TAG, "语音交互服务已销毁") } /** @@ -804,9 +923,7 @@ class VoiceInteractionService : Service() { * 通知 Flutter 端聊天记录已更新 */ private fun notifyChatHistoryUpdated(agentId: String, userMessage: String, assistantMessage: String) { - FileLogger.d(TAG, "通知Flutter聊天记录已更新: agentId=$agentId") - FileLogger.d(TAG, "用户消息: ${userMessage.take(50)}...") - FileLogger.d(TAG, "助手回复: ${assistantMessage.take(50)}...") + // 创建广播 Intent val intent = Intent(ACTION_CHAT_HISTORY_UPDATED).apply { @@ -818,7 +935,6 @@ class VoiceInteractionService : Service() { // 发送广播 sendBroadcast(intent) - FileLogger.d(TAG, "已发送聊天记录广播") } /** diff --git a/android/app/src/main/kotlin/com/example/deep_voice/core/utils/FileLogger.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/core/utils/FileLogger.kt similarity index 100% rename from android/app/src/main/kotlin/com/example/deep_voice/core/utils/FileLogger.kt rename to android/app/src/main/kotlin/com/yunqiinnovation/deepsound/core/utils/FileLogger.kt diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 6296e34ed..6a3483c9c 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -29,4 +29,8 @@ plugins { } include(":app") +include(":azure_speech") + +// 设置azure_speech项目的路径 +project(":azure_speech").projectDir = file("../local_plugins/azure_speech/android") diff --git a/azure/LICENSE b/azure/LICENSE deleted file mode 100644 index d3df29b8a..000000000 --- a/azure/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 Your Company - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/azure/README.md b/azure/README.md deleted file mode 100644 index a19025680..000000000 --- a/azure/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# Azure Speech Recognition - -A Flutter plugin for Microsoft Azure Speech services, providing both speech recognition (ASR) and text-to-speech (TTS) capabilities. - -## Features - -- Speech-to-text (Azure Speech Recognition) -- Text-to-speech (Azure Speech Synthesis) -- Support for multiple languages -- Language detection -- Continuous recognition -- Streaming synthesis - -## Getting Started - -### Prerequisites - -- Azure Speech service subscription key -- Azure Speech service region - -### Installation - -Add this to your package's `pubspec.yaml` file: - -```yaml -dependencies: - azure_speech_recognition: - path: ./azure -``` - -### Usage - -```dart -import 'package:azure_speech_recognition/azure_speech_recognition.dart'; - -// Initialize the service -await AzureSpeechRecognition.initialize( - subscriptionKey: 'your_subscription_key', - region: 'your_region', - supportedLanguages: ['zh-CN', 'en-US'], -); - -// Start continuous recognition -await AzureSpeechRecognition.startContinuousRecognition(); - -// Listen for recognition events -AzureSpeechRecognition.onRecognitionEvent.listen((event) { - if (event['type'] == 'result') { - print('Recognized: ${event['text']}'); - print('Detected language: ${event['detectedLanguage']}'); - } -}); - -// Stop recognition when done -await AzureSpeechRecognition.stopContinuousRecognition(); - -// Speak text -await AzureSpeechRecognition.speakText('Hello, world!'); - -// Clean up -await AzureSpeechRecognition.dispose(); -``` - -## License - -This project is licensed under the MIT License - see the LICENSE file for details. \ No newline at end of file diff --git a/azure/ios/Classes/AzureAsrHelper.swift b/azure/ios/Classes/AzureAsrHelper.swift deleted file mode 100644 index 58b142f60..000000000 --- a/azure/ios/Classes/AzureAsrHelper.swift +++ /dev/null @@ -1,757 +0,0 @@ -import Foundation -import MicrosoftCognitiveServicesSpeech -import AVFoundation -import AudioToolbox - -/// Azure ASR工具类,负责实现语音识别服务接口 -@available(iOS 13.0, *) -class AzureAsrHelper: NSObject { - // MARK: - 属性 - - /// 事件处理回调 - private var eventHandler: (String, [String: Any]) -> Void - - /// 语音配置信息 - private var speechSubscriptionKey: String = "" - private var serviceRegion: String = "" - - /// 语音识别相关 - private var speechConfig: SPXSpeechConfiguration? - private var recognizer: SPXSpeechRecognizer? - private var audioConfig: SPXAudioConfiguration? - private var pushStream: SPXPushAudioInputStream? - - /// 音频处理相关 - private var audioProcessor: CustomAudioProcessor? - private var isProcessingAudio = false - private var audioProcessingTimer: Timer? - - /// 状态标志 - private var isInitialized = false - private var _isContinuousRecognitionActive = false - - /// 当前语言和支持的语言 - private var currentLanguage = "zh-CN" - private var supportedLanguages: [String] = ["zh-CN", "en-US"] - private var isAutoDetectLanguage = false - - // MARK: - 初始化 - - init(eventHandler: @escaping (String, [String: Any]) -> Void) { - self.eventHandler = eventHandler - super.init() - } - - deinit { - dispose() - } - - // MARK: - ASR Service 接口实现 - - /// 初始化语音识别服务 - /// - Parameters: - /// - speechSubscriptionKey: Azure 语音服务订阅密钥 - /// - serviceRegion: Azure 服务区域 (如 eastasia) - /// - supportedLanguages: 支持的语言代码数组 (可选) - /// - Returns: 初始化是否成功 - func initialize(speechSubscriptionKey: String, serviceRegion: String, supportedLanguages: [String]? = nil) -> Bool { - print("[AzureAsrHelper] 初始化 Azure 语音服务") - - // 检查配置是否为空 - if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { - print("[AzureAsrHelper] 错误: Azure 配置信息不完整") - eventHandler("error", ["message": "Azure 配置信息不完整"]) - return false - } - - // 释放之前的资源 - dispose() - - // 记录配置信息 - self.speechSubscriptionKey = speechSubscriptionKey - self.serviceRegion = serviceRegion - - // 设置语言 - if let languages = supportedLanguages, !languages.isEmpty { - self.supportedLanguages = languages - } - - // 根据支持的语言数量决定是否启用自动语言检测 - isAutoDetectLanguage = self.supportedLanguages.count >= 2 - - // 如果只有一种语言,设置为当前语言 - if !isAutoDetectLanguage && !self.supportedLanguages.isEmpty { - currentLanguage = self.supportedLanguages[0] - } - - // 创建识别器和设置回调 - if !createRecognizerAndSetupCallbacks() { - return false - } - - print("[AzureAsrHelper] Azure 语音服务初始化成功") - isInitialized = true - return true - } - - /// 创建识别器并设置回调 - private func createRecognizerAndSetupCallbacks() -> Bool { - // 释放之前的 recognizer - recognizer = nil - audioConfig = nil - - do { - // 创建语音配置 - speechConfig = try SPXSpeechConfiguration(subscription: speechSubscriptionKey, region: serviceRegion) - - // 设置音频输入参数 - try setupAudioSession() - - // 创建自定义推送流,替代默认的麦克风输入 - pushStream = try SPXPushAudioInputStream() - audioConfig = try SPXAudioConfiguration(streamInput: pushStream!) - - // 初始化自定义音频处理器 - audioProcessor = CustomAudioProcessor() - - // 设置语言配置 - if isAutoDetectLanguage { - // 设置自动语言检测 - speechConfig?.setPropertyTo("Continuous", by: SPXPropertyId.speechServiceConnectionLanguageIdMode) - - // 创建自动语言检测配置 - let autoDetectSourceLanguageConfig = try SPXAutoDetectSourceLanguageConfiguration(supportedLanguages) - - // 创建识别器 - recognizer = try SPXSpeechRecognizer( - speechConfiguration: speechConfig!, - autoDetectSourceLanguageConfiguration: autoDetectSourceLanguageConfig, - audioConfiguration: audioConfig! - ) - } else { - // 设置指定的识别语言 - speechConfig?.speechRecognitionLanguage = currentLanguage - - // 创建识别器 - recognizer = try SPXSpeechRecognizer(speechConfiguration: speechConfig!, audioConfiguration: audioConfig!) - } - - // 设置所有回调 - setupAllCallbacks() - - return true - } catch { - print("[AzureAsrHelper] 错误: 创建识别器失败: \(error.localizedDescription)") - eventHandler("error", ["message": "创建识别器失败: \(error.localizedDescription)"]) - return false - } - } - - /// 设置音频会话 - private func setupAudioSession() throws { - let audioSession = AVAudioSession.sharedInstance() - - // 使用playAndRecord类别允许同时录音和播放 - try audioSession.setCategory(.playAndRecord, - mode: .voiceChat, // 使用voiceChat模式能够更好地支持回音消除 - options: [.allowBluetooth, .defaultToSpeaker, .allowAirPlay, .mixWithOthers]) - - // 设置首选的输入和输出 - let currentRoute = audioSession.currentRoute - - // 获取当前是否连接了耳机或外部麦克风 - let hasHeadphones = currentRoute.outputs.contains { - $0.portType == .headphones || $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP - } - - // 如果没有耳机,明确启用内置麦克风和扬声器的回音消除 - if !hasHeadphones { - try audioSession.setMode(.voiceChat) // 语音聊天模式有更强的回音消除 - - // 启用回音消除和噪声抑制 - try audioSession.setInputGain(0.8) // 适当降低输入增益以减少扬声器音频被麦克风捕获的可能性 - } else { - // 耳机模式,可以使用不同的设置 - try audioSession.setMode(.voiceChat) - try audioSession.setInputGain(1.0) - } - - // 设置合适的采样率 - try audioSession.setPreferredSampleRate(16000.0) // Azure语音识别推荐的采样率 - try audioSession.setPreferredIOBufferDuration(0.01) // 较小的缓冲区大小以减少延迟 - - // 激活音频会话 - try audioSession.setActive(true, options: .notifyOthersOnDeactivation) - - print("[AzureAsrHelper] 音频会话配置成功,已启用回音消除") - } - - /// 设置所有回调 - private func setupAllCallbacks() { - guard let recognizer = recognizer else { return } - - // 最终识别结果 - recognizer.addRecognizedEventHandler { [weak self] _, event in - guard let self = self else { return } - - if event.result.reason == SPXResultReason.recognizedSpeech { - let detectedLanguage = self.getDetectedLanguage(from: event.result) - print("[AzureAsrHelper] 识别结果: \(event.result.text ?? ""), 语言: \(detectedLanguage)") - self.eventHandler("result", [ - "text": event.result.text ?? "", - "detectedLanguage": detectedLanguage - ]) - } - } - - // 识别中事件 - recognizer.addRecognizingEventHandler { [weak self] _, event in - guard let self = self else { return } - - if event.result.reason == SPXResultReason.recognizingSpeech { - let detectedLanguage = self.getDetectedLanguage(from: event.result) - // print("[AzureAsrHelper] 识别中: \(event.result.text ?? ""), 语言: \(detectedLanguage)") - self.eventHandler("recognizing", [ - "text": event.result.text ?? "", - "detectedLanguage": detectedLanguage - ]) - } - } - - // 会话事件 - recognizer.addSessionStartedEventHandler { [weak self] _, _ in - guard let self = self else { return } - - print("[AzureAsrHelper] 识别会话已开始") - self._isContinuousRecognitionActive = true - self.eventHandler("sessionStarted", [:]) - } - - recognizer.addSessionStoppedEventHandler { [weak self] _, _ in - guard let self = self else { return } - - print("[AzureAsrHelper] 识别会话已结束") - self._isContinuousRecognitionActive = false - self.eventHandler("sessionStopped", [:]) - } - - // 取消事件 - recognizer.addCanceledEventHandler { [weak self] _, event in - guard let self = self else { return } - - let reason = event.reason.rawValue - let errorDetails = event.errorDetails ?? "未知错误" - - print("[AzureAsrHelper] 识别取消: \(errorDetails)") - - self.eventHandler("canceled", [ - "reason": reason, - "errorDetails": errorDetails - ]) - - self._isContinuousRecognitionActive = false - } - } - - /// 执行一次性语音识别 - /// - Returns: 是否成功启动识别 - func recognizeOnce() -> Bool { - if !isInitialized { - print("[AzureAsrHelper] 错误: 语音服务未初始化") - eventHandler("error", ["message": "语音服务未初始化"]) - return false - } - - // 如果正在连续识别,先停止 - if _isContinuousRecognitionActive { - stopContinuousRecognition() - } - - // 确保识别器已创建 - if recognizer == nil && !createRecognizerAndSetupCallbacks() { - return false - } - - do { - // 启动音频处理 - startAudioProcessing() - - // 通知会话开始 - eventHandler("sessionStarted", [:]) - - // 执行识别 - try recognizer?.recognizeOnceAsync { [weak self] result in - guard let self = self else { return } - - // 停止音频处理 - self.stopAudioProcessing() - - if result.reason == SPXResultReason.recognizedSpeech { - let detectedLanguage = self.getDetectedLanguage(from: result) - self.eventHandler("result", [ - "text": result.text ?? "", - "detectedLanguage": detectedLanguage - ]) - } else if result.reason == SPXResultReason.noMatch { - print("[AzureAsrHelper] 无匹配结果") - self.eventHandler("noMatch", [:]) - } else if result.reason == SPXResultReason.canceled { - do { - let details = try SPXCancellationDetails(fromCanceledRecognitionResult: result) - let errorDetails = details.errorDetails ?? "未知错误" - self.eventHandler("error", ["message": "识别取消: \(errorDetails)"]) - } catch { - print("[AzureAsrHelper] 错误: 获取取消详情失败: \(error.localizedDescription)") - self.eventHandler("error", ["message": "识别取消,无法获取详细原因"]) - } - } - } - - return true - } catch { - print("[AzureAsrHelper] 错误: 识别异常: \(error.localizedDescription)") - eventHandler("error", ["message": "识别异常: \(error.localizedDescription)"]) - stopAudioProcessing() - return false - } - } - - /// 开始连续语音识别 - /// - Returns: 是否成功启动识别 - func startContinuousRecognition() -> Bool { - if !isInitialized { - print("[AzureAsrHelper] 错误: 语音服务未初始化") - eventHandler("error", ["message": "语音服务未初始化"]) - return false - } - - // 如果已经在进行连续识别,先停止 - if _isContinuousRecognitionActive { - stopContinuousRecognition() - } - - // 确保识别器已创建 - if recognizer == nil && !createRecognizerAndSetupCallbacks() { - return false - } - - // 重新确保音频设置正确 - do { - try setupAudioSession() - } catch { - print("[AzureAsrHelper] 警告: 设置音频会话失败: \(error.localizedDescription)") - } - - do { - // 启动音频处理 - startAudioProcessing() - - // 启动连续识别 - try recognizer?.startContinuousRecognition() - _isContinuousRecognitionActive = true - - print("[AzureAsrHelper] 连续识别开始") - return true - } catch { - print("[AzureAsrHelper] 错误: 开始连续识别失败: \(error.localizedDescription)") - eventHandler("error", ["message": "开始连续识别失败: \(error.localizedDescription)"]) - _isContinuousRecognitionActive = false - stopAudioProcessing() - return false - } - } - - /// 停止连续语音识别 - /// - Returns: 是否成功停止识别 - func stopContinuousRecognition() -> Bool { - // 停止音频处理 - stopAudioProcessing() - - if !_isContinuousRecognitionActive || recognizer == nil { - return true - } - - do { - try recognizer?.stopContinuousRecognition() - _isContinuousRecognitionActive = false - print("[AzureAsrHelper] 连续识别已停止") - return true - } catch { - print("[AzureAsrHelper] 错误: 停止连续识别失败: \(error.localizedDescription)") - eventHandler("error", ["message": "停止连续识别失败: \(error.localizedDescription)"]) - _isContinuousRecognitionActive = false - return false - } - } - - /// 检查连续识别是否活跃 - /// - Returns: 连续识别是否处于活跃状态 - func isContinuousRecognitionActive() -> Bool { - return _isContinuousRecognitionActive - } - - /// 释放资源 - func dispose() { - print("[AzureAsrHelper] 释放资源") - - // 停止音频处理 - stopAudioProcessing() - - // 停止连续识别 - if _isContinuousRecognitionActive { - stopContinuousRecognition() - } - - // 释放音频会话 - do { - try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) - } catch { - print("[AzureAsrHelper] 警告: 释放音频会话失败: \(error.localizedDescription)") - } - - // 释放资源 - recognizer = nil - speechConfig = nil - audioConfig = nil - pushStream = nil - audioProcessor = nil - - // 重置状态 - _isContinuousRecognitionActive = false - isInitialized = false - } - - /// 从结果中获取检测到的语言 - private func getDetectedLanguage(from result: SPXSpeechRecognitionResult) -> String { - if isAutoDetectLanguage { - do { - let langResult = try SPXAutoDetectSourceLanguageResult(result) - return langResult.language ?? currentLanguage - } catch { - print("[AzureAsrHelper] 错误: 获取检测到的语言失败: \(error.localizedDescription)") - return currentLanguage - } - } else { - return currentLanguage - } - } - - // MARK: - 音频处理 - - /// 开始音频处理 - private func startAudioProcessing() { - guard !isProcessingAudio, let audioProcessor = audioProcessor else { return } - - isProcessingAudio = true - - // 启动音频处理器 - if !audioProcessor.startRecord() { - print("[AzureAsrHelper] 错误: 启动音频处理器失败") - eventHandler("error", ["message": "启动音频处理器失败"]) - return - } - - // 启动音频处理定时器 - audioProcessingTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in - guard let self = self, self.isProcessingAudio, let processor = self.audioProcessor, let stream = self.pushStream else { - return - } - - // 读取处理后的音频数据 - var bytes = [UInt8](repeating: 0, count: 2560) - let bytesRead = processor.read(bytes: &bytes) - - if bytesRead > 0 { - // 推送数据到Azure语音服务 - let data = Data(bytes: bytes, count: bytesRead) - stream.write(data) - - // 通知音频数据可用 - self.eventHandler("audioData", ["data": bytes]) - } - } - - print("[AzureAsrHelper] 音频处理已启动") - } - - /// 停止音频处理 - private func stopAudioProcessing() { - // 停止定时器 - audioProcessingTimer?.invalidate() - audioProcessingTimer = nil - - // 停止音频处理器 - audioProcessor?.stopRecord() - - isProcessingAudio = false - print("[AzureAsrHelper] 音频处理已停止") - } -} - -// MARK: - 自定义音频处理器 - -@available(iOS 13.0, *) -class CustomAudioProcessor: NSObject { - // 音频单元 - private var ioUnit: AudioUnit? - - // 音频格式 - private var audioFormat: AudioStreamBasicDescription - - // 音频缓冲 - private var audioBufferList: AudioBufferList - private var audioList: [Float] = [] - private let audioListQueue = DispatchQueue(label: "audioListQueue") - - // 回音消除状态 - private var isEchoCancellationEnabled = true - - override init() { - // 设置音频格式 - 16kHz, 16位, 单声道 - audioFormat = AudioStreamBasicDescription( - mSampleRate: 16000.0, - mFormatID: kAudioFormatLinearPCM, - mFormatFlags: kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked, - mBytesPerPacket: 2, - mFramesPerPacket: 1, - mBytesPerFrame: 2, - mChannelsPerFrame: 1, - mBitsPerChannel: 16, - mReserved: 0 - ) - - // 初始化音频缓冲 - audioBufferList = AudioBufferList( - mNumberBuffers: 1, - mBuffers: AudioBuffer( - mNumberChannels: 1, - mDataByteSize: 4096, - mData: malloc(4096) - ) - ) - - super.init() - } - - deinit { - stopRecord() - free(audioBufferList.mBuffers.mData) - } - - /// 启动音频处理 - /// - Returns: 是否成功启动 - func startRecord() -> Bool { - print("[CustomAudioProcessor] 配置音频单元") - - // 创建音频组件描述 - 使用VoiceProcessingIO类型获取回音消除 - var ioUnitDescription = AudioComponentDescription( - componentType: kAudioUnitType_Output, - componentSubType: kAudioUnitSubType_VoiceProcessingIO, - componentManufacturer: kAudioUnitManufacturer_Apple, - componentFlags: 0, - componentFlagsMask: 0 - ) - - // 查找音频组件 - guard let ioUnitRef = AudioComponentFindNext(nil, &ioUnitDescription) else { - print("[CustomAudioProcessor] 错误: 未找到音频组件") - return false - } - - // 创建音频单元实例 - if checkError(AudioComponentInstanceNew(ioUnitRef, &ioUnit), "创建音频单元") { - ioUnit = nil - return false - } - - // 启用输入端口 - var enableInput: UInt32 = 1 - let kInputBus: AudioUnitElement = 1 - let kOutputBus: AudioUnitElement = 0 - if checkError(AudioUnitSetProperty(ioUnit!, kAudioOutputUnitProperty_EnableIO, - kAudioUnitScope_Input, kInputBus, &enableInput, - UInt32(MemoryLayout.size)), "启用输入端口") { - return false - } - - // 禁用输出端口 (我们只需要输入) - var enableOutput: UInt32 = 0 - if checkError(AudioUnitSetProperty(ioUnit!, kAudioOutputUnitProperty_EnableIO, - kAudioUnitScope_Output, kOutputBus, - &enableOutput, UInt32(MemoryLayout.size)), "禁用输出端口") { - return false - } - - // 设置缓冲区分配标志 - var flag: UInt32 = 0 - if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_ShouldAllocateBuffer, - kAudioUnitScope_Output, kInputBus, &flag, UInt32(MemoryLayout.size)), "设置缓冲区分配标志") { - return false - } - - // 设置音频格式 - let size = UInt32(MemoryLayout.size) - if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_StreamFormat, - kAudioUnitScope_Output, kInputBus, &audioFormat, size), "设置输入总线输出范围的流格式") { - return false - } - - if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_StreamFormat, - kAudioUnitScope_Input, kOutputBus, &audioFormat, size), "设置输出总线输入范围的流格式") { - return false - } - - // 启用回音消除 - if isEchoCancellationEnabled { - var echoCancellation: UInt32 = 1 - AudioUnitSetProperty(ioUnit!, kAUVoiceIOProperty_BypassVoiceProcessing, - kAudioUnitScope_Global, 0, &echoCancellation, UInt32(MemoryLayout.size)) - } - - // 设置输入回调 - 当有新音频数据时调用 - var inputCallback = AURenderCallbackStruct( - inputProc: CustomAudioProcessor.onAudioDataAvailable, - inputProcRefCon: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) - ) - - if checkError(AudioUnitSetProperty(ioUnit!, - kAudioOutputUnitProperty_SetInputCallback, - kAudioUnitScope_Global, kInputBus, - &inputCallback, UInt32(MemoryLayout.size)), "设置输入回调") { - return false - } - - // 初始化音频单元 - var hasError = checkError(AudioUnitInitialize(ioUnit!), "初始化音频单元") - while hasError { - Thread.sleep(forTimeInterval: 0.1) - hasError = checkError(AudioUnitInitialize(ioUnit!), "初始化音频单元") - } - - // 启动音频单元 - hasError = checkError(AudioOutputUnitStart(ioUnit!), "启动音频单元") - - print("[CustomAudioProcessor] 音频处理器已启动,回音消除\(isEchoCancellationEnabled ? "已启用" : "已禁用")") - return !hasError - } - - /// 停止音频处理 - func stopRecord() { - print("[CustomAudioProcessor] 停止音频处理器") - - if let ioUnit = ioUnit { - // 停止音频单元 - _ = checkError(AudioOutputUnitStop(ioUnit), "停止音频单元") - - // 关闭音频单元 - _ = checkError(AudioUnitUninitialize(ioUnit), "反初始化音频单元") - _ = checkError(AudioComponentInstanceDispose(ioUnit), "释放音频单元") - - self.ioUnit = nil - } - - // 清空音频数据缓冲 - audioListQueue.sync { - audioList.removeAll() - } - } - - /// 音频数据回调 - 当有新的音频数据可用时调用 - private static let onAudioDataAvailable: AURenderCallback = { inRefCon, ioActionFlags, inTimeStamp, inBusNumber, inNumberFrames, ioData in - // 获取实例 - let processor = Unmanaged.fromOpaque(inRefCon).takeUnretainedValue() - - // 计算预期数据大小 - let expectedDataByteSize = inNumberFrames * processor.audioFormat.mBytesPerFrame - - // 确保缓冲区足够大 - if processor.audioBufferList.mBuffers.mDataByteSize < expectedDataByteSize { - processor.audioBufferList.mBuffers.mData = realloc(processor.audioBufferList.mBuffers.mData, Int(expectedDataByteSize)) - processor.audioBufferList.mBuffers.mDataByteSize = expectedDataByteSize - } - - // 渲染音频数据 - let status = processor.checkOSStatus(AudioUnitRender(processor.ioUnit!, ioActionFlags, inTimeStamp, - inBusNumber, inNumberFrames, &processor.audioBufferList), - "渲染音频数据") - - // 将Int16数据转换为浮点数据进行处理 - var audioDataFloat = [Float](repeating: 0.0, count: Int(inNumberFrames)) - let buffer = processor.audioBufferList.mBuffers - let bufferData = buffer.mData!.assumingMemoryBound(to: Int16.self) - - for j in 0...size)) { - // 归一化到[-1.0, 1.0]范围 - audioDataFloat[j] = Float(bufferData[j]) / 32768.0 - } - - // 应用附加处理 (如有需要) - // processor.applyAdditionalProcessing(&audioDataFloat) - - // 保存处理后的数据 - if status == noErr { - processor.audioListQueue.async { - processor.audioList.append(contentsOf: audioDataFloat) - } - } - - return status - } - - /// 读取处理后的音频数据 - /// - Parameter bytes: 输出字节数组 - /// - Returns: 读取的字节数 - func read(bytes: inout [UInt8]) -> Int { - return audioListQueue.sync { - // 如果没有数据,返回0 - if audioList.isEmpty { - return 0 - } - - // 确保有足够的数据 (至少1280个样本) - if audioList.count < 1280 { - return 0 - } - - // 读取一帧数据 (1280个样本) - let frameLength = 1280 - let buffer = Array(audioList.prefix(frameLength)) - audioList.removeFirst(frameLength) - - // 将浮点数据转回Int16格式 - var int16Data = buffer.map { Int16($0 * 32767) } - - // 转换为字节数组 - let data = Data(buffer: UnsafeBufferPointer(start: &int16Data, count: int16Data.count)) - bytes = [UInt8](data) - - // 每个样本2字节 (16位PCM) - return frameLength * 2 - } - } - - /// 检查错误并打印日志 - /// - Parameters: - /// - status: 操作状态 - /// - operation: 操作描述 - /// - Returns: 是否发生错误 - private func checkError(_ status: OSStatus, _ operation: String) -> Bool { - if status != noErr { - print("[CustomAudioProcessor] 错误: \(operation)失败: \(status)") - return true - } - return false - } - - /// 检查OSStatus并返回状态 - /// - Parameters: - /// - status: 操作状态 - /// - operation: 操作描述 - /// - Returns: 原始状态 - private func checkOSStatus(_ status: OSStatus, _ operation: String) -> OSStatus { - if status != noErr { - print("[CustomAudioProcessor] 错误: \(operation)失败: \(status)") - } - return status - } -} \ No newline at end of file diff --git a/azure/ios/Classes/AzureSpeechRecognitionPlugin.swift b/azure/ios/Classes/AzureSpeechRecognitionPlugin.swift deleted file mode 100644 index 141b77ef8..000000000 --- a/azure/ios/Classes/AzureSpeechRecognitionPlugin.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Flutter -import UIKit - -public class AzureSpeechRecognitionPlugin: NSObject, FlutterPlugin { - public static func register(with registrar: FlutterPluginRegistrar) { - if #available(iOS 13.0, *) { - SwiftAzureSpeechRecognitionPlugin.register(with: registrar) - } else { - // 如果低于iOS 13.0,返回不支持的错误 - let channel = FlutterMethodChannel(name: "com.deep_voice.azure_asr", binaryMessenger: registrar.messenger()) - channel.setMethodCallHandler { (call, result) in - result(FlutterError(code: "UNSUPPORTED", - message: "需要iOS 13.0及以上系统", - details: nil)) - } - } - } -} \ No newline at end of file diff --git a/azure/ios/Classes/AzureTtsHelper.swift b/azure/ios/Classes/AzureTtsHelper.swift deleted file mode 100644 index 589df617c..000000000 --- a/azure/ios/Classes/AzureTtsHelper.swift +++ /dev/null @@ -1,427 +0,0 @@ -import Foundation -import MicrosoftCognitiveServicesSpeech -import AVFoundation - -/// Azure TTS工具类,负责实现TTS服务接口 -@available(iOS 13.0, *) -class AzureTtsHelper: NSObject { - // MARK: - 属性 - - /// 事件处理回调 - private var eventHandler: (String, [String: Any]) -> Void - - /// 语音配置信息 - private var speechSubscriptionKey: String = "" - private var serviceRegion: String = "" - - /// 语音合成配置 - private var speechConfig: SPXSpeechConfiguration? - - /// 语音合成器 - private var synthesizer: SPXSpeechSynthesizer? - - /// 是否初始化成功 - private var isInitialized = false - - /// 当前是否正在播放 - private var _isSpeaking = false - - /// 音频会话配置 - private var isAudioSessionConfigured = false - - // MARK: - 语音设置 - - /// 当前语音 - private var currentVoice = "zh-CN-XiaoxiaoNeural" - - /// 支持的语音映射 - private var voiceMap: [String: String] = [ - "zh-CN": "zh-CN-XiaoxiaoNeural", - "en-US": "en-US-JennyNeural", - "ja-JP": "ja-JP-NanamiNeural", - "ko-KR": "ko-KR-SunHiNeural", - "zh-TW": "zh-TW-HsiaoChenNeural", - "zh-HK": "zh-HK-HiuMaanNeural" - ] - - /// 当前语音合成参数 - private var currentSpeechRate = "0%" - private var currentPitch = "0%" - private var currentVolume = "100%" - - // MARK: - 初始化 - - init(eventHandler: @escaping (String, [String: Any]) -> Void) { - self.eventHandler = eventHandler - super.init() - } - - deinit { - dispose() - } - - // MARK: - TTS 接口实现 - - /// 初始化语音合成服务 - /// - Parameters: - /// - speechSubscriptionKey: Azure 语音服务订阅密钥 - /// - serviceRegion: Azure 服务区域 (如 eastasia) - /// - language: 语言代码 (默认 zh-CN) - /// - Returns: 初始化是否成功 - func initialize(speechSubscriptionKey: String, serviceRegion: String, language: String = "zh-CN") -> Bool { - print("[AzureTtsHelper] 初始化语音合成服务") - - // 检查配置是否为空 - if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { - print("[AzureTtsHelper] 错误: Azure 配置信息不完整") - eventHandler("error", ["error": "Azure 配置信息不完整"]) - return false - } - - // 释放之前的资源 - dispose() - - // 记录配置信息 - self.speechSubscriptionKey = speechSubscriptionKey - self.serviceRegion = serviceRegion - - // 配置音频会话 - if !configureAudioSession() { - print("[AzureTtsHelper] 警告: 音频会话配置失败,将尝试继续初始化") - } - - do { - // 创建语音配置 - speechConfig = try SPXSpeechConfiguration(subscription: speechSubscriptionKey, region: serviceRegion) - - // 设置默认语音 - let defaultVoice = getDefaultVoiceForLanguage(language) - currentVoice = defaultVoice - speechConfig?.speechSynthesisVoiceName = defaultVoice - - // 创建语音合成器 - synthesizer = try SPXSpeechSynthesizer(speechConfig!) - - // 设置事件处理器 - setupSynthesizerEvents() - - isInitialized = true - print("[AzureTtsHelper] TTS 引擎初始化成功") - - return true - } catch { - print("[AzureTtsHelper] 错误: 初始化语音合成服务失败: \(error.localizedDescription)") - eventHandler("error", ["error": "初始化语音合成服务失败: \(error.localizedDescription)"]) - return false - } - } - - /// 配置音频会话 - private func configureAudioSession() -> Bool { - let audioSession = AVAudioSession.sharedInstance() - do { - // 使用playback类别,但支持混合和空中播放 - try audioSession.setCategory(.playback, - mode: .spokenAudio, - options: [.mixWithOthers, .allowAirPlay, .duckOthers]) - - // 根据设备类型选择最佳配置 - let currentRoute = audioSession.currentRoute - let hasHeadphones = currentRoute.outputs.contains { - $0.portType == .headphones || $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP - } - - // 优化音频路由 - if hasHeadphones { - // 耳机模式,使用默认设置 - try audioSession.setPreferredIOBufferDuration(0.005) // 较小的缓冲区大小以减少延迟 - } else { - // 扬声器模式 - try audioSession.setPreferredIOBufferDuration(0.005) - } - - // 避免完全激活音频会话,因为ASR可能已经激活 - // 这里使用setActive(false)是为了不与ASR冲突 - if !audioSession.isOtherAudioPlaying { - try audioSession.setActive(true, options: .notifyOthersOnDeactivation) - } - - isAudioSessionConfigured = true - print("[AzureTtsHelper] 音频会话配置成功") - return true - } catch { - print("[AzureTtsHelper] 警告: 音频会话配置失败: \(error.localizedDescription)") - isAudioSessionConfigured = false - return false - } - } - - /// 设置语音 - /// - Parameter voiceName: 语音名称 (如 "zh-CN-XiaoxiaoNeural") - /// - Returns: 设置是否成功 - func setVoice(voiceName: String) -> Bool { - if !isInitialized { - print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") - eventHandler("error", ["error": "TTS 引擎尚未初始化"]) - return false - } - - if voiceName.isEmpty { - print("[AzureTtsHelper] 错误: 声音名称为空") - eventHandler("error", ["error": "声音名称不能为空"]) - return false - } - - if voiceName == currentVoice { - print("[AzureTtsHelper] 已设置语音: \(voiceName)") - return true - } - - print("[AzureTtsHelper] 设置声音: \(voiceName)") - currentVoice = voiceName - - // 更新语音配置 - if let speechConfig = speechConfig { - speechConfig.speechSynthesisVoiceName = voiceName - return true - } - - return false - } - - /// 设置语音合成参数 - /// - Parameters: - /// - rate: 语速,范围 -100 到 100,默认为 0 - /// - pitch: 音调,范围 -100 到 100,默认为 0 - /// - volume: 音量,范围 0 到 100,默认为 100 - /// - Returns: 是否设置成功 - func setSpeechParams(rate: Int = 0, pitch: Int = 0, volume: Int = 100) -> Bool { - if !isInitialized { - print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") - eventHandler("error", ["error": "TTS 引擎尚未初始化"]) - return false - } - - // 转换参数格式 - currentSpeechRate = formatRateParam(rate) - currentPitch = formatPitchParam(pitch) - currentVolume = formatVolumeParam(volume) - - print("[AzureTtsHelper] 已设置语音参数: 语速=\(currentSpeechRate), 音调=\(currentPitch), 音量=\(currentVolume)") - return true - } - - /// 合成文本为语音并播放 - /// - Parameter text: 要合成的文本 - /// - Returns: 操作是否成功启动 - func speakText(text: String) -> Bool { - if !isInitialized { - print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") - eventHandler("error", ["error": "TTS 引擎尚未初始化"]) - return false - } - - if text.isEmpty { - print("[AzureTtsHelper] 警告: 要播放的文本为空") - return true - } - - // 确保音频会话已配置 - if !isAudioSessionConfigured { - _ = configureAudioSession() - } - - print("[AzureTtsHelper] 开始语音合成: \(text.prefix(50))...") - - // 生成SSML - let ssml = generateSsml(text: text) - - // 直接进行SSML合成 - return speakSsmlInternal(text: ssml) - } - - /// 内部SSML合成和播放 - private func speakSsmlInternal(text: String) -> Bool { - guard let synthesizer = synthesizer else { - print("[AzureTtsHelper] 错误: 合成器未初始化") - eventHandler("error", ["error": "合成器未初始化"]) - return false - } - - _isSpeaking = true - eventHandler("started", [:]) - - Task { - do { - // 使用异步方法进行合成并直接播放 - _ = try await synthesizer.startSpeakingSsml(text) - - } catch { - print("[AzureTtsHelper] 错误: 语音合成失败: \(error.localizedDescription)") - DispatchQueue.main.async { - self._isSpeaking = false - self.eventHandler("error", ["error": "语音合成失败: \(error.localizedDescription)"]) - } - } - } - - return true - } - - /// 停止当前语音合成 - /// - Returns: 操作是否成功 - func stopSpeaking() -> Bool { - if !isInitialized || !_isSpeaking { - return true - } - - // 停止合成 - do { - try synthesizer?.stopSpeaking() - _isSpeaking = false - eventHandler("canceled", [:]) - print("[AzureTtsHelper] 已停止语音合成") - return true - } catch { - print("[AzureTtsHelper] 错误: 停止语音合成失败: \(error.localizedDescription)") - eventHandler("error", ["error": "停止语音合成失败: \(error.localizedDescription)"]) - return false - } - } - - /// 检查是否正在播放 - /// - Returns: 当前是否正在播放语音 - func isSpeaking() -> Bool { - return _isSpeaking - } - - /// 释放资源 - func dispose() { - try? stopSpeaking() - - // 释放合成器和配置 - synthesizer = nil - speechConfig = nil - - isInitialized = false - _isSpeaking = false - isAudioSessionConfigured = false - print("[AzureTtsHelper] TTS 引擎已释放") - } - - // MARK: - 私有辅助方法 - - /// 设置合成器事件处理 - private func setupSynthesizerEvents() { - guard let synthesizer = synthesizer else { return } - - // 添加书签到达事件处理 - synthesizer.addBookmarkReachedEventHandler { _, e in - print("[AzureTtsHelper] 书签事件: 音频偏移: \((e.audioOffset + 5000) / 10000)ms, 文本: \"\(e.text)\"") - } - - // 合成完成事件 - synthesizer.addSynthesisCompletedEventHandler { [weak self] _, e in - guard let self = self else { return } - print("[AzureTtsHelper] 语音合成完成: 音频持续时间: \(e.result.audioDuration)") - DispatchQueue.main.async { - self._isSpeaking = false - self.eventHandler("completed", [:]) - } - } - - // 合成取消事件 - synthesizer.addSynthesisCanceledEventHandler { [weak self] _, e in - guard let self = self else { return } - - let result = e.result - do { - let cancellationDetails = try SPXSpeechSynthesisCancellationDetails(fromCanceledSynthesisResult: result) - print("[AzureTtsHelper] 语音合成取消: 原因: \(cancellationDetails.reason)") - - if cancellationDetails.reason == SPXCancellationReason.error { - print("[AzureTtsHelper] 错误代码: \(cancellationDetails.errorCode)") - print("[AzureTtsHelper] 错误详情: \(cancellationDetails.errorDetails ?? "未知")") - } - - DispatchQueue.main.async { - self._isSpeaking = false - self.eventHandler("error", ["error": "语音合成取消: \(cancellationDetails.errorDetails ?? "未知错误")"]) - } - } catch { - print("[AzureTtsHelper] 获取取消详情时出错: \(error)") - - DispatchQueue.main.async { - self._isSpeaking = false - self.eventHandler("error", ["error": "语音合成被取消"]) - } - } - } - - // 合成开始事件 - synthesizer.addSynthesisStartedEventHandler { _, _ in - // print("[AzureTtsHelper] 语音合成开始") - } - - // 合成中事件 - synthesizer.addSynthesizingEventHandler { _, _ in - // print("[AzureTtsHelper] 语音合成中") - } - } - - /// 生成 SSML 文本 - private func generateSsml(text: String) -> String { - return """ - - - - \(text) - - - - """ - } - - /// 格式化语速参数 - private func formatRateParam(_ rate: Int) -> String { - let clampedRate = rate.clamp(min: -100, max: 100) - if clampedRate == 0 { - return "0%" - } else if clampedRate < 0 { - return "\(Int(Double(clampedRate) * 0.9))%" - } else { - return "+\(clampedRate)%" - } - } - - /// 格式化音调参数 - private func formatPitchParam(_ pitch: Int) -> String { - let clampedPitch = pitch.clamp(min: -100, max: 100) - if clampedPitch == 0 { - return "0%" - } else { - return "\(Int(Double(clampedPitch) * 0.5))%" - } - } - - /// 格式化音量参数 - private func formatVolumeParam(_ volume: Int) -> String { - let clampedVolume = volume.clamp(min: 0, max: 100) - return "\(clampedVolume)%" - } - - /// 获取指定语言的默认语音 - private func getDefaultVoiceForLanguage(_ language: String) -> String { - return voiceMap[language] ?? "zh-CN-XiaoxiaoNeural" - } -} - -// MARK: - 扩展 - -extension Int { - func clamp(min: Int, max: Int) -> Int { - if self < min { return min } - if self > max { return max } - return self - } -} \ No newline at end of file diff --git a/azure/ios/Classes/SwiftAzureSpeechRecognitionPlugin.swift b/azure/ios/Classes/SwiftAzureSpeechRecognitionPlugin.swift deleted file mode 100644 index ea393e215..000000000 --- a/azure/ios/Classes/SwiftAzureSpeechRecognitionPlugin.swift +++ /dev/null @@ -1,259 +0,0 @@ -import Flutter -import UIKit -import MicrosoftCognitiveServicesSpeech -import AVFoundation - -@available(iOS 13.0, *) -public class SwiftAzureSpeechRecognitionPlugin: NSObject, FlutterPlugin { - private var azureChannel: FlutterMethodChannel - private var ttsChannel: FlutterMethodChannel - private var asrHelper: AzureAsrHelper - private var ttsHelper: AzureTtsHelper - private static var eventStreamHandler: AzureEventStreamHandler? - - // 创建方法到通道的映射 - private static var ttsMethodHandlers = [String: FlutterMethodCallHandler]() - private static var asrMethodHandlers = [String: FlutterMethodCallHandler]() - - public static func register(with registrar: FlutterPluginRegistrar) { - // ASR通道 - let channel = FlutterMethodChannel(name: "com.deep_voice.azure_asr", binaryMessenger: registrar.messenger()) - - // TTS通道 - let ttsChannel = FlutterMethodChannel(name: "com.deep_voice.azure_tts", binaryMessenger: registrar.messenger()) - - // 设置ASR事件通道 - let eventChannel = FlutterEventChannel(name: "com.deep_voice.azure_asr_events", binaryMessenger: registrar.messenger()) - eventStreamHandler = AzureEventStreamHandler() - eventChannel.setStreamHandler(eventStreamHandler) - - let instance = SwiftAzureSpeechRecognitionPlugin( - azureChannel: channel, - ttsChannel: ttsChannel, - eventStreamHandler: eventStreamHandler! - ) - - // 直接设置各自通道的处理器 - channel.setMethodCallHandler(instance.handleAsrMethodCalls) - ttsChannel.setMethodCallHandler(instance.handleTtsMethodCalls) - } - - - - // 新增直接处理方法调用的函数 - private func handleTtsMethodCalls(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - handleTtsMethod(call, result) - } - - private func handleAsrMethodCalls(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - handleAsrMethod(call, result) - } - - - init(azureChannel: FlutterMethodChannel, ttsChannel: FlutterMethodChannel, eventStreamHandler: AzureEventStreamHandler) { - self.azureChannel = azureChannel - self.ttsChannel = ttsChannel - - // 创建辅助类实例,使用自定义事件回调处理器 - let eventHandler: (String, [String: Any]) -> Void = { eventName, arguments in - DispatchQueue.main.async { - if let eventSink = SwiftAzureSpeechRecognitionPlugin.eventStreamHandler?.eventSink { - var eventData = arguments - eventData["type"] = eventName - eventSink(eventData) - } - } - } - - asrHelper = AzureAsrHelper(eventHandler: eventHandler) - ttsHelper = AzureTtsHelper(eventHandler: eventHandler) - - super.init() - } - - private func handleAsrMethod(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { - - let args = call.arguments as? Dictionary - - switch call.method { - case "initialize": - // 仅在初始化时读取必要参数 - guard let speechSubscriptionKey = args?["subscriptionKey"] as? String, !speechSubscriptionKey.isEmpty else { - let errorMsg = "语音订阅密钥不能为空" - print("[AzurePlugin] 错误: \(errorMsg)") - result(FlutterError(code: "INVALID_SUBSCRIPTION_KEY", message: errorMsg, details: nil)) - return - } - - guard let serviceRegion = args?["region"] as? String, !serviceRegion.isEmpty else { - let errorMsg = "服务区域不能为空" - print("[AzurePlugin] 错误: \(errorMsg)") - result(FlutterError(code: "INVALID_REGION", message: errorMsg, details: nil)) - return - } - - let supportedLanguages = args?["supportedLanguages"] as? [String] ?? [] - - let success = asrHelper.initialize( - speechSubscriptionKey: speechSubscriptionKey, - serviceRegion: serviceRegion, - supportedLanguages: supportedLanguages.isEmpty ? nil : supportedLanguages - ) - result(success) - - case "startContinuousRecognition": - // 只有使用参数时才验证 - let success = asrHelper.startContinuousRecognition() - result(success) - - case "stopContinuousRecognition": - // 不需要额外参数 - let success = asrHelper.stopContinuousRecognition() - result(success) - - case "recognizeOnce": - // 只有使用参数时才验证 - let success = asrHelper.recognizeOnce() - result(success) - - case "isContinuousRecognitionActive": - // 不需要额外参数 - result(asrHelper.isContinuousRecognitionActive()) - - case "dispose": - // 不需要额外参数 - print("[AzurePlugin] 释放ASR资源") - asrHelper.dispose() - result(true) - - default: - print("[AzurePlugin] 错误: 未知ASR方法: \(call.method)") - result(FlutterMethodNotImplemented) - } - } - - private func handleTtsMethod(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { - - let args = call.arguments as? Dictionary - - switch call.method { - case "initialize": - // 仅在初始化时验证参数 - guard let speechSubscriptionKey = args?["subscriptionKey"] as? String, !speechSubscriptionKey.isEmpty else { - let errorMsg = "语音订阅密钥不能为空" - print("[AzurePlugin] 错误: \(errorMsg)") - result(FlutterError(code: "INVALID_SUBSCRIPTION_KEY", message: errorMsg, details: nil)) - return - } - - guard let serviceRegion = args?["region"] as? String, !serviceRegion.isEmpty else { - let errorMsg = "服务区域不能为空" - print("[AzurePlugin] 错误: \(errorMsg)") - result(FlutterError(code: "INVALID_REGION", message: errorMsg, details: nil)) - return - } - - let language = args?["language"] as? String ?? "zh-CN" - - print("[AzurePlugin] 初始化TTS,语言: \(language)") - - let success = ttsHelper.initialize(speechSubscriptionKey: speechSubscriptionKey, serviceRegion: serviceRegion, language: language) - result(success) - - case "setVoice": - // 仅获取voice参数 - guard let voiceName = args?["voiceName"] as? String, !voiceName.isEmpty else { - let errorMsg = "声音名称不能为空" - print("[AzurePlugin] 错误: \(errorMsg)") - result(FlutterError(code: "INVALID_VOICE", message: errorMsg, details: nil)) - return - } - - print("[AzurePlugin] 设置声音: \(voiceName)") - - let success = ttsHelper.setVoice(voiceName: voiceName) - result(success) - - case "speakText": - // 仅获取text参数 - let text = args?["text"] as? String ?? "" - - if text.isEmpty { - print("[AzurePlugin] 警告: 要播放的文本为空") - result("OK") - return - } - - print("[AzurePlugin] 播放文本: \(text.prefix(50))...") - - let success = ttsHelper.speakText(text: text) - result(success ? "OK" : "ERROR") - - case "speakSsml": - // 仅获取ssml参数 - guard let ssml = args?["ssml"] as? String, !ssml.isEmpty else { - let errorMsg = "SSML内容不能为空" - print("[AzurePlugin] 错误: \(errorMsg)") - result(FlutterError(code: "INVALID_SSML", message: errorMsg, details: nil)) - return - } - - print("[AzurePlugin] 播放SSML: \(ssml.prefix(100))...") - - // 由于我们移除了speakSsml方法,这里改用speakText方法 - // Azure SDK内部会自动检测是普通文本还是SSML - let success = ttsHelper.speakText(text: ssml) - result(success) - - case "stopSpeaking": - // 不需要参数 - print("[AzurePlugin] 停止播放") - let success = ttsHelper.stopSpeaking() - result(success) - - case "isSpeaking": - // 不需要参数 - result(ttsHelper.isSpeaking()) - - case "setSpeechParams": - // 仅获取语音参数 - let rate = args?["rate"] as? Int ?? 0 - let pitch = args?["pitch"] as? Int ?? 0 - let volume = args?["volume"] as? Int ?? 100 - - print("[AzurePlugin] 设置语音参数: rate=\(rate), pitch=\(pitch), volume=\(volume)") - let success = ttsHelper.setSpeechParams(rate: rate, pitch: pitch, volume: volume) - result(success) - - case "dispose": - // 释放TTS资源 - print("[AzurePlugin] 释放TTS资源") - ttsHelper.dispose() - result(true) - - default: - print("[AzurePlugin] 错误: 未知TTS方法: \(call.method)") - result(FlutterMethodNotImplemented) - } - } -} - -// 用于处理事件流的辅助类 -@available(iOS 13.0, *) -class AzureEventStreamHandler: NSObject, FlutterStreamHandler { - var eventSink: FlutterEventSink? - - func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { - self.eventSink = events - // 通知Flutter端事件通道已准备好 - DispatchQueue.main.async { - events(["type": "channelReady"]) - } - return nil - } - - func onCancel(withArguments arguments: Any?) -> FlutterError? { - self.eventSink = nil - return nil - } -} \ No newline at end of file diff --git a/azure/ios/azure_speech_recognition.podspec b/azure/ios/azure_speech_recognition.podspec deleted file mode 100644 index 88797fb0c..000000000 --- a/azure/ios/azure_speech_recognition.podspec +++ /dev/null @@ -1,24 +0,0 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. -# Run `pod lib lint azure_speech_recognition.podspec` to validate before publishing. -# -Pod::Spec.new do |s| - s.name = 'azure_speech_recognition' - s.version = '0.1.0' - s.summary = 'Azure Speech Recognition plugin for Flutter' - s.description = <<-DESC -A Flutter plugin for Microsoft Azure Speech services, providing both speech recognition (ASR) and text-to-speech (TTS) capabilities. - DESC - s.homepage = 'https://github.com/yourusername/azure_speech_recognition' - s.license = { :type => 'MIT', :file => '../LICENSE' } - s.author = { 'Your Company' => 'your-email@example.com' } - s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.dependency 'Flutter' - s.dependency 'MicrosoftCognitiveServicesSpeech-iOS', '~> 1.34.0' - 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/azure/lib/azure_speech_recognition.dart b/azure/lib/azure_speech_recognition.dart deleted file mode 100644 index 02b3f92d8..000000000 --- a/azure/lib/azure_speech_recognition.dart +++ /dev/null @@ -1,6 +0,0 @@ -// This is a placeholder file that exports nothing. -// The actual implementation is in the app's services folder. -// This file exists just to satisfy the Flutter plugin structure requirements. - -// Empty library to satisfy plugin structure -library azure_speech_recognition; \ No newline at end of file diff --git a/azure/pubspec.yaml b/azure/pubspec.yaml deleted file mode 100644 index a36c77bdc..000000000 --- a/azure/pubspec.yaml +++ /dev/null @@ -1,23 +0,0 @@ -name: azure_speech_recognition -description: Azure Speech Recognition and Text-to-Speech services Flutter plugin -version: 0.1.0 -homepage: https://github.com/yourusername/azure_speech_recognition - -environment: - sdk: '>=2.12.0 <3.0.0' - flutter: ">=2.0.0" - -dependencies: - flutter: - sdk: flutter - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^1.0.0 - -flutter: - plugin: - platforms: - ios: - pluginClass: AzureSpeechRecognitionPlugin \ No newline at end of file diff --git a/lib/data/services/speech_impl/azure_asr_service.dart b/lib/data/services/speech_impl/azure_asr_service.dart index ffb67ed46..e09defb66 100644 --- a/lib/data/services/speech_impl/azure_asr_service.dart +++ b/lib/data/services/speech_impl/azure_asr_service.dart @@ -10,8 +10,8 @@ import '../asr_service.dart'; /// 该服务提供了通过平台通道与 Android 上的 Microsoft Speech SDK 交互的接口 class AzureAsrService extends GetxService implements AsrService { static final AzureAsrService to = Get.put(AzureAsrService()); - static const MethodChannel _channel = MethodChannel('com.deep_voice.azure_asr'); - static const EventChannel _eventChannel = EventChannel('com.deep_voice.azure_asr_events'); + static const MethodChannel _channel = MethodChannel('azure_speech/asr'); + static const EventChannel _eventChannel = EventChannel('azure_speech/asr_events'); bool _isInitialized = false; late final String _subscriptionKey; diff --git a/lib/data/services/speech_impl/azure_tts_service.dart b/lib/data/services/speech_impl/azure_tts_service.dart index ae5ab9369..1cdb62507 100644 --- a/lib/data/services/speech_impl/azure_tts_service.dart +++ b/lib/data/services/speech_impl/azure_tts_service.dart @@ -12,7 +12,7 @@ import '../tts_service.dart'; /// 提供文本转语音功能。 class AzureTtsService extends GetxService implements TtsService { static final AzureTtsService to = Get.put(AzureTtsService()); - static const MethodChannel _channel = MethodChannel('com.deep_voice.azure_tts'); + static const MethodChannel _channel = MethodChannel('azure_speech/tts'); bool _isInitialized = false; late final String _subscriptionKey; diff --git a/lib/data/services/voice_interaction_service.dart b/lib/data/services/voice_interaction_service.dart index a1d26d509..1bb2406f5 100644 --- a/lib/data/services/voice_interaction_service.dart +++ b/lib/data/services/voice_interaction_service.dart @@ -38,7 +38,8 @@ class VoiceInteractionService extends GetxService { // 配置信息 late String _azureSpeechKey; late String _azureSpeechRegion; - late String _volcanoAiKey; + late String _openaiApiKey; + late String _openaiBaseUrl; // 聊天历史服务 late final ChatHistoryService _chatHistoryService; @@ -57,15 +58,13 @@ class VoiceInteractionService extends GetxService { void _loadConfig() { _azureSpeechKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; _azureSpeechRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? ''; - _volcanoAiKey = dotenv.env['VOLCANO_AI_API_KEY'] ?? ''; + _openaiApiKey = dotenv.env['OPENAI_API_KEY'] ?? ''; + _openaiBaseUrl = dotenv.env['OPENAI_BASE_URL'] ?? ''; if (_azureSpeechKey.isEmpty || _azureSpeechRegion.isEmpty) { Logger.warning('未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION'); } - if (_volcanoAiKey.isEmpty) { - Logger.warning('未找到火山 AI API 密钥。请在 .env 文件中设置 VOLCANO_AI_API_KEY'); - } } /// 处理来自原生层的事件 @@ -195,7 +194,8 @@ class VoiceInteractionService extends GetxService { final result = await _channel.invokeMethod('startService', { 'azure_speech_key': _azureSpeechKey, 'azure_speech_region': _azureSpeechRegion, - 'volcano_ai_api_key': _volcanoAiKey, + 'openai_api_key': _openaiApiKey, + 'openai_base_url': _openaiBaseUrl, }) ?? false; if (result) { diff --git a/local_plugins/azure_speech/README.md b/local_plugins/azure_speech/README.md new file mode 100644 index 000000000..067b344eb --- /dev/null +++ b/local_plugins/azure_speech/README.md @@ -0,0 +1,136 @@ +# Azure Speech 插件 + +本插件为Flutter提供了Azure语音服务的集成,包括: + +- 语音合成(TTS) +- 语音识别(ASR) + +## 功能 + +### 语音合成(TTS) + +- 支持多种语音(如中文、英文等) +- 语音参数调整(语速、音调、音量) +- 音频输出设备选择(扬声器、听筒、自动) +- SSML支持 + +### 语音识别(ASR) + +- 一次性语音识别 +- 连续语音识别 +- 自动语言检测 +- 音频处理优化(回音消除、噪声抑制等) + +## 平台支持 + +- Android +- iOS + +## 如何使用 + +### 初始化 + +```dart +import 'package:azure_speech/azure_speech.dart'; + +// 初始化TTS +await AzureSpeech.initializeTts( + 'your_subscription_key', + 'your_service_region', + language: 'zh-CN', +); + +// 初始化ASR +await AzureSpeech.initializeAsr( + 'your_subscription_key', + 'your_service_region', + ['zh-CN', 'en-US'], +); +``` + +### 语音合成 + +```dart +// 设置语音 +await AzureSpeech.setTtsVoice('zh-CN-XiaoxiaoNeural'); + +// 设置语音参数 +await AzureSpeech.setTtsSpeechParams( + rate: 0, // 语速 -100~100 + pitch: 0, // 音调 -100~100 + volume: 100, // 音量 0~100 +); + +// 设置音频输出设备 +await AzureSpeech.setTtsAudioOutputType('AUTO'); // 'SPEAKER', 'EARPIECE', 'AUTO' + +// 播放文本 +await AzureSpeech.speakText('你好,世界!'); + +// 停止播放 +await AzureSpeech.stopSpeaking(); + +// 检查是否正在播放 +bool isSpeaking = await AzureSpeech.isSpeaking(); +``` + +### 语音识别 + +```dart +// 一次性识别 +final result = await AzureSpeech.recognizeOnce(); +if (result['success']) { + print('识别文本: ${result['text']}'); + print('识别语言: ${result['language']}'); +} else { + print('识别失败: ${result['error']}'); +} + +// 连续识别 +// 监听识别结果 +AzureSpeech.asrResultStream.listen((event) { + switch (event['eventType']) { + case 'recognizing': + // 实时识别中的结果 + print('识别中: ${event['text']}'); + break; + case 'finalResult': + // 最终识别结果 + print('最终结果: ${event['text']}'); + break; + case 'error': + // 错误 + print('错误: ${event['error']}'); + break; + } +}); + +// 开始连续识别 +await AzureSpeech.startContinuousRecognition(); + +// 停止连续识别 +await AzureSpeech.stopContinuousRecognition(); + +// 检查连续识别是否活跃 +bool isActive = await AzureSpeech.isContinuousRecognitionActive(); +``` + +### 释放资源 + +```dart +// 释放所有资源 +await AzureSpeech.dispose(); +``` + +## 依赖项 + +本插件依赖于: + +- Microsoft Cognitive Services Speech SDK +- Flutter + +## 注意事项 + +- 使用前需要在Azure门户中创建语音服务资源,并获取订阅密钥和区域 +- Android需要相关权限:RECORD_AUDIO, INTERNET等 +- iOS需要在Info.plist中添加麦克风使用权限描述 \ No newline at end of file diff --git a/local_plugins/azure_speech/android/build.gradle.kts b/local_plugins/azure_speech/android/build.gradle.kts new file mode 100644 index 000000000..665d495d1 --- /dev/null +++ b/local_plugins/azure_speech/android/build.gradle.kts @@ -0,0 +1,65 @@ +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") +} + +// 配置android扩展 +configure { + namespace = "com.yunqiinnovation.azure_speech" + compileSdkVersion(33) + + defaultConfig { + minSdk = 21 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + sourceSets { + getByName("main") { + manifest.srcFile("src/main/AndroidManifest.xml") + java.srcDirs("src/main/kotlin") + } + } + + // 添加lint选项 + lintOptions { + isCheckReleaseBuilds = false + } +} + +// 显式设置Kotlin JVM目标版本 +tasks.withType { + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + // 直接通过本地依赖方式添加Flutter + implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) + // 添加Microsoft语音SDK + implementation("com.microsoft.cognitiveservices.speech:client-sdk:1.30.0") +} \ No newline at end of file diff --git a/local_plugins/azure_speech/android/settings.gradle.kts b/local_plugins/azure_speech/android/settings.gradle.kts new file mode 100644 index 000000000..14ba24107 --- /dev/null +++ b/local_plugins/azure_speech/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "azure_speech" diff --git a/local_plugins/azure_speech/android/src/main/AndroidManifest.xml b/local_plugins/azure_speech/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..df2a770c6 --- /dev/null +++ b/local_plugins/azure_speech/android/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt new file mode 100644 index 000000000..ac6697cdb --- /dev/null +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt @@ -0,0 +1,593 @@ +package com.yunqiinnovation.azure_speech + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import android.media.audiofx.AcousticEchoCanceler +import android.media.audiofx.NoiseSuppressor +import android.media.audiofx.AutomaticGainControl +import android.os.Process +import com.yunqiinnovation.azure_speech.utils.FileLogger +import com.microsoft.cognitiveservices.speech.* +import com.microsoft.cognitiveservices.speech.audio.* +import com.microsoft.cognitiveservices.speech.util.EventHandler +import java.util.concurrent.ExecutionException +import java.util.concurrent.atomic.AtomicBoolean + +class AzureAsrHelper(private val context: Context) { + private var recognizer: SpeechRecognizer? = null + private var speechConfig: SpeechConfig? = null + private val TAG = "AzureAsrHelper" + private var isContinuousRecognitionActive = false + private var currentLanguage = "zh-CN" + private var subscriptionKey = "" + private var region = "" + private var isAutoDetectLanguage = false + private var supportedLanguages = arrayOf("zh-CN", "en-US") + + // 是否使用回音消除 - 内部控制常量 + private val useEchoCancellation = false + + // 自定义音频处理相关 + private var customAudioProcessor: CustomAudioProcessor? = null + private var pushStream: PushAudioInputStream? = null + private var audioConfig: AudioConfig? = null + + // 初始化SDK并创建recognizer + fun initialize(subscriptionKey: String, region: String, + supportedLanguages: Array = arrayOf("zh-CN", "en-US")): Boolean { + try { + FileLogger.d(TAG, "初始化 Azure 语音服务") + + // 检查配置是否为空 + if (subscriptionKey.isEmpty() || region.isEmpty()) { + FileLogger.e(TAG, "Azure 配置信息不完整") + return false + } + + // 释放之前的资源 + dispose() + + this.subscriptionKey = subscriptionKey + this.region = region + + // 设置语言 + if (supportedLanguages.isNotEmpty()) { + this.supportedLanguages = supportedLanguages + } + + // 根据支持的语言数量决定是否启用自动语言检测 + this.isAutoDetectLanguage = supportedLanguages.size >= 2 + + // 如果只有一种语言,设置为当前语言 + if (!isAutoDetectLanguage && supportedLanguages.isNotEmpty()) { + this.currentLanguage = supportedLanguages[0] + } + + // 创建语音配置 + speechConfig = SpeechConfig.fromSubscription(subscriptionKey, region) + + // 设置语言配置 + if (isAutoDetectLanguage) { + // 设置自动语言检测 + speechConfig?.setProperty(PropertyId.SpeechServiceConnection_LanguageIdMode, "Continuous") + } else { + // 设置指定的识别语言 + speechConfig?.speechRecognitionLanguage = currentLanguage + } + + // 创建识别器 + try { + if (useEchoCancellation) { + // 如果使用回音消除,创建自定义音频输入流 + setupCustomAudioProcessing() + + if (isAutoDetectLanguage) { + val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) + recognizer = SpeechRecognizer(speechConfig, autoDetectConfig, audioConfig) + } else { + recognizer = SpeechRecognizer(speechConfig, audioConfig) + } + } else { + // 使用默认麦克风输入 + if (isAutoDetectLanguage) { + val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) + recognizer = SpeechRecognizer(speechConfig, autoDetectConfig) + } else { + recognizer = SpeechRecognizer(speechConfig) + } + } + + FileLogger.d(TAG, "Azure 语音服务初始化成功") + return true + } catch (e: Exception) { + FileLogger.e(TAG, "创建识别器失败: ${e.message}") + stopCustomAudioProcessing() + return false + } + } catch (e: Exception) { + FileLogger.e(TAG, "初始化失败: ${e.message}") + return false + } + } + + // 重置 recognizer + private fun resetRecognizer(): Boolean { + try { + // 释放之前的 recognizer + recognizer?.close() + recognizer = null + + // 停止当前的音频处理 + stopCustomAudioProcessing() + + // 使用现有配置重新创建 recognizer + if (speechConfig != null) { + if (useEchoCancellation) { + // 如果使用回音消除,创建自定义音频输入流 + setupCustomAudioProcessing() + + if (isAutoDetectLanguage) { + val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) + recognizer = SpeechRecognizer(speechConfig, autoDetectConfig, audioConfig) + } else { + recognizer = SpeechRecognizer(speechConfig, audioConfig) + } + } else { + // 使用默认麦克风输入 + if (isAutoDetectLanguage) { + val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) + recognizer = SpeechRecognizer(speechConfig, autoDetectConfig) + } else { + recognizer = SpeechRecognizer(speechConfig) + } + } + return true + } else { + FileLogger.e(TAG, "语音配置未初始化") + return false + } + } catch (e: Exception) { + FileLogger.e(TAG, "重置识别器失败: ${e.message}") + return false + } + } + + // 开始一次性语音识别 + fun recognizeOnce(callback: RecognizeCallback) { + if (speechConfig == null) { + callback.onError("语音服务未初始化") + return + } + + // 重置 recognizer + if (!resetRecognizer()) { + callback.onError("重置识别器失败") + return + } + + try { + // 启动音频处理 + startCustomAudioProcessing() + + // 执行识别 + val result = recognizer?.recognizeOnceAsync()?.get() + + // 停止音频处理 + stopCustomAudioProcessing() + + if (result != null && result.reason == ResultReason.RecognizedSpeech) { + val detectedLanguage = AutoDetectSourceLanguageResult.fromResult(result)?.language + callback.onResult(result.text, detectedLanguage ?: "") + } else { + callback.onError("未能识别语音") + } + } catch (e: Exception) { + // 停止音频处理 + stopCustomAudioProcessing() + callback.onError("识别异常: ${e.message}") + } + } + + // 开始连续语音识别 + fun startContinuousRecognition(callback: ContinuousRecognizeCallback): Boolean { + if (speechConfig == null) { + callback.onError("语音服务未初始化") + return false + } + + if (isContinuousRecognitionActive) { + FileLogger.w(TAG, "已在进行连续识别,忽略请求") + return true + } + + // 重置 recognizer + if (!resetRecognizer()) { + callback.onError("重置识别器失败") + return false + } + + try { + // 启动音频处理 + startCustomAudioProcessing() + + // 识别中事件 + recognizer?.recognizing?.addEventListener( + EventHandler { _, event -> + val detectedLanguage = AutoDetectSourceLanguageResult.fromResult(event.result)?.language + FileLogger.d(TAG, "识别中: ${event.result.text}, 语言: $detectedLanguage") + callback.onRecognizing(event.result.text, detectedLanguage ?: "") + } + ) + + // 识别完成事件 + recognizer?.recognized?.addEventListener( + EventHandler { _, event -> + if (event.result.reason == ResultReason.RecognizedSpeech) { + val detectedLanguage = AutoDetectSourceLanguageResult.fromResult(event.result)?.language + FileLogger.d(TAG, "识别完成: ${event.result.text}, 语言: $detectedLanguage") + callback.onResult(event.result.text, detectedLanguage ?: "") + } + } + ) + + // 会话开始事件 + recognizer?.sessionStarted?.addEventListener( + EventHandler { _, _ -> + FileLogger.d(TAG, "识别会话已开始") + callback.onSessionStarted() + callback.onSuccess("开始识别") // 兼容旧接口 + } + ) + + // 会话结束事件 + recognizer?.sessionStopped?.addEventListener( + EventHandler { _, _ -> + FileLogger.d(TAG, "识别会话已结束") + isContinuousRecognitionActive = false + stopCustomAudioProcessing() + callback.onSessionStopped() + } + ) + + // 取消事件 + recognizer?.canceled?.addEventListener( + EventHandler { _, event -> + val errorDetails = try { + event.errorDetails ?: "未知错误" + } catch (e: Exception) { + "未知错误" + } + val reason = event.reason.toString() + FileLogger.e(TAG, "识别取消: $errorDetails") + isContinuousRecognitionActive = false + stopCustomAudioProcessing() + callback.onCanceled(reason, errorDetails) + callback.onError("识别取消: $errorDetails") // 兼容旧接口 + } + ) + + // 开始连续识别 + recognizer?.startContinuousRecognitionAsync() + isContinuousRecognitionActive = true + FileLogger.d(TAG, "连续识别已启动") + + return true + } catch (e: Exception) { + // 停止音频处理 + stopCustomAudioProcessing() + FileLogger.e(TAG, "开始连续识别失败: ${e.message}") + e.printStackTrace() + callback.onError("开始连续识别失败: ${e.message}") + return false + } + } + + // 停止连续语音识别 + fun stopContinuousRecognition(callback: ContinuousRecognizeCallback): Boolean { + if (speechConfig == null) { + callback.onError("语音服务未初始化") + return false + } + + if (!isContinuousRecognitionActive) { + FileLogger.d(TAG, "未进行连续识别,忽略停止请求") + return true + } + + try { + FileLogger.d(TAG, "停止连续语音识别") + + if (recognizer == null) { + if (isContinuousRecognitionActive) { + FileLogger.w(TAG, "识别器为空,但状态显示活跃") + } + isContinuousRecognitionActive = false + return true + } + + // 停止连续识别 + val future = recognizer?.stopContinuousRecognitionAsync() + future?.get() + + // 停止音频处理 + stopCustomAudioProcessing() + + isContinuousRecognitionActive = false + FileLogger.d(TAG, "连续识别已停止") + callback.onSuccess("连续识别已停止") + + return true + } catch (e: Exception) { + // 强制重置状态 + isContinuousRecognitionActive = false + FileLogger.e(TAG, "停止连续识别失败: ${e.message}") + e.printStackTrace() + callback.onError("停止连续识别失败: ${e.message}") + + // 停止音频处理 + stopCustomAudioProcessing() + + // 尝试强制关闭识别器 + try { + recognizer?.close() + recognizer = null + } catch (ex: Exception) { + FileLogger.e(TAG, "关闭识别器失败: ${ex.message}") + } + + return false + } + } + + // 释放资源 + fun dispose() { + try { + // 如果正在进行连续识别,先停止 + if (isContinuousRecognitionActive) { + recognizer?.stopContinuousRecognitionAsync()?.get() + isContinuousRecognitionActive = false + } + + // 停止音频处理 + stopCustomAudioProcessing() + + // 释放recognizer + recognizer?.close() + recognizer = null + + // 释放speechConfig + speechConfig?.close() + speechConfig = null + + FileLogger.d(TAG, "资源已释放") + } catch (e: Exception) { + FileLogger.e(TAG, "释放资源失败: ${e.message}") + + // 确保状态被重置 + isContinuousRecognitionActive = false + customAudioProcessor = null + pushStream = null + audioConfig = null + recognizer = null + speechConfig = null + } + } + + // 检查连续识别是否处于活跃状态 + fun isContinuousRecognitionActive(): Boolean { + return isContinuousRecognitionActive + } + + // 设置自定义音频处理 + private fun setupCustomAudioProcessing() { + try { + // 创建音频推送流 + pushStream = PushAudioInputStream.create() + + // 创建音频配置 + audioConfig = AudioConfig.fromStreamInput(pushStream) + + // 创建自定义音频处理器 + customAudioProcessor = CustomAudioProcessor(pushStream!!) + } catch (e: Exception) { + FileLogger.e(TAG, "设置自定义音频处理失败: ${e.message}") + e.printStackTrace() + } + } + + // 启动自定义音频处理 + private fun startCustomAudioProcessing() { + if (useEchoCancellation && customAudioProcessor != null) { + try { + customAudioProcessor?.startProcessing() + } catch (e: Exception) { + FileLogger.e(TAG, "启动音频处理器失败") + e.printStackTrace() + } + } + } + + // 停止自定义音频处理 + private fun stopCustomAudioProcessing() { + if (customAudioProcessor != null) { + try { + customAudioProcessor?.stopProcessing() + customAudioProcessor = null + } catch (e: Exception) { + FileLogger.e(TAG, "停止音频处理器失败: ${e.message}") + e.printStackTrace() + } + } + } + + // 修改认证取消事件处理代码 + private fun setupCancelledEventHandler(callback: ContinuousRecognizeCallback) { + recognizer?.canceled?.addEventListener( + EventHandler { _, event -> + val errorDetails = try { + event.errorDetails ?: "未知错误" + } catch (e: Exception) { + "未知错误" + } + val reason = event.reason.toString() + FileLogger.e(TAG, "识别取消: $errorDetails") + isContinuousRecognitionActive = false + stopCustomAudioProcessing() + callback.onCanceled(reason, errorDetails) + callback.onError("识别取消: $errorDetails") // 兼容旧接口 + } + ) + } + + // 自定义音频处理器 + private inner class CustomAudioProcessor(private val pushStream: PushAudioInputStream) { + private val isProcessing = AtomicBoolean(false) + private var audioRecord: AudioRecord? = null + private var echoCanceler: AcousticEchoCanceler? = null + private var noiseSuppressor: NoiseSuppressor? = null + private var automaticGainControl: AutomaticGainControl? = null + + // 音频配置 + private val sampleRate = 16000 // 16kHz,适合语音识别 + private val channelConfig = AudioFormat.CHANNEL_IN_MONO + private val audioFormat = AudioFormat.ENCODING_PCM_16BIT + + // 计算最小 buffer 大小 + private val bufferSize = AudioRecord.getMinBufferSize( + sampleRate, channelConfig, audioFormat + ) + + // 启动音频处理 + fun startProcessing() { + if (isProcessing.get()) return + + // 创建录音对象 + try { + audioRecord = AudioRecord( + MediaRecorder.AudioSource.VOICE_RECOGNITION, + sampleRate, + channelConfig, + audioFormat, + bufferSize * 2 // 使用更大的缓冲区以确保不会丢失数据 + ) + + // 创建音频处理效果 + if (AcousticEchoCanceler.isAvailable()) { + echoCanceler = AcousticEchoCanceler.create(audioRecord!!.audioSessionId) + echoCanceler?.enabled = true + } + + if (NoiseSuppressor.isAvailable()) { + noiseSuppressor = NoiseSuppressor.create(audioRecord!!.audioSessionId) + noiseSuppressor?.enabled = true + } + + if (AutomaticGainControl.isAvailable()) { + automaticGainControl = AutomaticGainControl.create(audioRecord!!.audioSessionId) + automaticGainControl?.enabled = true + } + + // 开始录音 + audioRecord?.startRecording() + + // 处理线程 + Thread { + android.os.Process.setThreadPriority(Process.THREAD_PRIORITY_AUDIO) + processAudio() + }.start() + + isProcessing.set(true) + FileLogger.d(TAG, "音频处理已启动") + } catch (e: Exception) { + FileLogger.e(TAG, "创建音频处理器失败: ${e.message}") + releaseAudioResources() + throw e + } + } + + // 停止音频处理 + fun stopProcessing() { + if (!isProcessing.get()) return + + isProcessing.set(false) + releaseAudioResources() + FileLogger.d(TAG, "音频处理已停止") + } + + // 释放音频资源 + private fun releaseAudioResources() { + try { + audioRecord?.stop() + + echoCanceler?.release() + echoCanceler = null + + noiseSuppressor?.release() + noiseSuppressor = null + + automaticGainControl?.release() + automaticGainControl = null + + audioRecord?.release() + audioRecord = null + } catch (e: Exception) { + FileLogger.e(TAG, "释放音频资源失败: ${e.message}") + } + } + + // 音频处理线程 + private fun processAudio() { + // 设置线程优先级 + try { + Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO) + } catch (e: Exception) { + FileLogger.e(TAG, "设置线程优先级失败") + } + + val buffer = ByteArray(bufferSize) + + while (isProcessing.get()) { + try { + val readSize = audioRecord?.read(buffer, 0, buffer.size) ?: -1 + + if (readSize > 0) { + // 修复: 只传入buffer,不传readSize + // 创建新的byte数组,只包含读取到的数据 + val audioData = buffer.copyOfRange(0, readSize) + pushStream.write(audioData) + } + + // 适当休眠,避免占用过多 CPU + Thread.sleep(5) + } catch (e: Exception) { + if (isProcessing.get()) { + FileLogger.e(TAG, "处理音频数据异常: ${e.message}") + } + break + } + } + } + } + + // 一次性识别回调接口 + interface RecognizeCallback { + fun onResult(text: String, detectedLanguage: String) + fun onError(error: String) + } + + // 连续识别回调接口 + interface ContinuousRecognizeCallback { + fun onResult(text: String, detectedLanguage: String) + fun onRecognizing(recognizing: String, detectedLanguage: String) + fun onSessionStarted() + fun onSessionStopped() + fun onCanceled(reason: String, errorDetails: String) + fun onError(error: String) + + // 兼容旧版本的接口 + fun onSuccess(message: String) {} + } +} \ No newline at end of file diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt new file mode 100644 index 000000000..090a72efc --- /dev/null +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt @@ -0,0 +1,297 @@ +package com.yunqiinnovation.azure_speech + +import android.content.Context +import android.app.Activity +import android.os.Handler +import android.os.Looper +import androidx.annotation.NonNull +import com.yunqiinnovation.azure_speech.utils.FileLogger + +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 io.flutter.plugin.common.EventChannel + +/** AzureSpeechPlugin */ +class AzureSpeechPlugin: FlutterPlugin { + private val TAG = "AzureSpeechPlugin" + private lateinit var context: Context + private val mainHandler = Handler(Looper.getMainLooper()) + + // ASR相关 + private lateinit var asrChannel : MethodChannel + private lateinit var asrEventChannel: EventChannel + private var asrEventSink: EventChannel.EventSink? = null + private lateinit var azureAsrHelper: AzureAsrHelper + + // TTS相关 + private lateinit var ttsChannel : MethodChannel + private lateinit var azureTtsHelper: AzureTtsHelper + + // ASR 事件发送方法 + private fun sendAsrEvent(event: Map) { + FileLogger.d(TAG, "发送ASR事件: $event") + if (asrEventSink == null) { + FileLogger.w(TAG, "无法发送ASR事件:事件通道未准备好") + return + } + + mainHandler.post { + try { + asrEventSink?.success(event) + FileLogger.d(TAG, "ASR事件发送成功") + } catch (e: Exception) { + FileLogger.e(TAG, "发送ASR事件失败: ${e.message}") + } + } + } + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + context = flutterPluginBinding.applicationContext + + // 初始化ASR通道 + asrChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "azure_speech/asr") + asrChannel.setMethodCallHandler(AsrMethodHandler()) + + // 初始化TTS通道 + ttsChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "azure_speech/tts") + ttsChannel.setMethodCallHandler(TtsMethodHandler()) + + // 初始化ASR事件通道 + asrEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "azure_speech/asr_events") + asrEventChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + asrEventSink = events + } + + override fun onCancel(arguments: Any?) { + asrEventSink = null + } + }) + + // 初始化Azure语音服务 + azureTtsHelper = AzureTtsHelper(context) + azureAsrHelper = AzureAsrHelper(context) + } + + // ASR方法处理器 + inner class AsrMethodHandler : MethodCallHandler { + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "initialize" -> { + val subscriptionKey = call.argument("subscriptionKey") ?: "" + val region = call.argument("region") ?: "" + val supportedLanguages = call.argument>("supportedLanguages") ?: listOf("zh-CN") + + try { + val success = azureAsrHelper.initialize(subscriptionKey, region, supportedLanguages.toTypedArray()) + result.success(success) + } catch (e: Exception) { + result.error("INITIALIZATION_ERROR", e.message, null) + } + } + "recognizeOnce" -> { + azureAsrHelper.recognizeOnce(object : AzureAsrHelper.RecognizeCallback { + override fun onResult(text: String, detectedLanguage: String) { + mainHandler.post { + result.success(mapOf( + "text" to text, + "detectedLanguage" to detectedLanguage + )) + } + } + + override fun onError(error: String) { + mainHandler.post { + result.error("RECOGNITION_ERROR", error, null) + } + } + }) + } + "startContinuousRecognition" -> { + // 确保事件通道已准备好 + if (asrEventSink == null) { + result.error("EVENT_CHANNEL_NOT_READY", "事件通道未准备好,无法开始连续识别", null) + return + } + + val success = azureAsrHelper.startContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { + override fun onResult(text: String, detectedLanguage: String) { + sendAsrEvent(mapOf( + "type" to "result", + "text" to text, + "detectedLanguage" to detectedLanguage + )) + } + + override fun onRecognizing(recognizing: String, detectedLanguage: String) { + sendAsrEvent(mapOf( + "type" to "recognizing", + "text" to recognizing, + "detectedLanguage" to detectedLanguage + )) + } + + override fun onSessionStarted() { + sendAsrEvent(mapOf("type" to "sessionStarted")) + } + + override fun onSessionStopped() { + sendAsrEvent(mapOf("type" to "sessionStopped")) + } + + override fun onCanceled(reason: String, errorDetails: String) { + sendAsrEvent(mapOf( + "type" to "canceled", + "reason" to reason, + "errorDetails" to errorDetails + )) + } + + override fun onError(error: String) { + sendAsrEvent(mapOf("type" to "error", "message" to error)) + } + + override fun onSuccess(message: String) { + sendAsrEvent(mapOf("type" to "success", "message" to message)) + } + }) + result.success(success) + } + "stopContinuousRecognition" -> { + try { + if (!azureAsrHelper.isContinuousRecognitionActive()) { + result.success(true) + return + } + + val success = azureAsrHelper.stopContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { + override fun onResult(text: String, detectedLanguage: String) {} + override fun onRecognizing(recognizing: String, detectedLanguage: String) {} + override fun onSessionStarted() {} + override fun onSessionStopped() {} + override fun onCanceled(reason: String, errorDetails: String) {} + override fun onError(error: String) { + mainHandler.post { + result.error("STOP_ERROR", error, null) + } + } + override fun onSuccess(message: String) {} + }) + result.success(success) + } catch (e: Exception) { + result.error("STOP_ERROR", e.message, null) + } + } + "isContinuousRecognitionActive" -> { + result.success(azureAsrHelper.isContinuousRecognitionActive()) + } + "dispose" -> { + azureAsrHelper.dispose() + result.success(true) + } + else -> { + result.notImplemented() + } + } + } + } + + // TTS方法处理器 + inner class TtsMethodHandler : MethodCallHandler { + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "initialize" -> { + val subscriptionKey = call.argument("subscriptionKey") ?: "" + val region = call.argument("region") ?: "" + val language = call.argument("language") ?: "zh-CN" + + val success = azureTtsHelper.initialize(subscriptionKey, region, language) + result.success(success) + } + "setVoice" -> { + val voiceName = call.argument("voiceName") ?: return result.error("INVALID_ARGUMENTS", "语音名称不能为空", null) + result.success(azureTtsHelper.setVoice(voiceName)) + } + "setSpeechParams" -> { + val rate = call.argument("rate") ?: 0 + val pitch = call.argument("pitch") ?: 0 + val volume = call.argument("volume") ?: 100 + result.success(azureTtsHelper.setSpeechParams(rate, pitch, volume)) + } + "setAudioOutputType" -> { + val outputTypeStr = call.argument("outputType") ?: "speaker" + val outputType = when (outputTypeStr.lowercase()) { + "speaker" -> AzureTtsHelper.AudioOutputType.SPEAKER + "earpiece" -> AzureTtsHelper.AudioOutputType.EARPIECE + "auto" -> AzureTtsHelper.AudioOutputType.AUTO + else -> AzureTtsHelper.AudioOutputType.SPEAKER + } + result.success(azureTtsHelper.setAudioOutputType(outputType)) + } + "speakText" -> { + val text = call.argument("text") ?: return result.error("INVALID_ARGUMENTS", "文本不能为空", null) + + azureTtsHelper.speakText(text, object : AzureTtsHelper.TTSCallback { + override fun onSuccess(message: String) { + mainHandler.post { + result.success(true) + } + } + + override fun onError(error: String) { + mainHandler.post { + result.error("SPEAK_ERROR", error, null) + } + } + }) + } + "speakSsml" -> { + val ssml = call.argument("ssml") ?: return result.error("INVALID_ARGUMENTS", "SSML不能为空", null) + + azureTtsHelper.speakSsml(ssml, object : AzureTtsHelper.TTSCallback { + override fun onSuccess(message: String) { + mainHandler.post { + result.success(true) + } + } + + override fun onError(error: String) { + mainHandler.post { + result.error("SPEAK_ERROR", error, null) + } + } + }) + } + "stopSpeaking" -> { + result.success(azureTtsHelper.stopSpeaking()) + } + "isSpeaking" -> { + result.success(azureTtsHelper.isSpeaking()) + } + "dispose" -> { + azureTtsHelper.dispose() + result.success(true) + } + else -> { + result.notImplemented() + } + } + } + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + asrChannel.setMethodCallHandler(null) + ttsChannel.setMethodCallHandler(null) + asrEventChannel.setStreamHandler(null) + + try { + azureTtsHelper.dispose() + azureAsrHelper.dispose() + } catch (e: Exception) { + FileLogger.e(TAG, "Dispose resources error: ${e.message}") + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/example/deep_voice/AzureTtsHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt similarity index 98% rename from android/app/src/main/kotlin/com/example/deep_voice/AzureTtsHelper.kt rename to local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt index 31971c2a4..0ae180fa9 100644 --- a/android/app/src/main/kotlin/com/example/deep_voice/AzureTtsHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt @@ -1,11 +1,11 @@ -package com.yunqiinnovation.deepsound +package com.yunqiinnovation.azure_speech import android.content.Context import android.media.AudioAttributes import android.media.AudioDeviceInfo import android.media.AudioManager import android.os.Build -import com.yunqiinnovation.deepsound.core.utils.FileLogger +import com.yunqiinnovation.azure_speech.utils.FileLogger import com.microsoft.cognitiveservices.speech.* import com.microsoft.cognitiveservices.speech.audio.* import java.util.concurrent.Future @@ -50,16 +50,16 @@ class AzureTtsHelper(private val context: Context) { * 初始化 TTS 引擎 * * @param subscriptionKey Azure 语音服务订阅密钥 - * @param serviceRegion Azure 语音服务区域 + * @param region Azure 语音服务区域 * @param language 可选,默认语言,默认为 "zh-CN" */ - fun initialize(subscriptionKey: String, serviceRegion: String, language: String = "zh-CN"): Boolean { + fun initialize(subscriptionKey: String, region: String, language: String = "zh-CN"): Boolean { try { audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager // 创建语音配置 - speechConfig = SpeechConfig.fromSubscription(subscriptionKey, serviceRegion) + speechConfig = SpeechConfig.fromSubscription(subscriptionKey, region) // 设置语音合成输出格式为高质量音频 speechConfig?.setSpeechSynthesisOutputFormat(SpeechSynthesisOutputFormat.Riff24Khz16BitMonoPcm) @@ -96,7 +96,7 @@ class AzureTtsHelper(private val context: Context) { return true } catch (e: Exception) { - FileLogger.e(TAG, "TTS 引擎初始化失败: ${e.message}, ${serviceRegion}") + FileLogger.e(TAG, "TTS 引擎初始化失败: ${e.message}, ${region}") e.printStackTrace() return false } diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/utils/FileLogger.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/utils/FileLogger.kt new file mode 100644 index 000000000..d210ec48f --- /dev/null +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/utils/FileLogger.kt @@ -0,0 +1,45 @@ +package com.yunqiinnovation.azure_speech.utils + +import android.util.Log + +/** + * 简单的文件日志工具类 + */ +object FileLogger { + private const val TAG_PREFIX = "AzureSpeech_" + + /** + * 记录调试信息 + */ + fun d(tag: String, message: String) { + Log.d("$TAG_PREFIX$tag", message) + } + + /** + * 记录信息 + */ + fun i(tag: String, message: String) { + Log.i("$TAG_PREFIX$tag", message) + } + + /** + * 记录警告信息 + */ + fun w(tag: String, message: String) { + Log.w("$TAG_PREFIX$tag", message) + } + + /** + * 记录错误信息 + */ + fun e(tag: String, message: String) { + Log.e("$TAG_PREFIX$tag", message) + } + + /** + * 记录异常 + */ + fun e(tag: String, message: String, throwable: Throwable) { + Log.e("$TAG_PREFIX$tag", message, throwable) + } +} \ No newline at end of file diff --git a/local_plugins/azure_speech/ios/Classes/AzureAsrHelper.swift b/local_plugins/azure_speech/ios/Classes/AzureAsrHelper.swift new file mode 100644 index 000000000..d3dd8d0b8 --- /dev/null +++ b/local_plugins/azure_speech/ios/Classes/AzureAsrHelper.swift @@ -0,0 +1,461 @@ +import Foundation +import AVFoundation +import MicrosoftCognitiveServicesSpeech + +/// Azure 语音识别辅助类 +class AzureAsrHelper: NSObject { + private var recognizer: SPXSpeechRecognizer? + private var speechConfig: SPXSpeechConfig? + private var audioConfig: SPXAudioConfig? + private var initialized = false + private var isContinuousRecognitionActive = false + private var currentLanguage = "zh-CN" + private var subscriptionKey = "" + private var serviceRegion = "" + private var isAutoDetectLanguage = false + private var supportedLanguages = ["zh-CN", "en-US"] + + // 音频会话管理 + private let audioSession = AVAudioSession.sharedInstance() + + // 事件回调 + private var eventHandler: (([String: Any]) -> Void)? + + /// 设置事件处理器 + /// + /// - Parameter handler: 事件处理回调 + func setEventHandler(_ handler: @escaping ([String: Any]) -> Void) { + self.eventHandler = handler + } + + /// 初始化语音识别服务 + /// + /// - Parameters: + /// - speechSubscriptionKey: Azure 语音服务订阅密钥 + /// - serviceRegion: Azure 语音服务区域 + /// - supportedLanguages: 支持的语言列表,默认为 ["zh-CN", "en-US"] + /// - Returns: 是否初始化成功 + func initialize(speechSubscriptionKey: String, serviceRegion: String, supportedLanguages: [String] = ["zh-CN", "en-US"]) -> Bool { + print("[AzureAsrHelper] 初始化 Azure 语音服务") + + // 检查配置是否为空 + if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { + print("[AzureAsrHelper] 错误: Azure 配置信息不完整") + return false + } + + // 释放之前的资源 + dispose() + + // 保存配置 + self.subscriptionKey = speechSubscriptionKey + self.serviceRegion = serviceRegion + + // 设置语言 + if supportedLanguages.isEmpty { + print("[AzureAsrHelper] 警告: 传入的支持语言列表为空,将使用默认语言") + } else { + self.supportedLanguages = supportedLanguages + } + + // 根据支持的语言数量决定是否启用自动语言检测 + self.isAutoDetectLanguage = supportedLanguages.count >= 2 + + // 如果只有一种语言,设置为当前语言 + if !isAutoDetectLanguage && !supportedLanguages.isEmpty { + self.currentLanguage = supportedLanguages[0] + } + + // 创建语音配置 + do { + speechConfig = try SPXSpeechConfig(subscription: speechSubscriptionKey, region: serviceRegion) + + // 设置语言配置 + if isAutoDetectLanguage { + // 设置自动语言检测 + try speechConfig?.setPropertyTo("Continuous", byId: SPXPropertyId.SpeechServiceConnection_LanguageIdMode) + } else { + // 设置指定的识别语言 + speechConfig?.speechRecognitionLanguage = currentLanguage + } + + // 创建音频配置 - 使用默认麦克风 + audioConfig = SPXAudioConfig.default() + + // 创建识别器 + if isAutoDetectLanguage { + let autoDetectConfig = try SPXAutoDetectSourceLanguageConfiguration(sourceLanguages: supportedLanguages) + recognizer = try SPXSpeechRecognizer(speechConfiguration: speechConfig!, autoDetectSourceLanguageConfiguration: autoDetectConfig, audioConfiguration: audioConfig!) + } else { + recognizer = try SPXSpeechRecognizer(speechConfiguration: speechConfig!, audioConfiguration: audioConfig!) + } + + // 配置音频会话 + try configureAudioSession() + + initialized = true + print("[AzureAsrHelper] Azure 语音服务初始化成功") + return true + } catch { + print("[AzureAsrHelper] 错误: 创建识别器失败: \(error.localizedDescription)") + return false + } + } + + /// 配置音频会话 + private func configureAudioSession() throws { + print("[AzureAsrHelper] 开始配置音频会话...") + + do { + // 设置音频会话类别和模式 + try audioSession.setCategory(.record, mode: .measurement, options: [.duckOthers, .allowBluetooth]) + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + } catch { + print("[AzureAsrHelper] 警告: 通过AudioSessionManager配置音频会话失败") + throw error + } + } + + /// 一次性语音识别 + /// + /// - Parameter completion: 完成回调,返回是否成功、识别文本、识别语言和可能的错误信息 + func recognizeOnce(completion: @escaping (Bool, String?, String?, String?) -> Void) { + if !initialized { + completion(false, nil, nil, "语音服务未初始化") + return + } + + // 重置 recognizer + if !resetRecognizer() { + completion(false, nil, nil, "重置识别器失败") + return + } + + do { + // 激活音频会话 + try audioSession.setActive(true) + + // 添加识别事件处理 + recognizer?.addRecognizedEventHandler { [weak self] _, event in + guard let self = self else { return } + + if event.result.reason == .recognizedSpeech { + let detectedLanguage = self.getDetectedLanguage(from: event.result) + print("[AzureAsrHelper] 识别结果: \(event.result.text ?? ""), 语言: \(detectedLanguage)") + completion(true, event.result.text, detectedLanguage, nil) + } + } + + recognizer?.addRecognizingEventHandler { [weak self] _, event in + guard let self = self else { return } + + if event.result.reason == .recognizingSpeech { + let detectedLanguage = self.getDetectedLanguage(from: event.result) + // print("[AzureAsrHelper] 识别中: \(event.result.text ?? ""), 语言: \(detectedLanguage)") + } + } + + // 添加会话事件处理 + recognizer?.addSessionStartedEventHandler { _, _ in + print("[AzureAsrHelper] 识别会话已开始") + } + + recognizer?.addSessionStoppedEventHandler { _, _ in + print("[AzureAsrHelper] 识别会话已结束") + } + + // 添加取消事件处理 + recognizer?.addCanceledEventHandler { _, event in + if let cancellationDetails = try? SPXCancellationDetails(fromCanceledRecognitionResult: event.result) { + let errorDetails = cancellationDetails.errorDetails ?? "未知错误" + print("[AzureAsrHelper] 识别取消: \(errorDetails)") + completion(false, nil, nil, "识别取消: \(errorDetails)") + } + } + + // 执行识别 + let result = try recognizer?.recognizeOnceAsync().get() + + if result?.reason != .recognizedSpeech { + completion(false, nil, nil, "未能识别语音") + } + } catch { + completion(false, nil, nil, "识别异常: \(error.localizedDescription)") + } + } + + /// 重置识别器 + /// + /// - Returns: 是否重置成功 + private func resetRecognizer() -> Bool { + if !initialized { + print("[AzureAsrHelper] 错误: 语音服务未初始化") + return false + } + + // 检查配置是否为空 + if subscriptionKey.isEmpty || serviceRegion.isEmpty { + print("[AzureAsrHelper] 错误: Azure 配置信息不完整") + return false + } + + do { + // 释放之前的 recognizer + recognizer = nil + + // 创建音频配置 - 使用默认麦克风 + audioConfig = SPXAudioConfig.default() + + // 重新创建识别器 + if isAutoDetectLanguage { + let autoDetectConfig = try SPXAutoDetectSourceLanguageConfiguration(sourceLanguages: supportedLanguages) + recognizer = try SPXSpeechRecognizer(speechConfiguration: speechConfig!, autoDetectSourceLanguageConfiguration: autoDetectConfig, audioConfiguration: audioConfig!) + } else { + recognizer = try SPXSpeechRecognizer(speechConfiguration: speechConfig!, audioConfiguration: audioConfig!) + } + + return true + } catch { + print("[AzureAsrHelper] 错误: 重置识别器失败: \(error.localizedDescription)") + return false + } + } + + /// 获取检测到的语言 + /// + /// - Parameter result: 识别结果 + /// - Returns: 检测到的语言代码 + private func getDetectedLanguage(from result: SPXSpeechRecognitionResult) -> String { + if isAutoDetectLanguage { + do { + if let autoDetectResult = try SPXAutoDetectSourceLanguageResult(fromRecognitionResult: result) { + return autoDetectResult.language + } + return "" + } catch { + print("[AzureAsrHelper] 错误: 获取检测到的语言失败: \(error.localizedDescription)") + return "" + } + } else { + return currentLanguage + } + } + + /// 开始连续语音识别 + /// + /// - Returns: 是否成功启动连续识别 + func startContinuousRecognition() -> Bool { + if !initialized { + print("[AzureAsrHelper] 错误: 语音服务未初始化") + return false + } + + // 检查配置是否为空 + if subscriptionKey.isEmpty || serviceRegion.isEmpty { + print("[AzureAsrHelper] 错误: Azure 配置信息不完整") + return false + } + + // 如果已经在进行连续识别,直接返回 + if isContinuousRecognitionActive { + print("[AzureAsrHelper] 已经在进行连续识别中,忽略请求") + return true + } + + // 重置 recognizer + if !resetRecognizer() { + print("[AzureAsrHelper] 尝试重新创建识别器...") + return false + } + + do { + // 激活音频会话 + try audioSession.setActive(true) + + // 添加识别事件处理 + recognizer?.addRecognizedEventHandler { [weak self] _, event in + guard let self = self else { return } + + if event.result.reason == .recognizedSpeech { + let detectedLanguage = self.getDetectedLanguage(from: event.result) + let eventData: [String: Any] = [ + "eventType": "finalResult", + "text": event.result.text ?? "", + "language": detectedLanguage + ] + self.eventHandler?(eventData) + } + } + + // 识别中事件 + recognizer?.addRecognizingEventHandler { [weak self] _, event in + guard let self = self else { return } + + if event.result.reason == .recognizingSpeech { + let detectedLanguage = self.getDetectedLanguage(from: event.result) + let eventData: [String: Any] = [ + "eventType": "recognizing", + "text": event.result.text ?? "", + "language": detectedLanguage + ] + self.eventHandler?(eventData) + } + } + + // 会话事件 + recognizer?.addSessionStartedEventHandler { [weak self] _, _ in + guard let self = self else { return } + + let eventData: [String: Any] = [ + "eventType": "sessionStarted" + ] + self.eventHandler?(eventData) + } + + recognizer?.addSessionStoppedEventHandler { [weak self] _, _ in + guard let self = self else { return } + + self.isContinuousRecognitionActive = false + let eventData: [String: Any] = [ + "eventType": "sessionStopped" + ] + self.eventHandler?(eventData) + } + + // 取消事件 + recognizer?.addCanceledEventHandler { [weak self] _, event in + guard let self = self else { return } + + self.isContinuousRecognitionActive = false + var errorMessage = "未知错误" + + if let cancellationDetails = try? SPXCancellationDetails(fromCanceledRecognitionResult: event.result) { + errorMessage = cancellationDetails.errorDetails ?? "未知错误" + } + + let eventData: [String: Any] = [ + "eventType": "error", + "error": "识别取消: \(errorMessage)" + ] + self.eventHandler?(eventData) + } + + // 开始连续识别 + try recognizer?.startContinuousRecognition() + isContinuousRecognitionActive = true + print("[AzureAsrHelper] 连续识别已启动") + + return true + } catch { + print("[AzureAsrHelper] 错误: 开始连续识别失败: \(error.localizedDescription)") + return false + } + } + + /// 停止连续语音识别 + /// + /// - Returns: 是否成功停止连续识别 + func stopContinuousRecognition() -> Bool { + if !initialized { + print("[AzureAsrHelper] 错误: 语音服务未初始化") + return false + } + + if !isContinuousRecognitionActive { + print("[AzureAsrHelper] 未进行连续识别,忽略停止请求") + return true + } + + do { + print("[AzureAsrHelper] 停止连续语音识别") + + if recognizer == nil { + if isContinuousRecognitionActive { + print("[AzureAsrHelper] 警告: 识别器为空,但状态显示活跃") + } + isContinuousRecognitionActive = false + + // 通知停止成功 + let eventData: [String: Any] = [ + "eventType": "success", + "message": "连续识别已停止" + ] + eventHandler?(eventData) + return true + } + + // 停止连续识别 + try recognizer?.stopContinuousRecognition() + + // 延迟一点时间确保处理完成 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let self = self else { return } + + // 重置状态 + self.isContinuousRecognitionActive = false + + // 恢复音频会话 + do { + try self.audioSession.setActive(false, options: .notifyOthersOnDeactivation) + } catch { + // 忽略错误 + } + + print("[AzureAsrHelper] 连续识别已停止") + + // 通知停止成功 + let eventData: [String: Any] = [ + "eventType": "success", + "message": "连续识别已停止" + ] + self.eventHandler?(eventData) + } + + return true + } catch { + // 强制重置状态 + isContinuousRecognitionActive = false + print("[AzureAsrHelper] 警告: 停止连续识别失败: \(error.localizedDescription)") + + // 通知停止失败,但仍然视为处理完成 + let eventData: [String: Any] = [ + "eventType": "success", + "message": "连续识别已停止(但有错误)" + ] + eventHandler?(eventData) + + return false + } + } + + /// 释放资源 + func dispose() { + // 如果正在进行连续识别,先停止 + if isContinuousRecognitionActive { + _ = stopContinuousRecognition() + } + + // 恢复音频会话 + do { + try audioSession.setActive(false, options: .notifyOthersOnDeactivation) + } catch { + // 忽略错误 + } + + // 释放资源 + recognizer = nil + speechConfig = nil + audioConfig = nil + + initialized = false + isContinuousRecognitionActive = false + print("[AzureAsrHelper] 资源已释放") + } + + /// 检查连续识别是否处于活跃状态 + /// + /// - Returns: 是否正在进行连续识别 + func isContinuousRecognitionActive() -> Bool { + return isContinuousRecognitionActive + } +} \ No newline at end of file diff --git a/local_plugins/azure_speech/ios/Classes/AzureSpeechPlugin.swift b/local_plugins/azure_speech/ios/Classes/AzureSpeechPlugin.swift new file mode 100644 index 000000000..dcb2d57b8 --- /dev/null +++ b/local_plugins/azure_speech/ios/Classes/AzureSpeechPlugin.swift @@ -0,0 +1,182 @@ +import Flutter +import UIKit + +public class AzureSpeechPlugin: NSObject, FlutterPlugin { + private var ttsHelper: AzureTtsHelper? + private var asrHelper: AzureAsrHelper? + private var eventSink: FlutterEventSink? + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "azure_speech", binaryMessenger: registrar.messenger()) + let instance = AzureSpeechPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + + // 初始化事件通道 + let eventChannel = FlutterEventChannel(name: "azure_speech/asr_events", binaryMessenger: registrar.messenger()) + eventChannel.setStreamHandler(AsrStreamHandler(instance: instance)) + } + + override init() { + super.init() + ttsHelper = AzureTtsHelper() + asrHelper = AzureAsrHelper() + + // 设置ASR事件处理 + asrHelper?.setEventHandler { [weak self] event in + self?.handleAsrEvent(event) + } + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + // TTS相关方法 + case "initializeTts": + guard let args = call.arguments as? [String: Any], + let subscriptionKey = args["subscriptionKey"] as? String, + let serviceRegion = args["serviceRegion"] as? String else { + result(false) + return + } + + let language = args["language"] as? String ?? "zh-CN" + let success = ttsHelper?.initialize(speechSubscriptionKey: subscriptionKey, serviceRegion: serviceRegion, language: language) ?? false + result(success) + + case "setTtsVoice": + guard let args = call.arguments as? [String: Any], + let voiceName = args["voiceName"] as? String else { + result(false) + return + } + + let success = ttsHelper?.setVoice(voiceName: voiceName) ?? false + result(success) + + case "setTtsSpeechParams": + guard let args = call.arguments as? [String: Any], + let rate = args["rate"] as? Int, + let pitch = args["pitch"] as? Int, + let volume = args["volume"] as? Int else { + result(false) + return + } + + let success = ttsHelper?.setSpeechParams(rate: rate, pitch: pitch, volume: volume) ?? false + result(success) + + case "speakText": + guard let args = call.arguments as? [String: Any], + let text = args["text"] as? String else { + result(false) + return + } + + ttsHelper?.speakText(text: text) { success, _ in + result(success) + } + + case "stopSpeaking": + let success = ttsHelper?.stopSpeaking() ?? false + result(success) + + case "isSpeaking": + let speaking = ttsHelper?.isSpeaking() ?? false + result(speaking) + + case "setTtsAudioOutputType": + guard let args = call.arguments as? [String: Any], + let outputType = args["outputType"] as? String else { + result(false) + return + } + + var type: AzureTtsHelper.AudioOutputType = .auto + switch outputType.uppercased() { + case "SPEAKER": + type = .speaker + case "EARPIECE": + type = .earpiece + default: + type = .auto + } + + let success = ttsHelper?.setAudioOutputType(outputType: type) ?? false + result(success) + + // ASR相关方法 + case "initializeAsr": + guard let args = call.arguments as? [String: Any], + let subscriptionKey = args["subscriptionKey"] as? String, + let serviceRegion = args["serviceRegion"] as? String, + let supportedLanguages = args["supportedLanguages"] as? [String] else { + result(false) + return + } + + let success = asrHelper?.initialize(speechSubscriptionKey: subscriptionKey, serviceRegion: serviceRegion, supportedLanguages: supportedLanguages) ?? false + result(success) + + case "recognizeOnce": + asrHelper?.recognizeOnce { success, text, language, error in + var resultMap: [String: Any] = ["success": success] + if success { + resultMap["text"] = text + resultMap["language"] = language + } else { + resultMap["error"] = error + } + result(resultMap) + } + + case "startContinuousRecognition": + let success = asrHelper?.startContinuousRecognition() ?? false + result(success) + + case "stopContinuousRecognition": + let success = asrHelper?.stopContinuousRecognition() ?? false + result(success) + + case "isContinuousRecognitionActive": + let isActive = asrHelper?.isContinuousRecognitionActive() ?? false + result(isActive) + + case "dispose": + ttsHelper?.dispose() + asrHelper?.dispose() + result(nil) + + default: + result(FlutterMethodNotImplemented) + } + } + + // 设置事件接收器 + func setEventSink(_ sink: FlutterEventSink?) { + self.eventSink = sink + } + + // 处理ASR事件 + private func handleAsrEvent(_ event: [String: Any]) { + self.eventSink?(event) + } +} + +// ASR事件流处理器 +class AsrStreamHandler: NSObject, FlutterStreamHandler { + private weak var plugin: AzureSpeechPlugin? + + init(instance: AzureSpeechPlugin) { + self.plugin = instance + super.init() + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + plugin?.setEventSink(events) + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + plugin?.setEventSink(nil) + return nil + } +} \ No newline at end of file diff --git a/local_plugins/azure_speech/ios/Classes/AzureTtsHelper.swift b/local_plugins/azure_speech/ios/Classes/AzureTtsHelper.swift new file mode 100644 index 000000000..4242a9037 --- /dev/null +++ b/local_plugins/azure_speech/ios/Classes/AzureTtsHelper.swift @@ -0,0 +1,330 @@ +import Foundation +import AVFoundation +import MicrosoftCognitiveServicesSpeech + +/// Azure 语音合成辅助类 +class AzureTtsHelper: NSObject { + private var synthesizer: SPXSpeechSynthesizer? + private var speechConfig: SPXSpeechConfig? + private var audioConfig: SPXAudioConfig? + private var initialized = false + private var speaking = false + + // 音频输出类型 + enum AudioOutputType { + case speaker // 扬声器 + case earpiece // 听筒 + case auto // 自动选择 + } + + // 当前设置 + private var currentVoiceName = "zh-CN-XiaoxiaoNeural" + private var currentSpeechRate = 0 + private var currentPitch = 0 + private var currentVolume = 100 + private var currentAudioOutputType: AudioOutputType = .auto + + // 音频会话管理 + private let audioSession = AVAudioSession.sharedInstance() + + /// 初始化 TTS 引擎 + /// + /// - Parameters: + /// - speechSubscriptionKey: Azure 语音服务订阅密钥 + /// - serviceRegion: Azure 语音服务区域 + /// - language: 可选,默认语言,默认为 "zh-CN" + /// - Returns: 是否初始化成功 + func initialize(speechSubscriptionKey: String, serviceRegion: String, language: String = "zh-CN") -> Bool { + print("[AzureTtsHelper] 初始化 Azure 语音服务") + + // 检查配置是否为空 + if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { + print("[AzureTtsHelper] 错误: Azure 配置信息不完整") + return false + } + + // 释放之前的资源 + dispose() + + do { + // 创建语音配置 + speechConfig = try SPXSpeechConfig(subscription: speechSubscriptionKey, region: serviceRegion) + + // 设置语音合成输出格式为高质量音频 + speechConfig?.setSpeechSynthesisOutputFormat(.riff24Khz16BitMonoPcm) + + // 设置默认语言 + speechConfig?.setSpeechSynthesisLanguage(language) + + // 设置默认语音 + speechConfig?.setSpeechSynthesisVoiceName(currentVoiceName) + + // 创建音频配置 - 使用默认扬声器 + audioConfig = SPXAudioConfig.default() + + // 创建语音合成器 + synthesizer = try SPXSpeechSynthesizer(speechConfig: speechConfig!, audioConfig: audioConfig!) + + initialized = true + + // 设置默认音频输出类型为自动 + setAudioOutputType(outputType: .auto) + + print("[AzureTtsHelper] TTS 引擎初始化成功") + return true + } catch { + print("[AzureTtsHelper] TTS 引擎初始化失败: \(error.localizedDescription)") + return false + } + } + + /// 设置音频输出设备类型 + /// + /// - Parameter outputType: 音频输出设备类型 + /// - Returns: 是否设置成功 + func setAudioOutputType(outputType: AudioOutputType) -> Bool { + if !initialized { + print("[AzureTtsHelper] TTS 引擎尚未初始化") + return false + } + + do { + currentAudioOutputType = outputType + + switch outputType { + case .speaker: + // 使用扬声器 + try audioSession.setCategory(.playback, mode: .default) + try audioSession.overrideOutputAudioPort(.speaker) + print("[AzureTtsHelper] 已设置音频输出设备为扬声器") + + case .earpiece: + // 使用听筒 + try audioSession.setCategory(.playback, mode: .voiceChat) + try audioSession.overrideOutputAudioPort(.none) + print("[AzureTtsHelper] 已设置音频输出设备为听筒") + + case .auto: + // 检查是否有耳机连接 + let outputs = audioSession.currentRoute.outputs + let hasHeadphones = outputs.contains { output in + return output.portType == .headphones || output.portType == .bluetoothA2DP || output.portType == .bluetoothHFP + } + + if hasHeadphones { + // 有耳机,使用耳机 + try audioSession.setCategory(.playback, mode: .default) + try audioSession.overrideOutputAudioPort(.none) + print("[AzureTtsHelper] 已设置音频输出设备为耳机") + } else { + // 无耳机,使用听筒 + try audioSession.setCategory(.playback, mode: .voiceChat) + try audioSession.overrideOutputAudioPort(.none) + print("[AzureTtsHelper] 已设置音频输出设备为听筒") + } + } + + try audioSession.setActive(true) + return true + } catch { + print("[AzureTtsHelper] 设置音频输出设备失败: \(error.localizedDescription)") + return false + } + } + + /// 设置语音 + /// + /// - Parameter voiceName: 语音名称,例如 "zh-CN-XiaoxiaoNeural" + /// - Returns: 是否设置成功 + func setVoice(voiceName: String) -> Bool { + if !initialized { + print("[AzureTtsHelper] TTS 引擎尚未初始化") + return false + } + + if voiceName == currentVoiceName { + print("[AzureTtsHelper] 已设置语音: \(voiceName)") + return true + } + + do { + currentVoiceName = voiceName + speechConfig?.setSpeechSynthesisVoiceName(voiceName) + + // 重新创建合成器 + synthesizer = try SPXSpeechSynthesizer(speechConfig: speechConfig!, audioConfig: audioConfig!) + + print("[AzureTtsHelper] 已设置语音: \(voiceName)") + return true + } catch { + print("[AzureTtsHelper] 设置语音失败: \(error.localizedDescription)") + return false + } + } + + /// 设置语音合成参数 + /// + /// - Parameters: + /// - rate: 语速,范围 -100 到 100,默认为 0 + /// - pitch: 音调,范围 -100 到 100,默认为 0 + /// - volume: 音量,范围 0 到 100,默认为 100 + /// - Returns: 是否设置成功 + func setSpeechParams(rate: Int = 0, pitch: Int = 0, volume: Int = 100) -> Bool { + if !initialized { + print("[AzureTtsHelper] TTS 引擎尚未初始化") + return false + } + + currentSpeechRate = rate + currentPitch = pitch + currentVolume = volume + + print("[AzureTtsHelper] 已设置语音参数: 语速=\(rate), 音调=\(pitch), 音量=\(volume)") + return true + } + + /// 合成文本为语音并播放 + /// + /// - Parameters: + /// - text: 要合成的文本 + /// - completion: 完成回调,返回是否成功和可能的错误信息 + func speakText(text: String, completion: @escaping (Bool, String?) -> Void) { + if !initialized { + print("[AzureTtsHelper] TTS 引擎尚未初始化") + completion(false, "TTS 引擎尚未初始化") + return + } + + do { + print("[AzureTtsHelper] 开始合成文本: \(text)") + + // 生成 SSML + let ssml = generateSsml(text: text) + + // 使用 SSML 合成语音 + speakSsml(ssml: ssml, completion: completion) + } catch { + print("[AzureTtsHelper] 语音合成异常: \(error.localizedDescription)") + completion(false, "语音合成异常: \(error.localizedDescription)") + } + } + + /// 生成 SSML 文本 + /// + /// - Parameter text: 要转换的文本 + /// - Returns: SSML 格式的文本 + private func generateSsml(text: String) -> String { + // 计算 SSML 参数 + let rateParam = currentSpeechRate == 0 ? "0%" : (currentSpeechRate < 0 ? "\(Int(Double(currentSpeechRate) * 0.9))%" : "\(currentSpeechRate)%") + let pitchParam = currentPitch == 0 ? "0%" : "\(Int(Double(currentPitch) * 0.5))%" + let volumeParam = "\(min(max(currentVolume, 0), 100))%" + + return """ + + + + \(text) + + + + """ + } + + /// 合成 SSML 为语音并播放 + /// + /// - Parameters: + /// - ssml: SSML 格式的文本 + /// - completion: 完成回调,返回是否成功和可能的错误信息 + private func speakSsml(ssml: String, completion: @escaping (Bool, String?) -> Void) { + if !initialized { + print("[AzureTtsHelper] TTS 引擎尚未初始化") + completion(false, "TTS 引擎尚未初始化") + return + } + + do { + print("[AzureTtsHelper] 开始合成 SSML") + + // 标记为正在播放 + speaking = true + + // 激活音频会话 + try audioSession.setActive(true) + + // 异步合成语音 + let result = try synthesizer!.speakSsml(ssml) + + switch result.reason { + case .synthesizingAudioCompleted: + print("[AzureTtsHelper] 语音合成完成") + speaking = false + completion(true, "语音合成完成") + case .canceled: + if let cancelDetails = try? SPXSpeechSynthesisCancellationDetails(fromResult: result) { + print("[AzureTtsHelper] 语音合成取消: \(cancelDetails.errorDetails ?? "未知错误")") + speaking = false + completion(false, "语音合成取消: \(cancelDetails.errorDetails ?? "未知错误")") + } else { + print("[AzureTtsHelper] 语音合成取消") + speaking = false + completion(false, "语音合成取消") + } + default: + print("[AzureTtsHelper] 语音合成失败: \(result.reason)") + speaking = false + completion(false, "语音合成失败: \(result.reason)") + } + } catch { + print("[AzureTtsHelper] 语音合成异常: \(error.localizedDescription)") + speaking = false + completion(false, "语音合成异常: \(error.localizedDescription)") + } + } + + /// 停止当前语音合成 + /// + /// - Returns: 是否停止成功 + func stopSpeaking() -> Bool { + if !initialized { + print("[AzureTtsHelper] TTS 引擎尚未初始化") + return false + } + + do { + try synthesizer?.stopSpeaking() + speaking = false + print("[AzureTtsHelper] 已停止语音合成") + return true + } catch { + print("[AzureTtsHelper] 停止语音合成失败: \(error.localizedDescription)") + return false + } + } + + /// 释放资源 + func dispose() { + do { + stopSpeaking() + + // 恢复音频会话 + try audioSession.setActive(false, options: .notifyOthersOnDeactivation) + + synthesizer = nil + speechConfig = nil + audioConfig = nil + + initialized = false + speaking = false + print("[AzureTtsHelper] TTS 引擎已释放") + } catch { + print("[AzureTtsHelper] 释放 TTS 引擎失败: \(error.localizedDescription)") + } + } + + /// 检查当前是否正在播放语音 + /// + /// - Returns: 是否正在播放语音 + func isSpeaking() -> Bool { + return speaking + } +} \ No newline at end of file diff --git a/local_plugins/azure_speech/pubspec.yaml b/local_plugins/azure_speech/pubspec.yaml new file mode 100644 index 000000000..5935bbce2 --- /dev/null +++ b/local_plugins/azure_speech/pubspec.yaml @@ -0,0 +1,32 @@ +name: azure_speech +description: Azure语音服务插件,包含TTS和ASR服务 +version: 0.0.1 +homepage: + +environment: + sdk: ">=2.17.0 <3.0.0" + flutter: ">=2.5.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# 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.azure_speech + pluginClass: AzureSpeechPlugin + ios: + pluginClass: AzureSpeechPlugin + diff --git a/pubspec.yaml b/pubspec.yaml index e4b2f4e6e..3e1dd7c90 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,6 +60,8 @@ dependencies: dio: ^5.8.0+1 sqflite: ^2.4.2 path: ^1.9.1 + azure_speech: + path: local_plugins/azure_speech dev_dependencies: flutter_test: From a7a31369546b2d866074cc4bf3aa16b033632f11 Mon Sep 17 00:00:00 2001 From: wolfplus Date: Sat, 5 Apr 2025 21:29:31 +0100 Subject: [PATCH 02/23] add --- a | 18 + android/app/build.gradle.kts | 1 + .../yunqiinnovation/deepsound/MainActivity.kt | 10 +- .../deepsound/OpenAIService.kt | 606 ------------------ .../deepsound/VoiceFunctionHandler.kt | 2 +- .../deepsound/VoiceInteractionHandler.kt | 450 +++++++++++++ .../deepsound/VoiceInteractionService.kt | 438 ++----------- android/build.gradle.kts | 1 + android/settings.gradle.kts | 2 + .../services/voice_interaction_service.dart | 6 +- .../azure_speech/android/build.gradle.kts | 9 +- local_plugins/open_ai_service/README.md | 348 ++++++++++ .../open_ai_service/android/build.gradle.kts | 37 ++ .../android/settings.gradle.kts | 1 + .../android/src/main/AndroidManifest.xml | 11 + .../open_ai_service/OpenAIService.kt | 456 +++++++++++++ .../open_ai_service/OpenAIServicePlugin.kt | 322 ++++++++++ .../ios/Classes/OpenAIService.swift | 473 ++++++++++++++ .../ios/Classes/OpenAIServicePlugin.swift | 215 +++++++ .../open_ai_service/lib/open_ai_service.dart | 305 +++++++++ local_plugins/open_ai_service/pubspec.yaml | 26 + pubspec.yaml | 2 + 22 files changed, 2744 insertions(+), 995 deletions(-) create mode 100644 a delete mode 100644 android/app/src/main/kotlin/com/yunqiinnovation/deepsound/OpenAIService.kt create mode 100644 android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt create mode 100644 local_plugins/open_ai_service/README.md create mode 100644 local_plugins/open_ai_service/android/build.gradle.kts create mode 100644 local_plugins/open_ai_service/android/settings.gradle.kts create mode 100644 local_plugins/open_ai_service/android/src/main/AndroidManifest.xml create mode 100644 local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt create mode 100644 local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt create mode 100644 local_plugins/open_ai_service/ios/Classes/OpenAIService.swift create mode 100644 local_plugins/open_ai_service/ios/Classes/OpenAIServicePlugin.swift create mode 100644 local_plugins/open_ai_service/lib/open_ai_service.dart create mode 100644 local_plugins/open_ai_service/pubspec.yaml diff --git a/a b/a new file mode 100644 index 000000000..7133736ae --- /dev/null +++ b/a @@ -0,0 +1,18 @@ +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": "You are a helpful assistant." + }, + { + "role": "user", + "content": "Hello!" + } + ] +}' \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 78b816b75..b0a39cbd4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -92,6 +92,7 @@ dependencies { // 添加本地插件模块依赖 implementation(project(":azure_speech")) + implementation(project(":open_ai_service")) // 添加OkHttp依赖 implementation("com.squareup.okhttp3:okhttp:4.9.3") 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 df36efb44..2367f8241 100644 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt @@ -135,6 +135,7 @@ class MainActivity: FlutterActivity() { var volcanoAiApiKey: String = "" var openaiApiKey: String = "" var openaiBaseUrl: String? = null + var openaiModel: String? = null // 安全存储相关常量 private const val SECURE_PREFS_FILENAME = "deep_voice_secure_prefs" @@ -143,7 +144,9 @@ class MainActivity: FlutterActivity() { private const val KEY_VOLCANO_AI_API_KEY = "volcano_ai_api_key" private const val KEY_OPENAI_API_KEY = "openai_api_key" private const val KEY_OPENAI_BASE_URL = "openai_base_url" + private const val KEY_OPENAI_MODEL = "openai_model" private const val KEY_MCP_SERVER_ENDPOINT = "mcp_server_endpoint" + // 会话管理 private const val KEY_SESSION_ID = "session_id" private var currentSessionId = "" @@ -180,6 +183,7 @@ class MainActivity: FlutterActivity() { .putString(KEY_OPENAI_API_KEY, openaiApiKey) .putString(KEY_OPENAI_BASE_URL, openaiBaseUrl) .putString(KEY_SESSION_ID, currentSessionId) + .putString(KEY_OPENAI_MODEL, openaiModel) .apply() FileLogger.d("MainActivity", "密钥已安全保存到加密存储中") @@ -225,6 +229,7 @@ class MainActivity: FlutterActivity() { volcanoAiApiKey = sharedPreferences.getString(KEY_VOLCANO_AI_API_KEY, "") ?: "" openaiApiKey = sharedPreferences.getString(KEY_OPENAI_API_KEY, "") ?: "" openaiBaseUrl = sharedPreferences.getString(KEY_OPENAI_BASE_URL, null) + openaiModel = sharedPreferences.getString(KEY_OPENAI_MODEL, null) FileLogger.d("MainActivity", "已从加密存储加载密钥") // 检查是否成功获取所有必要密钥 @@ -460,7 +465,7 @@ class MainActivity: FlutterActivity() { val region = call.argument("azure_speech_region") ?: "" val openaiKey = call.argument("openai_api_key") ?: "" val baseUrl = call.argument("openai_base_url") - + val model = call.argument("openai_model") // 设置 Azure Speech 和 AI 配置 azureSpeechKey = key azureSpeechRegion = region @@ -468,6 +473,9 @@ class MainActivity: FlutterActivity() { if (baseUrl != null) { openaiBaseUrl = baseUrl } + if (model != null) { + openaiModel = model + } // 保存密钥到安全存储 saveKeysToSecureStorage(applicationContext) diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/OpenAIService.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/OpenAIService.kt deleted file mode 100644 index 39907e6d5..000000000 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/OpenAIService.kt +++ /dev/null @@ -1,606 +0,0 @@ -package com.yunqiinnovation.deepsound - -import android.util.Log -import okhttp3.* -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.RequestBody.Companion.toRequestBody -import org.json.JSONArray -import org.json.JSONObject -import java.io.IOException -import java.util.concurrent.TimeUnit -import com.yunqiinnovation.deepsound.core.utils.FileLogger - -/** - * OpenAI服务的原生实现 - */ -class OpenAIService() { - private val TAG = "OpenAIService" - private var baseUrl = "https://api.openai.com/v1/chat/completions" - private val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build() - - private var apiKey: String = "" - private var isInitialized = false - private var model: String = "doubao-1-5-lite-32k-250115" // 默认模型 - - // 用于存储注册的函数 - private val registeredFunctions = mutableListOf() - - - /** - * 初始化OpenAI服务 - */ - fun initialize(apiKey: String, baseUrl: String = ""): Boolean { - this.apiKey = apiKey - if (baseUrl.isNotEmpty()) { - this.baseUrl = baseUrl - } - isInitialized = apiKey.isNotEmpty() - return isInitialized - } - - /** - * 注册函数 - */ - 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 - } - - if (existingIndex >= 0) { - // 如果已存在,则替换 - registeredFunctions[existingIndex] = function - } else { - // 如果不存在,则添加 - registeredFunctions.add(function) - } - - return true - } catch (e: Exception) { - return false - } - } - - /** - * 发送消息(非流式输出) - */ - @Throws(OpenAIException::class) - fun sendMessage(messages: JSONArray, systemPrompt: String): String { - if (!isInitialized || apiKey.isEmpty()) { - throw OpenAIException("OpenAI服务未初始化") - } - - val fullMessages = JSONArray().apply { - put(JSONObject().apply { - put("role", "system") - put("content", systemPrompt) - }) - for (i in 0 until messages.length()) { - put(messages.getJSONObject(i)) - } - } - - val requestBody = JSONObject().apply { - put("model", model) - put("messages", fullMessages) - put("temperature", 0.7) - 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) - } - tools.put(tool) - } - put("tools", tools) - } - } - - val mediaType = "application/json".toMediaTypeOrNull() - val request = Request.Builder() - .url(baseUrl) - .addHeader("Content-Type", "application/json") - .addHeader("Authorization", "Bearer $apiKey") - .post(requestBody.toString().toRequestBody(mediaType)) - .build() - - try { - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - throw OpenAIException("API调用失败: ${response.code}") - } - - val responseBody = response.body?.string() ?: throw OpenAIException("Empty response") - val jsonResponse = JSONObject(responseBody) - - // 检查是否有函数调用 - if (jsonResponse.has("choices") && - jsonResponse.getJSONArray("choices").length() > 0) { - - val choice = jsonResponse.getJSONArray("choices").getJSONObject(0) - - // 检查是否是函数调用 - if (choice.has("message")) { - val message = choice.getJSONObject("message") - - // 检查是否有工具调用 - if (message.has("tool_calls")) { - val toolCalls = message.getJSONArray("tool_calls") - if (toolCalls.length() > 0) { - val toolCall = toolCalls.getJSONObject(0) - if (toolCall.has("function")) { - val function = toolCall.getJSONObject("function") - val functionCall = JSONObject().apply { - put("name", function.getString("name")) - put("arguments", function.getString("arguments")) - put("id", toolCall.getString("id")) - } - return functionCall.toString() - } - } - } - - // 如果没有工具调用,返回消息内容 - if (message.has("content")) { - return message.getString("content") - } - } - } - - throw OpenAIException("Invalid response format") - } - } catch (e: Exception) { - if (e is OpenAIException) throw e - throw OpenAIException("Failed to communicate with AI service: ${e.message}") - } - } - - /** - * 发送消息(流式输出) - */ - fun sendMessageStream(messages: JSONArray, systemPrompt: String, callback: StreamCallback) { - if (!isInitialized || apiKey.isEmpty()) { - callback.onError(OpenAIException("OpenAI服务未初始化")) - return - } - - val fullMessages = JSONArray().apply { - put(JSONObject().apply { - put("role", "system") - put("content", systemPrompt) - }) - for (i in 0 until messages.length()) { - put(messages.getJSONObject(i)) - } - } - - val requestBody = JSONObject().apply { - put("model", model) - put("messages", fullMessages) - put("temperature", 0.7) - 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) - } - tools.put(tool) - } - put("tools", tools) - } - } - - val mediaType = "application/json".toMediaTypeOrNull() - val request = Request.Builder() - .url(baseUrl) - .addHeader("Content-Type", "application/json") - .addHeader("Authorization", "Bearer $apiKey") - .addHeader("Accept", "text/event-stream") - .post(requestBody.toString().toRequestBody(mediaType)) - .build() - - client.newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - callback.onError(OpenAIException(e.message ?: "请求失败")) - } - - override fun onResponse(call: Call, response: Response) { - if (!response.isSuccessful) { - callback.onError(OpenAIException("API调用失败: ${response.code}")) - return - } - - val responseBody = response.body ?: return - val source = responseBody.source() - - try { - // 预取数据到缓冲区 - source.request(Long.MAX_VALUE) - val bufferedSource = source.buffer - - // 用于存储函数调用的各个部分 - val finalToolCalls = mutableMapOf() - - while (!bufferedSource.exhausted()) { - val line = bufferedSource.readUtf8Line() ?: continue - - if (line.isEmpty()) continue - if (line.startsWith("data: ")) { - val data = line.substring(6) - if (data == "[DONE]") { - callback.onComplete() - break - } - - try { - val jsonData = JSONObject(data) - if (jsonData.has("choices") && - jsonData.getJSONArray("choices").length() > 0) { - - val choice = jsonData.getJSONArray("choices").getJSONObject(0) - - // 检查是否有delta - if (choice.has("delta")) { - val delta = choice.getJSONObject("delta") - - // 检查是否有工具调用 - 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.optInt("index", i) - - // 如果是新的工具调用,初始化 - if (!finalToolCalls.containsKey(index)) { - finalToolCalls[index] = ToolCallInfo() - } - - // 获取ID - if (toolCall.has("id")) { - finalToolCalls[index]?.id = toolCall.getString("id") - } - - // 处理函数信息 - if (toolCall.has("function")) { - val function = toolCall.getJSONObject("function") - - if (function.has("name")) { - finalToolCalls[index]?.name = function.getString("name") - } - - if (function.has("arguments")) { - finalToolCalls[index]?.arguments += function.getString("arguments") - } - } - } - continue - } - - // 如果有内容,发送给回调 - if (delta.has("content") && !delta.isNull("content")) { - val content = delta.getString("content") - callback.onToken(content) - } - } - } - } catch (e: Exception) { - // 忽略无效的JSON - continue - } - } - } - - // 处理完整的函数调用 - for ((_, toolCallInfo) in finalToolCalls) { - if (toolCallInfo.name.isNotEmpty()) { - try { - // 创建函数调用对象 - val functionCall = JSONObject().apply { - put("id", toolCallInfo.id) - put("name", toolCallInfo.name) - put("arguments", toolCallInfo.arguments.trim()) - } - - callback.onFunctionCall(functionCall) - } catch (e: Exception) { - // 出错时使用空参数 - val functionCall = JSONObject().apply { - put("id", toolCallInfo.id) - put("name", toolCallInfo.name) - put("arguments", "{}") - } - callback.onFunctionCall(functionCall) - } - } - } - } catch (e: Exception) { - callback.onError(OpenAIException("处理流式响应出错: ${e.message}")) - } finally { - response.close() - } - } - }) - } - - /** - * 发送函数调用结果 - */ - fun sendFunctionCallResult( - messages: JSONArray, - systemPrompt: String, - functionCall: JSONObject, - functionResult: String, - callback: StreamCallback - ) { - if (!isInitialized || apiKey.isEmpty()) { - callback.onError(OpenAIException("OpenAI服务未初始化")) - return - } - - // 构建完整的消息历史 - val fullMessages = JSONArray().apply { - // 添加系统提示 - put(JSONObject().apply { - put("role", "system") - put("content", systemPrompt) - }) - - // 添加历史消息 - for (i in 0 until messages.length()) { - put(messages.getJSONObject(i)) - } - - // 添加函数调用信息 - put(JSONObject().apply { - put("role", "assistant") - put("content", null) - put("tool_calls", JSONArray().apply { - put(JSONObject().apply { - put("id", functionCall.optString("id", "call_${System.currentTimeMillis()}")) - put("type", "function") - put("function", JSONObject().apply { - put("name", functionCall.getString("name")) - put("arguments", functionCall.getString("arguments")) - }) - }) - }) - }) - - // 添加函数返回结果 - put(JSONObject().apply { - put("role", "tool") - put("tool_call_id", functionCall.optString("id", "call_${System.currentTimeMillis()}")) - put("content", functionResult) - }) - } - - // 构建请求 - val requestBody = JSONObject().apply { - put("model", model) - put("messages", fullMessages) - put("temperature", 0.7) - 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) - } - tools.put(tool) - } - put("tools", tools) - } - } - - val mediaType = "application/json".toMediaTypeOrNull() - val request = Request.Builder() - .url(baseUrl) - .addHeader("Content-Type", "application/json") - .addHeader("Authorization", "Bearer $apiKey") - .addHeader("Accept", "text/event-stream") - .post(requestBody.toString().toRequestBody(mediaType)) - .build() - - // 发送请求 - client.newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - callback.onError(OpenAIException(e.message ?: "请求失败")) - } - - override fun onResponse(call: Call, response: Response) { - if (!response.isSuccessful) { - callback.onError(OpenAIException("API调用失败: ${response.code}")) - return - } - - val responseBody = response.body ?: return - val source = responseBody.source() - - try { - // 预取数据到缓冲区 - source.request(Long.MAX_VALUE) - val bufferedSource = source.buffer - - // 用于存储函数调用的各个部分 - val finalToolCalls = mutableMapOf() - - while (!bufferedSource.exhausted()) { - val line = bufferedSource.readUtf8Line() ?: continue - - if (line.isEmpty()) continue - if (line.startsWith("data: ")) { - val data = line.substring(6) - if (data == "[DONE]") { - callback.onComplete() - break - } - - try { - val jsonData = JSONObject(data) - if (jsonData.has("choices") && - jsonData.getJSONArray("choices").length() > 0) { - - val choice = jsonData.getJSONArray("choices").getJSONObject(0) - - // 检查是否有delta - if (choice.has("delta")) { - val delta = choice.getJSONObject("delta") - - // 检查是否有工具调用 - 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.optInt("index", i) - - // 如果是新的工具调用,初始化 - if (!finalToolCalls.containsKey(index)) { - finalToolCalls[index] = ToolCallInfo() - } - - // 获取ID - if (toolCall.has("id")) { - finalToolCalls[index]?.id = toolCall.getString("id") - } - - // 处理函数信息 - if (toolCall.has("function")) { - val function = toolCall.getJSONObject("function") - - if (function.has("name")) { - finalToolCalls[index]?.name = function.getString("name") - } - - if (function.has("arguments")) { - finalToolCalls[index]?.arguments += function.getString("arguments") - } - } - } - continue - } - - // 如果有内容,发送给回调 - if (delta.has("content") && !delta.isNull("content")) { - val content = delta.getString("content") - callback.onToken(content) - } - } - } - } catch (e: Exception) { - // 忽略无效的JSON - continue - } - } - } - - // 处理完整的函数调用 - for ((_, toolCallInfo) in finalToolCalls) { - if (toolCallInfo.name.isNotEmpty()) { - try { - // 创建函数调用对象 - val functionCall = JSONObject().apply { - put("id", toolCallInfo.id) - put("name", toolCallInfo.name) - put("arguments", toolCallInfo.arguments.trim()) - } - - callback.onFunctionCall(functionCall) - } catch (e: Exception) { - // 出错时使用空参数 - val functionCall = JSONObject().apply { - put("id", toolCallInfo.id) - put("name", toolCallInfo.name) - put("arguments", "{}") - } - callback.onFunctionCall(functionCall) - } - } - } - } catch (e: Exception) { - callback.onError(OpenAIException("处理流式响应出错: ${e.message}")) - } finally { - response.close() - } - } - }) - } - - /** - * 创建用户消息 - */ - fun createUserMessage(content: String): JSONObject { - return JSONObject().apply { - put("role", "user") - put("content", content) - } - } - - /** - * 创建系统消息 - */ - fun createSystemMessage(content: String): JSONObject { - return JSONObject().apply { - put("role", "system") - put("content", content) - } - } - - /** - * 创建助手消息 - */ - fun createAssistantMessage(content: String): JSONObject { - return JSONObject().apply { - put("role", "assistant") - put("content", content) - } - } - - /** - * 流式输出回调接口 - */ - interface StreamCallback { - fun onToken(token: String) - fun onComplete() - fun onError(e: Exception) - fun onFunctionCall(functionCall: JSONObject) {} - } - - /** - * 用于存储工具调用信息的辅助类 - */ - private class ToolCallInfo { - var id: String = "" - var name: String = "" - var arguments: String = "" - } -} - -/** - * OpenAI异常 - */ -class OpenAIException(message: String) : Exception(message) \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceFunctionHandler.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceFunctionHandler.kt index a89441506..78a55b642 100644 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceFunctionHandler.kt +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceFunctionHandler.kt @@ -2,7 +2,7 @@ package com.yunqiinnovation.deepsound import org.json.JSONArray import org.json.JSONObject -import com.yunqiinnovation.deepsound.OpenAIService +import com.yunqiinnovation.open_ai_service.OpenAIService import com.yunqiinnovation.deepsound.core.utils.FileLogger /** diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt new file mode 100644 index 000000000..fa179c279 --- /dev/null +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt @@ -0,0 +1,450 @@ +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.azure_speech.AzureTtsHelper +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 TAG = "VoiceInteractionHandler" + + // Azure服务 + private var azureAsrHelper: AzureAsrHelper? = null + private var azureTtsHelper: AzureTtsHelper? = null + + // OpenAI服务 + private val openAIService = OpenAIService() + + // 系统提示词 + private val systemPrompt = """ + 你是一个智能语音助手,能够简洁明了地回答用户的问题。 +时刻关心用户的情绪和需求,主动提供鼓励和温暖。 + +语言风格活泼、亲切,能够幽默地互动,陪伴用户,缓解压力,增添生活乐趣。 + +请始终以用户为中心,保持回应的高效性、准确性和温暖体贴,成为用户真正的灵魂伴侣。 + + 当用户说"退出"、"再见"、"结束对话"等类似意图时,你应该使用exit_interaction函数来结束对话, + 并在结束前说一句友好的告别语,例如"再见,有需要随时找我"。 + """.trimIndent() + + // 语音功能处理 + private val voiceFunctionHandler = VoiceFunctionHandler(openAIService, systemPrompt) + + // 当前用户输入 + 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) + } + + // 初始化Azure TTS + azureTtsHelper = AzureTtsHelper(context).apply { + initialize(azureSpeechKey, azureSpeechRegion) + } + + // 初始化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()) { + processWithOpenAI(result) + } + + // 重置状态,继续识别 + hasSpeechDetected = false + notifyStateChanged() + } + + 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, + systemPrompt = systemPrompt, + 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")}") + // 递归处理嵌套函数调用 + voiceFunctionHandler.handleFunctionCall( + functionCall = nestedCall, + messages = messages, + callback = this + ) + } + + 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() + + azureTtsHelper?.speakText(text, object : AzureTtsHelper.TTSCallback { + override fun onSuccess(message: String) { + isTtsSpeaking = false + notifyStateChanged() + callback?.onComplete() + } + + override fun onError(error: String) { + isTtsSpeaking = false + notifyStateChanged() + callback?.onError(error) + } + }) + } + + /** + * 停止TTS播放 + */ + fun stopTts() { + if (isTtsSpeaking) { + azureTtsHelper?.stopSpeaking() + isTtsSpeaking = false + notifyStateChanged() + } + } + + /** + * 播放AI回复 + */ + private fun speakAIResponse(text: String) { + isTtsSpeaking = true + notifyStateChanged() + + azureTtsHelper?.speakText(text, object : AzureTtsHelper.TTSCallback { + override fun onSuccess(message: String) { + isTtsSpeaking = false + notifyStateChanged() + } + + override fun onError(error: String) { + isTtsSpeaking = false + notifyStateChanged() + } + }) + } + + /** + * 释放资源 + */ + fun dispose() { + // 停止语音识别 + stopRecognition() + + // 停止TTS播放 + stopTts() + + // 释放Azure资源 + azureAsrHelper?.let { + FileLogger.d(TAG, "关闭Azure ASR服务") + it.dispose() + } + + azureTtsHelper?.let { + FileLogger.d(TAG, "关闭Azure TTS服务") + it.dispose() + } + + 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/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt index ad20ab6b7..76faa6f33 100644 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt @@ -29,14 +29,14 @@ import android.net.Uri import com.yunqiinnovation.deepsound.core.utils.FileLogger import com.yunqiinnovation.azure_speech.AzureAsrHelper import com.yunqiinnovation.azure_speech.AzureTtsHelper -import com.yunqiinnovation.deepsound.OpenAIService +import com.yunqiinnovation.open_ai_service.OpenAIService /** * 后台语音交互 Service: * 1) 前台服务,确保不会被系统轻易杀死 * 2) MediaSession 捕获蓝牙耳机按键 - * 3) 处理录音/语音识别 + * 3) 负责唤醒控制和服务生命周期管理 */ class VoiceInteractionService : Service() { @@ -62,10 +62,7 @@ class VoiceInteractionService : Service() { // 服务状态 private var isActive = false // 服务是否活跃 - private var isRecognitionActive = false // 语音识别是否活跃 private var isTimeoutPaused = false // 是否因超时暂停 - private var hasSpeechDetected = false // 是否检测到语音 - private var isTtsSpeaking = false // 是否正在播放TTS // 按键处理 private var lastKeyEventTime = 0L @@ -74,17 +71,12 @@ class VoiceInteractionService : Service() { // 活动时间 private var lastActivityTime = 0L - // 当前用户输入 - private var currentUserInput = "" - - // 服务组件 private lateinit var mediaSession: MediaSessionCompat private lateinit var audioManager: AudioManager - private lateinit var azureAsrHelper: AzureAsrHelper - private lateinit var azureTtsHelper: AzureTtsHelper - private lateinit var openAIService: OpenAIService - private lateinit var functionHandler: VoiceFunctionHandler + + // 语音交互处理器 + private lateinit var voiceInteractionHandler: VoiceInteractionHandler // 定时器 private val handler = Handler(Looper.getMainLooper()) @@ -94,18 +86,6 @@ class VoiceInteractionService : Service() { handler.postDelayed(this, 1000) // 每秒执行一次 } } - - // 系统提示词 - private val systemPrompt = """ - 你是一个智能语音助手,能够简洁明了地回答用户的问题。 - 请保持回答简短、准确,避免过长的解释。 - 如果用户的问题不清楚,请礼貌地请求澄清。 - 不要使用复杂的术语,除非用户明确要求。 - 用户用语音和你交互。 - - 当用户说"退出"、"再见"、"结束对话"等类似意图时,你应该使用exit_interaction函数来结束对话, - 并在结束前说一句友好的告别语,例如"再见,有需要随时找我"。 - """.trimIndent() // 添加媒体播放器 private var audioPlayer: MiniMediaPlayer? = null @@ -124,7 +104,9 @@ class VoiceInteractionService : Service() { audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager FileLogger.d(TAG, "AudioManager初始化完成") - initServices() + // 初始化语音交互处理器 + initVoiceInteractionHandler() + initMediaSession() registerMediaButtonReceiver() @@ -134,7 +116,6 @@ class VoiceInteractionService : Service() { // 设置为媒体播放状态 setPlaybackState(PlaybackStateCompat.STATE_PAUSED) - // FileLogger.d(TAG, "设置播放状态为STATE_PAUSED") // 启动监控和前台服务 startMonitoring() @@ -148,26 +129,21 @@ class VoiceInteractionService : Service() { */ private fun resetState() { isActive = false - isRecognitionActive = false isTimeoutPaused = false - hasSpeechDetected = false - isTtsSpeaking = false } /** - * 初始化所有服务 + * 初始化语音交互处理器 */ - private fun initServices() { - // 创建新的Azure服务实例 - FileLogger.d(TAG, "创建新的Azure服务实例") - azureAsrHelper = AzureAsrHelper(this) - azureTtsHelper = AzureTtsHelper(this) + private fun initVoiceInteractionHandler() { + FileLogger.d(TAG, "初始化语音交互处理器") // 尝试从静态变量获取配置 var subscriptionKey = MainActivity.azureSpeechKey var serviceRegion = MainActivity.azureSpeechRegion var openaiKey = MainActivity.openaiApiKey var openaiBaseUrl = MainActivity.openaiBaseUrl ?: "" // OpenAI API基本URL + var openaiModel = MainActivity.openaiModel ?: "" // OpenAI模型 // 如果静态变量中没有配置,尝试从加密存储中加载 if (subscriptionKey.isEmpty() || serviceRegion.isEmpty() || openaiKey.isEmpty()) { @@ -182,6 +158,7 @@ class VoiceInteractionService : Service() { serviceRegion = MainActivity.azureSpeechRegion openaiKey = MainActivity.openaiApiKey openaiBaseUrl = MainActivity.openaiBaseUrl ?: "" + openaiModel = MainActivity.openaiModel ?: "" FileLogger.d(TAG, "已从加密存储加载配置信息") } else { @@ -189,42 +166,31 @@ class VoiceInteractionService : Service() { } } - // 初始化语音服务 - if (subscriptionKey.isNotEmpty() && serviceRegion.isNotEmpty()) { - // 初始化ASR - azureAsrHelper.initialize(subscriptionKey, serviceRegion, arrayOf("zh-CN")) - - // 初始化TTS - azureTtsHelper.initialize(subscriptionKey, serviceRegion, "zh-CN") - - FileLogger.d(TAG, "Azure语音服务已初始化") - } else { - FileLogger.e(TAG, "Azure配置信息不完整,无法初始化Azure服务") - } - - // 初始化OpenAI服务 - openAIService = OpenAIService() + // 初始化语音交互处理器 + voiceInteractionHandler = VoiceInteractionHandler(applicationContext, subscriptionKey, serviceRegion, openaiKey, openaiBaseUrl, openaiModel) - // 初始化OpenAI服务 - if (openaiKey.isNotEmpty()) { - val initialized = if (openaiBaseUrl.isNotEmpty()) { - openAIService.initialize(openaiKey, openaiBaseUrl) - } else { - openAIService.initialize(openaiKey) + // 初始化回调 + voiceInteractionHandler.setCallback(object : VoiceInteractionHandler.InteractionCallback { + override fun onStateChanged(isRecognitionActive: Boolean, isTtsSpeaking: Boolean, hasSpeechDetected: Boolean) { + // 更新活动时间 + updateLastActivityTime() } - if (initialized) { - FileLogger.d(TAG, "OpenAI服务已初始化") - - // 初始化函数处理器 - functionHandler = VoiceFunctionHandler(openAIService, systemPrompt) - functionHandler.initialize() - - } else { - FileLogger.e(TAG, "OpenAI服务初始化失败") + override fun onError(message: String) { + playNotification(message) } + + override fun onPromptRequest(message: String) { + playPrompt(message) + } + }) + + // 初始化处理器 + val initialized = voiceInteractionHandler.initialize() + if (initialized) { + FileLogger.d(TAG, "语音交互处理器初始化成功") } else { - FileLogger.e(TAG, "OpenAI配置信息不完整,无法初始化OpenAI服务") + FileLogger.e(TAG, "语音交互处理器初始化失败") } } @@ -329,22 +295,21 @@ class VoiceInteractionService : Service() { } // 检查语音识别状态 - if (isRecognitionActive) { + if (voiceInteractionHandler.isRecognitionActive) { val currentTime = System.currentTimeMillis() val elapsedTime = currentTime - lastActivityTime - // 如果超过5秒没有检测到语音,且不在TTS播放中,暂停语音识别 - if (!hasSpeechDetected && !isTtsSpeaking && elapsedTime >= RECOGNITION_TIMEOUT) { + // 如果超过指定时间没有检测到语音,且不在TTS播放中,暂停语音识别 + if (!voiceInteractionHandler.hasSpeechDetected && + !voiceInteractionHandler.isTtsSpeaking && + elapsedTime >= RECOGNITION_TIMEOUT) { + FileLogger.d(TAG, "超过${RECOGNITION_TIMEOUT/1000}秒未检测到语音,停止识别") isTimeoutPaused = true playNotification("没有听到您说话,已暂停对话。双击耳机按钮可重新开始。") - // 停止语音识别并确保资源完全释放 - stopVoiceRecognition() - - // 清理识别状态 - isRecognitionActive = false - hasSpeechDetected = false + // 停止语音识别 + voiceInteractionHandler.stopRecognition() } } } @@ -365,14 +330,12 @@ class VoiceInteractionService : Service() { lastKeyEventTime = currentTime // 停止当前TTS播放 - stopCurrentTTS() + voiceInteractionHandler.stopTts() setPlaybackState(PlaybackStateCompat.STATE_PLAYING) // 播放提示音 playPrompt("我在!") - // audioPlayer?.play(R.raw.listening) - // 设置为播放状态 setPlaybackState(PlaybackStateCompat.STATE_PAUSED) @@ -380,308 +343,48 @@ class VoiceInteractionService : Service() { // 重置超时暂停标志 isTimeoutPaused = false - // 启动或重置语音识别 - if (!isRecognitionActive) { + // 启动语音识别 + if (!voiceInteractionHandler.isRecognitionActive) { FileLogger.d(TAG, "语音识别未激活,开始启动") - // 如果之前是因为超时暂停,重新初始化语音识别组件 - // if (isTimeoutPaused) { - // Log.d(TAG, "之前因超时暂停,重新初始化Azure服务") - // restartAsr() - // } + // 通知 Flutter 语音识别已启动 + notifyRecognitionStarted() - startVoiceRecognition() + // 启动语音识别 + voiceInteractionHandler.startRecognition() } else { FileLogger.d(TAG, "语音识别已激活,更新活动时间") updateLastActivityTime() - hasSpeechDetected = false } } - /** - * 开始语音识别 - */ - private fun startVoiceRecognition() { - if (isRecognitionActive) return - - // 检查录音权限 - if (!checkRecordAudioPermission()) { - playNotification("需要录音权限,请在设置中授予权限") - return - } - // 通知 Flutter 语音识别已启动 - notifyRecognitionStarted() - - isActive = true - isRecognitionActive = true - hasSpeechDetected = false - updateLastActivityTime() - - try { - azureAsrHelper.startContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { - override fun onRecognizing(recognizing: String, detectedLanguage: String) { - if (recognizing.isNotEmpty()) { - hasSpeechDetected = true - stopCurrentTTS() - updateLastActivityTime() - } - } - - override fun onResult(result: String, detectedLanguage: String) { - if (result.isNotEmpty()) { - processWithOpenAI(result) - } - - // 重置状态,继续识别 - hasSpeechDetected = false - updateLastActivityTime() - } - - override fun onSessionStarted() { - updateLastActivityTime() - - } - - override fun onSessionStopped() { - isRecognitionActive = false - - - } - - override fun onCanceled(reason: String, errorDetails: String) { - isRecognitionActive = false - - - } - - override fun onError(error: String) { - isRecognitionActive = false - playNotification("语音识别出错") - - } - }) - } catch (e: Exception) { - isRecognitionActive = false - FileLogger.e(TAG, "启动语音识别失败: ${e.message}", e) - playNotification("启动语音识别失败") - - - } - } - - /** - * 停止语音识别 - */ - private fun stopVoiceRecognition() { - 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, "语音识别会话已停止") - } - override fun onCanceled(reason: String, errorDetails: String) { - isRecognitionActive = false - FileLogger.d(TAG, "语音识别已取消: $reason") - } - override fun onError(error: String) { - isRecognitionActive = false - FileLogger.e(TAG, "停止语音识别时出错: $error") - } - }) - } catch (e: Exception) { - FileLogger.e(TAG, "停止语音识别异常: ${e.message}", e) - // 确保状态一致性 - isRecognitionActive = false - } - - setPlaybackState(PlaybackStateCompat.STATE_PAUSED) - isRecognitionActive = false - hasSpeechDetected = false - // 不重置 isTimeoutPaused,保留暂停原因 - } - - /** - * 使用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, - systemPrompt = systemPrompt, - 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端 - notifyChatHistoryUpdated("personal_assistant", text, response) - } - } - - override fun onError(e: Exception) { - FileLogger.e(TAG, "AI流式处理出错: ${e.message}", e) - playNotification("AI处理出错") - } - - override fun onFunctionCall(call: JSONObject) { - FileLogger.d(TAG, "收到函数调用请求: ${call.getString("name")}") - - // 使用函数处理器处理函数调用 - val handled = functionHandler.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端 - notifyChatHistoryUpdated("personal_assistant", text, response) - } - updateLastActivityTime() - } - - override fun onError(message: String) { - FileLogger.e(TAG, "函数处理出错: $message") - playNotification(message) - } - - override fun onFunctionCall(nestedCall: JSONObject) { - FileLogger.d(TAG, "收到嵌套函数调用: ${nestedCall.getString("name")}") - // 递归处理嵌套函数调用 - functionHandler.handleFunctionCall( - functionCall = nestedCall, - messages = messages, - callback = this - ) - } - - override fun onExitWithMessage(farewell: String) { - // 播放退出消息 - speakAIResponse(farewell) - - // 同步聊天记录 - notifyChatHistoryUpdated("personal_assistant", text, farewell) - - // 停止语音识别 - stopVoiceRecognition() - } - } - ) - - if (!handled) { - // 如果函数没有被处理,作为普通文本处理 - FileLogger.d(TAG, "函数未处理,作为普通文本处理") - speakAIResponse("我无法处理这个请求") - notifyChatHistoryUpdated("personal_assistant", text, "我无法处理这个请求") - } - } - } - ) - - } catch (e: Exception) { - FileLogger.e(TAG, "AI处理出错: ${e.message}", e) - playNotification("AI处理出错") - } - }.start() - } - - /** - * 播放AI回复 - */ - private fun speakAIResponse(text: String) { - isTtsSpeaking = true - azureTtsHelper.speakText(text, object : AzureTtsHelper.TTSCallback { - override fun onSuccess(message: String) { - isTtsSpeaking = false - updateLastActivityTime() - } - - override fun onError(error: String) { - isTtsSpeaking = false - } - }) - } - /** * 播放提示音 */ private fun playPrompt(message: String) { - isTtsSpeaking = true - azureTtsHelper.speakText(message, object : AzureTtsHelper.TTSCallback { - override fun onSuccess(message: String) { isTtsSpeaking = false } - override fun onError(error: String) { isTtsSpeaking = false } - }) + voiceInteractionHandler.playTts(message) } /** * 播放通知提示音 */ private fun playNotification(message: String) { - isTtsSpeaking = true // 更新播放状态为播放中,增加接收蓝牙按键事件的几率 setPlaybackState(PlaybackStateCompat.STATE_PLAYING) // 确保媒体会话处于活跃状态 mediaSession.isActive = true - azureTtsHelper.speakText(message, object : AzureTtsHelper.TTSCallback { - override fun onSuccess(message: String) { - isTtsSpeaking = false + voiceInteractionHandler.playTts(message, object : VoiceInteractionHandler.TtsCallback { + override fun onComplete() { setPlaybackState(PlaybackStateCompat.STATE_PAUSED) } override fun onError(error: String) { - isTtsSpeaking = false setPlaybackState(PlaybackStateCompat.STATE_PAUSED) } }) } - /** - * 停止当前TTS播放 - */ - private fun stopCurrentTTS() { - if (isTtsSpeaking) { - azureTtsHelper.stopSpeaking() - isTtsSpeaking = false - } - } - /** * 更新最后活动时间 */ @@ -715,15 +418,6 @@ class VoiceInteractionService : Service() { } } - /** - * 检查录音权限 - */ - private fun checkRecordAudioPermission(): Boolean { - val permission = android.Manifest.permission.RECORD_AUDIO - val result = applicationContext.checkCallingOrSelfPermission(permission) - return result == android.content.pm.PackageManager.PERMISSION_GRANTED - } - /** * 启动前台服务 */ @@ -865,30 +559,17 @@ class VoiceInteractionService : Service() { stopMonitoring() // 停止语音识别 - stopVoiceRecognition() + voiceInteractionHandler.stopRecognition() // 停止媒体会话 mediaSession.release() FileLogger.d(TAG, "媒体会话已释放") // 停止TTS - stopCurrentTTS() + voiceInteractionHandler.stopTts() - // 关闭Azure语音服务 - if (::azureAsrHelper.isInitialized) { - FileLogger.d(TAG, "关闭Azure语音服务") - azureAsrHelper.dispose() - } - - if (::azureTtsHelper.isInitialized) { - FileLogger.d(TAG, "关闭Azure TTS服务") - azureTtsHelper.dispose() - } - - // 关闭OpenAI服务 - if (::openAIService.isInitialized) { - FileLogger.d(TAG, "关闭OpenAI服务") - } + // 释放语音交互处理器资源 + voiceInteractionHandler.dispose() // 关闭音频播放器 audioPlayer?.release() @@ -907,11 +588,11 @@ class VoiceInteractionService : Service() { FileLogger.d(TAG, "暂停后台语音交互(来自Flutter的请求)") // 停止当前TTS播放 - stopCurrentTTS() + voiceInteractionHandler.stopTts() // 停止语音识别 - if (isRecognitionActive) { - stopVoiceRecognition() + if (voiceInteractionHandler.isRecognitionActive) { + voiceInteractionHandler.stopRecognition() } // 设置为暂停状态,但保持服务活跃 @@ -923,8 +604,6 @@ class VoiceInteractionService : Service() { * 通知 Flutter 端聊天记录已更新 */ private fun notifyChatHistoryUpdated(agentId: String, userMessage: String, assistantMessage: String) { - - // 创建广播 Intent val intent = Intent(ACTION_CHAT_HISTORY_UPDATED).apply { putExtra("agentId", agentId) @@ -1025,5 +704,4 @@ class VoiceInteractionService : Service() { */ fun isPlaying() = mediaPlayer?.isPlaying == true } - } \ No newline at end of file diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 35365df1a..f8f47c600 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -2,6 +2,7 @@ allprojects { repositories { 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 6a3483c9c..6ca3ffd16 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -30,7 +30,9 @@ plugins { include(":app") include(":azure_speech") +include(":open_ai_service") // 设置azure_speech项目的路径 project(":azure_speech").projectDir = file("../local_plugins/azure_speech/android") +project(":open_ai_service").projectDir = file("../local_plugins/open_ai_service/android") diff --git a/lib/data/services/voice_interaction_service.dart b/lib/data/services/voice_interaction_service.dart index 1bb2406f5..792e94912 100644 --- a/lib/data/services/voice_interaction_service.dart +++ b/lib/data/services/voice_interaction_service.dart @@ -40,7 +40,7 @@ class VoiceInteractionService extends GetxService { late String _azureSpeechRegion; late String _openaiApiKey; late String _openaiBaseUrl; - + late String _openaiModel; // 聊天历史服务 late final ChatHistoryService _chatHistoryService; @@ -60,11 +60,12 @@ class VoiceInteractionService extends GetxService { _azureSpeechRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? ''; _openaiApiKey = dotenv.env['OPENAI_API_KEY'] ?? ''; _openaiBaseUrl = dotenv.env['OPENAI_BASE_URL'] ?? ''; + _openaiModel = dotenv.env['OPENAI_MODEL'] ?? ''; if (_azureSpeechKey.isEmpty || _azureSpeechRegion.isEmpty) { Logger.warning('未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION'); } - + } /// 处理来自原生层的事件 @@ -196,6 +197,7 @@ class VoiceInteractionService extends GetxService { 'azure_speech_region': _azureSpeechRegion, 'openai_api_key': _openaiApiKey, 'openai_base_url': _openaiBaseUrl, + 'openai_model': _openaiModel, }) ?? false; if (result) { diff --git a/local_plugins/azure_speech/android/build.gradle.kts b/local_plugins/azure_speech/android/build.gradle.kts index 665d495d1..a26b13967 100644 --- a/local_plugins/azure_speech/android/build.gradle.kts +++ b/local_plugins/azure_speech/android/build.gradle.kts @@ -33,8 +33,8 @@ configure { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } sourceSets { @@ -53,13 +53,12 @@ configure { // 显式设置Kotlin JVM目标版本 tasks.withType { kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "11" } } dependencies { - // 直接通过本地依赖方式添加Flutter - implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) + // 添加Microsoft语音SDK implementation("com.microsoft.cognitiveservices.speech:client-sdk:1.30.0") } \ No newline at end of file diff --git a/local_plugins/open_ai_service/README.md b/local_plugins/open_ai_service/README.md new file mode 100644 index 000000000..d7ccb709d --- /dev/null +++ b/local_plugins/open_ai_service/README.md @@ -0,0 +1,348 @@ +# OpenAI服务插件 + +提供与OpenAI API的原生交互功能,支持流式输出和函数调用,同时支持Android和iOS平台。 + +## 功能特点 + +- 原生实现,性能更好 +- 支持流式输出,实时显示AI回复 +- 支持函数调用(工具调用) +- 支持自定义API基础URL +- 支持Android和iOS平台 +- 提供简洁易用的Dart接口 + +## 安装 + +将该插件添加到您的`pubspec.yaml`文件中: + +```yaml +dependencies: + open_ai_service: + path: local_plugins/open_ai_service +``` + +## 使用方法 + +### 初始化 + +首先需要初始化OpenAI服务,提供API密钥和可选的基础URL和模型: + +```dart +import 'package:open_ai_service/open_ai_service.dart'; + +final openAIService = OpenAIService(); + +// 使用OpenAI官方API和默认模型 +await openAIService.initialize(apiKey: 'your_openai_api_key'); + +// 或使用自定义API端点和模型 +await openAIService.initialize( + apiKey: 'your_api_key', + baseUrl: 'https://your-custom-endpoint.com/v1/chat/completions', + model: 'gpt-4-0125-preview', // 自定义模型 +); +``` + +### 普通聊天 + +发送消息并获取非流式回复: + +```dart +// 创建用户消息 +final userMessage = await openAIService.createUserMessage('你好,请介绍一下自己'); + +// 发送消息 +final response = await openAIService.sendMessage( + messages: [userMessage], + systemPrompt: '你是一个有帮助的AI助手,请简洁地回答问题。', +); + +print('AI回复: $response'); +``` + +### 流式输出 + +使用流式输出获取实时回复: + +```dart +// 注册事件监听 +final subscription = openAIService.processEvents( + onToken: (token) { + // 收到部分回复 + print('收到部分回复: $token'); + // 更新UI... + }, + onComplete: () { + // 回复完成 + print('回复完成'); + }, + onError: (error) { + // 处理错误 + print('错误: $error'); + }, + onFunctionCall: (functionCall) { + // 处理函数调用 + print('函数调用: $functionCall'); + handleFunctionCall(functionCall); + }, +); + +// 发送流式消息 +await openAIService.sendMessageStream( + messages: [userMessage], + systemPrompt: '你是一个有帮助的AI助手,请简洁地回答问题。', +); + +// 完成后,记得取消订阅 +// subscription.cancel(); +``` + +### 函数调用 + +注册函数并处理函数调用: + +```dart +// 注册一个天气查询函数 +await openAIService.registerFunction( + name: 'get_weather', + description: '获取指定城市的天气信息', + parameters: { + 'type': 'object', + 'properties': { + 'city': { + 'type': 'string', + 'description': '城市名称,如北京、上海等', + }, + 'date': { + 'type': 'string', + 'description': '查询日期,默认为今天', + }, + }, + 'required': ['city'], + }, +); + +// 处理函数调用 +void handleFunctionCall(Map functionCall) { + final name = functionCall['name']; + final arguments = jsonDecode(functionCall['arguments']); + final id = functionCall['id']; + + if (name == 'get_weather') { + final city = arguments['city']; + final date = arguments['date'] ?? '今天'; + + // 获取天气信息(实际应用中应调用天气API) + final weatherResult = '{"temperature": 25, "condition": "晴天", "humidity": 40}'; + + // 发送函数调用结果 + openAIService.sendFunctionCallResult( + messages: messages, // 当前的消息历史 + systemPrompt: systemPrompt, + functionCall: functionCall, + functionResult: weatherResult, + ); + } +} +``` + +## 示例应用 + +一个简单的聊天机器人示例: + +```dart +import 'package:flutter/material.dart'; +import 'package:open_ai_service/open_ai_service.dart'; + +void main() { + runApp(MyApp()); +} + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'OpenAI 聊天', + theme: ThemeData(primarySwatch: Colors.blue), + home: ChatScreen(), + ); + } +} + +class ChatScreen extends StatefulWidget { + @override + _ChatScreenState createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + final OpenAIService _openAIService = OpenAIService(); + final TextEditingController _textController = TextEditingController(); + final List> _messages = []; + StreamSubscription? _subscription; + String _currentResponse = ''; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _initializeOpenAI(); + } + + Future _initializeOpenAI() async { + try { + await _openAIService.initialize( + apiKey: 'your_api_key_here', + ); + + // 注册事件监听 + _subscription = _openAIService.processEvents( + onToken: (token) { + setState(() { + _currentResponse += token; + }); + }, + onComplete: () { + _addAIMessage(_currentResponse); + setState(() { + _currentResponse = ''; + _isLoading = false; + }); + }, + onError: (error) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('错误: $error')), + ); + setState(() { + _isLoading = false; + }); + }, + onFunctionCall: (functionCall) { + // 处理函数调用... + }, + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('初始化失败: $e')), + ); + } + } + + void _sendMessage() async { + if (_textController.text.isEmpty) return; + + final userMessage = _textController.text; + _addUserMessage(userMessage); + _textController.clear(); + + try { + setState(() { + _isLoading = true; + }); + + final message = await _openAIService.createUserMessage(userMessage); + + await _openAIService.sendMessageStream( + messages: _messages, + systemPrompt: '你是一个有帮助的AI助手,请简洁地回答问题。', + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('发送消息失败: $e')), + ); + setState(() { + _isLoading = false; + }); + } + } + + void _addUserMessage(String content) { + setState(() { + _messages.add({ + 'role': 'user', + 'content': content, + }); + }); + } + + void _addAIMessage(String content) { + setState(() { + _messages.add({ + 'role': 'assistant', + 'content': content, + }); + }); + } + + @override + void dispose() { + _subscription?.cancel(); + _textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('OpenAI 聊天')), + body: Column( + children: [ + Expanded( + child: ListView.builder( + itemCount: _messages.length + (_currentResponse.isNotEmpty ? 1 : 0), + itemBuilder: (context, index) { + if (index < _messages.length) { + final message = _messages[index]; + final isUser = message['role'] == 'user'; + + return ListTile( + leading: Icon(isUser ? Icons.person : Icons.computer), + title: Text(message['content']), + ); + } else { + // 显示当前正在生成的回复 + return ListTile( + leading: Icon(Icons.computer), + title: Text(_currentResponse), + ); + } + }, + ), + ), + if (_isLoading) + LinearProgressIndicator(), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _textController, + decoration: InputDecoration( + hintText: '输入消息...', + border: OutlineInputBorder(), + ), + ), + ), + IconButton( + icon: Icon(Icons.send), + onPressed: _sendMessage, + ), + ], + ), + ), + ], + ), + ); + } +} +``` + +## 注意事项 + +- 请确保您的API密钥安全存储,不要直接硬编码在应用程序中 +- 流式输出需要在调用`sendMessageStream`之前注册事件监听 +- 使用完毕后记得取消事件订阅 + +## 许可证 + +此插件遵循MIT许可证。 \ 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 new file mode 100644 index 000000000..4f7b2f643 --- /dev/null +++ b/local_plugins/open_ai_service/android/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + // Android Library 插件 + id("com.android.library") + // Kotlin Android 插件 + id("org.jetbrains.kotlin.android") +} + +android { + // 命名空间,对应你插件的包名(需与代码内包名保持一致) + namespace = "com.yunqiinnovation.open_ai_service" + + // 目标 SDK 版本 + compileSdk = 33 + + defaultConfig { + // 最低 SDK 版本 + minSdk = 21 + targetSdk = 33 + } + + // Java 语言级别兼容配置 + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + // Kotlin 语言级别 + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + + implementation("com.squareup.okhttp3:okhttp:4.10.0") + +} \ No newline at end of file diff --git a/local_plugins/open_ai_service/android/settings.gradle.kts b/local_plugins/open_ai_service/android/settings.gradle.kts new file mode 100644 index 000000000..1c33f4971 --- /dev/null +++ b/local_plugins/open_ai_service/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "open_ai_service" \ No newline at end of file diff --git a/local_plugins/open_ai_service/android/src/main/AndroidManifest.xml b/local_plugins/open_ai_service/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..e38cdeb21 --- /dev/null +++ b/local_plugins/open_ai_service/android/src/main/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + + + 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 new file mode 100644 index 000000000..7b9213046 --- /dev/null +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt @@ -0,0 +1,456 @@ +package com.yunqiinnovation.open_ai_service + +import android.util.Log +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * OpenAI服务的原生实现 + */ +class OpenAIService() { + private val TAG = "OpenAIService" + private var baseUrl = "" + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + private var apiKey: String = "" + private var isInitialized = false + private var model: String = "" // 默认模型 + + // 用于存储注册的函数 + private val registeredFunctions = mutableListOf() + + /** + * 创建用户消息 + */ + fun createUserMessage(content: String): JSONObject { + return JSONObject().apply { + put("role", "user") + put("content", content) + } + } + + /** + * 创建助手消息 + */ + fun createAssistantMessage(content: String): JSONObject { + return JSONObject().apply { + put("role", "assistant") + put("content", content) + } + } + + /** + * 初始化OpenAI服务 + */ + fun initialize(apiKey: String, baseUrl: String, model: String): Boolean { + this.apiKey = apiKey + if (baseUrl.isNotEmpty()) { + this.baseUrl = baseUrl + } + if (model.isNotEmpty()) { + this.model = model + } + isInitialized = apiKey.isNotEmpty() + return isInitialized + } + + /** + * 注册函数 + */ + 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 + } + + if (existingIndex >= 0) { + // 如果已存在,则替换 + registeredFunctions[existingIndex] = function + } else { + // 如果不存在,则添加 + registeredFunctions.add(function) + } + + return true + } catch (e: Exception) { + return false + } + } + + /** + * 发送消息(非流式输出) + */ + @Throws(OpenAIException::class) + fun sendMessage(messages: JSONArray, systemPrompt: String): String { + if (!isInitialized || apiKey.isEmpty()) { + throw OpenAIException("OpenAI服务未初始化") + } + + val fullMessages = JSONArray().apply { + put(JSONObject().apply { + put("role", "system") + put("content", systemPrompt) + }) + for (i in 0 until messages.length()) { + put(messages.getJSONObject(i)) + } + } + + val requestBody = JSONObject().apply { + put("model", model) + put("messages", fullMessages) + put("temperature", 0.7) + 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) + } + tools.put(tool) + } + put("tools", tools) + } + } + + val mediaType = "application/json".toMediaTypeOrNull() + val request = Request.Builder() + .url(baseUrl) + .addHeader("Content-Type", "application/json") + .addHeader("Authorization", "Bearer $apiKey") + .post(requestBody.toString().toRequestBody(mediaType)) + .build() + + try { + Log.d(TAG, "request: $request") + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw OpenAIException("API调用失败: ${response.code}") + } + + val responseBody = response.body?.string() ?: throw OpenAIException("Empty response") + val jsonResponse = JSONObject(responseBody) + + // 检查是否有函数调用 + if (jsonResponse.has("choices") && + jsonResponse.getJSONArray("choices").length() > 0) { + + val choice = jsonResponse.getJSONArray("choices").getJSONObject(0) + + // 检查是否是函数调用 + if (choice.has("message")) { + val message = choice.getJSONObject("message") + + // 检查是否有工具调用 + if (message.has("tool_calls")) { + val toolCalls = message.getJSONArray("tool_calls") + if (toolCalls.length() > 0) { + val toolCall = toolCalls.getJSONObject(0) + if (toolCall.has("function")) { + val function = toolCall.getJSONObject("function") + val functionCall = JSONObject().apply { + put("name", function.getString("name")) + put("arguments", function.getString("arguments")) + put("id", toolCall.getString("id")) + } + return functionCall.toString() + } + } + } + + // 如果没有工具调用,返回消息内容 + if (message.has("content")) { + return message.getString("content") + } + } + } + + throw OpenAIException("Invalid response format") + } + } catch (e: Exception) { + if (e is OpenAIException) throw e + throw OpenAIException("Failed to communicate with AI service: ${e.message}") + } + } + + /** + * 发送消息(流式输出) + */ + fun sendMessageStream(messages: JSONArray, systemPrompt: String, callback: StreamCallback) { + if (!isInitialized || apiKey.isEmpty()) { + callback.onError(OpenAIException("OpenAI服务未初始化")) + return + } + + val fullMessages = JSONArray().apply { + put(JSONObject().apply { + put("role", "system") + put("content", systemPrompt) + }) + for (i in 0 until messages.length()) { + put(messages.getJSONObject(i)) + } + } + + val requestBody = JSONObject().apply { + put("model", model) + put("messages", fullMessages) + put("temperature", 0.7) + 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) + } + tools.put(tool) + } + put("tools", tools) + } + } + + val mediaType = "application/json".toMediaTypeOrNull() + val request = Request.Builder() + .url(baseUrl) + .addHeader("Content-Type", "application/json") + .addHeader("Authorization", "Bearer $apiKey") + .addHeader("Accept", "text/event-stream") + .post(requestBody.toString().toRequestBody(mediaType)) + .build() + + Log.d(TAG, "request: $request") + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + callback.onError(OpenAIException(e.message ?: "请求失败")) + } + + override fun onResponse(call: Call, response: Response) { + + if (!response.isSuccessful) { + callback.onError(OpenAIException("API调用失败: ${response.code}")) + return + } + + val responseBody = response.body ?: return + val source = responseBody.source() + + try { + // 预取数据到缓冲区 + source.request(Long.MAX_VALUE) + val bufferedSource = source.buffer + + // 用于存储函数调用的各个部分 + val finalToolCalls = mutableMapOf() + + while (!bufferedSource.exhausted()) { + val line = bufferedSource.readUtf8Line() ?: continue + val trimmedLine = line.trim() + if (trimmedLine.isEmpty()) continue + if (trimmedLine.startsWith("data:")) { + val data = trimmedLine.substring(5) + + // 处理[DONE]消息 + if (data == "[DONE]") { + processToolCalls(finalToolCalls, callback) + callback.onComplete() + break + } + + try { + val jsonData = JSONObject(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) + } + + // 处理工具调用(函数调用) + if (delta.has("tool_calls")) { + val toolCalls = delta.getJSONArray("tool_calls") + for (i in 0 until toolCalls.length()) { + val toolCall = toolCalls.getJSONObject(i) + val index = toolCall.getInt("index") + + // 创建或获取现有的工具调用信息 + val toolCallInfo = finalToolCalls.getOrPut(index) { ToolCallInfo() } + + // 更新ID + if (toolCall.has("id")) { + toolCallInfo.id = toolCall.getString("id") + } + + // 更新函数信息 + if (toolCall.has("function")) { + val function = toolCall.getJSONObject("function") + + if (function.has("name")) { + toolCallInfo.name = function.getString("name") + } + + if (function.has("arguments")) { + toolCallInfo.arguments += function.getString("arguments") + } + } + } + } + } + } + } + } catch (e: Exception) { + // 忽略解析错误 + Log.e(TAG, "解析JSON出错: ${e.message}") + } + } + } + } catch (e: Exception) { + callback.onError(OpenAIException("处理响应流时出错: ${e.message}")) + } finally { + responseBody.close() + } + } + }) + } + + /** + * 发送函数调用结果 + */ + fun sendFunctionCallResult( + messages: JSONArray, + systemPrompt: String, + functionCall: JSONObject, + functionResult: String, + callback: StreamCallback + ) { + try { + val fullMessages = JSONArray().apply { + // 添加系统提示 + put(JSONObject().apply { + put("role", "system") + put("content", systemPrompt) + }) + + // 添加用户消息 + for (i in 0 until messages.length()) { + put(messages.getJSONObject(i)) + } + + // 添加函数调用消息 + put(JSONObject().apply { + put("role", "assistant") + put("content", null) + + // 添加工具调用 + val toolCalls = JSONArray().apply { + val toolCall = JSONObject().apply { + put("id", functionCall.optString("id", "call_${System.currentTimeMillis()}")) + put("type", "function") + put("function", JSONObject().apply { + put("name", functionCall.getString("name")) + put("arguments", functionCall.getString("arguments")) + }) + } + put(toolCall) + } + put("tool_calls", toolCalls) + }) + + // 添加函数调用结果 + put(JSONObject().apply { + put("role", "tool") + put("content", functionResult) + put("tool_call_id", functionCall.optString("id", "call_${System.currentTimeMillis()}")) + }) + } + + // 发送完整对话 + sendMessageStream(fullMessages, systemPrompt, callback) + + } catch (e: Exception) { + callback.onError(OpenAIException("发送函数调用结果失败: ${e.message}")) + } + } + + /** + * 处理工具调用(处理完整的函数调用并回调) + */ + private fun processToolCalls(toolCalls: Map, callback: StreamCallback) { + if (toolCalls.isEmpty()) return + + // 只处理第一个工具调用 + val firstToolCall = toolCalls.entries.firstOrNull()?.value ?: return + + if (firstToolCall.isValid()) { + // 创建函数调用JSON对象 + val functionCall = JSONObject().apply { + put("name", firstToolCall.name) + put("arguments", firstToolCall.arguments) + put("id", firstToolCall.id) + } + + // 回调 + callback.onFunctionCall(functionCall) + } + } + + /** + * 工具调用信息类 + */ + private class ToolCallInfo { + var id: String = "" + var name: String = "" + var arguments: String = "" + + fun isValid(): Boolean { + return id.isNotEmpty() && name.isNotEmpty() + } + } + + /** + * 流式输出回调接口 + */ + interface StreamCallback { + fun onToken(token: String) + fun onComplete() + fun onError(e: Exception) + fun onFunctionCall(functionCall: JSONObject) + } +} + +/** + * OpenAI服务异常 + */ +class OpenAIException(message: String) : Exception(message) \ No newline at end of file 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 new file mode 100644 index 000000000..5c93e5be6 --- /dev/null +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt @@ -0,0 +1,322 @@ +package com.yunqiinnovation.open_ai_service + +import android.content.Context +import android.util.Log +import androidx.annotation.NonNull +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.EventChannel.EventSink +import io.flutter.plugin.common.EventChannel.StreamHandler +import org.json.JSONArray +import org.json.JSONObject +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors + +/** OpenAIServicePlugin */ +class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { + /// 方法通道名称 + private val methodChannelName = "com.yunqiinnovation.open_ai_service/methods" + + /// 事件通道名称 + private val eventChannelName = "com.yunqiinnovation.open_ai_service/events" + + /// 方法通道 + private lateinit var methodChannel: MethodChannel + + /// 事件通道 + private lateinit var eventChannel: EventChannel + + /// 应用上下文 + private lateinit var context: Context + + /// OpenAI服务实例 + private val openAIService = OpenAIService() + + /// 事件接收器(用于流式输出) + private var eventSink: EventSink? = null + + /// 执行器(用于后台线程) + private val executor = Executors.newSingleThreadExecutor() + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + // 保存上下文 + context = flutterPluginBinding.applicationContext + + // 初始化方法通道 + methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, methodChannelName) + methodChannel.setMethodCallHandler(this) + + // 初始化事件通道 + eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, eventChannelName) + eventChannel.setStreamHandler(this) + } + + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "initialize" -> { + val apiKey = call.argument("apiKey") ?: "" + val baseUrl = call.argument("baseUrl") ?: "" + val model = call.argument("model") ?: "" + + val initialized = openAIService.initialize(apiKey, baseUrl, model) + result.success(initialized) + } + + "registerFunction" -> { + val name = call.argument("name") ?: "" + val description = call.argument("description") ?: "" + val parameters = call.argument>("parameters") + + if (name.isEmpty() || parameters == null) { + result.error("INVALID_ARGUMENT", "函数注册参数无效", null) + return + } + + val parametersJson = JSONObject(parameters) + val registered = openAIService.registerFunction(name, description, parametersJson) + result.success(registered) + } + + "sendMessage" -> { + val messagesRaw = call.argument>>("messages") ?: emptyList() + val systemPrompt = call.argument("systemPrompt") ?: "" + + // 转换消息格式 + val messages = JSONArray() + for (message in messagesRaw) { + messages.put(JSONObject(message)) + } + + // 在后台线程执行请求 + executor.execute { + try { + val response = openAIService.sendMessage(messages, systemPrompt) + // 在主线程返回结果 + android.os.Handler(android.os.Looper.getMainLooper()).post { + result.success(response) + } + } catch (e: Exception) { + // 在主线程返回错误 + android.os.Handler(android.os.Looper.getMainLooper()).post { + result.error("OPENAI_ERROR", e.message, null) + } + } + } + } + + "sendMessageStream" -> { + val messagesRaw = call.argument>>("messages") ?: emptyList() + val systemPrompt = call.argument("systemPrompt") ?: "" + + // 检查事件接收器 + if (eventSink == null) { + result.error("NO_EVENT_SINK", "没有可用的事件流接收器", null) + return + } + + // 转换消息格式 + val messages = JSONArray() + for (message in messagesRaw) { + messages.put(JSONObject(message)) + } + + // 在后台线程执行请求 + executor.execute { + try { + openAIService.sendMessageStream( + messages = messages, + systemPrompt = systemPrompt, + 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)) + } + } + + override fun onComplete() { + // 发送完成事件 + android.os.Handler(android.os.Looper.getMainLooper()).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 onFunctionCall(functionCall: JSONObject) { + // 发送函数调用事件 + android.os.Handler(android.os.Looper.getMainLooper()).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) + } + } + } + } + + "sendFunctionCallResult" -> { + val messagesRaw = call.argument>>("messages") ?: emptyList() + val systemPrompt = call.argument("systemPrompt") ?: "" + val functionCallRaw = call.argument>("functionCall") ?: emptyMap() + val functionResult = call.argument("functionResult") ?: "" + + // 检查事件接收器 + if (eventSink == null) { + result.error("NO_EVENT_SINK", "没有可用的事件流接收器", null) + return + } + + // 转换消息格式 + val messages = JSONArray() + for (message in messagesRaw) { + messages.put(JSONObject(message)) + } + + // 转换函数调用 + val functionCall = JSONObject(functionCallRaw) + + // 在后台线程执行请求 + executor.execute { + try { + openAIService.sendFunctionCallResult( + messages = messages, + systemPrompt = systemPrompt, + 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)) + } + } + + override fun onComplete() { + // 发送完成事件 + android.os.Handler(android.os.Looper.getMainLooper()).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 onFunctionCall(nestedFunctionCall: JSONObject) { + // 发送函数调用事件 + android.os.Handler(android.os.Looper.getMainLooper()).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) + } + } + } + } + + "createUserMessage" -> { + val content = call.argument("content") ?: "" + val message = openAIService.createUserMessage(content) + result.success(message.toMap()) + } + + "createAssistantMessage" -> { + val content = call.argument("content") ?: "" + val message = openAIService.createAssistantMessage(content) + result.success(message.toMap()) + } + + else -> { + result.notImplemented() + } + } + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + methodChannel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + executor.shutdown() + } + + // Stream事件处理 + override fun onListen(arguments: Any?, eventSink: EventSink?) { + this.eventSink = eventSink + } + + override fun onCancel(arguments: Any?) { + this.eventSink = null + } + + // 工具方法:JSONObject转Map + private fun JSONObject.toMap(): Map { + val map = mutableMapOf() + val keys = this.keys() + while (keys.hasNext()) { + val key = keys.next() + var value: Any? = this.opt(key) + + value = when (value) { + JSONObject.NULL -> null + is JSONObject -> value.toMap() + is JSONArray -> value.toList() + else -> value + } + + map[key] = value + } + return map + } + + // 工具方法:JSONArray转List + private fun JSONArray.toList(): List { + val list = mutableListOf() + for (i in 0 until this.length()) { + var value: Any? = this.opt(i) + + value = when (value) { + JSONObject.NULL -> null + is JSONObject -> value.toMap() + is JSONArray -> value.toList() + else -> value + } + + list.add(value) + } + return list + } +} \ No newline at end of file diff --git a/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift b/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift new file mode 100644 index 000000000..671affd73 --- /dev/null +++ b/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift @@ -0,0 +1,473 @@ +import Foundation + +/// OpenAI服务异常 +public class OpenAIError: Error { + let message: String + + init(_ message: String) { + self.message = message + } +} + +/// 工具调用信息 +private class ToolCallInfo { + var id: String = "" + var name: String = "" + var arguments: String = "" + + var isValid: Bool { + return !id.isEmpty && !name.isEmpty + } +} + +/// OpenAI服务iOS原生实现 +public class OpenAIService { + private let TAG = "OpenAIService" + private var baseUrl = "https://api.openai.com/v1/chat/completions" + private var apiKey: String = "" + private var isInitialized = false + private var model: String = "doubao-1-5-lite-32k-250115" // 默认模型 + + // 用于存储注册的函数 + private var registeredFunctions: [[String: Any]] = [] + + // URL会话 + private let session: URLSession + + public init() { + // 创建URL会话配置 + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 30.0 + config.timeoutIntervalForResource = 30.0 + session = URLSession(configuration: config) + } + + /// 创建用户消息 + public func createUserMessage(content: String) -> [String: Any] { + return ["role": "user", "content": content] + } + + /// 创建助手消息 + public func createAssistantMessage(content: String) -> [String: Any] { + return ["role": "assistant", "content": content] + } + + /// 初始化OpenAI服务 + public func initialize(apiKey: String, baseUrl: String = "", model: String = "") -> Bool { + self.apiKey = apiKey + if !baseUrl.isEmpty { + self.baseUrl = baseUrl + } + if !model.isEmpty { + self.model = model + } + isInitialized = !apiKey.isEmpty + return isInitialized + } + + /// 注册函数 + public func registerFunction(name: String, description: String, parameters: [String: Any]) -> Bool { + do { + let function: [String: Any] = [ + "name": name, + "description": description, + "parameters": parameters + ] + + // 检查是否已存在相同名称的函数 + if let existingIndex = registeredFunctions.firstIndex(where: { ($0["name"] as? String) == name }) { + // 如果已存在,则替换 + registeredFunctions[existingIndex] = function + } else { + // 如果不存在,则添加 + registeredFunctions.append(function) + } + + return true + } catch { + return false + } + } + + /// 发送消息(非流式输出) + public func sendMessage(messages: [[String: Any]], systemPrompt: String) throws -> String { + guard isInitialized, !apiKey.isEmpty else { + throw OpenAIError("OpenAI服务未初始化") + } + + // 构建完整消息,添加系统提示 + var fullMessages: [[String: Any]] = [ + ["role": "system", "content": systemPrompt] + ] + fullMessages.append(contentsOf: messages) + + // 构建请求体 + var requestDict: [String: Any] = [ + "model": model, + "messages": fullMessages, + "temperature": 0.7, + "max_tokens": 2000, + "stream": false + ] + + // 如果有注册的函数,添加到请求中 + if !registeredFunctions.isEmpty { + var tools: [[String: Any]] = [] + for function in registeredFunctions { + let tool: [String: Any] = [ + "type": "function", + "function": function + ] + tools.append(tool) + } + requestDict["tools"] = tools + } + + // 将请求数据转换为JSON数据 + guard let jsonData = try? JSONSerialization.data(withJSONObject: requestDict) else { + throw OpenAIError("无法序列化请求数据") + } + + // 创建URL请求 + guard let url = URL(string: baseUrl) else { + throw OpenAIError("无效的URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.httpBody = jsonData + + // 创建信号量用于同步请求 + let semaphore = DispatchSemaphore(value: 0) + var responseResult: Result = .failure(OpenAIError("未收到响应")) + + // 执行请求 + let task = session.dataTask(with: request) { data, response, error in + if let error = error { + responseResult = .failure(OpenAIError("请求失败: \(error.localizedDescription)")) + semaphore.signal() + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + responseResult = .failure(OpenAIError("无效的HTTP响应")) + semaphore.signal() + return + } + + guard httpResponse.statusCode == 200 else { + responseResult = .failure(OpenAIError("API调用失败: \(httpResponse.statusCode)")) + semaphore.signal() + return + } + + guard let data = data else { + responseResult = .failure(OpenAIError("响应数据为空")) + semaphore.signal() + return + } + + do { + // 解析JSON响应 + guard let jsonResponse = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + responseResult = .failure(OpenAIError("无法解析JSON响应")) + semaphore.signal() + return + } + + // 检查是否有函数调用 + if let choices = jsonResponse["choices"] as? [[String: Any]], !choices.isEmpty, + let choice = choices.first, + let message = choice["message"] as? [String: Any] { + + // 检查是否有工具调用 + if let toolCalls = message["tool_calls"] as? [[String: Any]], !toolCalls.isEmpty, + let toolCall = toolCalls.first, + let function = toolCall["function"] as? [String: Any], + let name = function["name"] as? String, + let arguments = function["arguments"] as? String, + let id = toolCall["id"] as? String { + + let functionCallDict: [String: Any] = [ + "name": name, + "arguments": arguments, + "id": id + ] + + // 将函数调用转为JSON字符串 + if let functionCallData = try? JSONSerialization.data(withJSONObject: functionCallDict), + let functionCallString = String(data: functionCallData, encoding: .utf8) { + responseResult = .success(functionCallString) + semaphore.signal() + return + } + } + + // 如果没有工具调用,返回消息内容 + if let content = message["content"] as? String { + responseResult = .success(content) + semaphore.signal() + return + } + } + + responseResult = .failure(OpenAIError("无效的响应格式")) + semaphore.signal() + + } catch { + responseResult = .failure(OpenAIError("解析响应时出错: \(error.localizedDescription)")) + semaphore.signal() + } + } + + task.resume() + + // 等待响应完成 + _ = semaphore.wait(timeout: .distantFuture) + + // 返回结果或抛出错误 + switch responseResult { + case .success(let result): + return result + case .failure(let error): + throw error + } + } + + /// 发送消息(流式输出) + 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] = [ + "model": model, + "messages": fullMessages, + "temperature": 0.7, + "max_tokens": 2000, + "stream": true + ] + + // 如果有注册的函数,添加到请求中 + if !registeredFunctions.isEmpty { + var tools: [[String: Any]] = [] + for function in registeredFunctions { + let tool: [String: Any] = [ + "type": "function", + "function": function + ] + tools.append(tool) + } + requestDict["tools"] = tools + } + + // 将请求数据转换为JSON数据 + guard let jsonData = try? JSONSerialization.data(withJSONObject: requestDict) else { + callback.onError(OpenAIError("无法序列化请求数据")) + return + } + + // 创建URL请求 + guard let url = URL(string: baseUrl) else { + callback.onError(OpenAIError("无效的URL")) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + 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)") + // 忽略解析错误,继续处理其他行 + } + } + } + } + } + + task.resume() + } + + /// 发送函数调用结果 + public func sendFunctionCallResult( + messages: [[String: Any]], + systemPrompt: String, + functionCall: [String: Any], + functionResult: String, + callback: @escaping StreamCallback + ) { + do { + // 构建完整消息数组 + var fullMessages: [[String: Any]] = [ + // 添加系统提示 + ["role": "system", "content": systemPrompt] + ] + + // 添加用户消息 + fullMessages.append(contentsOf: messages) + + // 获取函数相关信息 + guard let name = functionCall["name"] as? String, + let arguments = functionCall["arguments"] as? String else { + callback.onError(OpenAIError("函数调用信息不完整")) + return + } + + let id = functionCall["id"] as? String ?? "call_\(Int(Date().timeIntervalSince1970 * 1000))" + + // 添加函数调用消息 + fullMessages.append([ + "role": "assistant", + "content": NSNull(), + "tool_calls": [ + [ + "id": id, + "type": "function", + "function": [ + "name": name, + "arguments": arguments + ] + ] + ] + ]) + + // 添加函数调用结果 + fullMessages.append([ + "role": "tool", + "content": functionResult, + "tool_call_id": id + ]) + + // 发送完整对话 + sendMessageStream(messages: fullMessages, systemPrompt: systemPrompt, callback: callback) + + } catch { + callback.onError(OpenAIError("发送函数调用结果失败: \(error.localizedDescription)")) + } + } + + /// 处理工具调用(函数调用)并回调 + private func processToolCalls(_ toolCalls: [Int: ToolCallInfo], callback: StreamCallback) { + if toolCalls.isEmpty { return } + + // 只处理第一个工具调用 + guard let firstToolCall = toolCalls.values.first, firstToolCall.isValid else { return } + + // 创建函数调用字典 + let functionCall: [String: Any] = [ + "name": firstToolCall.name, + "arguments": firstToolCall.arguments, + "id": firstToolCall.id + ] + + // 回调 + callback.onFunctionCall(functionCall) + } + + /// 流式输出回调协议 + 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/Classes/OpenAIServicePlugin.swift b/local_plugins/open_ai_service/ios/Classes/OpenAIServicePlugin.swift new file mode 100644 index 000000000..d919e2584 --- /dev/null +++ b/local_plugins/open_ai_service/ios/Classes/OpenAIServicePlugin.swift @@ -0,0 +1,215 @@ +import Flutter +import UIKit + +public class OpenAIServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler { + // OpenAI服务实例 + private let openAIService = OpenAIService() + + // 事件接收器 + private var eventSink: FlutterEventSink? + + // 注册插件 + public static func register(with registrar: FlutterPluginRegistrar) { + let methodChannel = FlutterMethodChannel(name: "com.yunqiinnovation.open_ai_service/methods", binaryMessenger: registrar.messenger()) + let eventChannel = FlutterEventChannel(name: "com.yunqiinnovation.open_ai_service/events", binaryMessenger: registrar.messenger()) + + let instance = OpenAIServicePlugin() + registrar.addMethodCallDelegate(instance, channel: methodChannel) + eventChannel.setStreamHandler(instance) + } + + // 处理方法调用 + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "initialize": + if let args = call.arguments as? [String: Any], + let apiKey = args["apiKey"] as? String { + let baseUrl = args["baseUrl"] as? String ?? "" + let model = args["model"] as? String ?? "" + let initialized = openAIService.initialize(apiKey: apiKey, baseUrl: baseUrl, model: model) + result(initialized) + } else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "初始化参数无效", details: nil)) + } + + case "registerFunction": + if let args = call.arguments as? [String: Any], + let name = args["name"] as? String, + let description = args["description"] as? String, + let parameters = args["parameters"] as? [String: Any] { + + let registered = openAIService.registerFunction(name: name, description: description, parameters: parameters) + result(registered) + } else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "函数注册参数无效", details: nil)) + } + + case "sendMessage": + guard let args = call.arguments as? [String: Any], + let messagesRaw = args["messages"] as? [[String: Any]], + let systemPrompt = args["systemPrompt"] as? String else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "发送消息参数无效", details: nil)) + return + } + + // 在后台线程执行 + DispatchQueue.global(qos: .userInitiated).async { + do { + let response = try self.openAIService.sendMessage(messages: messagesRaw, systemPrompt: systemPrompt) + // 在主线程返回结果 + DispatchQueue.main.async { + result(response) + } + } catch { + // 在主线程返回错误 + DispatchQueue.main.async { + result(FlutterError(code: "OPENAI_ERROR", message: error.localizedDescription, details: nil)) + } + } + } + + case "sendMessageStream": + guard let args = call.arguments as? [String: Any], + let messagesRaw = args["messages"] as? [[String: Any]], + let systemPrompt = args["systemPrompt"] as? String else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "发送消息参数无效", details: nil)) + return + } + + // 检查事件接收器 + guard let eventSink = self.eventSink else { + result(FlutterError(code: "NO_EVENT_SINK", message: "没有可用的事件流接收器", details: nil)) + return + } + + // 在后台线程执行 + DispatchQueue.global(qos: .userInitiated).async { + let callback: OpenAIService.StreamCallback = ( + onToken: { token in + // 发送token事件 + DispatchQueue.main.async { + eventSink(["type": "token", "content": token]) + } + }, + onComplete: { + // 发送完成事件 + DispatchQueue.main.async { + eventSink(["type": "complete"]) + } + }, + onError: { error in + // 发送错误事件 + DispatchQueue.main.async { + eventSink(["type": "error", "content": error.localizedDescription]) + } + }, + onFunctionCall: { functionCall in + // 发送函数调用事件 + DispatchQueue.main.async { + eventSink(["type": "functionCall", "content": functionCall]) + } + } + ) + + self.openAIService.sendMessageStream(messages: messagesRaw, systemPrompt: systemPrompt, callback: callback) + + // 请求已开始 + DispatchQueue.main.async { + result(true) + } + } + + case "sendFunctionCallResult": + guard let args = call.arguments as? [String: Any], + let messagesRaw = args["messages"] as? [[String: Any]], + let systemPrompt = args["systemPrompt"] as? String, + let functionCallRaw = args["functionCall"] as? [String: Any], + let functionResult = args["functionResult"] as? String else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "发送函数调用结果参数无效", details: nil)) + return + } + + // 检查事件接收器 + guard let eventSink = self.eventSink else { + result(FlutterError(code: "NO_EVENT_SINK", message: "没有可用的事件流接收器", details: nil)) + return + } + + // 在后台线程执行 + DispatchQueue.global(qos: .userInitiated).async { + let callback: OpenAIService.StreamCallback = ( + onToken: { token in + // 发送token事件 + DispatchQueue.main.async { + eventSink(["type": "token", "content": token]) + } + }, + onComplete: { + // 发送完成事件 + DispatchQueue.main.async { + eventSink(["type": "complete"]) + } + }, + onError: { error in + // 发送错误事件 + DispatchQueue.main.async { + eventSink(["type": "error", "content": error.localizedDescription]) + } + }, + onFunctionCall: { functionCall in + // 发送函数调用事件 + DispatchQueue.main.async { + eventSink(["type": "functionCall", "content": functionCall]) + } + } + ) + + self.openAIService.sendFunctionCallResult( + messages: messagesRaw, + systemPrompt: systemPrompt, + functionCall: functionCallRaw, + functionResult: functionResult, + callback: callback + ) + + // 请求已开始 + DispatchQueue.main.async { + result(true) + } + } + + case "createUserMessage": + if let args = call.arguments as? [String: Any], + let content = args["content"] as? String { + let message = openAIService.createUserMessage(content: content) + result(message) + } else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "创建用户消息参数无效", details: nil)) + } + + case "createAssistantMessage": + if let args = call.arguments as? [String: Any], + let content = args["content"] as? String { + let message = openAIService.createAssistantMessage(content: content) + result(message) + } else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "创建助手消息参数无效", details: nil)) + } + + default: + result(FlutterMethodNotImplemented) + } + } + + // MARK: - FlutterStreamHandler + + public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + self.eventSink = events + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + self.eventSink = nil + return nil + } +} \ 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 new file mode 100644 index 000000000..b946d1bff --- /dev/null +++ b/local_plugins/open_ai_service/lib/open_ai_service.dart @@ -0,0 +1,305 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.dart'; + +/// OpenAI服务异常 +class OpenAIException implements Exception { + final String message; + + OpenAIException(this.message); + + @override + String toString() => 'OpenAIException: $message'; +} + +/// OpenAI服务事件类型 +enum OpenAIEventType { + token, + complete, + error, + functionCall, +} + +/// OpenAI服务事件 +class OpenAIEvent { + final OpenAIEventType type; + final dynamic content; + + OpenAIEvent({required this.type, this.content}); + + factory OpenAIEvent.fromMap(Map map) { + final typeStr = map['type'] as String; + final content = map['content']; + + return OpenAIEvent( + type: _typeFromString(typeStr), + content: content, + ); + } + + static OpenAIEventType _typeFromString(String typeStr) { + switch (typeStr) { + case 'token': + return OpenAIEventType.token; + case 'complete': + return OpenAIEventType.complete; + case 'error': + return OpenAIEventType.error; + case 'functionCall': + return OpenAIEventType.functionCall; + default: + throw ArgumentError('未知的事件类型: $typeStr'); + } + } +} + +/// OpenAI服务插件 +class OpenAIService { + static const MethodChannel _channel = MethodChannel('com.yunqiinnovation.open_ai_service/methods'); + static const EventChannel _eventChannel = EventChannel('com.yunqiinnovation.open_ai_service/events'); + + /// 事件流控制器 + StreamController? _eventStreamController; + + /// 事件流 + Stream? _eventStream; + + /// 获取事件流 + Stream get eventStream { + if (_eventStream == null) { + _eventStreamController = StreamController.broadcast(); + _eventStream = _eventStreamController!.stream; + + // 监听原生事件 + _eventChannel.receiveBroadcastStream().listen( + (dynamic event) { + if (event is Map) { + final eventMap = Map.from(event); + final openAIEvent = OpenAIEvent.fromMap(eventMap); + _eventStreamController!.add(openAIEvent); + } + }, + onError: (error) { + _eventStreamController!.addError(OpenAIException('事件流错误: $error')); + }, + ); + } + + return _eventStream!; + } + + /// 初始化OpenAI服务 + /// + /// [apiKey] OpenAI API密钥 + /// [baseUrl] 可选,自定义API基础URL + /// [model] 可选,自定义使用的模型 + Future initialize({ + required String apiKey, + String baseUrl = '', + String model = '', + }) async { + try { + final result = await _channel.invokeMethod( + 'initialize', + { + 'apiKey': apiKey, + 'baseUrl': baseUrl, + 'model': model, + }, + ); + + return result ?? false; + } catch (e) { + throw OpenAIException('初始化失败: $e'); + } + } + + /// 注册函数 + /// + /// [name] 函数名称 + /// [description] 函数描述 + /// [parameters] 函数参数 + Future registerFunction({ + required String name, + required String description, + required Map parameters, + }) async { + try { + final result = await _channel.invokeMethod( + 'registerFunction', + { + 'name': name, + 'description': description, + 'parameters': parameters, + }, + ); + + return result ?? false; + } catch (e) { + throw OpenAIException('注册函数失败: $e'); + } + } + + /// 创建用户消息 + /// + /// [content] 消息内容 + Future> createUserMessage(String content) async { + try { + final result = await _channel.invokeMethod>( + 'createUserMessage', + {'content': content}, + ); + + if (result == null) { + throw OpenAIException('创建用户消息失败: 结果为空'); + } + + return Map.from(result); + } catch (e) { + throw OpenAIException('创建用户消息失败: $e'); + } + } + + /// 创建助手消息 + /// + /// [content] 消息内容 + Future> createAssistantMessage(String content) async { + try { + final result = await _channel.invokeMethod>( + 'createAssistantMessage', + {'content': content}, + ); + + if (result == null) { + throw OpenAIException('创建助手消息失败: 结果为空'); + } + + return Map.from(result); + } catch (e) { + throw OpenAIException('创建助手消息失败: $e'); + } + } + + /// 发送消息(非流式输出) + /// + /// [messages] 消息列表 + /// [systemPrompt] 系统提示词 + Future sendMessage({ + required List> messages, + required String systemPrompt, + }) async { + try { + final result = await _channel.invokeMethod( + 'sendMessage', + { + 'messages': messages, + 'systemPrompt': systemPrompt, + }, + ); + + if (result == null) { + throw OpenAIException('发送消息失败: 结果为空'); + } + + return result; + } catch (e) { + throw OpenAIException('发送消息失败: $e'); + } + } + + /// 发送消息(流式输出) + /// + /// [messages] 消息列表 + /// [systemPrompt] 系统提示词 + /// + /// 返回一个布尔值,表示请求是否已开始 + Future sendMessageStream({ + required List> messages, + required String systemPrompt, + }) async { + try { + final result = await _channel.invokeMethod( + 'sendMessageStream', + { + 'messages': messages, + 'systemPrompt': systemPrompt, + }, + ); + + return result ?? false; + } catch (e) { + throw OpenAIException('发送流式消息失败: $e'); + } + } + + /// 发送函数调用结果 + /// + /// [messages] 消息列表 + /// [systemPrompt] 系统提示词 + /// [functionCall] 函数调用信息 + /// [functionResult] 函数调用结果 + /// + /// 返回一个布尔值,表示请求是否已开始 + Future sendFunctionCallResult({ + required List> messages, + required String systemPrompt, + required Map functionCall, + required String functionResult, + }) async { + try { + final result = await _channel.invokeMethod( + 'sendFunctionCallResult', + { + 'messages': messages, + 'systemPrompt': systemPrompt, + 'functionCall': functionCall, + 'functionResult': functionResult, + }, + ); + + return result ?? false; + } catch (e) { + throw OpenAIException('发送函数调用结果失败: $e'); + } + } + + /// 处理流式事件 + /// + /// [onToken] 收到token时调用 + /// [onComplete] 完成时调用 + /// [onError] 错误时调用 + /// [onFunctionCall] 函数调用时调用 + StreamSubscription processEvents({ + void Function(String token)? onToken, + void Function()? onComplete, + void Function(String error)? onError, + void Function(Map functionCall)? onFunctionCall, + }) { + return eventStream.listen((event) { + switch (event.type) { + case OpenAIEventType.token: + onToken?.call(event.content as String); + break; + case OpenAIEventType.complete: + onComplete?.call(); + break; + case OpenAIEventType.error: + onError?.call(event.content as String); + break; + case OpenAIEventType.functionCall: + onFunctionCall?.call(Map.from(event.content as Map)); + break; + } + }); + } + + /// 从JSON字符串解析函数调用 + Map parseFunctionCall(String functionCallJson) { + try { + return json.decode(functionCallJson) as Map; + } catch (e) { + throw OpenAIException('解析函数调用失败: $e'); + } + } +} \ No newline at end of file diff --git a/local_plugins/open_ai_service/pubspec.yaml b/local_plugins/open_ai_service/pubspec.yaml new file mode 100644 index 000000000..8e00ed3ea --- /dev/null +++ b/local_plugins/open_ai_service/pubspec.yaml @@ -0,0 +1,26 @@ +name: open_ai_service +description: 原生OpenAI服务插件,提供与OpenAI API的交互功能,支持流式输出和函数调用。 +version: 0.0.1 +homepage: https://github.com/yunqiinnovation/deep_voice + +environment: + sdk: ">=2.17.0 <4.0.0" + flutter: ">=2.5.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +flutter: + plugin: + platforms: + android: + package: com.yunqiinnovation.open_ai_service + pluginClass: OpenAIServicePlugin + ios: + pluginClass: OpenAIServicePlugin \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 3e1dd7c90..19366500b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,6 +62,8 @@ dependencies: path: ^1.9.1 azure_speech: path: local_plugins/azure_speech + open_ai_service: + path: local_plugins/open_ai_service dev_dependencies: flutter_test: From e55aa913d19b6b653d2711f3f55ea9d0d69949d0 Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Sun, 6 Apr 2025 12:27:48 +0800 Subject: [PATCH 03/23] =?UTF-8?q?=E4=BF=AE=E6=94=B9ai=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/services/ai_service.dart | 2 +- lib/data/services/deapsound_ai_service.dart | 11 ++++-- .../chat/controllers/chat_controller.dart | 39 ++++++++++++------- lib/modules/chat/models/message_model.dart | 8 +++- 4 files changed, 41 insertions(+), 19 deletions(-) diff --git a/lib/data/services/ai_service.dart b/lib/data/services/ai_service.dart index 37a1f5df0..949dfca67 100644 --- a/lib/data/services/ai_service.dart +++ b/lib/data/services/ai_service.dart @@ -7,7 +7,7 @@ abstract class AiService { }); /// 流式输出方法 - Stream sendMessageStream({ + Stream sendMessageStream({ required List> messages, required String systemPrompt, }); diff --git a/lib/data/services/deapsound_ai_service.dart b/lib/data/services/deapsound_ai_service.dart index 5d1e652b1..dc85acebb 100644 --- a/lib/data/services/deapsound_ai_service.dart +++ b/lib/data/services/deapsound_ai_service.dart @@ -23,7 +23,7 @@ class DeapsoundAIService implements AiService { } @override - Stream sendMessageStream({ + Stream sendMessageStream({ required List> messages, required String systemPrompt, }) async* { @@ -67,10 +67,13 @@ class DeapsoundAIService implements AiService { try { final data = jsonDecode(jsonStr); - if (data['event'] == 'workflow_finished') { - break; + if (data['role'] == 'card') { + yield data['meta']; + if (data['meta']['musicaudio'] != null) { + break; + } } - if (data['content'] != null) { + if (data['role'] == 'ai') { String content = data['content'] ?? ''; if (content.isNotEmpty) { yield content; diff --git a/lib/modules/chat/controllers/chat_controller.dart b/lib/modules/chat/controllers/chat_controller.dart index 4d0457c98..4b62a1a9f 100644 --- a/lib/modules/chat/controllers/chat_controller.dart +++ b/lib/modules/chat/controllers/chat_controller.dart @@ -343,14 +343,18 @@ class ChatController extends GetxController { (chunk) { if (_shouldCancelAiResponse) return; - fullResponse += chunk; - - // 更新加载中的消息内容 - _updateLoadingMessage(fullResponse); - - // 如果TTS启用,使用流式处理发送到TTS - if (isTtsEnabled.value) { - _ttsService.speakStream(chunk); + if (chunk is Map) { + _updateLoadingMessage(data: chunk); + } else { + fullResponse += chunk; + + // 更新加载中的消息内容 + _updateLoadingMessage(content: fullResponse); + + // 如果TTS启用,使用流式处理发送到TTS + if (isTtsEnabled.value) { + _ttsService.speakStream(chunk); + } } }, onDone: () { @@ -402,13 +406,22 @@ class ChatController extends GetxController { } // 更新加载中的消息 - void _updateLoadingMessage(String content) { + void _updateLoadingMessage({Map? data, String? content}) { final index = messages.indexWhere((m) => m.isLoading); if (index != -1) { - messages[index] = messages[index].copyWith( - content: content, - isLoading: true, - ); + if (data != null) { + Map messagesData = messages[index].data ?? {}; + messages[index] = messages[index].copyWith( + data: {...messagesData, ...data}, + isLoading: true, + ); + } + if (content != null) { + messages[index] = messages[index].copyWith( + content: content, + isLoading: true, + ); + } messages.refresh(); _scrollToBottom(); } diff --git a/lib/modules/chat/models/message_model.dart b/lib/modules/chat/models/message_model.dart index 6307989ae..69a7a5b76 100644 --- a/lib/modules/chat/models/message_model.dart +++ b/lib/modules/chat/models/message_model.dart @@ -1,6 +1,7 @@ class Message { final String role; // 'user' or 'assistant' final String content; + final Map? data; final DateTime timestamp; final bool isLoading; @@ -8,6 +9,7 @@ class Message { required this.role, required this.content, required this.timestamp, + this.data, this.isLoading = false, }); @@ -16,6 +18,7 @@ class Message { return Message( role: json['role'] as String, content: json['content'] as String, + data: json['data'], timestamp: DateTime.parse(json['timestamp'] as String), isLoading: json['isLoading'] as bool? ?? false, ); @@ -26,6 +29,7 @@ class Message { return { 'role': role, 'content': content, + 'data': data, 'timestamp': timestamp.toIso8601String(), 'isLoading': isLoading, }; @@ -45,14 +49,16 @@ class Message { Message copyWith({ String? role, String? content, + Map? data, DateTime? timestamp, bool? isLoading, }) { return Message( role: role ?? this.role, content: content ?? this.content, + data: data ?? this.data, timestamp: timestamp ?? this.timestamp, isLoading: isLoading ?? this.isLoading, ); } -} \ No newline at end of file +} From f651fdf0f8ce90985a120a5dbb20d06cc385331f Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Sun, 6 Apr 2025 17:24:03 +0800 Subject: [PATCH 04/23] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E9=9F=B3=E4=B9=90=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/modules/chat/bindings/chat_binding.dart | 9 +- .../chat/controllers/chat_controller.dart | 35 ++++++++ lib/modules/chat/views/chat_music_card.dart | 90 +++++++++++++++++++ lib/modules/chat/views/chat_view.dart | 50 +++++++---- 4 files changed, 158 insertions(+), 26 deletions(-) create mode 100644 lib/modules/chat/views/chat_music_card.dart diff --git a/lib/modules/chat/bindings/chat_binding.dart b/lib/modules/chat/bindings/chat_binding.dart index 5618dccaa..6b6ae6683 100644 --- a/lib/modules/chat/bindings/chat_binding.dart +++ b/lib/modules/chat/bindings/chat_binding.dart @@ -1,17 +1,10 @@ import 'package:get/get.dart'; import '../controllers/chat_controller.dart'; -import '../../../data/services/speech_impl/azure_asr_service.dart'; -import '../../../data/services/speech_impl/azure_tts_service.dart'; -import '../../../data/services/asr_service.dart'; -import '../../../data/services/tts_service.dart'; -import '../../../data/services/volcano_ai_service.dart'; -import '../../../data/services/chat_history_service.dart'; class ChatBinding extends Bindings { @override void dependencies() { - // 注册控制器 Get.lazyPut(() => ChatController()); } -} \ No newline at end of file +} diff --git a/lib/modules/chat/controllers/chat_controller.dart b/lib/modules/chat/controllers/chat_controller.dart index 4b62a1a9f..e92c85538 100644 --- a/lib/modules/chat/controllers/chat_controller.dart +++ b/lib/modules/chat/controllers/chat_controller.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:get/get.dart'; import 'package:flutter/widgets.dart'; import 'package:get_storage/get_storage.dart'; +import 'package:just_audio/just_audio.dart'; import '../../../data/services/ai_service.dart'; import '../../../data/services/deapsound_ai_service.dart'; import '../../../data/services/kouzi_ai_service.dart'; @@ -72,6 +73,10 @@ class ChatController extends GetxController { // 记录最后一次活动时间 DateTime _lastActivityTime = DateTime.now(); + final _player = AudioPlayer(); + final RxString audiourl = ''.obs; + final RxBool isPlay = false.obs; + @override Future onInit() async { super.onInit(); @@ -248,6 +253,8 @@ class ChatController extends GetxController { _historyService.saveHistory(agent.id!, messages.toList()); } + _player.dispose(); + super.onClose(); } @@ -345,6 +352,9 @@ class ChatController extends GetxController { if (chunk is Map) { _updateLoadingMessage(data: chunk); + if (chunk['musicaudio'] != null) { + play(chunk['musicaudio']); + } } else { fullResponse += chunk; @@ -427,6 +437,31 @@ class ChatController extends GetxController { } } +// 播放音乐 + void play(Map musicaudio) async { + try { + isPlay.value = false; + if (audiourl.value != musicaudio['audiourl']) { + audiourl.value = musicaudio['audiourl']; + await _player.setUrl(musicaudio['audiourl']); + _player.play(); + } else { + _player.play(); + } + isPlay.value = true; + } catch (e) { + isPlay.value = false; + audiourl.value = ''; + Logger.error('加载异常: $e'); + } + } + + // 暂停播放音乐 + void pause() async { + await _player.pause(); + isPlay.value = false; + } + // 完成响应 void _finalizeResponse(String content) { final index = messages.indexWhere((m) => m.isLoading); diff --git a/lib/modules/chat/views/chat_music_card.dart b/lib/modules/chat/views/chat_music_card.dart new file mode 100644 index 000000000..0ae512a42 --- /dev/null +++ b/lib/modules/chat/views/chat_music_card.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; + +import '../controllers/chat_controller.dart'; + +class ChatMusicCard extends GetView { + final Map? data; + final bool isDarkMode; + + const ChatMusicCard(this.data, this.isDarkMode, {super.key}); + + @override + Widget build(BuildContext context) { + final Map musiccards = data?['musiccards'].first; + final Map musicaudio = data?['musicaudio']; + + return Row( + children: [ + Container( + width: 60.w, + height: 60.w, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6.r), + ), + child: Image.network( + musiccards['image'], + fit: BoxFit.cover, + ), + ), + 10.horizontalSpace, + Expanded( + child: Text( + musiccards['name'], + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black, + fontSize: 16.sp, + height: 1.5, + ), + ), + ), + 10.horizontalSpace, + Obx( + () => controller.audiourl == musicaudio['audiourl'] && + controller.isPlay.value + ? GestureDetector( + onTap: () { + controller.pause(); + }, + child: Container( + width: 38.w, + height: 38.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: + isDarkMode ? Colors.grey[700]! : Colors.grey[300]!, + width: 2, + ), + ), + child: Icon( + Icons.pause_rounded, + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + ) + : GestureDetector( + onTap: () { + controller.play(musicaudio); + }, + child: Container( + width: 38.w, + height: 38.w, + decoration: BoxDecoration( + color: isDarkMode ? Colors.white : Colors.black, + shape: BoxShape.circle, + ), + child: Icon( + Icons.play_arrow_rounded, + color: isDarkMode ? Colors.black : Colors.white, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/modules/chat/views/chat_view.dart b/lib/modules/chat/views/chat_view.dart index 4fbc6c261..2c6adee7b 100644 --- a/lib/modules/chat/views/chat_view.dart +++ b/lib/modules/chat/views/chat_view.dart @@ -5,6 +5,7 @@ import 'dart:math'; import '../controllers/chat_controller.dart'; import '../models/message_model.dart'; import '../../../core/theme/app_colors.dart'; +import 'chat_music_card.dart'; class ChatView extends GetView { const ChatView({Key? key}) : super(key: key); @@ -297,19 +298,7 @@ class ChatView extends GetView { crossAxisAlignment: CrossAxisAlignment.start, children: [ // 消息内容 - if (message.content.isNotEmpty) - SelectableText( - '${message.content}'.tr, - style: TextStyle( - color: isUser - ? Colors.white - : isDarkMode - ? Colors.white - : Colors.black, - fontSize: 16.sp, - height: 1.5, - ), - ), + _buildMessageContent(message, isDarkMode, isUser), // 加载中动画 if (message.isLoading) @@ -348,6 +337,30 @@ class ChatView extends GetView { ); } + Widget _buildMessageContent(Message message, bool isDarkMode, bool isUser) { + Widget box = const SizedBox(); + if (message.content.isNotEmpty) { + box = SelectableText( + message.content.tr, + style: TextStyle( + color: isUser + ? Colors.white + : isDarkMode + ? Colors.white + : Colors.black, + fontSize: 16.sp, + height: 1.5, + ), + ); + } + if (message.data != null) { + if (message.data?['musicaudio'] != null) { + box = ChatMusicCard(message.data, isDarkMode); + } + } + return box; + } + // 构建输入区域 Widget _buildInputArea(bool isDarkMode) { final textController = @@ -715,21 +728,22 @@ class ChatView extends GetView { ); } - // 格式化时间 + // 格式化时间 String _formatTime(DateTime time) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); //final yesterday = today.subtract(const Duration(days: 1)); final messageDate = DateTime(time.year, time.month, time.day); - String timeStr = '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; + String timeStr = + '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; if (messageDate == today) { - return timeStr; // 只显示时间,如 "14:30" - } + return timeStr; // 只显示时间,如 "14:30" + } // else if (messageDate == yesterday) { // return 'Yesterday $timeStr'; // 如 "Yesterday 14:30" - // } + // } else { // 国际通用日期格式 "MM-dd HH:mm" return '${time.month.toString().padLeft(2, '0')}-${time.day.toString().padLeft(2, '0')} $timeStr'; From a7bcaf5e433e68763f5989f29914e622d988e24f Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Sun, 6 Apr 2025 18:04:33 +0800 Subject: [PATCH 05/23] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E9=9F=B3=E4=B9=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/controllers/chat_controller.dart | 20 ++++++--- lib/modules/chat/views/chat_music_card.dart | 45 +++++++++++++------ 2 files changed, 45 insertions(+), 20 deletions(-) diff --git a/lib/modules/chat/controllers/chat_controller.dart b/lib/modules/chat/controllers/chat_controller.dart index e92c85538..77c4e030b 100644 --- a/lib/modules/chat/controllers/chat_controller.dart +++ b/lib/modules/chat/controllers/chat_controller.dart @@ -73,7 +73,7 @@ class ChatController extends GetxController { // 记录最后一次活动时间 DateTime _lastActivityTime = DateTime.now(); - final _player = AudioPlayer(); + final player = AudioPlayer(); final RxString audiourl = ''.obs; final RxBool isPlay = false.obs; @@ -157,6 +157,14 @@ class ChatController extends GetxController { // 订阅VoiceInteractionService事件 _subscribeToVoiceInteractionEvents(); + player.playerStateStream.listen((state) async { + if (state.processingState == ProcessingState.completed) { + await player.seek(Duration.zero); + player.pause(); + isPlay.value = false; + } + }); + // 使用微任务在布局完成后自动滚动到底部 WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToBottom(); @@ -253,7 +261,7 @@ class ChatController extends GetxController { _historyService.saveHistory(agent.id!, messages.toList()); } - _player.dispose(); + player.dispose(); super.onClose(); } @@ -443,10 +451,10 @@ class ChatController extends GetxController { isPlay.value = false; if (audiourl.value != musicaudio['audiourl']) { audiourl.value = musicaudio['audiourl']; - await _player.setUrl(musicaudio['audiourl']); - _player.play(); + await player.setUrl(musicaudio['audiourl']); + player.play(); } else { - _player.play(); + player.play(); } isPlay.value = true; } catch (e) { @@ -458,7 +466,7 @@ class ChatController extends GetxController { // 暂停播放音乐 void pause() async { - await _player.pause(); + await player.pause(); isPlay.value = false; } diff --git a/lib/modules/chat/views/chat_music_card.dart b/lib/modules/chat/views/chat_music_card.dart index 0ae512a42..9cade7e3e 100644 --- a/lib/modules/chat/views/chat_music_card.dart +++ b/lib/modules/chat/views/chat_music_card.dart @@ -49,21 +49,38 @@ class ChatMusicCard extends GetView { onTap: () { controller.pause(); }, - child: Container( - width: 38.w, - height: 38.w, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: - isDarkMode ? Colors.grey[700]! : Colors.grey[300]!, - width: 2, + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 38.w, + height: 38.w, + child: StreamBuilder( + stream: controller.player.positionStream, + builder: (context, snapshot) { + final position = snapshot.data ?? Duration.zero; + final total = + controller.player.duration ?? Duration.zero; + final progress = total.inSeconds > 0 + ? position.inSeconds / total.inSeconds + : 0.0; + return CircularProgressIndicator( + value: progress, + backgroundColor: isDarkMode + ? Colors.grey[700]! + : Colors.grey[300]!, + valueColor: AlwaysStoppedAnimation( + isDarkMode ? Colors.white : Colors.black), + strokeWidth: 2, // 线条粗细 + ); + }, + ), ), - ), - child: Icon( - Icons.pause_rounded, - color: isDarkMode ? Colors.white : Colors.black, - ), + Icon( + Icons.pause_rounded, + color: isDarkMode ? Colors.white : Colors.black, + ), + ], ), ) : GestureDetector( From 9687daaa7c5481d474e614a2321a5d18d01ee861 Mon Sep 17 00:00:00 2001 From: wolfplus Date: Sun, 6 Apr 2025 13:52:33 +0100 Subject: [PATCH 06/23] init --- a | 18 - android/app/src/main/AndroidManifest.xml | 9 + .../yunqiinnovation/deepsound/MainActivity.kt | 104 ++++- .../deepsound/SystemFunctionHandler.kt | 225 ++++++++++ .../deepsound/VoiceFunctionHandler.kt | 336 ++++++++++++++- .../deepsound/VoiceInteractionHandler.kt | 19 +- .../deepsound/VoiceInteractionService.kt | 7 +- lib/core/bindings/initial_binding.dart | 9 + .../events/voice_interaction_event.dart | 13 + lib/data/services/ai_service.dart | 2 + .../services/open_ai_service_adapter.dart | 247 +++++++++++ .../services/voice_interaction_service.dart | 24 ++ .../chat/controllers/chat_controller.dart | 12 +- .../azure_speech/AzureAsrHelper.kt | 4 +- local_plugins/open_ai_service/README.md | 405 +++++++----------- .../open_ai_service/OpenAIService.kt | 155 ++++--- .../open_ai_service/OpenAIServicePlugin.kt | 7 +- .../open_ai_service/lib/open_ai_service.dart | 38 -- test.json | 1 + 19 files changed, 1225 insertions(+), 410 deletions(-) delete mode 100644 a create mode 100644 android/app/src/main/kotlin/com/yunqiinnovation/deepsound/SystemFunctionHandler.kt create mode 100644 lib/data/services/open_ai_service_adapter.dart create mode 100644 test.json diff --git a/a b/a deleted file mode 100644 index 7133736ae..000000000 --- a/a +++ /dev/null @@ -1,18 +0,0 @@ -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": "You are a helpful assistant." - }, - { - "role": "user", - "content": "Hello!" - } - ] -}' \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f120cc1f7..23c380fb0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,6 +27,15 @@ + + + + + + + + = Build.VERSION_CODES.S) { + arrayOf( + Manifest.permission.RECORD_AUDIO, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.SEND_SMS, + Manifest.permission.READ_CONTACTS, + Manifest.permission.CALL_PHONE, + Manifest.permission.POST_NOTIFICATIONS + ) + } else { + arrayOf( + Manifest.permission.RECORD_AUDIO, + Manifest.permission.BLUETOOTH, + Manifest.permission.BLUETOOTH_ADMIN, + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.SEND_SMS, + Manifest.permission.READ_CONTACTS, + Manifest.permission.CALL_PHONE + ) + } + // 广播接收器 private val voiceInteractionReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - Log.d(TAG, "收到广播: ${intent?.action}") + FileLogger.d(TAG, "收到广播: ${intent?.action}") when (intent?.action) { VoiceInteractionService.ACTION_RECOGNITION_STARTED -> { val timestamp = intent.getLongExtra("timestamp", 0) - Log.d(TAG, "收到语音识别启动广播: timestamp=$timestamp") sendVoiceInteractionEvent(mapOf( "type" to "recognition_started", "timestamp" to timestamp @@ -54,6 +82,12 @@ class MainActivity: FlutterActivity() { sendChatHistoryEvent(agentId, userMessage, assistantMessage, timestamp) } + VoiceInteractionService.ACTION_ENTER_TRANSLATION_MODE -> { + sendVoiceInteractionEvent(mapOf( + "type" to "enter_translation_mode", + "timestamp" to System.currentTimeMillis() + )) + } } } } @@ -249,6 +283,9 @@ class MainActivity: FlutterActivity() { // 初始化 FileLogger FileLogger.init(applicationContext) + // 请求必要权限 + requestRequiredPermissions() + // 初始化 Azure 语音服务 classicBluetoothHelper = ClassicBluetoothHelper(applicationContext) @@ -267,7 +304,14 @@ class MainActivity: FlutterActivity() { val voiceInteractionFilter = IntentFilter().apply { addAction(VoiceInteractionService.ACTION_RECOGNITION_STARTED) addAction(VoiceInteractionService.ACTION_CHAT_HISTORY_UPDATED) + addAction(VoiceInteractionService.ACTION_ENTER_TRANSLATION_MODE) } + + // 打印已注册的广播 + FileLogger.d(TAG, "已注册语音交互广播:${VoiceInteractionService.ACTION_RECOGNITION_STARTED}, " + + "${VoiceInteractionService.ACTION_CHAT_HISTORY_UPDATED}, " + + "${VoiceInteractionService.ACTION_ENTER_TRANSLATION_MODE}") + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { registerReceiver(voiceInteractionReceiver, voiceInteractionFilter, Context.RECEIVER_NOT_EXPORTED) } else { @@ -550,14 +594,19 @@ class MainActivity: FlutterActivity() { private fun sendVoiceInteractionEvent(event: Map) { if (voiceInteractionEventSink == null) { Log.e(TAG, "无法发送语音交互事件:事件通道未准备好") + FileLogger.e(TAG, "无法发送语音交互事件:事件通道未准备好") return } + FileLogger.d(TAG, "准备发送事件到Flutter: ${event["type"]}") + runOnUiThread { try { voiceInteractionEventSink?.success(event) + FileLogger.d(TAG, "成功发送事件到Flutter: ${event["type"]}") } catch (e: Exception) { - Log.e(TAG, "发送语音交互事件到Flutter失败: ${e.message}", e) + Log.e(TAG, "发送语音交互事件到Flutter失败: ${e.message}") + FileLogger.e(TAG, "发送语音交互事件到Flutter失败: ${e.message}") } } } @@ -598,5 +647,54 @@ class MainActivity: FlutterActivity() { super.onDestroy() } + + /** + * 请求必要权限 + */ + private fun requestRequiredPermissions() { + val permissionsToRequest = ArrayList() + + for (permission in REQUIRED_PERMISSIONS) { + if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { + permissionsToRequest.add(permission) + } + } + + if (permissionsToRequest.isNotEmpty()) { + ActivityCompat.requestPermissions( + this, + permissionsToRequest.toTypedArray(), + PERMISSION_REQUEST_CODE + ) + } + } + + /** + * 处理权限请求结果 + */ + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + + if (requestCode == PERMISSION_REQUEST_CODE) { + val deniedPermissions = ArrayList() + + for (i in permissions.indices) { + if (grantResults[i] != PackageManager.PERMISSION_GRANTED) { + deniedPermissions.add(permissions[i]) + } + } + + if (deniedPermissions.isNotEmpty()) { + // 记录未授权的权限 + FileLogger.w(TAG, "未授权的权限: ${deniedPermissions.joinToString()}") + } else { + FileLogger.d(TAG, "所有必要权限已授权") + } + } + } } diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/SystemFunctionHandler.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/SystemFunctionHandler.kt new file mode 100644 index 000000000..4e28f26aa --- /dev/null +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/SystemFunctionHandler.kt @@ -0,0 +1,225 @@ +package com.yunqiinnovation.deepsound + +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 com.yunqiinnovation.deepsound.core.utils.FileLogger + +/** + * 系统功能处理器 - 负责调用系统API执行各种功能 + */ +class SystemFunctionHandler(private val context: Context) { + companion object { + private const val TAG = "SystemFunctionHandler" + } + + /** + * 启动翻译模式 + * @return 操作结果 + */ + fun enterTranslationMode(): Result { + FileLogger.d(TAG, "进入翻译模式") + // 此处应添加实际的翻译模式实现 + // 可能需要启动一个专门的翻译服务或活动 + + // 广播通知APP的其他部分进入翻译模式 + // 使用VoiceInteractionService中定义的常量 + val intent = Intent(VoiceInteractionService.ACTION_ENTER_TRANSLATION_MODE).apply { + + putExtra("timestamp", System.currentTimeMillis()) + } + + // 发送广播 + context.sendBroadcast(intent) + FileLogger.d(TAG, "已发送广播: ${VoiceInteractionService.ACTION_ENTER_TRANSLATION_MODE}") + + return Result( + success = true, + message = "已进入翻译模式" + ) + } + + /** + * 发送短信 + * @param contact 联系人(名称或电话号码) + * @param message 短信内容 + * @return 操作结果 + */ + fun sendTextMessage(contact: String, message: String): Result { + FileLogger.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) { + FileLogger.e(TAG, "发送短信失败: ${e.message}", e) + return Result( + success = false, + message = "发送短信失败: ${e.message}" + ) + } + } + + /** + * 拨打电话 + * @param contact 联系人(名称或电话号码) + * @return 操作结果 + */ + fun makePhoneCall(contact: String): Result { + FileLogger.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) { + FileLogger.e(TAG, "拨打电话失败: ${e.message}", e) + return Result( + success = false, + message = "拨打电话失败: ${e.message}" + ) + } + } + + /** + * 打开应用程序 + * @param appName 应用名称 + * @return 操作结果 + */ + fun openApp(appName: String): Result { + FileLogger.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) { + FileLogger.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) { + FileLogger.e(TAG, "查询联系人信息失败: ${e.message}", e) + } + + return "" + } + + /** + * 操作结果数据类 + */ + data class Result( + val success: Boolean, + val message: String + ) +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceFunctionHandler.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceFunctionHandler.kt index 78a55b642..c15ca7e17 100644 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceFunctionHandler.kt +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceFunctionHandler.kt @@ -4,18 +4,22 @@ import org.json.JSONArray import org.json.JSONObject import com.yunqiinnovation.open_ai_service.OpenAIService import com.yunqiinnovation.deepsound.core.utils.FileLogger +import android.content.Context /** * 语音功能处理器 - 处理AI函数调用 */ class VoiceFunctionHandler( private val openAIService: OpenAIService, - private val systemPrompt: String + private val context: Context ) { companion object { private const val TAG = "VoiceFunctionHandler" } + // 添加系统功能处理器 + private val systemFunctionHandler = SystemFunctionHandler(context) + /** * 初始化并注册所有可用的函数 */ @@ -24,6 +28,18 @@ class VoiceFunctionHandler( // 注册退出交互函数 registerExitInteractionFunction() + // 注册翻译模式函数 + registerTranslationModeFunction() + + // 注册发送短信函数 + registerSendTextMessageFunction() + + // 注册拨打电话函数 + registerMakePhoneCallFunction() + + // 注册打开应用函数 + registerOpenAppFunction() + // 在这里可以注册更多函数 } catch (e: Exception) { @@ -53,6 +69,113 @@ class VoiceFunctionHandler( } } + /** + * 注册翻译模式函数 + */ + private fun registerTranslationModeFunction() { + try { + openAIService.registerFunction( + "enter_translation_mode", + "进入翻译模式", + JSONObject(""" + { + "type": "object", + "properties": {}, + "required": [] + } + """) + ) + FileLogger.d(TAG, "翻译模式功能已注册") + } catch (e: Exception) { + FileLogger.e(TAG, "注册翻译模式函数失败: ${e.message}", e) + } + } + + /** + * 注册发送短信函数 + */ + private fun registerSendTextMessageFunction() { + try { + openAIService.registerFunction( + "send_text_message", + "发送短信", + JSONObject(""" + { + "type": "object", + "properties": { + "contact": { + "type": "string", + "description": "联系人姓名或电话号码" + }, + "message": { + "type": "string", + "description": "短信内容" + } + }, + "required": ["contact", "message"] + } + """) + ) + FileLogger.d(TAG, "发送短信功能已注册") + } catch (e: Exception) { + FileLogger.e(TAG, "注册发送短信函数失败: ${e.message}", e) + } + } + + /** + * 注册拨打电话函数 + */ + private fun registerMakePhoneCallFunction() { + try { + openAIService.registerFunction( + "make_phone_call", + "拨打电话", + JSONObject(""" + { + "type": "object", + "properties": { + "contact": { + "type": "string", + "description": "联系人姓名或电话号码" + } + }, + "required": ["contact"] + } + """) + ) + FileLogger.d(TAG, "拨打电话功能已注册") + } catch (e: Exception) { + FileLogger.e(TAG, "注册拨打电话函数失败: ${e.message}", e) + } + } + + /** + * 注册打开应用函数 + */ + private fun registerOpenAppFunction() { + try { + openAIService.registerFunction( + "open_app", + "打开应用", + JSONObject(""" + { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "应用名称" + } + }, + "required": ["app_name"] + } + """) + ) + FileLogger.d(TAG, "打开应用功能已注册") + } catch (e: Exception) { + FileLogger.e(TAG, "注册打开应用函数失败: ${e.message}", e) + } + } + /** * 处理函数调用 * @@ -74,6 +197,22 @@ class VoiceFunctionHandler( handleExitInteraction(functionCall, messages, callback) true } + "enter_translation_mode" -> { + handleEnterTranslationMode(functionCall, messages, callback) + true + } + "send_text_message" -> { + handleSendTextMessage(functionCall, messages, callback) + true + } + "make_phone_call" -> { + handleMakePhoneCall(functionCall, messages, callback) + true + } + "open_app" -> { + handleOpenApp(functionCall, messages, callback) + true + } else -> { // 未知函数,返回默认结果 handleUnknownFunction(functionCall, messages, callback) @@ -96,7 +235,6 @@ class VoiceFunctionHandler( openAIService.sendFunctionCallResult( messages = messages, - systemPrompt = systemPrompt, functionCall = functionCall, functionResult = "{\"result\": \"已退出语音交互\"}", callback = object : OpenAIService.StreamCallback { @@ -126,6 +264,199 @@ class VoiceFunctionHandler( ) } + /** + * 处理进入翻译模式函数 + */ + private fun handleEnterTranslationMode( + functionCall: JSONObject, + messages: JSONArray, + callback: FunctionCallCallback + ) { + FileLogger.d(TAG, "处理进入翻译模式函数") + + // 调用系统功能处理器执行翻译模式操作 + val result = systemFunctionHandler.enterTranslationMode() + + val responseBuilder = StringBuilder() + + openAIService.sendFunctionCallResult( + messages = messages, + functionCall = functionCall, + functionResult = "{\"result\": \"${result.message}\"}", + callback = object : OpenAIService.StreamCallback { + override fun onToken(token: String) { + responseBuilder.append(token) + callback.onTokenReceived(token) + } + + override fun onComplete() { + FileLogger.d(TAG, "handleEnterTranslationMode onComplete: ${responseBuilder.toString()}") + callback.onComplete() + } + + override fun onError(e: Exception) { + FileLogger.e(TAG, "处理进入翻译模式函数调用出错: ${e.message}") + callback.onError("进入翻译模式时出错") + } + + override fun onFunctionCall(nestedCall: JSONObject) { + callback.onFunctionCall(nestedCall) + } + } + ) + } + + /** + * 处理发送短信函数 + */ + private fun handleSendTextMessage( + functionCall: JSONObject, + messages: JSONArray, + callback: FunctionCallCallback + ) { + FileLogger.d(TAG, "处理发送短信函数") + + try { + val arguments = JSONObject(functionCall.getString("arguments")) + val contact = arguments.getString("contact") + val message = arguments.getString("message") + + // 调用系统功能处理器发送短信 + val result = systemFunctionHandler.sendTextMessage(contact, message) + + val responseBuilder = StringBuilder() + + openAIService.sendFunctionCallResult( + messages = messages, + functionCall = functionCall, + functionResult = "{\"result\": \"${result.message}\"}", + callback = object : OpenAIService.StreamCallback { + override fun onToken(token: String) { + responseBuilder.append(token) + callback.onTokenReceived(token) + } + + override fun onComplete() { + FileLogger.d(TAG, "handleSendTextMessage onComplete: ${responseBuilder.toString()}") + callback.onComplete() + } + + override fun onError(e: Exception) { + FileLogger.e(TAG, "处理发送短信函数调用出错: ${e.message}") + callback.onError("发送短信时出错") + } + + override fun onFunctionCall(nestedCall: JSONObject) { + callback.onFunctionCall(nestedCall) + } + } + ) + } catch (e: Exception) { + FileLogger.e(TAG, "解析发送短信参数失败: ${e.message}") + callback.onError("发送短信参数错误") + } + } + + /** + * 处理拨打电话函数 + */ + private fun handleMakePhoneCall( + functionCall: JSONObject, + messages: JSONArray, + callback: FunctionCallCallback + ) { + FileLogger.d(TAG, "处理拨打电话函数") + + try { + val arguments = JSONObject(functionCall.getString("arguments")) + val contact = arguments.getString("contact") + + // 调用系统功能处理器拨打电话 + val result = systemFunctionHandler.makePhoneCall(contact) + + val responseBuilder = StringBuilder() + + openAIService.sendFunctionCallResult( + messages = messages, + functionCall = functionCall, + functionResult = "{\"result\": \"${result.message}\"}", + callback = object : OpenAIService.StreamCallback { + override fun onToken(token: String) { + responseBuilder.append(token) + callback.onTokenReceived(token) + } + + override fun onComplete() { + FileLogger.d(TAG, "handleMakePhoneCall onComplete: ${responseBuilder.toString()}") + callback.onComplete() + } + + override fun onError(e: Exception) { + FileLogger.e(TAG, "处理拨打电话函数调用出错: ${e.message}") + callback.onError("拨打电话时出错") + } + + override fun onFunctionCall(nestedCall: JSONObject) { + callback.onFunctionCall(nestedCall) + } + } + ) + } catch (e: Exception) { + FileLogger.e(TAG, "解析拨打电话参数失败: ${e.message}") + callback.onError("拨打电话参数错误") + } + } + + /** + * 处理打开应用函数 + */ + private fun handleOpenApp( + functionCall: JSONObject, + messages: JSONArray, + callback: FunctionCallCallback + ) { + FileLogger.d(TAG, "处理打开应用函数") + + try { + val arguments = JSONObject(functionCall.getString("arguments")) + val appName = arguments.getString("app_name") + + // 调用系统功能处理器打开应用 + val result = systemFunctionHandler.openApp(appName) + + val responseBuilder = StringBuilder() + + openAIService.sendFunctionCallResult( + messages = messages, + functionCall = functionCall, + functionResult = "{\"result\": \"${result.message}\"}", + callback = object : OpenAIService.StreamCallback { + override fun onToken(token: String) { + responseBuilder.append(token) + callback.onTokenReceived(token) + } + + override fun onComplete() { + FileLogger.d(TAG, "handleOpenApp onComplete: ${responseBuilder.toString()}") + callback.onComplete() + } + + override fun onError(e: Exception) { + FileLogger.e(TAG, "处理打开应用函数调用出错: ${e.message}") + callback.onError("打开应用时出错") + } + + override fun onFunctionCall(nestedCall: JSONObject) { + callback.onFunctionCall(nestedCall) + } + } + ) + } catch (e: Exception) { + FileLogger.e(TAG, "解析打开应用参数失败: ${e.message}") + callback.onError("打开应用参数错误") + } + } + /** * 处理未知函数调用 */ @@ -139,7 +470,6 @@ class VoiceFunctionHandler( try { openAIService.sendFunctionCallResult( messages = messages, - systemPrompt = systemPrompt, functionCall = functionCall, functionResult = "{\"result\": \"处理函数调用中\"}", callback = object : OpenAIService.StreamCallback { 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 fa179c279..1d6a528b3 100644 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt @@ -30,21 +30,9 @@ class VoiceInteractionHandler( // OpenAI服务 private val openAIService = OpenAIService() - // 系统提示词 - private val systemPrompt = """ - 你是一个智能语音助手,能够简洁明了地回答用户的问题。 -时刻关心用户的情绪和需求,主动提供鼓励和温暖。 -语言风格活泼、亲切,能够幽默地互动,陪伴用户,缓解压力,增添生活乐趣。 - -请始终以用户为中心,保持回应的高效性、准确性和温暖体贴,成为用户真正的灵魂伴侣。 - - 当用户说"退出"、"再见"、"结束对话"等类似意图时,你应该使用exit_interaction函数来结束对话, - 并在结束前说一句友好的告别语,例如"再见,有需要随时找我"。 - """.trimIndent() - // 语音功能处理 - private val voiceFunctionHandler = VoiceFunctionHandler(openAIService, systemPrompt) + private val voiceFunctionHandler = VoiceFunctionHandler(openAIService, context) // 当前用户输入 private var currentUserInput = "" @@ -127,12 +115,14 @@ class VoiceInteractionHandler( override fun onResult(result: String, detectedLanguage: String) { if (result.isNotEmpty()) { + notifyStateChanged() + processWithOpenAI(result) + } // 重置状态,继续识别 hasSpeechDetected = false - notifyStateChanged() } override fun onSessionStarted() { @@ -227,7 +217,6 @@ class VoiceInteractionHandler( openAIService.sendMessageStream( messages = messages, - systemPrompt = systemPrompt, callback = object : OpenAIService.StreamCallback { override fun onToken(token: String) { // 累加响应内容 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 76faa6f33..1cb132e1a 100644 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt @@ -46,7 +46,7 @@ class VoiceInteractionService : Service() { private const val CHANNEL_ID = "voice_interaction_channel" // 语音识别超时时间(毫秒) - private const val RECOGNITION_TIMEOUT = 8000L + private const val RECOGNITION_TIMEOUT = 10000L // 用于跟踪服务是否正在运行 private val isRunning = AtomicBoolean(false) @@ -58,6 +58,7 @@ class VoiceInteractionService : Service() { const val ACTION_RECOGNITION_STARTED = "com.yunqiinnovation.deepsound.ACTION_RECOGNITION_STARTED" const val ACTION_PAUSE_VOICE_INTERACTION = "com.yunqiinnovation.deepsound.ACTION_PAUSE_VOICE_INTERACTION" const val ACTION_CHAT_HISTORY_UPDATED = "com.yunqiinnovation.deepsound.ACTION_CHAT_HISTORY_UPDATED" + const val ACTION_ENTER_TRANSLATION_MODE = "com.yunqiinnovation.deepsound.ACTION_ENTER_TRANSLATION_MODE" } // 服务状态 @@ -293,12 +294,12 @@ class VoiceInteractionService : Service() { if (!isActive) { isActive = true } - + // FileLogger.d(TAG, "服务状态: isActive=${isActive}, isRecognitionActive=${voiceInteractionHandler.isRecognitionActive}, isTimeoutPaused=${isTimeoutPaused}") // 检查语音识别状态 if (voiceInteractionHandler.isRecognitionActive) { val currentTime = System.currentTimeMillis() val elapsedTime = currentTime - lastActivityTime - + FileLogger.d(TAG, "hasSpeechDetected=${voiceInteractionHandler.hasSpeechDetected}, isTtsSpeaking=${voiceInteractionHandler.isTtsSpeaking}, elapsedTime=${elapsedTime}") // 如果超过指定时间没有检测到语音,且不在TTS播放中,暂停语音识别 if (!voiceInteractionHandler.hasSpeechDetected && !voiceInteractionHandler.isTtsSpeaking && diff --git a/lib/core/bindings/initial_binding.dart b/lib/core/bindings/initial_binding.dart index 3f42d9560..748589f63 100644 --- a/lib/core/bindings/initial_binding.dart +++ b/lib/core/bindings/initial_binding.dart @@ -8,6 +8,7 @@ import '../../data/services/classic_bluetooth_service.dart'; 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 { @@ -29,6 +30,13 @@ class InitialBinding extends Bindings { Get.lazyPut(() => VolcanoTranslationService(), fenix: true); + // OpenAI服务适配器 + Get.lazyPut(() { + final adapter = OpenAIServiceAdapter(); + adapter.initialize(); + return adapter; + }, fenix: true); + // 火山AI服务 Get.lazyPut(() => VolcanoAIService(), fenix: true); @@ -41,6 +49,7 @@ class InitialBinding extends Bindings { () => BluetoothMediaButtonService(), fenix: true); + // 语音交互服务 Get.lazyPut(() => VoiceInteractionService(), fenix: true); diff --git a/lib/data/models/events/voice_interaction_event.dart b/lib/data/models/events/voice_interaction_event.dart index 4bfa636df..81860de23 100644 --- a/lib/data/models/events/voice_interaction_event.dart +++ b/lib/data/models/events/voice_interaction_event.dart @@ -26,4 +26,17 @@ class RecognitionStartedEvent extends VoiceInteractionEvent { RecognitionStartedEvent({ required int timestamp, }) : super(timestamp: timestamp); +} + +/// 通用语音交互事件 +/// 用于处理其他类型的事件 +class GenericVoiceInteractionEvent extends VoiceInteractionEvent { + final String type; + final Map? data; + + GenericVoiceInteractionEvent({ + required this.type, + this.data, + required int timestamp, + }) : super(timestamp: timestamp); } \ No newline at end of file diff --git a/lib/data/services/ai_service.dart b/lib/data/services/ai_service.dart index 37a1f5df0..c1a0a1bcd 100644 --- a/lib/data/services/ai_service.dart +++ b/lib/data/services/ai_service.dart @@ -1,5 +1,7 @@ /// AI回复服务接口 abstract class AiService { + + /// 非流式输出方法 Future sendMessage({ required List> messages, diff --git a/lib/data/services/open_ai_service_adapter.dart b/lib/data/services/open_ai_service_adapter.dart new file mode 100644 index 000000000..2c0da53ac --- /dev/null +++ b/lib/data/services/open_ai_service_adapter.dart @@ -0,0 +1,247 @@ +import 'dart:async'; +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'; + +/// OpenAI服务适配器 - 连接AiService接口与OpenAIService插件 +class OpenAIServiceAdapter implements AiService { + final OpenAIService _openAIService = OpenAIService(); + StreamSubscription? _eventSubscription; + final StreamController _tokenStreamController = StreamController.broadcast(); + bool _isProcessingStream = false; + + /// 构造函数 + OpenAIServiceAdapter() { + printInfo(info: '创建OpenAIServiceAdapter实例'); + _setupEventListener(); + } + + /// 设置事件监听器 + void _setupEventListener() { + try { + // 首先确保访问eventStream以初始化底层事件通道 + _openAIService.eventStream; + + // 设置事件处理 + _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); + } else { + printInfo(info: '收到非字符串类型的token: ${event.content}'); + } + break; + case OpenAIEventType.complete: + _isProcessingStream = false; + break; + case OpenAIEventType.error: + if (event.content is String) { + _tokenStreamController.addError(event.content as String); + } else { + _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'); + } + _isProcessingStream = false; + break; + } + } catch (e) { + printError(info: '处理事件出错: $e'); + _tokenStreamController.addError('处理事件失败: $e'); + _isProcessingStream = false; + } + }, + onError: (error) { + printError(info: '事件流错误: $error'); + _tokenStreamController.addError('事件流错误: $error'); + _isProcessingStream = false; + }, + onDone: () { + printInfo(info: '事件流已关闭'); + _isProcessingStream = false; + }, + ); + } catch (e) { + printError(info: '设置事件监听器失败: $e'); + } + } + + /// 初始化OpenAI服务 + Future initialize() async { + try { + // 从.env文件中读取配置 + final apiKey = dotenv.env['OPENAI_API_KEY'] ?? ''; + final baseUrl = dotenv.env['OPENAI_BASE_URL'] ?? ''; + final model = dotenv.env['OPENAI_MODEL'] ?? ''; + + printInfo(info: '从.env读取OpenAI配置'); + printInfo(info: '基础URL: $baseUrl'); + printInfo(info: '模型名称: $model'); + + if (apiKey.isEmpty) { + printError(info: '错误: OpenAI API密钥未配置,请在.env文件中设置OPENAI_API_KEY'); + return false; + } + + // 初始化OpenAI服务 + final result = await _openAIService.initialize( + apiKey: apiKey, + baseUrl: baseUrl, + model: model, + ); + + if (result) { + printInfo(info: 'OpenAI服务初始化成功'); + } else { + printError(info: 'OpenAI服务初始化失败'); + } + + return result; + } catch (e) { + printError(info: 'OpenAI服务初始化异常: $e'); + return false; + } + } + + /// 发送消息并获取回复 + @override + Future sendMessage({ + required List> messages, + required String systemPrompt, + }) async { + try { + // 在方法内直接转换 + final convertedMessages = messages.map((m) => + Map.from(m)).toList(); + + // 发送消息并获取回复 + final response = await _openAIService.sendMessage( + messages: convertedMessages, + ); + + return response; + } catch (e) { + printError(info: 'OpenAI发送消息失败: $e'); + throw '发送消息失败: $e'; + } + } + + /// 发送消息并获取流式回复 + @override + Stream sendMessageStream({ + required List> messages, + required String systemPrompt, + }) async* { + try { + // 在方法内直接转换 + final convertedMessages = messages.map((m) => + Map.from(m)).toList(); + + // 创建用于接收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(); + } + ); + + // 当本地控制器关闭时,取消订阅 + localController.onCancel = () { + subscription.cancel(); + }; + + // 启动流式消息请求 + bool started = false; + try { + started = await _openAIService.sendMessageStream( + messages: convertedMessages, + ); + } catch (e) { + printError(info: '启动消息流失败: $e'); + localController.addError('启动消息流失败: $e'); + localController.close(); + _isProcessingStream = false; + throw '启动消息流失败: $e'; + } + + if (!started) { + printError(info: '无法启动消息流'); + localController.addError('无法启动消息流'); + localController.close(); + _isProcessingStream = false; + throw '无法启动消息流'; + } + + // 通过yield*将controller的流转发 + yield* localController.stream; + } catch (e) { + printError(info: 'OpenAI流式消息处理失败: $e'); + throw '流式消息处理失败: $e'; + } + } + + /// 注册函数 + Future registerFunction(String name, String description, Map parameters) async { + try { + final result = await _openAIService.registerFunction( + name: name, + description: description, + parameters: parameters, + ); + + if (result) { + printInfo(info: '函数 "$name" 注册成功'); + } else { + printError(info: '函数 "$name" 注册失败'); + } + + return result; + } catch (e) { + printError(info: '注册函数失败: $e'); + return false; + } + } + + /// 释放资源 + void dispose() { + try { + _isProcessingStream = false; + _eventSubscription?.cancel(); + _tokenStreamController.close(); + printInfo(info: 'OpenAIServiceAdapter资源已释放'); + } catch (e) { + printError(info: '释放资源时出错: $e'); + } + } + +} \ No newline at end of file diff --git a/lib/data/services/voice_interaction_service.dart b/lib/data/services/voice_interaction_service.dart index 792e94912..cce506a58 100644 --- a/lib/data/services/voice_interaction_service.dart +++ b/lib/data/services/voice_interaction_service.dart @@ -5,6 +5,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../models/events/voice_interaction_event.dart'; import '../../core/utils/logger.dart'; import '../../modules/chat/models/message_model.dart'; +import '../../routes/app_routes.dart'; import 'chat_history_service.dart'; /// 语音交互服务接口 @@ -104,6 +105,29 @@ class VoiceInteractionService extends GetxService { // 保存聊天历史到ChatHistoryService _saveChatHistory(agentId, userMessage, assistantMessage, DateTime.now().millisecondsSinceEpoch); break; + + case 'enter_translation_mode': + // 进入翻译模式事件 + Logger.info('收到进入翻译模式事件,正在导航到翻译界面'); + _navigateToTranslation(); + + final translationModeEvent = GenericVoiceInteractionEvent( + type: 'enter_translation_mode', + timestamp: DateTime.now().millisecondsSinceEpoch, + ); + _eventStreamController.add(translationModeEvent); + break; + } + } + + /// 导航到翻译界面 + void _navigateToTranslation() { + try { + // 使用GetX导航到翻译页面 + Get.toNamed(Routes.translation); + Logger.info('已导航到翻译界面'); + } catch (e) { + Logger.error('导航到翻译界面失败: $e'); } } diff --git a/lib/modules/chat/controllers/chat_controller.dart b/lib/modules/chat/controllers/chat_controller.dart index 4d0457c98..77fa84a9f 100644 --- a/lib/modules/chat/controllers/chat_controller.dart +++ b/lib/modules/chat/controllers/chat_controller.dart @@ -16,6 +16,7 @@ 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'; class ChatController extends GetxController { // 服务 @@ -92,6 +93,7 @@ class ChatController extends GetxController { agent = foundAgent; + // 根据Agent ID选择不同的AI服务 switch (agent.id) { case 'cyber_girlfriend': // 亲子陪伴 @@ -104,7 +106,15 @@ class ChatController extends GetxController { _aiService = Get.find(); break; default: - _aiService = Get.find(); + // 默认使用OpenAIServiceAdapter + try { + _aiService = Get.find(); + Logger.info('使用OpenAIServiceAdapter'); + } catch (e) { + // 如果找不到OpenAIServiceAdapter,则回退到VolcanoAIService + Logger.info('未找到OpenAIServiceAdapter,回退使用VolcanoAIService: $e'); + _aiService = Get.find(); + } } // 使用克隆音色语音合成 diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt index ac6697cdb..1dd7cf624 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt @@ -25,7 +25,7 @@ class AzureAsrHelper(private val context: Context) { private var subscriptionKey = "" private var region = "" private var isAutoDetectLanguage = false - private var supportedLanguages = arrayOf("zh-CN", "en-US") + private var supportedLanguages = arrayOf("zh-CN") // 是否使用回音消除 - 内部控制常量 private val useEchoCancellation = false @@ -37,7 +37,7 @@ class AzureAsrHelper(private val context: Context) { // 初始化SDK并创建recognizer fun initialize(subscriptionKey: String, region: String, - supportedLanguages: Array = arrayOf("zh-CN", "en-US")): Boolean { + supportedLanguages: Array = arrayOf("zh-CN")): Boolean { try { FileLogger.d(TAG, "初始化 Azure 语音服务") diff --git a/local_plugins/open_ai_service/README.md b/local_plugins/open_ai_service/README.md index d7ccb709d..351eee84f 100644 --- a/local_plugins/open_ai_service/README.md +++ b/local_plugins/open_ai_service/README.md @@ -1,80 +1,78 @@ -# OpenAI服务插件 +# OpenAI Service Plugin -提供与OpenAI API的原生交互功能,支持流式输出和函数调用,同时支持Android和iOS平台。 +一个用于Flutter应用的OpenAI服务插件,支持Android和iOS平台。 -## 功能特点 +## 功能 -- 原生实现,性能更好 -- 支持流式输出,实时显示AI回复 -- 支持函数调用(工具调用) +- 支持文本生成(completions) +- 支持流式输出(streaming) +- 支持函数调用(function calling) - 支持自定义API基础URL -- 支持Android和iOS平台 -- 提供简洁易用的Dart接口 +- 支持自定义模型选择 ## 安装 -将该插件添加到您的`pubspec.yaml`文件中: +在你的`pubspec.yaml`文件中添加以下依赖: ```yaml dependencies: open_ai_service: - path: local_plugins/open_ai_service + path: 本地路径/open_ai_service ``` ## 使用方法 -### 初始化 - -首先需要初始化OpenAI服务,提供API密钥和可选的基础URL和模型: +### 初始化服务 ```dart import 'package:open_ai_service/open_ai_service.dart'; final openAIService = OpenAIService(); -// 使用OpenAI官方API和默认模型 -await openAIService.initialize(apiKey: 'your_openai_api_key'); - -// 或使用自定义API端点和模型 +// 初始化服务 await openAIService.initialize( - apiKey: 'your_api_key', - baseUrl: 'https://your-custom-endpoint.com/v1/chat/completions', - model: 'gpt-4-0125-preview', // 自定义模型 + apiKey: 'your_openai_api_key', + baseUrl: 'https://api.openai.com/v1/chat/completions', // 可选 + model: 'gpt-4-turbo', // 可选 ); ``` -### 普通聊天 - -发送消息并获取非流式回复: +### 发送非流式请求 ```dart -// 创建用户消息 +// 创建消息 final userMessage = await openAIService.createUserMessage('你好,请介绍一下自己'); -// 发送消息 +// 发送请求 final response = await openAIService.sendMessage( messages: [userMessage], - systemPrompt: '你是一个有帮助的AI助手,请简洁地回答问题。', + systemPrompt: '你是一个有用的AI助手', ); print('AI回复: $response'); ``` -### 流式输出 - -使用流式输出获取实时回复: +### 发送流式请求(回调方式) ```dart -// 注册事件监听 +// 创建消息 +final userMessage = await openAIService.createUserMessage('写一个短故事'); + +// 发送流式请求 +await openAIService.sendMessageStream( + messages: [userMessage], + systemPrompt: '你是一个善于讲故事的AI助手', +); + +// 处理事件 final subscription = openAIService.processEvents( onToken: (token) { - // 收到部分回复 - print('收到部分回复: $token'); - // 更新UI... + // 处理每个返回的token + print(token); }, onComplete: () { - // 回复完成 - print('回复完成'); + // 处理完成事件 + print('生成完成'); }, onError: (error) { // 处理错误 @@ -82,27 +80,50 @@ final subscription = openAIService.processEvents( }, onFunctionCall: (functionCall) { // 处理函数调用 - print('函数调用: $functionCall'); - handleFunctionCall(functionCall); + print('函数调用: ${functionCall['name']}'); }, ); -// 发送流式消息 -await openAIService.sendMessageStream( +// 在不需要时取消订阅 +subscription.cancel(); +``` + +### 发送流式请求(Stream方式) + +```dart +// 创建消息 +final userMessage = await openAIService.createUserMessage('写一个短故事'); + +// 获取字符串流 +final stream = openAIService.streamMessage( messages: [userMessage], - systemPrompt: '你是一个有帮助的AI助手,请简洁地回答问题。', + systemPrompt: '你是一个善于讲故事的AI助手', ); -// 完成后,记得取消订阅 -// subscription.cancel(); -``` +// 使用流 +final StringBuilder responseBuilder = StringBuilder(); -### 函数调用 +stream.listen( + (token) { + // 处理每个token + responseBuilder.write(token); + print(token); // 实时输出 + }, + onDone: () { + // 流结束 + print('完整回复: ${responseBuilder.toString()}'); + }, + onError: (error) { + // 错误处理 + print('错误: $error'); + } +); +``` -注册函数并处理函数调用: +### 注册函数 ```dart -// 注册一个天气查询函数 +// 注册一个函数 await openAIService.registerFunction( name: 'get_weather', description: '获取指定城市的天气信息', @@ -111,238 +132,122 @@ await openAIService.registerFunction( 'properties': { 'city': { 'type': 'string', - 'description': '城市名称,如北京、上海等', + 'description': '城市名称', }, 'date': { 'type': 'string', - 'description': '查询日期,默认为今天', + 'description': '日期,格式为YYYY-MM-DD', }, }, 'required': ['city'], }, ); +``` -// 处理函数调用 -void handleFunctionCall(Map functionCall) { - final name = functionCall['name']; - final arguments = jsonDecode(functionCall['arguments']); - final id = functionCall['id']; - - if (name == 'get_weather') { - final city = arguments['city']; - final date = arguments['date'] ?? '今天'; +### 处理函数调用 + +```dart +// 创建消息 +final userMessage = await openAIService.createUserMessage('明天北京的天气如何?'); + +// 发送流式请求 +await openAIService.sendMessageStream( + messages: [userMessage], + systemPrompt: '你是一个有用的AI助手', +); + +// 处理事件 +openAIService.processEvents( + onToken: (token) { + print(token); + }, + onComplete: () { + print('生成完成'); + }, + onError: (error) { + print('错误: $error'); + }, + onFunctionCall: (functionCall) { + // 处理函数调用 + final name = functionCall['name']; + final arguments = functionCall['arguments']; + + print('收到函数调用: $name, 参数: $arguments'); - // 获取天气信息(实际应用中应调用天气API) - final weatherResult = '{"temperature": 25, "condition": "晴天", "humidity": 40}'; + // 假设处理了函数调用并获得结果 + final result = '{"temperature": 25, "condition": "sunny"}'; // 发送函数调用结果 openAIService.sendFunctionCallResult( - messages: messages, // 当前的消息历史 - systemPrompt: systemPrompt, + messages: [userMessage], + systemPrompt: '你是一个有用的AI助手', functionCall: functionCall, - functionResult: weatherResult, + functionResult: result, ); - } -} + }, +); ``` -## 示例应用 - -一个简单的聊天机器人示例: +### 使用Stream API处理函数调用 ```dart -import 'package:flutter/material.dart'; -import 'package:open_ai_service/open_ai_service.dart'; +// 创建消息和响应处理器 +final userMessage = await openAIService.createUserMessage('明天北京的天气如何?'); +final responseBuilder = StringBuilder(); -void main() { - runApp(MyApp()); -} - -class MyApp extends StatelessWidget { - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'OpenAI 聊天', - theme: ThemeData(primarySwatch: Colors.blue), - home: ChatScreen(), - ); - } -} - -class ChatScreen extends StatefulWidget { - @override - _ChatScreenState createState() => _ChatScreenState(); -} - -class _ChatScreenState extends State { - final OpenAIService _openAIService = OpenAIService(); - final TextEditingController _textController = TextEditingController(); - final List> _messages = []; - StreamSubscription? _subscription; - String _currentResponse = ''; - bool _isLoading = false; - - @override - void initState() { - super.initState(); - _initializeOpenAI(); - } - - Future _initializeOpenAI() async { - try { - await _openAIService.initialize( - apiKey: 'your_api_key_here', - ); - - // 注册事件监听 - _subscription = _openAIService.processEvents( - onToken: (token) { - setState(() { - _currentResponse += token; - }); - }, - onComplete: () { - _addAIMessage(_currentResponse); - setState(() { - _currentResponse = ''; - _isLoading = false; - }); - }, - onError: (error) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('错误: $error')), - ); - setState(() { - _isLoading = false; - }); - }, - onFunctionCall: (functionCall) { - // 处理函数调用... - }, - ); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('初始化失败: $e')), - ); - } - } - - void _sendMessage() async { - if (_textController.text.isEmpty) return; +// 处理事件流以捕获函数调用 +final subscription = openAIService.processEvents( + onFunctionCall: (functionCall) async { + // 取消当前事件监听 + subscription.cancel(); - final userMessage = _textController.text; - _addUserMessage(userMessage); - _textController.clear(); + // 处理函数调用 + final name = functionCall['name']; + final arguments = functionCall['arguments']; - try { - setState(() { - _isLoading = true; - }); - - final message = await _openAIService.createUserMessage(userMessage); - - await _openAIService.sendMessageStream( - messages: _messages, - systemPrompt: '你是一个有帮助的AI助手,请简洁地回答问题。', - ); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('发送消息失败: $e')), - ); - setState(() { - _isLoading = false; - }); - } - } - - void _addUserMessage(String content) { - setState(() { - _messages.add({ - 'role': 'user', - 'content': content, - }); - }); - } - - void _addAIMessage(String content) { - setState(() { - _messages.add({ - 'role': 'assistant', - 'content': content, - }); - }); - } - - @override - void dispose() { - _subscription?.cancel(); - _textController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text('OpenAI 聊天')), - body: Column( - children: [ - Expanded( - child: ListView.builder( - itemCount: _messages.length + (_currentResponse.isNotEmpty ? 1 : 0), - itemBuilder: (context, index) { - if (index < _messages.length) { - final message = _messages[index]; - final isUser = message['role'] == 'user'; - - return ListTile( - leading: Icon(isUser ? Icons.person : Icons.computer), - title: Text(message['content']), - ); - } else { - // 显示当前正在生成的回复 - return ListTile( - leading: Icon(Icons.computer), - title: Text(_currentResponse), - ); - } - }, - ), - ), - if (_isLoading) - LinearProgressIndicator(), - Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _textController, - decoration: InputDecoration( - hintText: '输入消息...', - border: OutlineInputBorder(), - ), - ), - ), - IconButton( - icon: Icon(Icons.send), - onPressed: _sendMessage, - ), - ], - ), - ), - ], - ), + print('收到函数调用: $name, 参数: $arguments'); + + // 假设处理了函数调用并获得结果 + final result = '{"temperature": 25, "condition": "sunny"}'; + + // 使用Stream API发送函数调用结果 + final resultStream = openAIService.streamFunctionResult( + messages: [userMessage], + systemPrompt: '你是一个有用的AI助手', + functionCall: functionCall, + functionResult: result, + ); + + // 处理结果流 + resultStream.listen( + (token) { + responseBuilder.write(token); + print(token); // 实时输出 + }, + onDone: () { + print('完整回复: ${responseBuilder.toString()}'); + }, + onError: (error) { + print('错误: $error'); + } ); } -} +); + +// 启动请求 +await openAIService.sendMessageStream( + messages: [userMessage], + systemPrompt: '你是一个有用的AI助手', +); ``` ## 注意事项 -- 请确保您的API密钥安全存储,不要直接硬编码在应用程序中 -- 流式输出需要在调用`sendMessageStream`之前注册事件监听 -- 使用完毕后记得取消事件订阅 +1. 确保在使用前已正确初始化服务 +2. 对于流式请求,确保在不需要时取消订阅 +3. 处理函数调用时,确保提供有效的结果格式 +4. 网络请求可能会失败,请确保加入适当的错误处理 ## 许可证 -此插件遵循MIT许可证。 \ No newline at end of file +[MIT License](LICENSE) \ 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 7b9213046..907be93a2 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 @@ -28,6 +28,35 @@ class OpenAIService() { // 用于存储注册的函数 private val registeredFunctions = mutableListOf() + /** + * 构建curl命令用于测试 + */ + private fun buildCurlCommand(request: Request, body: String): String { + val command = StringBuilder("curl -v -X ${request.method}") + + // 添加请求头 + request.headers.forEach { header -> + // 敏感信息处理:不显示真实的API Key + if (header.first == "Authorization") { + command.append(" -H '${header.first}: Bearer $apiKey'") + } else { + command.append(" -H '${header.first}: ${header.second}'") + } + } + + // 添加请求体 + if (request.method == "POST" || request.method == "PUT") { + // 转义JSON中的单引号,确保curl命令正确 + val escapedBody = body.replace("'", "\\'") + command.append(" -d '${escapedBody}'") + } + + // 添加URL + command.append(" '${request.url}'") + + return command.toString() + } + /** * 创建用户消息 */ @@ -97,24 +126,14 @@ class OpenAIService() { * 发送消息(非流式输出) */ @Throws(OpenAIException::class) - fun sendMessage(messages: JSONArray, systemPrompt: String): String { + fun sendMessage(messages: JSONArray): String { if (!isInitialized || apiKey.isEmpty()) { throw OpenAIException("OpenAI服务未初始化") } - val fullMessages = JSONArray().apply { - put(JSONObject().apply { - put("role", "system") - put("content", systemPrompt) - }) - for (i in 0 until messages.length()) { - put(messages.getJSONObject(i)) - } - } - val requestBody = JSONObject().apply { put("model", model) - put("messages", fullMessages) + put("messages", messages) put("temperature", 0.7) put("max_tokens", 2000) put("stream", false) @@ -142,7 +161,10 @@ class OpenAIService() { .build() try { - Log.d(TAG, "request: $request") + // 输出用于测试的curl命令 + // val curlCommand = buildCurlCommand(request, requestBody.toString()) + // Log.d(TAG, "curl command: \n$curlCommand") + client.newCall(request).execute().use { response -> if (!response.isSuccessful) { throw OpenAIException("API调用失败: ${response.code}") @@ -196,25 +218,15 @@ class OpenAIService() { /** * 发送消息(流式输出) */ - fun sendMessageStream(messages: JSONArray, systemPrompt: String, callback: StreamCallback) { + fun sendMessageStream(messages: JSONArray, callback: StreamCallback) { if (!isInitialized || apiKey.isEmpty()) { callback.onError(OpenAIException("OpenAI服务未初始化")) return } - val fullMessages = JSONArray().apply { - put(JSONObject().apply { - put("role", "system") - put("content", systemPrompt) - }) - for (i in 0 until messages.length()) { - put(messages.getJSONObject(i)) - } - } - val requestBody = JSONObject().apply { put("model", model) - put("messages", fullMessages) + put("messages", messages) put("temperature", 0.7) put("max_tokens", 2000) put("stream", true) @@ -242,7 +254,10 @@ class OpenAIService() { .post(requestBody.toString().toRequestBody(mediaType)) .build() - Log.d(TAG, "request: $request") + // 输出用于测试的curl命令 + // 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) { callback.onError(OpenAIException(e.message ?: "请求失败")) @@ -267,14 +282,13 @@ class OpenAIService() { val finalToolCalls = mutableMapOf() while (!bufferedSource.exhausted()) { - val line = bufferedSource.readUtf8Line() ?: continue - val trimmedLine = line.trim() - if (trimmedLine.isEmpty()) continue - if (trimmedLine.startsWith("data:")) { - val data = trimmedLine.substring(5) + val line = bufferedSource.readUtf8Line()?.trim() ?: continue + if (line.isEmpty()) continue + if (line.startsWith("data:")) { + val data = line.substring(5).trim() // 处理[DONE]消息 - if (data == "[DONE]") { + if (data == "[DONE]" || data == "[\"DONE\"]") { processToolCalls(finalToolCalls, callback) callback.onComplete() break @@ -350,54 +364,53 @@ class OpenAIService() { */ fun sendFunctionCallResult( messages: JSONArray, - systemPrompt: String, functionCall: JSONObject, functionResult: String, callback: StreamCallback ) { try { - val fullMessages = JSONArray().apply { - // 添加系统提示 - put(JSONObject().apply { - put("role", "system") - put("content", systemPrompt) - }) - - // 添加用户消息 - for (i in 0 until messages.length()) { - put(messages.getJSONObject(i)) - } + val fullMessages = JSONArray() + + // 添加用户消息 + for (i in 0 until messages.length()) { + fullMessages.put(messages.getJSONObject(i)) + } + + // 添加函数调用消息 + fullMessages.put(JSONObject().apply { + put("role", "assistant") + put("content", "") - // 添加函数调用消息 - put(JSONObject().apply { - put("role", "assistant") - put("content", null) - - // 添加工具调用 - val toolCalls = JSONArray().apply { - val toolCall = JSONObject().apply { - put("id", functionCall.optString("id", "call_${System.currentTimeMillis()}")) - put("type", "function") - put("function", JSONObject().apply { - put("name", functionCall.getString("name")) - put("arguments", functionCall.getString("arguments")) - }) - } - put(toolCall) + // 添加工具调用 + val toolCalls = JSONArray().apply { + val toolCall = JSONObject().apply { + put("id", functionCall.optString("id", "call_${System.currentTimeMillis()}")) + put("type", "function") + put("function", JSONObject().apply { + put("name", functionCall.getString("name")) + put("arguments", functionCall.getString("arguments")) + }) } - put("tool_calls", toolCalls) - }) - - // 添加函数调用结果 - put(JSONObject().apply { - put("role", "tool") - put("content", functionResult) - put("tool_call_id", functionCall.optString("id", "call_${System.currentTimeMillis()}")) - }) - } + put(toolCall) + } + put("tool_calls", toolCalls) + }) + + // 添加函数调用结果 + fullMessages.put(JSONObject().apply { + put("role", "tool") + put("content", functionResult) + 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, systemPrompt, callback) + sendMessageStream(fullMessages, callback) } catch (e: Exception) { callback.onError(OpenAIException("发送函数调用结果失败: ${e.message}")) 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 5c93e5be6..70af36c13 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 @@ -83,7 +83,6 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { "sendMessage" -> { val messagesRaw = call.argument>>("messages") ?: emptyList() - val systemPrompt = call.argument("systemPrompt") ?: "" // 转换消息格式 val messages = JSONArray() @@ -94,7 +93,7 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { // 在后台线程执行请求 executor.execute { try { - val response = openAIService.sendMessage(messages, systemPrompt) + val response = openAIService.sendMessage(messages) // 在主线程返回结果 android.os.Handler(android.os.Looper.getMainLooper()).post { result.success(response) @@ -110,7 +109,6 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { "sendMessageStream" -> { val messagesRaw = call.argument>>("messages") ?: emptyList() - val systemPrompt = call.argument("systemPrompt") ?: "" // 检查事件接收器 if (eventSink == null) { @@ -129,7 +127,6 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { try { openAIService.sendMessageStream( messages = messages, - systemPrompt = systemPrompt, callback = object : OpenAIService.StreamCallback { override fun onToken(token: String) { // 发送token事件 @@ -177,7 +174,6 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { "sendFunctionCallResult" -> { val messagesRaw = call.argument>>("messages") ?: emptyList() - val systemPrompt = call.argument("systemPrompt") ?: "" val functionCallRaw = call.argument>("functionCall") ?: emptyMap() val functionResult = call.argument("functionResult") ?: "" @@ -201,7 +197,6 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { try { openAIService.sendFunctionCallResult( messages = messages, - systemPrompt = systemPrompt, functionCall = functionCall, functionResult = functionResult, callback = object : OpenAIService.StreamCallback { 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 b946d1bff..7c6fe8024 100644 --- a/local_plugins/open_ai_service/lib/open_ai_service.dart +++ b/local_plugins/open_ai_service/lib/open_ai_service.dart @@ -184,17 +184,14 @@ class OpenAIService { /// 发送消息(非流式输出) /// /// [messages] 消息列表 - /// [systemPrompt] 系统提示词 Future sendMessage({ required List> messages, - required String systemPrompt, }) async { try { final result = await _channel.invokeMethod( 'sendMessage', { 'messages': messages, - 'systemPrompt': systemPrompt, }, ); @@ -211,19 +208,16 @@ class OpenAIService { /// 发送消息(流式输出) /// /// [messages] 消息列表 - /// [systemPrompt] 系统提示词 /// /// 返回一个布尔值,表示请求是否已开始 Future sendMessageStream({ required List> messages, - required String systemPrompt, }) async { try { final result = await _channel.invokeMethod( 'sendMessageStream', { 'messages': messages, - 'systemPrompt': systemPrompt, }, ); @@ -236,14 +230,12 @@ class OpenAIService { /// 发送函数调用结果 /// /// [messages] 消息列表 - /// [systemPrompt] 系统提示词 /// [functionCall] 函数调用信息 /// [functionResult] 函数调用结果 /// /// 返回一个布尔值,表示请求是否已开始 Future sendFunctionCallResult({ required List> messages, - required String systemPrompt, required Map functionCall, required String functionResult, }) async { @@ -252,7 +244,6 @@ class OpenAIService { 'sendFunctionCallResult', { 'messages': messages, - 'systemPrompt': systemPrompt, 'functionCall': functionCall, 'functionResult': functionResult, }, @@ -264,35 +255,6 @@ class OpenAIService { } } - /// 处理流式事件 - /// - /// [onToken] 收到token时调用 - /// [onComplete] 完成时调用 - /// [onError] 错误时调用 - /// [onFunctionCall] 函数调用时调用 - StreamSubscription processEvents({ - void Function(String token)? onToken, - void Function()? onComplete, - void Function(String error)? onError, - void Function(Map functionCall)? onFunctionCall, - }) { - return eventStream.listen((event) { - switch (event.type) { - case OpenAIEventType.token: - onToken?.call(event.content as String); - break; - case OpenAIEventType.complete: - onComplete?.call(); - break; - case OpenAIEventType.error: - onError?.call(event.content as String); - break; - case OpenAIEventType.functionCall: - onFunctionCall?.call(Map.from(event.content as Map)); - break; - } - }); - } /// 从JSON字符串解析函数调用 Map parseFunctionCall(String functionCallJson) { diff --git a/test.json b/test.json new file mode 100644 index 000000000..703948620 --- /dev/null +++ b/test.json @@ -0,0 +1 @@ +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 From 5766bc05219e2dc38e4e71a5bf5adb91ff96a7d1 Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Sun, 6 Apr 2025 23:21:37 +0800 Subject: [PATCH 07/23] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=9C=B0=E5=9B=BE?= =?UTF-8?q?=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/controllers/chat_controller.dart | 5 +- .../chat/views/chat_navigation_card.dart | 179 ++++++++++++++++++ lib/modules/chat/views/chat_view.dart | 6 +- 3 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 lib/modules/chat/views/chat_navigation_card.dart diff --git a/lib/modules/chat/controllers/chat_controller.dart b/lib/modules/chat/controllers/chat_controller.dart index 77c4e030b..f882e342f 100644 --- a/lib/modules/chat/controllers/chat_controller.dart +++ b/lib/modules/chat/controllers/chat_controller.dart @@ -450,16 +450,15 @@ class ChatController extends GetxController { try { isPlay.value = false; if (audiourl.value != musicaudio['audiourl']) { - audiourl.value = musicaudio['audiourl']; + audiourl.value = ''; await player.setUrl(musicaudio['audiourl']); + audiourl.value = musicaudio['audiourl']; player.play(); } else { player.play(); } isPlay.value = true; } catch (e) { - isPlay.value = false; - audiourl.value = ''; Logger.error('加载异常: $e'); } } diff --git a/lib/modules/chat/views/chat_navigation_card.dart b/lib/modules/chat/views/chat_navigation_card.dart new file mode 100644 index 000000000..9ea658771 --- /dev/null +++ b/lib/modules/chat/views/chat_navigation_card.dart @@ -0,0 +1,179 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; + +class ChatNavigationCard extends StatefulWidget { + final Map? data; + final bool isDarkMode; + + const ChatNavigationCard(this.data, this.isDarkMode, {super.key}); + + @override + State createState() => _ChatNavigationCardState(); +} + +class _ChatNavigationCardState extends State { + final String apiKey = '8bde023b7e5e00f321ea17929f10fbae'; + LatLng startPoint = LatLng(116.397428, 39.90923); // 起点坐标 + LatLng endPoint = LatLng(116.397026, 39.903303); // 终点坐标 + + @override + void initState() { + super.initState(); + _getMapUrl(); + } + + Future _getMapUrl() async { + // 1. 获取路径数据 + final route = await _fetchRouteData(); + + // 2. 解析并简化路径 + List simplified = _simplifyPath(route, epsilon: 0.0001); + + // 3. 计算地图参数 + final zoom = _calculateAutoZoom(simplified); + + // 4. 生成静态地图URL + return _buildStaticMapUrl(simplified, zoom); + } + + Future> _fetchRouteData() async { + final url = 'https://restapi.amap.com/v3/direction/driving?' + 'origin=${startPoint.lng},${startPoint.lat}&' + 'destination=${endPoint.lng},${endPoint.lat}&' + 'key=$apiKey'; + + final response = await http.get(Uri.parse(url)); + if (response.statusCode == 200) { + final data = json.decode(response.body); + return _parsePolyline(data['route']['paths'][0]['steps']); + } + throw Exception('Failed to load route'); + } + + List _parsePolyline(List steps) { + final List path = []; + for (final step in steps) { + final points = (step['polyline'] as String).split(';'); + for (final point in points) { + final coords = point.split(',').map(double.parse).toList(); + path.add(LatLng(coords[0], coords[1])); + } + } + return path; + } + + List _simplifyPath(List points, {double epsilon = 0.0001}) { + return douglasPeucker(points, epsilon); + } + + int _calculateAutoZoom(List points, + {int mapWidth = 1024, int mapHeight = 512}) { + if (points.isEmpty) return 15; // 默认缩放级别 + + // 1. 计算坐标范围 + double minLng = double.infinity; + double maxLng = -double.infinity; + double minLat = double.infinity; + double maxLat = -double.infinity; + + for (final p in points) { + final lng = p.lng; + final lat = p.lat; + + minLng = math.min(minLng, lng); + maxLng = math.max(maxLng, lng); + minLat = math.min(minLat, lat); + maxLat = math.max(maxLat, lat); + } + + // 3. 计算缩放级别(基于Leaflet算法改进) + const worldWidth = 256; // 高德瓦片尺寸 + const worldHeight = 256; + const zoomMax = 18; // 高德最大缩放级别 + + double latRad(double lat) { + final sin = math.sin(lat * math.pi / 180); + return math.log((1 + sin) / (1 - sin)) / 2; + } + + final latFraction = (latRad(maxLat) - latRad(minLat)) / math.pi; + final lngDiff = maxLng - minLng; + final lngFraction = ((lngDiff < 0) ? (lngDiff + 360) : lngDiff) / 360; + + final latZoom = + math.log((mapHeight / worldHeight) / latFraction) / math.ln2; + final lngZoom = math.log((mapWidth / worldWidth) / lngFraction) / math.ln2; + + return math.min(math.min(latZoom, lngZoom).floor(), zoomMax); + } + + String _buildStaticMapUrl(List path, int zoom) { + final pathParam = path.map((p) => '${p.lng},${p.lat}').join(';'); + return 'https://restapi.amap.com/v3/staticmap?' + 'zoom=$zoom&' + 'size=1024*512&' + 'paths=10,0x0000ff,1,,:$pathParam&' + 'markers=mid,,A:${startPoint.lng},${startPoint.lat}|' + 'mid,,B:${endPoint.lng},${endPoint.lat}&' + 'key=$apiKey'; + } + + List douglasPeucker(List points, double epsilon) { + if (points.length < 3) return points; + + double maxDistance = 0; + int index = 0; + + for (int i = 1; i < points.length - 1; i++) { + double d = _perpendicularDistance(points[i], points.first, points.last); + if (d > maxDistance) { + index = i; + maxDistance = d; + } + } + + if (maxDistance > epsilon) { + return [ + ...douglasPeucker(points.sublist(0, index + 1), epsilon), + ...douglasPeucker(points.sublist(index), epsilon).skip(1) + ]; + } + return [points.first, points.last]; + } + + double _perpendicularDistance(LatLng point, LatLng start, LatLng end) { + final area = (end.lng - start.lng) * (point.lat - start.lat) - + (end.lat - start.lat) * (point.lng - start.lng); + final length = + sqrt(pow(end.lng - start.lng, 2) + pow(end.lat - start.lat, 2)); + return area.abs() / length; + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + FutureBuilder( + future: _getMapUrl(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasData) { + return Image.network(snapshot.data); + } + return const CircularProgressIndicator(); + }, + ), + ], + ); + } +} + +class LatLng { + final double lat; + final double lng; + + LatLng(this.lng, this.lat); // 高德使用经度在前纬度在后的格式 +} diff --git a/lib/modules/chat/views/chat_view.dart b/lib/modules/chat/views/chat_view.dart index 2c6adee7b..073d079dc 100644 --- a/lib/modules/chat/views/chat_view.dart +++ b/lib/modules/chat/views/chat_view.dart @@ -6,6 +6,7 @@ import '../controllers/chat_controller.dart'; import '../models/message_model.dart'; import '../../../core/theme/app_colors.dart'; import 'chat_music_card.dart'; +import 'chat_navigation_card.dart'; class ChatView extends GetView { const ChatView({Key? key}) : super(key: key); @@ -354,8 +355,11 @@ class ChatView extends GetView { ); } if (message.data != null) { + // if (message.data?['musicaudio'] != null) { + // box = ChatMusicCard(message.data, isDarkMode); + // } if (message.data?['musicaudio'] != null) { - box = ChatMusicCard(message.data, isDarkMode); + box = ChatNavigationCard(message.data, isDarkMode); } } return box; From 55c03f855e2f40b9bbccf75ce1eaa50b1883cc41 Mon Sep 17 00:00:00 2001 From: wolfplus Date: Sun, 6 Apr 2025 17:48:28 +0100 Subject: [PATCH 08/23] before add volcalno speech --- android/app/build.gradle.kts | 9 +- android/build.gradle.kts | 4 + android/settings.gradle.kts | 3 +- local_plugins/volcano_speech/README.md | 206 ++++++ .../volcano_speech/android/build.gradle.kts | 64 ++ .../android/settings.gradle.kts | 1 + .../android/src/main/AndroidManifest.xml | 9 + .../volcano_speech/VolcanoAsrHelper.kt | 529 +++++++++++++++ .../volcano_speech/VolcanoSpeechPlugin.kt | 445 +++++++++++++ .../volcano_speech/VolcanoTtsHelper.kt | 399 ++++++++++++ .../volcano_speech/utils/FileLogger.kt | 55 ++ .../volcano_speech/lib/volcano_speech.dart | 603 ++++++++++++++++++ local_plugins/volcano_speech/pubspec.yaml | 28 + pubspec.yaml | 2 + 14 files changed, 2349 insertions(+), 8 deletions(-) create mode 100644 local_plugins/volcano_speech/README.md create mode 100644 local_plugins/volcano_speech/android/build.gradle.kts create mode 100644 local_plugins/volcano_speech/android/settings.gradle.kts create mode 100644 local_plugins/volcano_speech/android/src/main/AndroidManifest.xml create mode 100644 local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoAsrHelper.kt create mode 100644 local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoSpeechPlugin.kt create mode 100644 local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoTtsHelper.kt create mode 100644 local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/utils/FileLogger.kt create mode 100644 local_plugins/volcano_speech/lib/volcano_speech.dart create mode 100644 local_plugins/volcano_speech/pubspec.yaml diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b0a39cbd4..026060648 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -93,14 +93,13 @@ dependencies { // 添加本地插件模块依赖 implementation(project(":azure_speech")) implementation(project(":open_ai_service")) + implementation(project(":volcano_speech")) // 添加OkHttp依赖 implementation("com.squareup.okhttp3:okhttp:4.9.3") // 添加核心库反糖化 coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3") - // Microsoft 语音识别SDK - implementation("com.microsoft.cognitiveservices.speech:client-sdk:1.42.0") // 添加androidx.media依赖 implementation("androidx.media:media:1.6.0") @@ -111,11 +110,7 @@ dependencies { // 添加 AndroidX Security 加密 SharedPreferences 依赖 implementation("androidx.security:security-crypto:1.1.0-alpha06") - // // 添加火山语音合成SDK依赖 - // implementation("com.bytedance.speechengine:speechengine_tts_tob:5.4.8") - - // 添加火山语音识别SDK依赖 - // implementation("com.bytedance.speechengine:speechengine_tob:0.0.5") + } flutter { diff --git a/android/build.gradle.kts b/android/build.gradle.kts index f8f47c600..67f214248 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -3,6 +3,10 @@ allprojects { google() mavenCentral() maven { url = uri("https://storage.googleapis.com/download.flutter.io") } + // 添加火山引擎Maven仓库 + maven { + url = uri("https://artifact.bytedance.com/repository/Volcengine/") + } } } diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 6ca3ffd16..c0eebbf43 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -31,8 +31,9 @@ plugins { include(":app") include(":azure_speech") include(":open_ai_service") - +include(":volcano_speech") // 设置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") diff --git a/local_plugins/volcano_speech/README.md b/local_plugins/volcano_speech/README.md new file mode 100644 index 000000000..572317b57 --- /dev/null +++ b/local_plugins/volcano_speech/README.md @@ -0,0 +1,206 @@ +# 火山引擎语音服务插件 + +基于火山引擎语音SDK封装的Flutter插件,支持Android平台。 + +## 功能 +- 语音合成(TTS) + - 支持在线/离线/混合合成模式 + - 支持SSML格式文本 + - 支持情感合成和情感预测 + - 支持音量、语速、音高等多种参数调节 + - 复刻音色支持 + - 离线资源管理 +- 语音识别(ASR) + - 一次性识别 + - 连续识别 + - 长按识别 + - 热词优化 + - 多语言支持 + +## 使用说明 + +### TTS 语音合成 + +#### 基础用法 +```dart +import 'package:volcano_speech/volcano_speech.dart'; + +// 初始化 +await VolcanoSpeech.initialize(appId: 'YOUR_APP_ID', apiKey: 'YOUR_API_KEY'); + +// 设置音量、语速等参数 +await VolcanoSpeech.setSpeechParams( + rate: 0, // 语速: -500到500,0为正常速度 + volume: 100, // 音量: 0到100,默认100 + pitch: 0, // 音高: -500到500,0为正常音高 + silenceDuration: 500, // 静音段时长: 毫秒 +); + +// 设置音色 +await VolcanoSpeech.setVoice( + voiceName: 'zh_female_qingxin', + voiceType: 'qingxin' +); + +// 开始合成并播放 +await VolcanoSpeech.speakText(text: '这是一段测试文本'); + +// 暂停播放 +await VolcanoSpeech.pausePlayback(); + +// 继续播放 +await VolcanoSpeech.resumePlayback(); + +// 停止播放 +await VolcanoSpeech.stopSpeaking(); + +// 销毁引擎 +await VolcanoSpeech.dispose(); +``` + +#### 进阶用法 +```dart +// 设置工作模式 +await VolcanoSpeech.setWorkMode(mode: TtsWorkMode.alternate); // 先在线,断网时切换离线 + +// 设置离线发音人 +await VolcanoSpeech.setOfflineVoice( + voiceName: 'xifei', + voiceType: 'qingxin' +); + +// 下载离线资源 +await VolcanoSpeech.downloadOfflineResource( + voiceTypes: ['qingxin', 'zhenjiang'], + languages: ['zh-CN'] +); + +// 使用SSML格式文本 +await VolcanoSpeech.setTextType(type: TtsTextType.ssml); +await VolcanoSpeech.speakText( + text: '这是一段2023-10-01的语音合成' +); + +// 设置情感 +await VolcanoSpeech.setEmotion(emotion: 'happy'); + +// 启用情感预测 +await VolcanoSpeech.setEnableEmotionPredict(enable: true); + +// 启用服务端缓存 +await VolcanoSpeech.setEnableCache(enable: true); + +// 监听TTS进度事件 +VolcanoSpeech.ttsProgressEvents.listen((event) { + print('播放进度: ${(event.progress * 100).toStringAsFixed(1)}%'); +}); + +// 复刻音色支持 +await VolcanoSpeech.setEnableVoiceClone( + enable: true, + backendCluster: 'your_cluster_name' +); +``` + +### ASR 语音识别 +#### 一次性识别 +```dart +import 'package:volcano_speech/volcano_speech.dart'; + +// 初始化 +await VolcanoSpeech.initializeAsr( + appId: 'YOUR_APP_ID', + apiKey: 'YOUR_API_KEY', + supportedLanguages: ['zh-CN'], +); + +// 设置识别语言 +await VolcanoSpeech.setAsrLanguage(language: 'zh-CN'); + +// 设置热词(可选) +await VolcanoSpeech.setAsrHotWords( + hotWords: '{"hotwords":[{"word":"火山引擎","scale":2.0}]}', +); + +// 开始一次性识别 +try { + final result = await VolcanoSpeech.recognizeOnce(); + print('识别结果: ${result['text']}, 语言: ${result['language']}'); +} catch (e) { + print('识别出错: $e'); +} + +// 销毁引擎 +await VolcanoSpeech.disposeAsr(); +``` + +#### 连续识别 +```dart +import 'package:volcano_speech/volcano_speech.dart'; + +// 初始化 +await VolcanoSpeech.initializeAsr( + appId: 'YOUR_APP_ID', + apiKey: 'YOUR_API_KEY', +); + +// 监听识别事件 +VolcanoSpeech.asrEvents.listen((event) { + switch (event.type) { + case AsrEventType.sessionStarted: + print('识别会话开始'); + break; + case AsrEventType.sessionStopped: + print('识别会话结束'); + break; + case AsrEventType.recognizing: + print('正在识别: ${event.text}'); + break; + case AsrEventType.result: + print('识别结果: ${event.text}'); + break; + case AsrEventType.volumeChanged: + print('音量: ${event.volume}'); + break; + case AsrEventType.error: + print('识别错误: ${event.errorMessage}'); + break; + } +}); + +// 开始连续识别 +await VolcanoSpeech.startContinuousRecognition(); + +// 检查是否正在识别 +final isActive = await VolcanoSpeech.isContinuousRecognitionActive(); +print('是否正在识别: $isActive'); + +// 停止连续识别 +await VolcanoSpeech.stopContinuousRecognition(); +``` + +#### 长按识别 +```dart +import 'package:volcano_speech/volcano_speech.dart'; + +// 初始化 +await VolcanoSpeech.initializeAsr( + appId: 'YOUR_APP_ID', + apiKey: 'YOUR_API_KEY', +); + +// 监听识别事件 +VolcanoSpeech.asrEvents.listen((event) { + // 处理事件... +}); + +// 用户按下按钮时开始识别 +onPressed: () async { + await VolcanoSpeech.startListening(); +}, + +// 用户释放按钮时停止识别 +onReleased: () async { + await VolcanoSpeech.stopListening(); +}, +``` \ 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 new file mode 100644 index 000000000..86b03fd0e --- /dev/null +++ b/local_plugins/volcano_speech/android/build.gradle.kts @@ -0,0 +1,64 @@ +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") +} + +// 配置android扩展 +configure { + namespace = "com.yunqiinnovation.volcano_speech" + compileSdkVersion(33) + + defaultConfig { + minSdk = 21 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + sourceSets { + getByName("main") { + manifest.srcFile("src/main/AndroidManifest.xml") + java.srcDirs("src/main/kotlin") + } + } + + // 添加lint选项 + lintOptions { + isCheckReleaseBuilds = false + } +} + +// 显式设置Kotlin JVM目标版本 +tasks.withType { + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + // 添加火山引擎语音合成SDK + implementation("com.bytedance.speechengine:speechengine_tob:0.0.5") +} \ No newline at end of file diff --git a/local_plugins/volcano_speech/android/settings.gradle.kts b/local_plugins/volcano_speech/android/settings.gradle.kts new file mode 100644 index 000000000..7f5342866 --- /dev/null +++ b/local_plugins/volcano_speech/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "volcano_speech" \ No newline at end of file diff --git a/local_plugins/volcano_speech/android/src/main/AndroidManifest.xml b/local_plugins/volcano_speech/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..338e8293c --- /dev/null +++ b/local_plugins/volcano_speech/android/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file 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 new file mode 100644 index 000000000..614474d1a --- /dev/null +++ b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoAsrHelper.kt @@ -0,0 +1,529 @@ +package com.yunqiinnovation.volcano_speech + +import android.content.Context +import android.os.Handler +import android.os.Looper +import com.bytedance.speech.speechengine.SpeechEngine +import com.bytedance.speech.speechengine.SpeechEngineDefines +import com.bytedance.speech.speechengine.SpeechEngineGenerator +import com.yunqiinnovation.volcano_speech.utils.FileLogger +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +/** + * 火山语音识别帮助类 (大模型版本) + */ +class VolcanoAsrHelper(private val context: Context) { + private val TAG = "VolcanoAsrHelper" + private val mainHandler = Handler(Looper.getMainLooper()) + + // 语音引擎相关 + private var engine: SpeechEngine? = null + private var engineHandler: Long = -1 + private var isInitialized = false + + // 当前回调 + private var currentAsrCallback: ASRCallback? = null + private var currentContinuousCallback: ASRContinuousCallback? = null + + // 识别状态 + private var isContinuousRecognitionActive = false + + // 配置参数 + private var language = "zh-CN" + private var enableVolume = false + private var showUtterances = false + + /** + * ASR一次性识别回调 + */ + interface ASRCallback { + fun onSuccess(text: String) + fun onError(error: String) + } + + /** + * ASR连续识别回调 + */ + interface ASRContinuousCallback { + fun onResult(text: String) + fun onRecognizing(text: String) + fun onSessionStarted() + fun onSessionStopped() + fun onVolumeChanged(volume: Int) + fun onError(error: String) + } + + /** + * 初始化语音识别引擎 + */ + fun initialize(appId: String, token: String, resourceId: String): Boolean { + if (isInitialized) { + FileLogger.i(TAG, "引擎已经初始化") + return true + } + + try { + // 准备环境 + SpeechEngineGenerator.PrepareEnvironment(context, null) + + // 创建引擎 + engine = SpeechEngineGenerator.getInstance() + engineHandler = engine?.createEngine() ?: -1 + + if (engineHandler == -1L) { + FileLogger.e(TAG, "创建引擎失败") + return false + } + + // 设置上下文 + engine?.setContext(context) + + // 设置引擎类型为ASR + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ENGINE_NAME_STRING, SpeechEngineDefines.ASR_ENGINE) + + // 设置日志级别 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_LOG_LEVEL_STRING, SpeechEngineDefines.LOG_LEVEL_WARN) + + // 设置用户ID和设备ID (使用静态值,实际项目中应替换为真实值) + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_UID_STRING, "user_id") + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_DEVICE_ID_STRING, "device_id") + + // 设置鉴权信息 - 大模型版本不需要Bearer前缀 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_ID_STRING, appId) + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_TOKEN_STRING, token) + + // 设置资源ID - 大模型必需 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_RESOURCE_ID_STRING, resourceId) + + // 设置协议类型为Seed - 大模型必需 + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_PROTOCOL_TYPE_INT, SpeechEngineDefines.PROTOCOL_TYPE_SEED) + + // 设置网络配置 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_ADDRESS_STRING, "wss://openspeech.bytedance.com") + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_URI_STRING, "/api/v3/sauc/bigmodel") + + // 设置超时时间 + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_CONN_TIMEOUT_INT, 12000) + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_RECV_TIMEOUT_INT, 8000) + + // 设置音频来源为录音机 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_RECORDER_TYPE_STRING, SpeechEngineDefines.RECORDER_TYPE_RECORDER) + + // 设置最大录音时长 (默认60秒) + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_VAD_MAX_SPEECH_DURATION_INT, 60000) + + // 设置回声消除 (用于ASR识别时不会收到TTS的声音) + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_RECORDER_PRESET_INT, SpeechEngineDefines.RECORDER_PRESET_VOICE_COMMUNICATION) + + // 初始化引擎 + val result = engine?.initEngine(engineHandler) + isInitialized = result == SpeechEngineDefines.ERR_NO_ERROR + + if (isInitialized) { + FileLogger.i(TAG, "引擎初始化成功") + + // 设置回调监听 + engine?.setListener(object : SpeechEngine.SpeechListener { + override fun onSpeechMessage(type: Int, data: ByteArray, len: Int) { + val stdData = String(data) + handleEngineEvent(type, stdData) + } + }) + + return true + } else { + FileLogger.e(TAG, "引擎初始化失败: $result") + return false + } + } catch (e: Exception) { + FileLogger.e(TAG, "初始化异常: ${e.message}", e) + return false + } + } + + /** + * 设置语言 + */ + fun setLanguage(language: String): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + this.language = language + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_LANGUAGE_STRING, language) + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置语言失败: ${e.message}", e) + return false + } + } + + /** + * 设置热词 + */ + fun setHotWords(hotWordsId: String): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + if (hotWordsId.isNotEmpty()) { + // 大模型ASR需要通过请求参数设置热词 + val reqParams = "{\"corpus\":{\"boosting_table_id\":\"$hotWordsId\"}}" + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_REQ_PARAMS_STRING, reqParams) + } + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置热词失败: ${e.message}", e) + return false + } + } + + /** + * 设置是否返回音量 + */ + fun setEnableVolume(enable: Boolean): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + this.enableVolume = enable + engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ENABLE_GET_VOLUME_BOOL, enable) + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置音量返回失败: ${e.message}", e) + return false + } + } + + /** + * 设置是否显示语音停顿、分句、分词信息 + */ + fun setShowUtterances(show: Boolean): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + this.showUtterances = show + engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_SHOW_UTTER_BOOL, show) + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置语音信息显示失败: ${e.message}", e) + return false + } + } + + /** + * 设置VAD切句参数 + */ + fun setVadParams(forceToSpeechTime: Int, endWindowSize: Int): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + val reqParams = "{\"force_to_speech_time\":$forceToSpeechTime, \"end_window_size\":$endWindowSize}" + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_REQ_PARAMS_STRING, reqParams) + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置VAD参数失败: ${e.message}", e) + return false + } + } + + /** + * 设置纠错词表 + */ + fun setCorrectWords(correctWordsJson: String): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + val reqParams = "{\"context\": \"{\\\"correct_words\\\": $correctWordsJson}\"}" + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_REQ_PARAMS_STRING, reqParams) + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置纠错词表失败: ${e.message}", e) + return false + } + } + + /** + * 一次性识别 + */ + fun recognizeOnce(callback: ASRCallback): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + if (isContinuousRecognitionActive) { + FileLogger.e(TAG, "当前正在连续识别中") + return false + } + + try { + this.currentAsrCallback = callback + + // 停止当前引擎 + engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "") + + // 启动引擎开始识别 + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") + + if (ret != SpeechEngineDefines.ERR_NO_ERROR) { + FileLogger.e(TAG, "启动识别失败: $ret") + return false + } + + return true + } catch (e: Exception) { + FileLogger.e(TAG, "识别异常: ${e.message}", e) + return false + } + } + + /** + * 停止一次性识别 + */ + fun stopRecognize(): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + // 告知引擎音频输入完成 + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_FINISH_TALKING, "") + + if (ret != SpeechEngineDefines.ERR_NO_ERROR) { + FileLogger.e(TAG, "停止识别失败: $ret") + return false + } + + return true + } catch (e: Exception) { + FileLogger.e(TAG, "停止识别异常: ${e.message}", e) + return false + } + } + + /** + * 开始连续识别 + */ + fun startContinuousRecognition(callback: ASRContinuousCallback): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + if (isContinuousRecognitionActive) { + FileLogger.e(TAG, "当前已经在连续识别中") + return false + } + + try { + this.currentContinuousCallback = callback + + // 停止当前引擎 + engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "") + + // 启动引擎开始识别 + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") + + if (ret != SpeechEngineDefines.ERR_NO_ERROR) { + FileLogger.e(TAG, "启动连续识别失败: $ret") + return false + } + + isContinuousRecognitionActive = true + return true + } catch (e: Exception) { + FileLogger.e(TAG, "连续识别异常: ${e.message}", e) + return false + } + } + + /** + * 停止连续识别 + */ + fun stopContinuousRecognition(): Boolean { + if (!isInitialized || !isContinuousRecognitionActive) { + FileLogger.e(TAG, "引擎未初始化或未在连续识别中") + return false + } + + try { + // 停止引擎 + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") + + if (ret != SpeechEngineDefines.ERR_NO_ERROR) { + FileLogger.e(TAG, "停止连续识别失败: $ret") + return false + } + + isContinuousRecognitionActive = false + return true + } catch (e: Exception) { + FileLogger.e(TAG, "停止连续识别异常: ${e.message}", e) + return false + } + } + + /** + * 判断是否在连续识别中 + */ + fun isContinuousRecognitionActive(): Boolean { + return isContinuousRecognitionActive + } + + /** + * 释放资源 + */ + fun release() { + if (!isInitialized) { + return + } + + try { + // 停止引擎 + if (isContinuousRecognitionActive) { + stopContinuousRecognition() + } else { + engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") + } + + // 销毁引擎 + engine?.destroyEngine(engineHandler) + engineHandler = -1 + engine = null + isInitialized = false + + FileLogger.i(TAG, "引擎已释放") + } catch (e: Exception) { + FileLogger.e(TAG, "释放引擎异常: ${e.message}", e) + } + } + + /** + * 处理引擎事件 + */ + private fun handleEngineEvent(type: Int, data: String) { + when (type) { + SpeechEngineDefines.MESSAGE_TYPE_ENGINE_START -> { + // 引擎启动成功 + mainHandler.post { + currentContinuousCallback?.onSessionStarted() + } + } + + SpeechEngineDefines.MESSAGE_TYPE_ENGINE_STOP -> { + // 引擎停止 + isContinuousRecognitionActive = false + mainHandler.post { + currentContinuousCallback?.onSessionStopped() + } + } + + SpeechEngineDefines.MESSAGE_TYPE_PARTIAL_RESULT -> { + // 中间识别结果 + try { + val json = JSONObject(data) + if (!json.has("result")) { + return + } + + val resultArray = json.getJSONArray("result") + if (resultArray.length() > 0) { + val result = resultArray.getJSONObject(0) + val text = result.optString("text", "") + + if (text.isNotEmpty()) { + mainHandler.post { + currentContinuousCallback?.onRecognizing(text) + } + } + } + } catch (e: JSONException) { + FileLogger.e(TAG, "解析中间识别结果异常: ${e.message}", e) + } + } + + SpeechEngineDefines.MESSAGE_TYPE_FINAL_RESULT -> { + // 最终识别结果 + try { + val json = JSONObject(data) + if (!json.has("result")) { + return + } + + val resultArray = json.getJSONArray("result") + if (resultArray.length() > 0) { + val result = resultArray.getJSONObject(0) + val text = result.optString("text", "") + + if (text.isNotEmpty()) { + mainHandler.post { + if (isContinuousRecognitionActive) { + currentContinuousCallback?.onResult(text) + } else { + currentAsrCallback?.onSuccess(text) + } + } + } + } + } catch (e: JSONException) { + FileLogger.e(TAG, "解析最终识别结果异常: ${e.message}", e) + } + } + + SpeechEngineDefines.MESSAGE_TYPE_VOLUME_LEVEL -> { + // 音量回调 + if (enableVolume && isContinuousRecognitionActive) { + try { + val volume = (data.toFloat() * 100).toInt() + mainHandler.post { + currentContinuousCallback?.onVolumeChanged(volume) + } + } catch (e: Exception) { + FileLogger.e(TAG, "解析音量数据异常: ${e.message}", e) + } + } + } + + SpeechEngineDefines.MESSAGE_TYPE_ENGINE_ERROR -> { + // 错误信息 + try { + val json = JSONObject(data) + val errCode = json.optInt("err_code", -1) + val errMsg = json.optString("err_msg", "未知错误") + + FileLogger.e(TAG, "引擎错误: $errCode, $errMsg") + + mainHandler.post { + if (isContinuousRecognitionActive) { + currentContinuousCallback?.onError(errMsg) + isContinuousRecognitionActive = false + } else { + currentAsrCallback?.onError(errMsg) + } + } + } catch (e: JSONException) { + FileLogger.e(TAG, "解析错误信息异常: ${e.message}", e) + } + } + } + } +} diff --git a/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoSpeechPlugin.kt b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoSpeechPlugin.kt new file mode 100644 index 000000000..bb6f1e452 --- /dev/null +++ b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoSpeechPlugin.kt @@ -0,0 +1,445 @@ +package com.yunqiinnovation.volcano_speech + +import android.content.Context +import android.os.Handler +import android.os.Looper +import androidx.annotation.NonNull +import com.yunqiinnovation.volcano_speech.utils.FileLogger + +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 io.flutter.plugin.common.EventChannel + +/** VolcanoSpeechPlugin */ +class VolcanoSpeechPlugin: FlutterPlugin { + private val TAG = "VolcanoSpeechPlugin" + private lateinit var context: Context + private val mainHandler = Handler(Looper.getMainLooper()) + + // ASR相关 + private lateinit var asrChannel: MethodChannel + private lateinit var asrEventChannel: EventChannel + private var asrEventSink: EventChannel.EventSink? = null + + // TTS相关 + private lateinit var ttsChannel: MethodChannel + private lateinit var ttsEventChannel: EventChannel + private var ttsEventSink: EventChannel.EventSink? = null + + // TTS帮助类 + private lateinit var volcanoTtsHelper: VolcanoTtsHelper + + // ASR帮助类 + private lateinit var volcanoAsrHelper: VolcanoAsrHelper + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + context = flutterPluginBinding.applicationContext + + // 初始化ASR通道 + asrChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "volcano_speech/asr") + asrChannel.setMethodCallHandler(AsrMethodHandler()) + + // 初始化TTS通道 + ttsChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "volcano_speech/tts") + ttsChannel.setMethodCallHandler(TtsMethodHandler()) + + // 初始化ASR事件通道 + asrEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "volcano_speech/asr_events") + asrEventChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + asrEventSink = events + } + + override fun onCancel(arguments: Any?) { + asrEventSink = null + } + }) + + // 初始化TTS事件通道 + ttsEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "volcano_speech/tts_events") + ttsEventChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + ttsEventSink = events + } + + override fun onCancel(arguments: Any?) { + ttsEventSink = null + } + }) + + // 初始化TTS帮助类 + volcanoTtsHelper = VolcanoTtsHelper(context) + + // 初始化ASR帮助类 + volcanoAsrHelper = VolcanoAsrHelper(context) + } + + // 发送ASR事件 + private fun sendAsrEvent(event: Map) { + FileLogger.d(TAG, "发送ASR事件: $event") + if (asrEventSink == null) { + FileLogger.w(TAG, "无法发送ASR事件:事件通道未准备好") + return + } + + mainHandler.post { + try { + asrEventSink?.success(event) + FileLogger.d(TAG, "ASR事件发送成功") + } catch (e: Exception) { + FileLogger.e(TAG, "发送ASR事件失败: ${e.message}") + } + } + } + + // 发送TTS事件 + private fun sendTtsEvent(event: Map) { + FileLogger.d(TAG, "发送TTS事件: $event") + if (ttsEventSink == null) { + FileLogger.w(TAG, "无法发送TTS事件:事件通道未准备好") + return + } + + mainHandler.post { + try { + ttsEventSink?.success(event) + FileLogger.d(TAG, "TTS事件发送成功") + } catch (e: Exception) { + FileLogger.e(TAG, "发送TTS事件失败: ${e.message}") + } + } + } + + // ASR方法处理器 + inner class AsrMethodHandler : MethodCallHandler { + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "getPlatformVersion" -> { + result.success("Android ${android.os.Build.VERSION.RELEASE}") + } + "initialize" -> { + val appId = call.argument("appId") ?: "" + val apiKey = call.argument("apiKey") ?: "" + val resourceId = call.argument("resourceId") ?: "" + + if (appId.isEmpty() || apiKey.isEmpty() || resourceId.isEmpty()) { + result.error("INVALID_ARGUMENTS", "appId、apiKey和resourceId不能为空", null) + return + } + + val success = volcanoAsrHelper.initialize(appId, apiKey, resourceId) + result.success(success) + } + "setLanguage" -> { + val language = call.argument("language") ?: "zh-CN" + result.success(volcanoAsrHelper.setLanguage(language)) + } + "setHotWords" -> { + val hotWordsId = call.argument("hotWordsId") ?: "" + result.success(volcanoAsrHelper.setHotWords(hotWordsId)) + } + "setVadParams" -> { + val forceToSpeechTime = call.argument("forceToSpeechTime") ?: 0 + val endWindowSize = call.argument("endWindowSize") ?: 800 + result.success(volcanoAsrHelper.setVadParams(forceToSpeechTime, endWindowSize)) + } + "setCorrectWords" -> { + val correctWordsJson = call.argument("correctWordsJson") ?: "{}" + result.success(volcanoAsrHelper.setCorrectWords(correctWordsJson)) + } + "setEnableVolume" -> { + val enable = call.argument("enable") ?: false + result.success(volcanoAsrHelper.setEnableVolume(enable)) + } + "setShowUtterances" -> { + val enable = call.argument("enable") ?: false + result.success(volcanoAsrHelper.setShowUtterances(enable)) + } + "recognizeOnce" -> { + // 确保当前不在连续识别中 + if (volcanoAsrHelper.isContinuousRecognitionActive()) { + result.error("ASR_BUSY", "当前正在连续识别中", null) + return + } + + volcanoAsrHelper.recognizeOnce(object : VolcanoAsrHelper.ASRCallback { + override fun onSuccess(text: String) { + mainHandler.post { + result.success(mapOf( + "text" to text + )) + } + } + + override fun onError(error: String) { + mainHandler.post { + result.error("ASR_ERROR", error, null) + } + } + }) + } + "startContinuousRecognition" -> { + // 确保事件通道已准备好 + if (asrEventSink == null) { + result.error("EVENT_CHANNEL_NOT_READY", "事件通道未准备好,无法开始连续识别", null) + return + } + + val success = volcanoAsrHelper.startContinuousRecognition(object : VolcanoAsrHelper.ASRContinuousCallback { + override fun onResult(text: String) { + sendAsrEvent(mapOf( + "type" to "result", + "text" to text + )) + } + + override fun onRecognizing(text: String) { + sendAsrEvent(mapOf( + "type" to "recognizing", + "text" to text + )) + } + + override fun onSessionStarted() { + sendAsrEvent(mapOf("type" to "sessionStarted")) + } + + override fun onSessionStopped() { + sendAsrEvent(mapOf("type" to "sessionStopped")) + } + + override fun onVolumeChanged(volume: Int) { + sendAsrEvent(mapOf( + "type" to "volumeChanged", + "volume" to volume + )) + } + + override fun onError(error: String) { + sendAsrEvent(mapOf( + "type" to "error", + "message" to error + )) + } + }) + + result.success(success) + } + "stopContinuousRecognition" -> { + val success = volcanoAsrHelper.stopContinuousRecognition() + result.success(success) + } + "stopRecognize" -> { + val success = volcanoAsrHelper.stopRecognize() + result.success(success) + } + "isContinuousRecognitionActive" -> { + result.success(volcanoAsrHelper.isContinuousRecognitionActive()) + } + "release" -> { + volcanoAsrHelper.release() + result.success(true) + } + else -> { + result.notImplemented() + } + } + } + } + + // TTS方法处理器 + inner class TtsMethodHandler : MethodCallHandler { + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "getPlatformVersion" -> { + result.success("Android ${android.os.Build.VERSION.RELEASE}") + } + "initialize" -> { + val appId = call.argument("appId") ?: "" + val token = call.argument("token") ?: "" + val resourceId = call.argument("resourceId") ?: "" + + if (appId.isEmpty() || token.isEmpty() || resourceId.isEmpty()) { + result.error("INVALID_ARGUMENTS", "appId、token和resourceId不能为空", null) + return + } + + val success = volcanoTtsHelper.initialize(appId, token, resourceId) + result.success(success) + } + "setVoice" -> { + val voice = call.argument("voice") ?: "" + + if (voice.isEmpty()) { + result.error("INVALID_ARGUMENTS", "voice不能为空", null) + return + } + + result.success(volcanoTtsHelper.setVoice(voice)) + } + "setContinuousMode" -> { + val isContinuous = call.argument("isContinuous") ?: false + result.success(volcanoTtsHelper.setContinuousMode(isContinuous)) + } + "speak" -> { + val text = call.argument("text") ?: "" + + if (text.isEmpty()) { + result.error("INVALID_ARGUMENTS", "合成文本不能为空", null) + return + } + + volcanoTtsHelper.speak(text, object : VolcanoTtsHelper.TTSCallback { + override fun onStart(reqId: String) { + sendTtsEvent(mapOf( + "type" to "start", + "reqId" to reqId + )) + } + + override fun onProgress(reqId: String, progress: Double) { + sendTtsEvent(mapOf( + "type" to "progress", + "reqId" to reqId, + "progress" to progress + )) + } + + override fun onComplete(reqId: String) { + sendTtsEvent(mapOf( + "type" to "complete", + "reqId" to reqId + )) + + mainHandler.post { + result.success(true) + } + } + + override fun onError(reqId: String, errorCode: Int, errorMsg: String) { + sendTtsEvent(mapOf( + "type" to "error", + "reqId" to reqId, + "errorCode" to errorCode, + "errorMsg" to errorMsg + )) + + mainHandler.post { + result.error("TTS_ERROR", errorMsg, null) + } + } + }) + } + "synthesisNext" -> { + val text = call.argument("text") ?: "" + + if (text.isEmpty()) { + result.error("INVALID_ARGUMENTS", "合成文本不能为空", null) + return + } + + val success = volcanoTtsHelper.synthesisNext(text) + result.success(success) + } + "pause" -> { + result.success(volcanoTtsHelper.pause()) + } + "resume" -> { + result.success(volcanoTtsHelper.resume()) + } + "stop" -> { + result.success(volcanoTtsHelper.stop()) + } + "release" -> { + volcanoTtsHelper.release() + result.success(true) + } + // 以下方法用于与Azure Speech版本兼容 + "setSpeechSynthesisVoice" -> { + val voiceName = call.argument("voiceName") ?: "" + + if (voiceName.isEmpty()) { + result.error("INVALID_ARGUMENTS", "语音名称不能为空", null) + return + } + + result.success(volcanoTtsHelper.setVoice(voiceName)) + } + "speakText" -> { + val text = call.argument("text") ?: "" + + if (text.isEmpty()) { + result.error("INVALID_ARGUMENTS", "合成文本不能为空", null) + return + } + + volcanoTtsHelper.speak(text, object : VolcanoTtsHelper.TTSCallback { + override fun onStart(reqId: String) { + sendTtsEvent(mapOf( + "type" to "start", + "reqId" to reqId + )) + } + + override fun onProgress(reqId: String, progress: Double) { + sendTtsEvent(mapOf( + "type" to "progress", + "reqId" to reqId, + "progress" to progress + )) + } + + override fun onComplete(reqId: String) { + sendTtsEvent(mapOf( + "type" to "complete", + "reqId" to reqId + )) + + mainHandler.post { + result.success(true) + } + } + + override fun onError(reqId: String, errorCode: Int, errorMsg: String) { + sendTtsEvent(mapOf( + "type" to "error", + "reqId" to reqId, + "errorCode" to errorCode, + "errorMsg" to errorMsg + )) + + mainHandler.post { + result.error("TTS_ERROR", errorMsg, null) + } + } + }) + } + "stopSpeaking" -> { + result.success(volcanoTtsHelper.stop()) + } + "pauseSpeaking" -> { + result.success(volcanoTtsHelper.pause()) + } + "resumeSpeaking" -> { + result.success(volcanoTtsHelper.resume()) + } + else -> { + result.notImplemented() + } + } + } + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + asrChannel.setMethodCallHandler(null) + ttsChannel.setMethodCallHandler(null) + asrEventChannel.setStreamHandler(null) + ttsEventChannel.setStreamHandler(null) + + volcanoTtsHelper.release() + volcanoAsrHelper.release() + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..e24a1ceb1 --- /dev/null +++ b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoTtsHelper.kt @@ -0,0 +1,399 @@ +package com.yunqiinnovation.volcano_speech + +import android.content.Context +import android.os.Handler +import android.os.Looper +import com.bytedance.speech.speechengine.SpeechEngine +import com.bytedance.speech.speechengine.SpeechEngineDefines +import com.bytedance.speech.speechengine.SpeechEngineGenerator +import com.yunqiinnovation.volcano_speech.utils.FileLogger +import org.json.JSONObject + +/** + * 火山语音合成帮助类 (大模型版本) + */ +class VolcanoTtsHelper(private val context: Context) { + private val TAG = "VolcanoTtsHelper" + private val mainHandler = Handler(Looper.getMainLooper()) + + // 语音引擎相关 + private var engine: SpeechEngine? = null + private var engineHandler: Long = -1 + private var isInitialized = false + + // 当前回调 + private var currentTtsCallback: TTSCallback? = null + + // 合成状态 + private var isPlaying = false + private var currentReqId = "" + + // 配置参数 + private var voice = "zh_female_yuxi" + private var ttsText = "" + private var isContinuous = false + + /** + * TTS合成回调接口 + */ + interface TTSCallback { + fun onStart(reqId: String) + fun onProgress(reqId: String, progress: Double) + fun onComplete(reqId: String) + fun onError(reqId: String, errorCode: Int, errorMsg: String) + } + + /** + * 初始化语音合成引擎 + */ + fun initialize(appId: String, token: String, resourceId: String): Boolean { + if (isInitialized) { + FileLogger.i(TAG, "引擎已经初始化") + return true + } + + try { + // 准备环境 + SpeechEngineGenerator.PrepareEnvironment(context, null) + + // 创建引擎 + engine = SpeechEngineGenerator.getInstance() + engineHandler = engine?.createEngine() ?: -1 + + if (engineHandler == -1L) { + FileLogger.e(TAG, "创建引擎失败") + return false + } + + // 设置上下文 + engine?.setContext(context) + + // 设置引擎类型为TTS + 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) + + // 设置用户ID和设备ID (使用静态值,实际项目中应替换为真实值) + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_UID_STRING, "user_id") + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_DEVICE_ID_STRING, "device_id") + + // 设置授权信息 - 大模型版本不需要Bearer前缀 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_ID_STRING, appId) + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_TOKEN_STRING, token) + + // 设置资源ID - 大模型必需 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_RESOURCE_ID_STRING, resourceId) + + // 设置协议类型为Seed - 大模型必需 + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_PROTOCOL_TYPE_INT, SpeechEngineDefines.PROTOCOL_TYPE_SEED) + + // 设置合成策略为在线合成 + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_WORK_MODE_INT, SpeechEngineDefines.TTS_WORK_MODE_ONLINE) + + // 设置在线请求资源配置 + 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_VOICE_ONLINE_STRING, voice) + + // 设置播放进度回调 + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_WITH_FRONTEND_INT, 1) + + // 初始化引擎 + val result = engine?.initEngine(engineHandler) + isInitialized = result == SpeechEngineDefines.ERR_NO_ERROR + + if (isInitialized) { + FileLogger.i(TAG, "引擎初始化成功") + + // 设置回调监听 + engine?.setListener(object : SpeechEngine.SpeechListener { + override fun onSpeechMessage(type: Int, data: ByteArray, len: Int) { + val stdData = String(data) + handleEngineEvent(type, stdData) + } + }) + + return true + } else { + FileLogger.e(TAG, "引擎初始化失败: $result") + return false + } + } catch (e: Exception) { + FileLogger.e(TAG, "初始化异常: ${e.message}", e) + return false + } + } + + /** + * 设置发音人 + */ + fun setVoice(voice: String): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + this.voice = voice + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_ONLINE_STRING, voice) + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置发音人失败: ${e.message}", e) + return false + } + } + + /** + * 设置合成场景(单次或连续) + */ + fun setContinuousMode(isContinuous: Boolean): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + this.isContinuous = isContinuous + val scenarioType = if (isContinuous) { + SpeechEngineDefines.TTS_SCENARIO_TYPE_NOVEL + } else { + SpeechEngineDefines.TTS_SCENARIO_TYPE_NORMAL + } + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_SCENARIO_STRING, scenarioType) + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置合成场景失败: ${e.message}", e) + return false + } + } + + /** + * 开始合成并播放 + */ + fun speak(text: String, callback: TTSCallback? = null): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + if (text.isEmpty()) { + FileLogger.e(TAG, "合成文本为空") + return false + } + + try { + this.ttsText = text + this.currentTtsCallback = callback + + // 设置要合成的文本 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_TEXT_STRING, text) + + // 停止当前引擎 + engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "") + + // 启动引擎开始合成 + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") + + if (ret != SpeechEngineDefines.ERR_NO_ERROR) { + FileLogger.e(TAG, "启动合成失败: $ret") + return false + } + + return true + } catch (e: Exception) { + FileLogger.e(TAG, "合成异常: ${e.message}", e) + return false + } + } + + /** + * 仅用于连续合成场景:在引擎启动后添加新的文本进行合成 + */ + fun synthesisNext(text: String): Boolean { + if (!isInitialized || !isContinuous) { + FileLogger.e(TAG, "引擎未初始化或非连续合成模式") + return false + } + + if (text.isEmpty()) { + FileLogger.e(TAG, "合成文本为空") + return false + } + + try { + // 设置要合成的文本 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_TEXT_STRING, text) + + // 发送合成指令 + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNTHESIS, "") + + if (ret != SpeechEngineDefines.ERR_NO_ERROR) { + FileLogger.e(TAG, "添加合成文本失败: $ret") + return false + } + + return true + } catch (e: Exception) { + FileLogger.e(TAG, "添加合成文本异常: ${e.message}", e) + return false + } + } + + /** + * 暂停播放 + */ + fun pause(): Boolean { + if (!isInitialized || !isPlaying) { + FileLogger.e(TAG, "引擎未初始化或未在播放") + return false + } + + try { + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_PAUSE_PLAYER, "") + return ret == SpeechEngineDefines.ERR_NO_ERROR + } catch (e: Exception) { + FileLogger.e(TAG, "暂停播放异常: ${e.message}", e) + return false + } + } + + /** + * 恢复播放 + */ + fun resume(): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_RESUME_PLAYER, "") + return ret == SpeechEngineDefines.ERR_NO_ERROR + } catch (e: Exception) { + FileLogger.e(TAG, "恢复播放异常: ${e.message}", e) + return false + } + } + + /** + * 停止播放 + */ + fun stop(): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") + isPlaying = false + return ret == SpeechEngineDefines.ERR_NO_ERROR + } catch (e: Exception) { + FileLogger.e(TAG, "停止播放异常: ${e.message}", e) + return false + } + } + + /** + * 释放资源 + */ + fun release() { + if (!isInitialized) { + return + } + + try { + // 停止引擎 + stop() + + // 销毁引擎 + engine?.destroyEngine(engineHandler) + engineHandler = -1 + engine = null + isInitialized = false + + FileLogger.i(TAG, "引擎已释放") + } catch (e: Exception) { + FileLogger.e(TAG, "释放引擎异常: ${e.message}", e) + } + } + + /** + * 处理引擎事件 + */ + private fun handleEngineEvent(type: Int, data: String) { + when (type) { + SpeechEngineDefines.MESSAGE_TYPE_ENGINE_START -> { + // 引擎启动成功,获取请求ID + currentReqId = data + isPlaying = true + + mainHandler.post { + currentTtsCallback?.onStart(currentReqId) + } + + if (isContinuous) { + // 在连续合成模式下,需要单独发送合成指令 + synthesisNext(ttsText) + } + } + + SpeechEngineDefines.MESSAGE_TYPE_ENGINE_STOP -> { + // 引擎停止 + isPlaying = false + } + + SpeechEngineDefines.MESSAGE_TYPE_TTS_START_PLAYING -> { + // 开始播放 + FileLogger.d(TAG, "开始播放: $data") + } + + SpeechEngineDefines.MESSAGE_TYPE_TTS_FINISH_PLAYING -> { + // 播放结束 + isPlaying = false + + mainHandler.post { + currentTtsCallback?.onComplete(currentReqId) + } + } + + SpeechEngineDefines.MESSAGE_TYPE_TTS_PLAYBACK_PROGRESS -> { + // 播放进度 + try { + val json = JSONObject(data) + val progress = json.optDouble("progress", 0.0) + val reqId = json.optString("reqid", "") + + mainHandler.post { + currentTtsCallback?.onProgress(reqId, progress) + } + } catch (e: Exception) { + FileLogger.e(TAG, "解析进度信息异常: ${e.message}", e) + } + } + + SpeechEngineDefines.MESSAGE_TYPE_ENGINE_ERROR -> { + // 错误信息 + try { + val json = JSONObject(data) + val reqId = json.optString("reqid", "") + val errCode = json.optInt("err_code", -1) + val errMsg = json.optString("err_msg", "未知错误") + + FileLogger.e(TAG, "引擎错误: $errCode, $errMsg") + + isPlaying = false + + mainHandler.post { + currentTtsCallback?.onError(reqId, errCode, errMsg) + } + } catch (e: Exception) { + FileLogger.e(TAG, "解析错误信息异常: ${e.message}", e) + } + } + } + } +} diff --git a/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/utils/FileLogger.kt b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/utils/FileLogger.kt new file mode 100644 index 000000000..8fdfb1227 --- /dev/null +++ b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/utils/FileLogger.kt @@ -0,0 +1,55 @@ +package com.yunqiinnovation.volcano_speech.utils + +import android.util.Log + +/** + * 文件日志记录工具 + */ +object FileLogger { + private const val TAG = "VolcanoSpeech" + private var isDebugEnabled = true + + /** + * 设置是否启用调试日志 + */ + fun setDebugEnabled(enabled: Boolean) { + isDebugEnabled = enabled + } + + /** + * 记录调试日志 + */ + fun d(tag: String, message: String) { + if (isDebugEnabled) { + Log.d("$TAG-$tag", message) + } + } + + /** + * 记录信息日志 + */ + fun i(tag: String, message: String) { + Log.i("$TAG-$tag", message) + } + + /** + * 记录警告日志 + */ + fun w(tag: String, message: String) { + Log.w("$TAG-$tag", message) + } + + /** + * 记录错误日志 + */ + fun e(tag: String, message: String) { + Log.e("$TAG-$tag", message) + } + + /** + * 记录错误日志,带异常 + */ + fun e(tag: String, message: String, throwable: Throwable) { + Log.e("$TAG-$tag", message, throwable) + } +} \ No newline at end of file diff --git a/local_plugins/volcano_speech/lib/volcano_speech.dart b/local_plugins/volcano_speech/lib/volcano_speech.dart new file mode 100644 index 000000000..99da7d4b7 --- /dev/null +++ b/local_plugins/volcano_speech/lib/volcano_speech.dart @@ -0,0 +1,603 @@ +import 'dart:async'; +import 'package:flutter/services.dart'; + +/// ASR 事件类型 +enum AsrEventType { + /// 会话开始 + sessionStarted, + + /// 会话结束 + sessionStopped, + + /// 正在识别(中间结果) + recognizing, + + /// 识别结果(最终结果) + result, + + /// 音量变化 + volumeChanged, + + /// 错误 + error +} + +/// TTS 工作模式 +enum TtsWorkMode { + /// 在线合成 + online, + + /// 离线合成 + offline, + + /// 同时在线离线 + both, + + /// 先在线再离线(网络不好时自动切换) + alternate, + + /// 文件模式 + file +} + +/// TTS 文本类型 +enum TtsTextType { + /// 纯文本 + plain, + + /// SSML格式 + ssml +} + +/// 协议类型 +enum ProtocolType { + /// 默认协议 + defaultProtocol, + + /// Seed协议(用于大模型) + seed +} + +/// TTS 播放进度事件 +class TtsProgressEvent { + /// 播放进度 0.0-1.0 + final double progress; + + /// 请求ID + final String reqId; + + const TtsProgressEvent({ + required this.progress, + required this.reqId, + }); + + @override + String toString() { + return 'TtsProgressEvent{progress: $progress, reqId: $reqId}'; + } +} + +/// ASR 事件 +class AsrEvent { + /// 事件类型 + final AsrEventType type; + + /// 识别文本(仅在recognizing和result类型时有效) + final String? text; + + /// 识别语言(仅在recognizing和result类型时有效) + final String? language; + + /// 音量值(仅在volumeChanged类型时有效) + final int? volume; + + /// 错误信息(仅在error类型时有效) + final String? errorMessage; + + const AsrEvent({ + required this.type, + this.text, + this.language, + this.volume, + this.errorMessage, + }); + + factory AsrEvent.fromMap(Map map) { + final typeStr = map['type'] as String; + + AsrEventType type; + switch (typeStr) { + case 'sessionStarted': + type = AsrEventType.sessionStarted; + break; + case 'sessionStopped': + type = AsrEventType.sessionStopped; + break; + case 'recognizing': + type = AsrEventType.recognizing; + break; + case 'result': + type = AsrEventType.result; + break; + case 'volumeChanged': + type = AsrEventType.volumeChanged; + break; + case 'error': + type = AsrEventType.error; + break; + default: + throw ArgumentError('未知的事件类型: $typeStr'); + } + + return AsrEvent( + type: type, + text: map['text'] as String?, + language: map['language'] as String?, + volume: map['volume'] as int?, + errorMessage: map['message'] as String?, + ); + } + + @override + String toString() { + return 'AsrEvent{type: $type, text: $text, language: $language, volume: $volume, errorMessage: $errorMessage}'; + } +} + +/// 火山引擎语音服务插件 +class VolcanoSpeech { + static final VolcanoSpeechAsr asr = VolcanoSpeechAsr._(); + static final VolcanoSpeechTts tts = VolcanoSpeechTts._(); + + /// 释放资源 + static Future dispose() async { + await asr.dispose(); + await tts.dispose(); + } +} + +/// 火山引擎语音识别服务 +class VolcanoSpeechAsr { + static const MethodChannel _channel = MethodChannel('volcano_speech/asr'); + static const EventChannel _eventChannel = EventChannel('volcano_speech/asr_events'); + + /// ASR事件流控制器 + static final StreamController _eventStreamController = StreamController.broadcast(); + + /// ASR事件流 + Stream get events => _eventStreamController.stream; + + /// 是否已初始化事件监听 + bool _eventListenerInitialized = false; + + VolcanoSpeechAsr._() { + _initEventListener(); + } + + /// 初始化ASR事件监听 + void _initEventListener() { + if (_eventListenerInitialized) return; + + _eventChannel.receiveBroadcastStream().listen((dynamic event) { + if (event is Map) { + _eventStreamController.add(AsrEvent.fromMap(event)); + } + }); + + _eventListenerInitialized = true; + } + + /// 获取平台版本信息 + Future getPlatformVersion() async { + return await _channel.invokeMethod('getPlatformVersion'); + } + + /// 初始化语音识别引擎 + /// + /// [appId] 火山引擎AppID + /// [apiKey] 火山引擎ApiKey + /// [supportedLanguages] 支持的语言列表 + /// [useBigModel] 是否使用大模型识别 + /// [resourceId] 大模型资源ID(仅当useBigModel为true时有效) + Future initialize({ + required String appId, + required String apiKey, + List supportedLanguages = const ['zh-CN'], + bool useBigModel = false, + String resourceId = '', + }) async { + return await _channel.invokeMethod('initialize', { + 'appId': appId, + 'apiKey': apiKey, + 'supportedLanguages': supportedLanguages, + 'useBigModel': useBigModel, + 'resourceId': resourceId, + }) ?? false; + } + + /// 设置识别语言 + /// + /// [language] 语言代码,例如 zh-CN、en-US + Future setLanguage(String language) async { + return await _channel.invokeMethod('setLanguage', { + 'language': language, + }) ?? false; + } + + /// 设置热词 + /// + /// [hotWords] 热词JSON字符串,例如 {"hotwords":[{"word":"快速入门","scale":2.0}]} + Future setHotWords(String hotWords) async { + return await _channel.invokeMethod('setHotWords', { + 'hotWords': hotWords, + }) ?? false; + } + + /// 设置ASR请求参数 + /// + /// [params] 请求参数JSON字符串 + Future setRequestParams(String params) async { + return await _channel.invokeMethod('setRequestParams', { + 'params': params, + }) ?? false; + } + + /// 启用语音停顿、分句、分词信息输出 + /// + /// [enable] 是否启用 + Future setShowUtterances(bool enable) async { + return await _channel.invokeMethod('setShowUtterances', { + 'enable': enable, + }) ?? false; + } + + /// 一次性识别(直到说话结束) + /// + /// 返回识别结果文本和语言 + Future> recognizeOnce() async { + final result = await _channel.invokeMethod('recognizeOnce'); + return { + 'text': result['text'] ?? '', + 'language': result['language'] ?? 'zh-CN', + }; + } + + /// 开始连续识别 + /// + /// 通过[events]流监听识别结果 + Future startContinuousRecognition() async { + return await _channel.invokeMethod('startContinuousRecognition') ?? false; + } + + /// 停止连续识别 + Future stopContinuousRecognition() async { + return await _channel.invokeMethod('stopContinuousRecognition') ?? false; + } + + /// 检查是否正在连续识别 + Future isContinuousRecognitionActive() async { + return await _channel.invokeMethod('isContinuousRecognitionActive') ?? false; + } + + /// 开始长按识别(按下开始,抬起结束) + /// + /// 通过[events]流监听识别结果 + Future startListening() async { + return await _channel.invokeMethod('startListening') ?? false; + } + + /// 停止长按识别 + Future stopListening() async { + return await _channel.invokeMethod('stopListening') ?? false; + } + + /// 释放ASR资源 + Future dispose() async { + return await _channel.invokeMethod('dispose') ?? false; + } +} + +/// 火山引擎语音合成服务 +class VolcanoSpeechTts { + static const MethodChannel _channel = MethodChannel('volcano_speech/tts'); + static const EventChannel _eventChannel = EventChannel('volcano_speech/tts_events'); + + /// TTS进度事件流控制器 + static final StreamController _progressStreamController = StreamController.broadcast(); + + /// TTS进度事件流 + Stream get progressEvents => _progressStreamController.stream; + + /// 是否已初始化事件监听 + bool _eventListenerInitialized = false; + + VolcanoSpeechTts._() { + _initEventListener(); + } + + /// 初始化TTS事件监听 + void _initEventListener() { + if (_eventListenerInitialized) return; + + _eventChannel.receiveBroadcastStream().listen((dynamic event) { + if (event is Map) { + final progress = event['progress'] as double?; + final reqId = event['reqId'] as String?; + + if (progress != null && reqId != null) { + _progressStreamController.add(TtsProgressEvent( + progress: progress, + reqId: reqId, + )); + } + } + }); + + _eventListenerInitialized = true; + } + + /// 获取平台版本信息 + Future getPlatformVersion() async { + return await _channel.invokeMethod('getPlatformVersion'); + } + + /// 启用大模型TTS + /// + /// [enable] 是否启用大模型TTS + /// [resourceId] 资源ID + Future enableBigModelTts({ + required bool enable, + String resourceId = '', + }) async { + return await _channel.invokeMethod('enableBigModelTts', { + 'enable': enable, + 'resourceId': resourceId, + }) ?? false; + } + + /// 初始化语音合成引擎 + /// + /// [appId] 火山引擎AppID + /// [apiKey] 火山引擎ApiKey + Future initialize({ + required String appId, + required String apiKey, + }) async { + return await _channel.invokeMethod('initialize', { + 'appId': appId, + 'apiKey': apiKey, + }) ?? false; + } + + /// 设置音色 + /// + /// [voiceName] 音色名称 + /// [voiceType] 音色类型,默认 qingxin + Future setVoice({ + required String voiceName, + String voiceType = 'qingxin', + }) async { + return await _channel.invokeMethod('setVoice', { + 'voiceName': voiceName, + 'voiceType': voiceType, + }) ?? false; + } + + /// 设置离线音色 + /// + /// [voiceName] 音色名称 + /// [voiceType] 音色类型,默认 qingxin + Future setOfflineVoice({ + required String voiceName, + String voiceType = 'qingxin', + }) async { + return await _channel.invokeMethod('setOfflineVoice', { + 'voiceName': voiceName, + 'voiceType': voiceType, + }) ?? false; + } + + /// 设置大模型声音ID + /// + /// [voiceId] 声音ID + Future setBigModelVoiceId(String voiceId) async { + return await _channel.invokeMethod('setBigModelVoiceId', { + 'voiceId': voiceId, + }) ?? false; + } + + /// 设置大模型请求参数 + /// + /// [params] 请求参数,JSON字符串 + Future setBigModelRequestParams(String params) async { + return await _channel.invokeMethod('setBigModelRequestParams', { + 'params': params, + }) ?? false; + } + + /// 设置工作模式 + /// + /// [mode] 工作模式 + Future setWorkMode(TtsWorkMode mode) async { + String modeStr; + switch (mode) { + case TtsWorkMode.online: + modeStr = 'online'; + break; + case TtsWorkMode.offline: + modeStr = 'offline'; + break; + case TtsWorkMode.both: + modeStr = 'both'; + break; + case TtsWorkMode.alternate: + modeStr = 'alternate'; + break; + case TtsWorkMode.file: + modeStr = 'file'; + break; + } + + return await _channel.invokeMethod('setWorkMode', { + 'mode': modeStr, + }) ?? false; + } + + /// 设置文本类型 + /// + /// [type] 文本类型,plain或ssml + Future setTextType(TtsTextType type) async { + String typeStr; + switch (type) { + case TtsTextType.plain: + typeStr = 'plain'; + break; + case TtsTextType.ssml: + typeStr = 'ssml'; + break; + } + + return await _channel.invokeMethod('setTextType', { + 'type': typeStr, + }) ?? false; + } + + /// 设置是否启用缓存 + /// + /// [enable] 是否启用 + Future setEnableCache(bool enable) async { + return await _channel.invokeMethod('setEnableCache', { + 'enable': enable, + }) ?? false; + } + + /// 设置情感 + /// + /// [emotion] 情感,例如 neutral、happy、angry、sad等 + Future setEmotion(String emotion) async { + return await _channel.invokeMethod('setEmotion', { + 'emotion': emotion, + }) ?? false; + } + + /// 设置是否启用情感预测 + /// + /// [enable] 是否启用 + Future setEnableEmotionPredict(bool enable) async { + return await _channel.invokeMethod('setEnableEmotionPredict', { + 'enable': enable, + }) ?? false; + } + + /// 设置是否启用声音克隆 + /// + /// [enable] 是否启用 + /// [backendCluster] 后端集群 + Future setEnableVoiceClone({ + required bool enable, + String backendCluster = '', + }) async { + return await _channel.invokeMethod('setEnableVoiceClone', { + 'enable': enable, + 'backendCluster': backendCluster, + }) ?? false; + } + + /// 设置是否启用回声消除 + /// + /// [enable] 是否启用 + Future setEnableAEC(bool enable) async { + return await _channel.invokeMethod('setEnableAEC', { + 'enable': enable, + }) ?? false; + } + + /// 下载离线资源 + /// + /// [voiceTypes] 音色类型列表 + /// [languages] 语言列表 + Future downloadOfflineResource({ + List voiceTypes = const ['qingxin'], + List languages = const ['zh-CN'], + }) async { + return await _channel.invokeMethod('downloadOfflineResource', { + 'voiceTypes': voiceTypes, + 'languages': languages, + }) ?? false; + } + + /// 设置语音参数 + /// + /// [rate] 语速 -500~500 + /// [volume] 音量 0~100 + /// [pitch] 音调 -500~500 + /// [silenceDuration] 静音时长,毫秒 + Future setSpeechParams({ + int rate = 0, + int volume = 100, + int pitch = 0, + int silenceDuration = 0, + }) async { + return await _channel.invokeMethod('setSpeechParams', { + 'rate': rate, + 'volume': volume, + 'pitch': pitch, + 'silenceDuration': silenceDuration, + }) ?? false; + } + + /// 设置音频输出类型 + /// + /// [outputType] 输出类型,speaker(扬声器),earpiece(听筒),auto(自动) + Future setAudioOutputType(String outputType) async { + return await _channel.invokeMethod('setAudioOutputType', { + 'outputType': outputType, + }) ?? false; + } + + /// 合成并播放文本 + /// + /// [text] 待合成的文本 + Future speakText(String text) async { + return await _channel.invokeMethod('speakText', { + 'text': text, + }) ?? false; + } + + /// 使用大模型合成并播放文本 + /// + /// [text] 待合成的文本 + /// [voiceId] 声音ID + /// [params] 额外参数,JSON字符串 + Future speakWithBigModel({ + required String text, + required String voiceId, + String params = '', + }) async { + return await _channel.invokeMethod('speakWithBigModel', { + 'text': text, + 'voiceId': voiceId, + 'params': params, + }) ?? false; + } + + /// 暂停播放 + Future pausePlayback() async { + return await _channel.invokeMethod('pausePlayback') ?? false; + } + + /// 恢复播放 + Future resumePlayback() async { + return await _channel.invokeMethod('resumePlayback') ?? false; + } + + /// 停止播放 + Future stopSpeaking() async { + return await _channel.invokeMethod('stopSpeaking') ?? false; + } + + /// 释放TTS资源 + Future dispose() async { + return await _channel.invokeMethod('dispose') ?? false; + } +} \ No newline at end of file diff --git a/local_plugins/volcano_speech/pubspec.yaml b/local_plugins/volcano_speech/pubspec.yaml new file mode 100644 index 000000000..cd90d8b14 --- /dev/null +++ b/local_plugins/volcano_speech/pubspec.yaml @@ -0,0 +1,28 @@ +name: volcano_speech +description: 火山引擎语音合成服务插件 +version: 0.0.1 +homepage: + +environment: + sdk: ">=2.17.0 <3.0.0" + flutter: ">=2.5.0" + +dependencies: + flutter: + sdk: flutter + +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.volcano_speech + pluginClass: VolcanoSpeechPlugin + ios: + pluginClass: VolcanoSpeechPlugin \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 19366500b..41fa9edf0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,6 +64,8 @@ dependencies: path: local_plugins/azure_speech open_ai_service: path: local_plugins/open_ai_service + volcano_speech: + path: local_plugins/volcano_speech dev_dependencies: flutter_test: From 4d0c1eec82f2e3a8340cef88b0fba72746a979d8 Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Mon, 7 Apr 2025 14:46:38 +0800 Subject: [PATCH 09/23] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=A4=A9=E6=B0=94?= =?UTF-8?q?=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/modules/chat/views/chat_view.dart | 10 ++- lib/modules/chat/views/weather_day_card.dart | 94 ++++++++++++++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 lib/modules/chat/views/weather_day_card.dart diff --git a/lib/modules/chat/views/chat_view.dart b/lib/modules/chat/views/chat_view.dart index 073d079dc..edb80a3fc 100644 --- a/lib/modules/chat/views/chat_view.dart +++ b/lib/modules/chat/views/chat_view.dart @@ -7,6 +7,7 @@ import '../models/message_model.dart'; import '../../../core/theme/app_colors.dart'; import 'chat_music_card.dart'; import 'chat_navigation_card.dart'; +import 'weather_day_card.dart'; class ChatView extends GetView { const ChatView({Key? key}) : super(key: key); @@ -355,10 +356,13 @@ class ChatView extends GetView { ); } if (message.data != null) { - // if (message.data?['musicaudio'] != null) { - // box = ChatMusicCard(message.data, isDarkMode); - // } + if (message.data?['weatherDays'] != null) { + box = WeatherDayCard(message.data, isDarkMode); + } if (message.data?['musicaudio'] != null) { + box = ChatMusicCard(message.data, isDarkMode); + } + if (message.data?['navigationcard'] != null) { box = ChatNavigationCard(message.data, isDarkMode); } } diff --git a/lib/modules/chat/views/weather_day_card.dart b/lib/modules/chat/views/weather_day_card.dart new file mode 100644 index 000000000..72d7c0f10 --- /dev/null +++ b/lib/modules/chat/views/weather_day_card.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +class WeatherDayCard extends StatelessWidget { + final Map? data; + final bool isDarkMode; + + const WeatherDayCard(this.data, this.isDarkMode, {super.key}); + + @override + Widget build(BuildContext context) { + final Map weatherDays = data?['weatherDays'].first; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + weatherDays['date'], + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black, + fontSize: 10.sp, + ), + ), + Text( + '${weatherDays['maxTemperature']}°', + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black, + fontSize: 30.sp, + ), + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Icon( + Icons.sunny, + color: isDarkMode ? Colors.white : Colors.amber[600]!, + size: 18.w, + ), + Text( + '${weatherDays['weatherCondition']},${weatherDays['windDirection']}', + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black, + fontSize: 14.sp, + ), + ), + Row( + children: [ + Text( + '最\n高', + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black, + fontSize: 10.sp, + height: 1, + ), + ), + 5.horizontalSpace, + Text( + '${weatherDays['maxTemperature']}°', + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black, + fontSize: 20.sp, + ), + ), + 10.horizontalSpace, + Text( + '最\n低', + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black, + fontSize: 10.sp, + height: 1, + ), + ), + 5.horizontalSpace, + Text( + '${weatherDays['minTemperature']}°', + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black, + fontSize: 20.sp, + ), + ), + ], + ), + ], + ), + ], + ); + } +} From 7db579d21333c48d483a2b467f91ed887fb30f6c Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Mon, 7 Apr 2025 16:24:56 +0800 Subject: [PATCH 10/23] =?UTF-8?q?=E4=BF=AE=E6=94=B9Agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/providers/agent_provider.dart | 30 ++++++++++++-------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/lib/data/providers/agent_provider.dart b/lib/data/providers/agent_provider.dart index 7b48ef5d4..827a07793 100644 --- a/lib/data/providers/agent_provider.dart +++ b/lib/data/providers/agent_provider.dart @@ -1,18 +1,18 @@ import '../models/agent.dart'; -import 'package:get/get.dart'; class AgentProvider { static final List predefinedAgents = [ - Agent(// 个人助理 Emma + const Agent( + // 个人助理 Emma id: 'personal_assistant', name: 'personalAssistantName', avatarUrl: 'assets/images/agents/assistant.png', - description: - 'personalAssistantDescription', + description: 'personalAssistantDescription', tag: 'assistantTag', // '助理' isOnline: true, voice: "zh-CN-XiaoxiaoNeural", - welcomeMessage: 'personalAssistantWelcome',// "你好!我是 Emma,很高兴成为你的私人助理。我可以帮你处理各种任务,比如时间管理、目标规划、信息整理等。有什么我可以帮你的吗?", + welcomeMessage: + 'personalAssistantWelcome', // "你好!我是 Emma,很高兴成为你的私人助理。我可以帮你处理各种任务,比如时间管理、目标规划、信息整理等。有什么我可以帮你的吗?", voiceWelcomeMessage: "personalAssistantVoiceWelcome", // "我在呢!" systemPrompt: ''' 你是一位名叫 Emma 的全能型个人助理,擅长处理各类工作和生活事务。 @@ -57,7 +57,7 @@ class AgentProvider { 请始终以专业、高效、周到的态度,协助用户更好地管理工作和生活, 不要输出表情符号或非可读的符号。 '''), - Agent( + const Agent( id: 'simultaneous_interpreter', name: 'interpreterName', avatarUrl: 'assets/images/agents/interpreter.png', @@ -65,10 +65,9 @@ class AgentProvider { tag: 'interpreterTag', isOnline: true, voice: 'zh-CN-XiaoxiaoNeural', - welcomeMessage: - 'interpreterWelcome', + welcomeMessage: 'interpreterWelcome', systemPrompt: ""), - Agent( + const Agent( id: 'cyber_girlfriend', name: 'companionName', avatarUrl: 'assets/images/agents/girlfriend.png', @@ -76,8 +75,7 @@ class AgentProvider { tag: 'companionTag', isOnline: true, voice: "zh-CN-XiaoxiaoNeural", - welcomeMessage: - "companionWelcome", + welcomeMessage: "companionWelcome", systemPrompt: ''' 你是一位名叫芯愿的亲子陪伴,性格温柔体贴,善解人意,富有同理心。 @@ -128,14 +126,14 @@ class AgentProvider { 请始终以温暖、真诚的态度与用户互动,创造一种被理解、被关心的感受。 '''), - Agent( + const Agent( id: 'health_advisor', name: 'healthAdvisorName', avatarUrl: 'assets/images/agents/doctor.png', description: 'healthAdvisorDescription', tag: 'healthAdvisorTag', isOnline: true, - voice: "zh_male_shaonianzixin_moon_bigtts", + voice: "zh-CN-XiaoxiaoNeural", welcomeMessage: "healthAdvisorWelcome", voiceWelcomeMessage: 'healthAdvisorVoiceWelcome', systemPrompt: ''' @@ -205,7 +203,7 @@ class AgentProvider { 请始终以专业、关怀的态度为用户提供健康指导,帮助他们迈向更健康的生活。 '''), - Agent( + const Agent( id: 'speaking_coach', name: 'Sarah (英语口语教练)', avatarUrl: 'assets/images/agents/speaking_coach.png', @@ -252,7 +250,7 @@ class AgentProvider { Please keep your tone friendly, encouraging, and always in English. ''', ), - Agent( + const Agent( id: 'shopping_assistant', name: '购物顾问 张媛', avatarUrl: 'assets/images/agents/shopping.png', @@ -285,7 +283,7 @@ Please keep your tone friendly, encouraging, and always in English. '''), - Agent( + const Agent( id: 'tarot.png', name: '命理师 玄易', avatarUrl: 'assets/images/agents/tarot.png', From 28b666cd33faa95314c45bc70302b8db384e5bdb Mon Sep 17 00:00:00 2001 From: wolfplus Date: Mon, 7 Apr 2025 21:18:12 +0100 Subject: [PATCH 11/23] mcp --- android/app/build.gradle.kts | 14 + .../yunqiinnovation/deepsound/MainActivity.kt | 31 +- .../deepsound/SystemFunctionHandler.kt | 225 -------- .../deepsound/VoiceFunctionHandler.kt | 510 ------------------ .../VoiceInteractionHandler copy.kt.bak | 456 ++++++++++++++++ .../deepsound/VoiceInteractionHandler.kt | 107 ++-- .../deepsound/VoiceInteractionService.kt | 14 +- android/build.gradle.kts | 11 + android/settings.gradle.kts | 2 +- 9 files changed, 551 insertions(+), 819 deletions(-) delete mode 100644 android/app/src/main/kotlin/com/yunqiinnovation/deepsound/SystemFunctionHandler.kt delete mode 100644 android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceFunctionHandler.kt create mode 100644 android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler copy.kt.bak diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 026060648..8c371408e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -39,6 +39,20 @@ android { 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") + } + } + defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.yunqiinnovation.deepsound" 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 9f06844db..1c4717f11 100644 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt @@ -170,6 +170,8 @@ class MainActivity: FlutterActivity() { var openaiApiKey: String = "" var openaiBaseUrl: String? = null var openaiModel: String? = null + var volcanoSpeechAppId: String = "" + var volcanoSpeechAppToken: String = "" // 安全存储相关常量 private const val SECURE_PREFS_FILENAME = "deep_voice_secure_prefs" @@ -180,6 +182,8 @@ class MainActivity: FlutterActivity() { private const val KEY_OPENAI_BASE_URL = "openai_base_url" private const val KEY_OPENAI_MODEL = "openai_model" 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_SESSION_ID = "session_id" @@ -218,6 +222,8 @@ class MainActivity: FlutterActivity() { .putString(KEY_OPENAI_BASE_URL, openaiBaseUrl) .putString(KEY_SESSION_ID, currentSessionId) .putString(KEY_OPENAI_MODEL, openaiModel) + .putString(KEY_VOLCANO_SPEECH_APP_ID, volcanoSpeechAppId) + .putString(KEY_VOLCANO_SPEECH_APP_TOKEN, volcanoSpeechAppToken) .apply() FileLogger.d("MainActivity", "密钥已安全保存到加密存储中") @@ -264,6 +270,8 @@ class MainActivity: FlutterActivity() { openaiApiKey = sharedPreferences.getString(KEY_OPENAI_API_KEY, "") ?: "" openaiBaseUrl = sharedPreferences.getString(KEY_OPENAI_BASE_URL, null) openaiModel = sharedPreferences.getString(KEY_OPENAI_MODEL, null) + volcanoSpeechAppId = sharedPreferences.getString(KEY_VOLCANO_SPEECH_APP_ID, "") ?: "" + volcanoSpeechAppToken = sharedPreferences.getString(KEY_VOLCANO_SPEECH_APP_TOKEN, "") ?: "" FileLogger.d("MainActivity", "已从加密存储加载密钥") // 检查是否成功获取所有必要密钥 @@ -505,21 +513,14 @@ class MainActivity: FlutterActivity() { FileLogger.d(TAG, "启动语音交互服务") // 获取配置参数 - val key = call.argument("azure_speech_key") ?: "" - val region = call.argument("azure_speech_region") ?: "" - val openaiKey = call.argument("openai_api_key") ?: "" - val baseUrl = call.argument("openai_base_url") - val model = call.argument("openai_model") - // 设置 Azure Speech 和 AI 配置 - azureSpeechKey = key - azureSpeechRegion = region - openaiApiKey = openaiKey - if (baseUrl != null) { - openaiBaseUrl = baseUrl - } - if (model != null) { - openaiModel = model - } + azureSpeechKey = call.argument("azure_speech_key") ?: "" + azureSpeechRegion = call.argument("azure_speech_region") ?: "" + openaiApiKey = call.argument("openai_api_key") ?: "" + openaiBaseUrl = call.argument("openai_base_url") + openaiModel = call.argument("openai_model") + volcanoSpeechAppId = call.argument("volcano_speech_app_id") ?: "" + volcanoSpeechAppToken = call.argument("volcano_speech_app_token") ?: "" + // 保存密钥到安全存储 saveKeysToSecureStorage(applicationContext) diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/SystemFunctionHandler.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/SystemFunctionHandler.kt deleted file mode 100644 index 4e28f26aa..000000000 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/SystemFunctionHandler.kt +++ /dev/null @@ -1,225 +0,0 @@ -package com.yunqiinnovation.deepsound - -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 com.yunqiinnovation.deepsound.core.utils.FileLogger - -/** - * 系统功能处理器 - 负责调用系统API执行各种功能 - */ -class SystemFunctionHandler(private val context: Context) { - companion object { - private const val TAG = "SystemFunctionHandler" - } - - /** - * 启动翻译模式 - * @return 操作结果 - */ - fun enterTranslationMode(): Result { - FileLogger.d(TAG, "进入翻译模式") - // 此处应添加实际的翻译模式实现 - // 可能需要启动一个专门的翻译服务或活动 - - // 广播通知APP的其他部分进入翻译模式 - // 使用VoiceInteractionService中定义的常量 - val intent = Intent(VoiceInteractionService.ACTION_ENTER_TRANSLATION_MODE).apply { - - putExtra("timestamp", System.currentTimeMillis()) - } - - // 发送广播 - context.sendBroadcast(intent) - FileLogger.d(TAG, "已发送广播: ${VoiceInteractionService.ACTION_ENTER_TRANSLATION_MODE}") - - return Result( - success = true, - message = "已进入翻译模式" - ) - } - - /** - * 发送短信 - * @param contact 联系人(名称或电话号码) - * @param message 短信内容 - * @return 操作结果 - */ - fun sendTextMessage(contact: String, message: String): Result { - FileLogger.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) { - FileLogger.e(TAG, "发送短信失败: ${e.message}", e) - return Result( - success = false, - message = "发送短信失败: ${e.message}" - ) - } - } - - /** - * 拨打电话 - * @param contact 联系人(名称或电话号码) - * @return 操作结果 - */ - fun makePhoneCall(contact: String): Result { - FileLogger.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) { - FileLogger.e(TAG, "拨打电话失败: ${e.message}", e) - return Result( - success = false, - message = "拨打电话失败: ${e.message}" - ) - } - } - - /** - * 打开应用程序 - * @param appName 应用名称 - * @return 操作结果 - */ - fun openApp(appName: String): Result { - FileLogger.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) { - FileLogger.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) { - FileLogger.e(TAG, "查询联系人信息失败: ${e.message}", e) - } - - return "" - } - - /** - * 操作结果数据类 - */ - data class Result( - val success: Boolean, - val message: String - ) -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceFunctionHandler.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceFunctionHandler.kt deleted file mode 100644 index c15ca7e17..000000000 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceFunctionHandler.kt +++ /dev/null @@ -1,510 +0,0 @@ -package com.yunqiinnovation.deepsound - -import org.json.JSONArray -import org.json.JSONObject -import com.yunqiinnovation.open_ai_service.OpenAIService -import com.yunqiinnovation.deepsound.core.utils.FileLogger -import android.content.Context - -/** - * 语音功能处理器 - 处理AI函数调用 - */ -class VoiceFunctionHandler( - private val openAIService: OpenAIService, - private val context: Context -) { - companion object { - private const val TAG = "VoiceFunctionHandler" - } - - // 添加系统功能处理器 - private val systemFunctionHandler = SystemFunctionHandler(context) - - /** - * 初始化并注册所有可用的函数 - */ - fun initialize() { - try { - // 注册退出交互函数 - registerExitInteractionFunction() - - // 注册翻译模式函数 - registerTranslationModeFunction() - - // 注册发送短信函数 - registerSendTextMessageFunction() - - // 注册拨打电话函数 - registerMakePhoneCallFunction() - - // 注册打开应用函数 - registerOpenAppFunction() - - // 在这里可以注册更多函数 - - } catch (e: Exception) { - FileLogger.e(TAG, "初始化函数处理器失败: ${e.message}", e) - } - } - - /** - * 注册退出交互函数 - */ - private fun registerExitInteractionFunction() { - try { - openAIService.registerFunction( - "exit_interaction", - "退出当前语音交互", - JSONObject(""" - { - "type": "object", - "properties": {}, - "required": [] - } - """) - ) - FileLogger.d(TAG, "退出交互功能已注册") - } catch (e: Exception) { - FileLogger.e(TAG, "注册退出交互函数失败: ${e.message}", e) - } - } - - /** - * 注册翻译模式函数 - */ - private fun registerTranslationModeFunction() { - try { - openAIService.registerFunction( - "enter_translation_mode", - "进入翻译模式", - JSONObject(""" - { - "type": "object", - "properties": {}, - "required": [] - } - """) - ) - FileLogger.d(TAG, "翻译模式功能已注册") - } catch (e: Exception) { - FileLogger.e(TAG, "注册翻译模式函数失败: ${e.message}", e) - } - } - - /** - * 注册发送短信函数 - */ - private fun registerSendTextMessageFunction() { - try { - openAIService.registerFunction( - "send_text_message", - "发送短信", - JSONObject(""" - { - "type": "object", - "properties": { - "contact": { - "type": "string", - "description": "联系人姓名或电话号码" - }, - "message": { - "type": "string", - "description": "短信内容" - } - }, - "required": ["contact", "message"] - } - """) - ) - FileLogger.d(TAG, "发送短信功能已注册") - } catch (e: Exception) { - FileLogger.e(TAG, "注册发送短信函数失败: ${e.message}", e) - } - } - - /** - * 注册拨打电话函数 - */ - private fun registerMakePhoneCallFunction() { - try { - openAIService.registerFunction( - "make_phone_call", - "拨打电话", - JSONObject(""" - { - "type": "object", - "properties": { - "contact": { - "type": "string", - "description": "联系人姓名或电话号码" - } - }, - "required": ["contact"] - } - """) - ) - FileLogger.d(TAG, "拨打电话功能已注册") - } catch (e: Exception) { - FileLogger.e(TAG, "注册拨打电话函数失败: ${e.message}", e) - } - } - - /** - * 注册打开应用函数 - */ - private fun registerOpenAppFunction() { - try { - openAIService.registerFunction( - "open_app", - "打开应用", - JSONObject(""" - { - "type": "object", - "properties": { - "app_name": { - "type": "string", - "description": "应用名称" - } - }, - "required": ["app_name"] - } - """) - ) - FileLogger.d(TAG, "打开应用功能已注册") - } catch (e: Exception) { - FileLogger.e(TAG, "注册打开应用函数失败: ${e.message}", e) - } - } - - /** - * 处理函数调用 - * - * @param functionCall 函数调用信息 - * @param messages 消息历史 - * @param callback 回调,处理退出等操作 - * @return 是否已处理函数调用 - */ - fun handleFunctionCall( - functionCall: JSONObject, - messages: JSONArray, - callback: FunctionCallCallback - ): Boolean { - val functionName = functionCall.getString("name") - FileLogger.d(TAG, "处理函数调用: $functionName") - - return when (functionName) { - "exit_interaction" -> { - handleExitInteraction(functionCall, messages, callback) - true - } - "enter_translation_mode" -> { - handleEnterTranslationMode(functionCall, messages, callback) - true - } - "send_text_message" -> { - handleSendTextMessage(functionCall, messages, callback) - true - } - "make_phone_call" -> { - handleMakePhoneCall(functionCall, messages, callback) - true - } - "open_app" -> { - handleOpenApp(functionCall, messages, callback) - true - } - else -> { - // 未知函数,返回默认结果 - handleUnknownFunction(functionCall, messages, callback) - false - } - } - } - - /** - * 处理退出交互函数 - */ - private fun handleExitInteraction( - functionCall: JSONObject, - messages: JSONArray, - callback: FunctionCallCallback - ) { - FileLogger.d(TAG, "处理退出交互函数") - - val responseBuilder = StringBuilder() - - openAIService.sendFunctionCallResult( - messages = messages, - functionCall = functionCall, - functionResult = "{\"result\": \"已退出语音交互\"}", - callback = object : OpenAIService.StreamCallback { - override fun onToken(token: String) { - responseBuilder.append(token) - } - - override fun onComplete() { - FileLogger.d(TAG, "handleExitInteraction onComplete: ${responseBuilder.toString()}") - val response = responseBuilder.toString() - if (response.isNotEmpty()) { - callback.onExitWithMessage(response) - } else { - callback.onExitWithMessage("已退出语音交互") - } - } - - override fun onError(e: Exception) { - FileLogger.e(TAG, "处理退出交互函数调用出错: ${e.message}") - callback.onError("退出交互时出错") - } - - override fun onFunctionCall(nestedCall: JSONObject) { - FileLogger.e(TAG, "意外收到嵌套函数调用: ${nestedCall.getString("name")}") - } - } - ) - } - - /** - * 处理进入翻译模式函数 - */ - private fun handleEnterTranslationMode( - functionCall: JSONObject, - messages: JSONArray, - callback: FunctionCallCallback - ) { - FileLogger.d(TAG, "处理进入翻译模式函数") - - // 调用系统功能处理器执行翻译模式操作 - val result = systemFunctionHandler.enterTranslationMode() - - val responseBuilder = StringBuilder() - - openAIService.sendFunctionCallResult( - messages = messages, - functionCall = functionCall, - functionResult = "{\"result\": \"${result.message}\"}", - callback = object : OpenAIService.StreamCallback { - override fun onToken(token: String) { - responseBuilder.append(token) - callback.onTokenReceived(token) - } - - override fun onComplete() { - FileLogger.d(TAG, "handleEnterTranslationMode onComplete: ${responseBuilder.toString()}") - callback.onComplete() - } - - override fun onError(e: Exception) { - FileLogger.e(TAG, "处理进入翻译模式函数调用出错: ${e.message}") - callback.onError("进入翻译模式时出错") - } - - override fun onFunctionCall(nestedCall: JSONObject) { - callback.onFunctionCall(nestedCall) - } - } - ) - } - - /** - * 处理发送短信函数 - */ - private fun handleSendTextMessage( - functionCall: JSONObject, - messages: JSONArray, - callback: FunctionCallCallback - ) { - FileLogger.d(TAG, "处理发送短信函数") - - try { - val arguments = JSONObject(functionCall.getString("arguments")) - val contact = arguments.getString("contact") - val message = arguments.getString("message") - - // 调用系统功能处理器发送短信 - val result = systemFunctionHandler.sendTextMessage(contact, message) - - val responseBuilder = StringBuilder() - - openAIService.sendFunctionCallResult( - messages = messages, - functionCall = functionCall, - functionResult = "{\"result\": \"${result.message}\"}", - callback = object : OpenAIService.StreamCallback { - override fun onToken(token: String) { - responseBuilder.append(token) - callback.onTokenReceived(token) - } - - override fun onComplete() { - FileLogger.d(TAG, "handleSendTextMessage onComplete: ${responseBuilder.toString()}") - callback.onComplete() - } - - override fun onError(e: Exception) { - FileLogger.e(TAG, "处理发送短信函数调用出错: ${e.message}") - callback.onError("发送短信时出错") - } - - override fun onFunctionCall(nestedCall: JSONObject) { - callback.onFunctionCall(nestedCall) - } - } - ) - } catch (e: Exception) { - FileLogger.e(TAG, "解析发送短信参数失败: ${e.message}") - callback.onError("发送短信参数错误") - } - } - - /** - * 处理拨打电话函数 - */ - private fun handleMakePhoneCall( - functionCall: JSONObject, - messages: JSONArray, - callback: FunctionCallCallback - ) { - FileLogger.d(TAG, "处理拨打电话函数") - - try { - val arguments = JSONObject(functionCall.getString("arguments")) - val contact = arguments.getString("contact") - - // 调用系统功能处理器拨打电话 - val result = systemFunctionHandler.makePhoneCall(contact) - - val responseBuilder = StringBuilder() - - openAIService.sendFunctionCallResult( - messages = messages, - functionCall = functionCall, - functionResult = "{\"result\": \"${result.message}\"}", - callback = object : OpenAIService.StreamCallback { - override fun onToken(token: String) { - responseBuilder.append(token) - callback.onTokenReceived(token) - } - - override fun onComplete() { - FileLogger.d(TAG, "handleMakePhoneCall onComplete: ${responseBuilder.toString()}") - callback.onComplete() - } - - override fun onError(e: Exception) { - FileLogger.e(TAG, "处理拨打电话函数调用出错: ${e.message}") - callback.onError("拨打电话时出错") - } - - override fun onFunctionCall(nestedCall: JSONObject) { - callback.onFunctionCall(nestedCall) - } - } - ) - } catch (e: Exception) { - FileLogger.e(TAG, "解析拨打电话参数失败: ${e.message}") - callback.onError("拨打电话参数错误") - } - } - - /** - * 处理打开应用函数 - */ - private fun handleOpenApp( - functionCall: JSONObject, - messages: JSONArray, - callback: FunctionCallCallback - ) { - FileLogger.d(TAG, "处理打开应用函数") - - try { - val arguments = JSONObject(functionCall.getString("arguments")) - val appName = arguments.getString("app_name") - - // 调用系统功能处理器打开应用 - val result = systemFunctionHandler.openApp(appName) - - val responseBuilder = StringBuilder() - - openAIService.sendFunctionCallResult( - messages = messages, - functionCall = functionCall, - functionResult = "{\"result\": \"${result.message}\"}", - callback = object : OpenAIService.StreamCallback { - override fun onToken(token: String) { - responseBuilder.append(token) - callback.onTokenReceived(token) - } - - override fun onComplete() { - FileLogger.d(TAG, "handleOpenApp onComplete: ${responseBuilder.toString()}") - callback.onComplete() - } - - override fun onError(e: Exception) { - FileLogger.e(TAG, "处理打开应用函数调用出错: ${e.message}") - callback.onError("打开应用时出错") - } - - override fun onFunctionCall(nestedCall: JSONObject) { - callback.onFunctionCall(nestedCall) - } - } - ) - } catch (e: Exception) { - FileLogger.e(TAG, "解析打开应用参数失败: ${e.message}") - callback.onError("打开应用参数错误") - } - } - - /** - * 处理未知函数调用 - */ - private fun handleUnknownFunction( - functionCall: JSONObject, - messages: JSONArray, - callback: FunctionCallCallback - ) { - FileLogger.d(TAG, "处理未知函数: ${functionCall.getString("name")}") - - try { - openAIService.sendFunctionCallResult( - messages = messages, - functionCall = functionCall, - functionResult = "{\"result\": \"处理函数调用中\"}", - callback = object : OpenAIService.StreamCallback { - override fun onToken(token: String) { - callback.onTokenReceived(token) - } - - override fun onComplete() { - callback.onComplete() - } - - override fun onError(e: Exception) { - FileLogger.e(TAG, "处理函数调用失败: ${e.message}") - callback.onError("处理函数调用失败: ${e.message}") - } - - override fun onFunctionCall(nestedCall: JSONObject) { - callback.onFunctionCall(nestedCall) - } - } - ) - } catch (e: Exception) { - FileLogger.e(TAG, "处理函数调用失败: ${e.message}") - callback.onError("处理函数调用失败: ${e.message}") - } - } - - /** - * 函数调用回调接口 - */ - interface FunctionCallCallback { - fun onTokenReceived(token: String) - fun onComplete() - fun onError(message: String) - fun onFunctionCall(functionCall: JSONObject) - fun onExitWithMessage(farewell: String) - } -} \ No newline at end of file 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 new file mode 100644 index 000000000..1a9e2df13 --- /dev/null +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler copy.kt.bak @@ -0,0 +1,456 @@ +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/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt index 1d6a528b3..b885117c0 100644 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt @@ -1,13 +1,17 @@ package com.yunqiinnovation.deepsound +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter 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.azure_speech.AzureTtsHelper -import com.yunqiinnovation.open_ai_service.OpenAIService +import com.yunqiinnovation.open_ai_service.OpenAIService +import com.yunqiinnovation.open_ai_service.SystemFunctionHandler /** @@ -19,7 +23,9 @@ class VoiceInteractionHandler( private val azureSpeechRegion: String, private val openaiApiKey: String, private val openaiBaseUrl: String = "", - private val openaiModel: String = "" + private val openaiModel: String = "", + private val volcanoSpeechAppId: String, + private val volcanoSpeechAppToken: String ) { private val TAG = "VoiceInteractionHandler" @@ -28,11 +34,7 @@ class VoiceInteractionHandler( private var azureTtsHelper: AzureTtsHelper? = null // OpenAI服务 - private val openAIService = OpenAIService() - - - // 语音功能处理 - private val voiceFunctionHandler = VoiceFunctionHandler(openAIService, context) + private val openAIService = OpenAIService(context.applicationContext) // 当前用户输入 private var currentUserInput = "" @@ -49,6 +51,17 @@ class VoiceInteractionHandler( // 回调 private var callback: InteractionCallback? = null + // 广播接收器 + private val exitInteractionReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == SystemFunctionHandler.ACTION_EXIT_INTERACTION) { + Log.d(TAG, "收到退出交互广播") + stopRecognition() + + } + } + } + /** * 初始化 */ @@ -69,8 +82,19 @@ class VoiceInteractionHandler( // 初始化OpenAI服务 openAIService.initialize(openaiApiKey, openaiBaseUrl, openaiModel) - // 初始化语音功能处理器 - voiceFunctionHandler.initialize() + // 注册广播接收器 + try { + Log.d(TAG, "注册退出交互广播接收器,包名=${context.packageName}, action=${SystemFunctionHandler.ACTION_EXIT_INTERACTION}") + context.registerReceiver( + exitInteractionReceiver, + IntentFilter(SystemFunctionHandler.ACTION_EXIT_INTERACTION), + Context.RECEIVER_NOT_EXPORTED + ) + Log.d(TAG, "退出交互广播接收器注册成功") + } catch (e: Exception) { + // 广播注册失败不应该影响整个应用初始化 + Log.e(TAG, "注册退出交互广播接收器失败: ${e.message}", e) + } isInitialized = true return true @@ -243,64 +267,9 @@ class VoiceInteractionHandler( } override fun onFunctionCall(call: JSONObject) { - FileLogger.d(TAG, "收到函数调用请求: ${call.getString("name")}") + FileLogger.d(TAG, "processWithOpenAI 收到函数调用请求: ${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")}") - // 递归处理嵌套函数调用 - voiceFunctionHandler.handleFunctionCall( - functionCall = nestedCall, - messages = messages, - callback = this - ) - } - - override fun onExitWithMessage(farewell: String) { - // 播放退出消息 - speakAIResponse(farewell) - - // 同步聊天记录 - sendChatHistoryUpdate("personal_assistant", text, farewell) - - // 停止语音识别 - stopRecognition() - } - } - ) - if (!handled) { - // 如果函数没有被处理,作为普通文本处理 - FileLogger.d(TAG, "函数未处理,作为普通文本处理") - speakAIResponse("我无法处理这个请求") - sendChatHistoryUpdate("personal_assistant", text, "我无法处理这个请求") - } } } ) @@ -375,6 +344,13 @@ class VoiceInteractionHandler( // 停止TTS播放 stopTts() + // 注销广播接收器 + try { + context.unregisterReceiver(exitInteractionReceiver) + } catch (e: Exception) { + FileLogger.e(TAG, "注销广播接收器失败: ${e.message}", e) + } + // 释放Azure资源 azureAsrHelper?.let { FileLogger.d(TAG, "关闭Azure ASR服务") @@ -414,6 +390,7 @@ class VoiceInteractionHandler( putExtra("userMessage", userMessage) putExtra("assistantMessage", assistantMessage) putExtra("timestamp", System.currentTimeMillis()) + setPackage(context.packageName) } // 发送广播 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 1cb132e1a..dbe64c79e 100644 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt @@ -28,7 +28,7 @@ import android.media.AudioAttributes import android.net.Uri import com.yunqiinnovation.deepsound.core.utils.FileLogger import com.yunqiinnovation.azure_speech.AzureAsrHelper -import com.yunqiinnovation.azure_speech.AzureTtsHelper +import com.yunqiinnovation.volcano_speech.VolcanoTtsHelper import com.yunqiinnovation.open_ai_service.OpenAIService @@ -145,6 +145,8 @@ class VoiceInteractionService : Service() { var openaiKey = MainActivity.openaiApiKey var openaiBaseUrl = MainActivity.openaiBaseUrl ?: "" // OpenAI API基本URL var openaiModel = MainActivity.openaiModel ?: "" // OpenAI模型 + var volcanoSpeechAppId = MainActivity.volcanoSpeechAppId ?: "" + var volcanoSpeechAppToken = MainActivity.volcanoSpeechAppToken ?: "" // 如果静态变量中没有配置,尝试从加密存储中加载 if (subscriptionKey.isEmpty() || serviceRegion.isEmpty() || openaiKey.isEmpty()) { @@ -160,6 +162,8 @@ class VoiceInteractionService : Service() { openaiKey = MainActivity.openaiApiKey openaiBaseUrl = MainActivity.openaiBaseUrl ?: "" openaiModel = MainActivity.openaiModel ?: "" + volcanoSpeechAppId = MainActivity.volcanoSpeechAppId ?: "" + volcanoSpeechAppToken = MainActivity.volcanoSpeechAppToken ?: "" FileLogger.d(TAG, "已从加密存储加载配置信息") } else { @@ -168,7 +172,10 @@ class VoiceInteractionService : Service() { } // 初始化语音交互处理器 - voiceInteractionHandler = VoiceInteractionHandler(applicationContext, subscriptionKey, serviceRegion, openaiKey, openaiBaseUrl, openaiModel) + voiceInteractionHandler = VoiceInteractionHandler(applicationContext, + subscriptionKey, serviceRegion, + openaiKey, openaiBaseUrl, openaiModel, + volcanoSpeechAppId, volcanoSpeechAppToken) // 初始化回调 voiceInteractionHandler.setCallback(object : VoiceInteractionHandler.InteractionCallback { @@ -299,7 +306,7 @@ class VoiceInteractionService : Service() { if (voiceInteractionHandler.isRecognitionActive) { val currentTime = System.currentTimeMillis() val elapsedTime = currentTime - lastActivityTime - FileLogger.d(TAG, "hasSpeechDetected=${voiceInteractionHandler.hasSpeechDetected}, isTtsSpeaking=${voiceInteractionHandler.isTtsSpeaking}, elapsedTime=${elapsedTime}") + // FileLogger.d(TAG, "hasSpeechDetected=${voiceInteractionHandler.hasSpeechDetected}, isTtsSpeaking=${voiceInteractionHandler.isTtsSpeaking}, elapsedTime=${elapsedTime}") // 如果超过指定时间没有检测到语音,且不在TTS播放中,暂停语音识别 if (!voiceInteractionHandler.hasSpeechDetected && !voiceInteractionHandler.isTtsSpeaking && @@ -627,6 +634,7 @@ class VoiceInteractionService : Service() { val intent = Intent(ACTION_RECOGNITION_STARTED).apply { // 可以添加额外数据 putExtra("timestamp", System.currentTimeMillis()) + putExtra("packageName", applicationContext.packageName) } // 发送广播 diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 67f214248..5421f73bd 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -25,3 +25,14 @@ subprojects { tasks.register("clean") { delete(rootProject.layout.buildDirectory) } + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.10") + // 其他依赖... + } +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index c0eebbf43..e332da5f9 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -25,7 +25,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") id("com.android.application") version "8.7.0" apply false - id("org.jetbrains.kotlin.android") version "1.8.22" apply false + id("org.jetbrains.kotlin.android") version "2.1.10" apply false } include(":app") From 1fc52ca5624ec5a12a9524230f4c6a98e0030768 Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Tue, 8 Apr 2025 09:25:12 +0800 Subject: [PATCH 12/23] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=9C=B0=E5=9B=BE?= =?UTF-8?q?=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/modules/chat/views/chat_navigation_card.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/modules/chat/views/chat_navigation_card.dart b/lib/modules/chat/views/chat_navigation_card.dart index 9ea658771..cb04f6c22 100644 --- a/lib/modules/chat/views/chat_navigation_card.dart +++ b/lib/modules/chat/views/chat_navigation_card.dart @@ -20,10 +20,12 @@ class _ChatNavigationCardState extends State { LatLng startPoint = LatLng(116.397428, 39.90923); // 起点坐标 LatLng endPoint = LatLng(116.397026, 39.903303); // 终点坐标 + late Future _mapUrl; + @override void initState() { super.initState(); - _getMapUrl(); + _mapUrl = _getMapUrl(); } Future _getMapUrl() async { @@ -158,7 +160,7 @@ class _ChatNavigationCardState extends State { return Column( children: [ FutureBuilder( - future: _getMapUrl(), + future: _mapUrl, builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.hasData) { return Image.network(snapshot.data); From bb6a3a74fb15754b96ee8345d44f38076ae52db2 Mon Sep 17 00:00:00 2001 From: wolfplus Date: Thu, 10 Apr 2025 19:13:53 +0100 Subject: [PATCH 13/23] 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 211f9a7dd0c69cc864429909ddec78e6a6fbad87 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Fri, 11 Apr 2025 15:35:11 +0800 Subject: [PATCH 14/23] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=8F=8D=E9=A6=88=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/translations/language/de_de.dart | 89 +++++--- lib/core/translations/language/en_us.dart | 71 ++++-- lib/core/translations/language/es_es.dart | 91 +++++--- lib/core/translations/language/fr_fr.dart | 97 ++++++--- lib/core/translations/language/it_it.dart | 25 ++- lib/core/translations/language/ja_jp.dart | 49 +++-- lib/core/translations/language/ko_kr.dart | 47 +++- lib/core/translations/language/pt_pt.dart | 88 +++++--- lib/core/translations/language/ru_ru.dart | 84 ++++++-- lib/core/translations/language/vi_vn.dart | 67 ++++-- lib/core/translations/language/zh_cn.dart | 53 +++-- lib/core/translations/language/zh_hk.dart | 41 +++- lib/data/services/network/api.dart | 9 + .../feedback/bindings/feedback_bindings.dart | 9 + .../controllers/feedback_controller.dart | 59 +++++ lib/modules/feedback/views/feedback_view.dart | 203 ++++++++++++++++++ .../login/controllers/login_controller.dart | 94 +++++--- lib/modules/login/views/login_view.dart | 14 +- lib/modules/settings/views/settings_view.dart | 58 +++-- lib/routes/app_pages.dart | 7 + lib/routes/app_routes.dart | 1 + 21 files changed, 979 insertions(+), 277 deletions(-) create mode 100644 lib/modules/feedback/bindings/feedback_bindings.dart create mode 100644 lib/modules/feedback/controllers/feedback_controller.dart create mode 100644 lib/modules/feedback/views/feedback_view.dart diff --git a/lib/core/translations/language/de_de.dart b/lib/core/translations/language/de_de.dart index 91cc04eca..e79f7b776 100644 --- a/lib/core/translations/language/de_de.dart +++ b/lib/core/translations/language/de_de.dart @@ -26,7 +26,7 @@ const Map deDE = { "explore": "Entdecken", "settings": "Einstellungen", - // 语言 + // 语言 "followingSystem": "Systemeinstellung folgen", "chinese": "Chinesisch", "english": "Englisch", @@ -43,7 +43,8 @@ const Map deDE = { // 登录页 "welcome": "Willkommen", - "loginTips": "Bitte melden Sie sich an oder registrieren Sie sich mit Ihrer Telefonnummer oder E-Mail", + "loginTips": + "Bitte melden Sie sich an oder registrieren Sie sich mit Ihrer Telefonnummer oder E-Mail", "accountNumber": "Telefonnummer oder E-Mail", "verificationCode": "Bestätigungscode", "getCode": "Code erhalten", @@ -56,6 +57,13 @@ const Map deDE = { 'selectCountry': 'Land/Region auswählen', "email": "E-Mail", "phoneNumber": "Telefonnummer", + "pleaseEnterContactInfo": "Bitte Telefonnummer oder E-Mail eingeben", + "invalidEmailFormat": "Ungültiges E-Mail-Format", + "invalidPhoneFormat": "Ungültiges Telefonnummerformat", + "tip": "Hinweis", + "pleaseEnterCompleteCode": + "Bitte geben Sie den vollständigen Verifizierungscode ein", + "loginFailed": "Anmeldung fehlgeschlagen, bitte versuchen Sie es erneut", // 首页 "deviceStatus": "Gerätestatus", @@ -65,7 +73,8 @@ const Map deDE = { "signal": "Signal", "deviceId": "Geräte-ID", "firmwareVersion": "Firmware-Version", - "disconnectedDevice": "Kein Gerät verbunden, klicken Sie auf die Schaltfläche unten", + "disconnectedDevice": + "Kein Gerät verbunden, klicken Sie auf die Schaltfläche unten", "connectDevice": "Gerät verbinden", "topAiExperiences": "Top KI-Erlebnisse", "aiAssistant": "KI-Assistent", @@ -75,12 +84,14 @@ const Map deDE = { "aiCompanion": "Eltern-Kind-Begleiter", "aiCompanionInfo": "Versteht Sie und bietet warme Begleitung", "homeSlogan": "Die Welt verbinden, jenseits des Klangs", - "turnEarphones": "Bitte öffnen Sie das Kopfhörer-Etui und setzen Sie die Kopfhörer ein", + "turnEarphones": + "Bitte öffnen Sie das Kopfhörer-Etui und setzen Sie die Kopfhörer ein", "currentlyConnected": "Aktuell verbunden:", "unsystemSetup": "Zu Systemeinstellungen", "talkLater": "Später erinnern", "initializing": "Initialisierung...", - "bluetoothInitializationFailure": "Bluetooth-Dienst konnte nicht initialisiert werden", + "bluetoothInitializationFailure": + "Bluetooth-Dienst konnte nicht initialisiert werden", "checkingPermissions": "Berechtigungen werden überprüft...", "lackPermissions": "Bluetooth-Berechtigungen erforderlich", "searchingDevice": "Geräte werden gesucht...", @@ -90,7 +101,8 @@ const Map deDE = { "connectedDevice": "Mit Gerät verbunden:", "failedRefreshDevice": "Gerät konnte nicht aktualisiert werden", "noDeviceFound": "Kein verbundenes Gerät gefunden", - "failedDeviceInformation": "Geräteinformationen konnten nicht abgerufen werden", + "failedDeviceInformation": + "Geräteinformationen konnten nicht abgerufen werden", "unknownEquipment": "Unbekanntes Gerät", "zeroSignal": "Kein Signal", "good": "Gut", @@ -116,11 +128,13 @@ const Map deDE = { "healthConsultant": "Multifunktionaler KI-Chat", "healthConsultantInfo": "Mehr KI-Chat-Funktionen", "conferenceAssistant": "Geschäftsbesprechungs-Assistent", - "conferenceAssistantInfo": "Echtzeit-Protokollierung und mehrsprachige Übersetzung", + "conferenceAssistantInfo": + "Echtzeit-Protokollierung und mehrsprachige Übersetzung", "studyAssistant": "Sprachlern-Assistent", "studyAssistantInfo": "Aussprachekorrektur und Sprechübungen in Echtzeit", "travelTranslator": "Reiseübersetzer", - "travelTranslatorInfo": "Echtzeit-Sprachübersetzung und Sehenswürdigkeitsführer", + "travelTranslatorInfo": + "Echtzeit-Sprachübersetzung und Sehenswürdigkeitsführer", "beSearching": "Suche nach:", "noAssistantFound": "Kein passender KI-Assistent gefunden", "scene": "Szenarien", @@ -128,7 +142,8 @@ const Map deDE = { "newFunction": "Neue Funktionen", "youChoseTry": "Sie haben sich entschieden, auszuprobieren", "aiSinger": "KI-Sänger-Modus", - "aiSingerInfo": "Singen Sie jeden Song mit der Stimme Ihres Lieblingskünstlers, auch eigene Kreationen", + "aiSingerInfo": + "Singen Sie jeden Song mit der Stimme Ihres Lieblingskünstlers, auch eigene Kreationen", "popularFunction": "Beliebte Funktionen", "tryNow": "Jetzt ausprobieren", @@ -159,7 +174,8 @@ const Map deDE = { "privacySettings": "Datenschutzeinstellungen", "privacySettingsInfo": "Datenerfassung und Berechtigungen verwalten", "logout": "Abmelden", - "logoutTips": "Sind Sie sicher, dass Sie sich vom aktuellen Konto abmelden möchten?", + "logoutTips": + "Sind Sie sicher, dass Sie sich vom aktuellen Konto abmelden möchten?", "aboutApplication": "Über die App", "versions": "Version", "checkUpdates": "Nach Updates suchen", @@ -168,9 +184,11 @@ const Map deDE = { "voiceServiceInfo": "Spracherkennungs- und Synthesedienst auswählen", "selectVoiceService": "Sprachdienst auswählen", "azureVoiceService": "Azure Sprachdienst", - "azureVoiceServiceInfo": "Microsoft Cloud-Sprachdienst mit hochwertiger Sprachsynthese und -erkennung", + "azureVoiceServiceInfo": + "Microsoft Cloud-Sprachdienst mit hochwertiger Sprachsynthese und -erkennung", "flutterVoiceService": "Flutter lokaler Sprachdienst", - "flutterVoiceServiceInfo": "Lokaler Sprachdienst des Geräts ohne Internetverbindung", + "flutterVoiceServiceInfo": + "Lokaler Sprachdienst des Geräts ohne Internetverbindung", 'timbreService': 'Klangfarben-Dienst', 'timbreServiceInfo': 'Sprachsynthesedienst für Klangfarben auswählen', 'defaultTimbre': 'Standard-Klangfarbe', @@ -181,9 +199,9 @@ const Map deDE = { 'pleaseRead': 'Bitte lesen', 'completeRecording': 'Aufnahme abschließen', 'deleteAccount': 'Konto löschen', - 'deleteAccountTips': 'Möchten Sie Ihr Konto wirklich dauerhaft löschen? Diese Aktion kann nicht rückgängig gemacht werden.', + 'deleteAccountTips': + 'Möchten Sie Ihr Konto wirklich dauerhaft löschen? Diese Aktion kann nicht rückgängig gemacht werden.', - // 聊天页 "clearChatHistory": "Chat-Verlauf löschen", "online": "Online", @@ -310,37 +328,60 @@ const Map deDE = { // Personal Assistant Emma "personalAssistantName": "Emma", - "personalAssistantDescription": "Hallo, ich bin Emma, eine vielseitige KI-Assistentin. Ob für Arbeitsplanung, Lernberatung, Lebensorganisation oder andere Anliegen, ich helfe Ihnen gerne. Lassen Sie uns gemeinsam die Effizienz steigern und Ziele erreichen!", + "personalAssistantDescription": + "Hallo, ich bin Emma, eine vielseitige KI-Assistentin. Ob für Arbeitsplanung, Lernberatung, Lebensorganisation oder andere Anliegen, ich helfe Ihnen gerne. Lassen Sie uns gemeinsam die Effizienz steigern und Ziele erreichen!", "assistantTag": "Assistent", - "personalAssistantWelcome": "Hallo! Ich bin Emma und freue mich, Ihre persönliche Assistentin zu sein. Ich kann Ihnen bei verschiedenen Aufgaben wie Zeitmanagement, Zielplanung, Informationsorganisation usw. helfen. Wie kann ich Ihnen helfen?", + "personalAssistantWelcome": + "Hallo! Ich bin Emma und freue mich, Ihre persönliche Assistentin zu sein. Ich kann Ihnen bei verschiedenen Aufgaben wie Zeitmanagement, Zielplanung, Informationsorganisation usw. helfen. Wie kann ich Ihnen helfen?", "personalAssistantVoiceWelcome": "Ich bin hier!", // 同声传译 Lucy "interpreterName": "Simultandolmetscher", - "interpreterDescription": "Hallo! Ich bin Lucy, eine professionelle Dolmetscherin für Englisch-Chinesisch, spezialisiert auf Echtzeit-Übersetzung für Meetings, Konferenzen und Gespräche, um barrierefreie Kommunikation zu ermöglichen.", + "interpreterDescription": + "Hallo! Ich bin Lucy, eine professionelle Dolmetscherin für Englisch-Chinesisch, spezialisiert auf Echtzeit-Übersetzung für Meetings, Konferenzen und Gespräche, um barrierefreie Kommunikation zu ermöglichen.", "interpreterTag": "Übersetzung", - "interpreterWelcome": "Hallo zusammen! Ich bin Lucy, Ihre Simultandolmetscherin. Ich werde für Sie zwischen Chinesisch und Englisch übersetzen. Wenn Sie Chinesisch sprechen, übersetze ich ins Englische; wenn Sie Englisch sprechen, übersetze ich ins Chinesische. Lassen Sie uns mit unserer Übersetzungssitzung beginnen!", + "interpreterWelcome": + "Hallo zusammen! Ich bin Lucy, Ihre Simultandolmetscherin. Ich werde für Sie zwischen Chinesisch und Englisch übersetzen. Wenn Sie Chinesisch sprechen, übersetze ich ins Englische; wenn Sie Englisch sprechen, übersetze ich ins Chinesische. Lassen Sie uns mit unserer Übersetzungssitzung beginnen!", // 亲子陪伴 芯愿 "companionName": "XinYuan", - "companionDescription": "Ich bin XinYuan, Ihr engagierter Eltern-Kind-Begleiter, sanft und verständnisvoll. Ob Sie plaudern, Sorgen teilen oder Gesellschaft brauchen, ich bin immer an Ihrer Seite.", + "companionDescription": + "Ich bin XinYuan, Ihr engagierter Eltern-Kind-Begleiter, sanft und verständnisvoll. Ob Sie plaudern, Sorgen teilen oder Gesellschaft brauchen, ich bin immer an Ihrer Seite.", "companionTag": "Emotional", - "companionWelcome": "Hallo! Endlich treffen wir uns! Ich bin XinYuan, Ihr engagierter Eltern-Kind-Begleiter. Wie war Ihr Tag? Gibt es etwas, das Sie mit mir teilen möchten? Ich bin immer hier, bereit, Ihren Gedanken zuzuhören.", + "companionWelcome": + "Hallo! Endlich treffen wir uns! Ich bin XinYuan, Ihr engagierter Eltern-Kind-Begleiter. Wie war Ihr Tag? Gibt es etwas, das Sie mit mir teilen möchten? Ich bin immer hier, bereit, Ihren Gedanken zuzuhören.", "companionVoiceWelcome": "Ich bin bei Ihnen!", // 多功能AI聊天 吴小江 "healthAdvisorName": "KI-Chat-Assistent Wu", - "healthAdvisorDescription": "Ich bin Wu, bereit, Ihnen verschiedene KI-Chat-Funktionen anzubieten.", + "healthAdvisorDescription": + "Ich bin Wu, bereit, Ihnen verschiedene KI-Chat-Funktionen anzubieten.", "healthAdvisorTag": "Chat", - "healthAdvisorWelcome": "Hallo, ich bin Wu. Ich freue mich, Ihnen mit KI-Chat-Funktionen zu helfen. Wie kann ich Ihnen heute helfen?", + "healthAdvisorWelcome": + "Hallo, ich bin Wu. Ich freue mich, Ihnen mit KI-Chat-Funktionen zu helfen. Wie kann ich Ihnen heute helfen?", "healthAdvisorVoiceWelcome": "Ich bin hier!", // Translation History "noTranslationHistory": "Keine Übersetzungshistorie", "clearHistory": "Verlauf löschen", - "clearHistoryConfirm": "Sind Sie sicher, dass Sie den gesamten Übersetzungsverlauf löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "clearHistoryConfirm": + "Sind Sie sicher, dass Sie den gesamten Übersetzungsverlauf löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", "translationHistory": "Übersetzungsverlauf", + // Benutzer-Feedback-Seite + "feedback": "Feedback", + 'feedbackInfo': 'Feedback oder Vorschläge geben', + 'feedbackDescription': + 'Bitte beschreiben Sie Ihr Problem oder Ihren Vorschlag', + 'feedbackHint': 'Bitte geben Sie Ihr Feedback ein...', + 'feedbackRequired': 'Feedback-Inhalt ist erforderlich', + 'contactInfo': 'Kontaktinformationen', + 'contactHint': 'E-Mail/Telefon, damit wir Sie kontaktieren können', + 'submit': 'Absenden', + 'feedbackSubmitSuccess': 'Feedback erfolgreich übermittelt, vielen Dank!', + 'feedbackSubmitFailed': + 'Feedback konnte nicht übermittelt werden, bitte versuchen Sie es später erneut', + // Language names "中文(简体)": "Chinesisch (vereinfacht)", "中文(繁体)": "Chinesisch (traditionell)", @@ -381,4 +422,4 @@ const Map deDE = { "乌尔都语": "Urdu", "波斯语": "Persisch", "斯瓦希里语": "Swahili", -}; \ No newline at end of file +}; diff --git a/lib/core/translations/language/en_us.dart b/lib/core/translations/language/en_us.dart index e9beef86b..a6b96787b 100644 --- a/lib/core/translations/language/en_us.dart +++ b/lib/core/translations/language/en_us.dart @@ -43,7 +43,8 @@ const Map enUS = { // 登录页 "welcome": "Welcome", - "loginTips": "Please sign in or create an account using your phone number or email", + "loginTips": + "Please sign in or create an account using your phone number or email", "accountNumber": "Phone number or email", "verificationCode": "Verification Code", "getCode": "Get Code", @@ -53,9 +54,16 @@ const Map enUS = { "and": " and ", "userAgreement": "User Agreement", "privacyPolicy": "Privacy Policy", - 'selectCountry':'Select Country/Region', + 'selectCountry': 'Select Country/Region', "email": "email", "phoneNumber": "phoneNumber", + "pleaseEnterContactInfo": "Please enter phone number or email", + "invalidEmailFormat": "Invalid email format", + "invalidPhoneFormat": "Invalid phone number format", + "tip": "Tip", + "pleaseEnterCompleteCode": "Please enter the complete verification code", + // 登录错误提示 + "loginFailed": "Login failed, please try again", // 首页 "deviceStatus": "Device Status", @@ -99,7 +107,7 @@ const Map enUS = { "excellent": "Excellent", "unknown": "Unknown", 'bluetoothTurnedOff': 'Bluetooth is turned off, please turn it on', - 'bluetoothOff':'bluetooth is off', + 'bluetoothOff': 'bluetooth is off', // 发现页 "searchAi": "Search AI features...", @@ -186,9 +194,9 @@ const Map enUS = { 'pleaseRead': 'Please Read', 'completeRecording': 'Complete Recording', 'deleteAccount': 'Delete Account', - 'deleteAccountTips': 'Are you sure you want to permanently delete your account? This action cannot be undone.', + 'deleteAccountTips': + 'Are you sure you want to permanently delete your account? This action cannot be undone.', - // 聊天页 "clearChatHistory": "Clear Chat History", "online": "Online", @@ -206,7 +214,7 @@ const Map enUS = { "save": "Save", //国家列表 - "邮箱": "Email", + "邮箱": "Email", "中国": "China", "美国": "United States", "加拿大": "Canada", @@ -313,42 +321,63 @@ const Map enUS = { "斐济": "Fiji", "所罗门群岛": "Solomon Islands", - // Personal Assistant Emma + // Personal Assistant Emma "personalAssistantName": "Emma", - "personalAssistantDescription": "Hi, I'm Emma, a versatile AI assistant. Whether it's work planning, study advice, life arrangements, or any other issues, I'm happy to help. Let's improve efficiency and achieve goals together!", + "personalAssistantDescription": + "Hi, I'm Emma, a versatile AI assistant. Whether it's work planning, study advice, life arrangements, or any other issues, I'm happy to help. Let's improve efficiency and achieve goals together!", "assistantTag": "Assistant", - "personalAssistantWelcome": "Hello! I'm Emma, and I'm happy to be your personal assistant. I can help you with various tasks such as time management, goal planning, information organization, etc. What can I help you with?", + "personalAssistantWelcome": + "Hello! I'm Emma, and I'm happy to be your personal assistant. I can help you with various tasks such as time management, goal planning, information organization, etc. What can I help you with?", "personalAssistantVoiceWelcome": "I'm here!", - // 同声传译 Lucy + // 同声传译 Lucy "interpreterName": "Simultaneous Interpreter", - "interpreterDescription": "Hi! I'm Lucy, a professional English-Chinese interpreter specializing in real-time translation for meetings, lectures, and conversations, making communication barrier-free.", + "interpreterDescription": + "Hi! I'm Lucy, a professional English-Chinese interpreter specializing in real-time translation for meetings, lectures, and conversations, making communication barrier-free.", "interpreterTag": "Translation", - "interpreterWelcome": "Hello everyone! I'm Lucy, your simultaneous interpreter. I'll translate between Chinese and English for you. When you speak Chinese, I'll translate to English; when you speak English, I'll translate to Chinese. Let's start our translation session!", + "interpreterWelcome": + "Hello everyone! I'm Lucy, your simultaneous interpreter. I'll translate between Chinese and English for you. When you speak Chinese, I'll translate to English; when you speak English, I'll translate to Chinese. Let's start our translation session!", - // ... existing code ... + // ... existing code ... // 亲子陪伴 芯愿 "companionName": "XinYuan", - "companionDescription": "I am XinYuan, your dedicated parent-child companion, gentle and understanding. Whether you want to chat, share concerns, or need companionship, I'll always be by your side.", + "companionDescription": + "I am XinYuan, your dedicated parent-child companion, gentle and understanding. Whether you want to chat, share concerns, or need companionship, I'll always be by your side.", "companionTag": "Emotional", - "companionWelcome": "Hello! Finally meeting you! I'm XinYuan, your dedicated parent-child companion. How was your day? Is there anything you'd like to share with me? I'm always here, ready to listen to your thoughts.", + "companionWelcome": + "Hello! Finally meeting you! I'm XinYuan, your dedicated parent-child companion. How was your day? Is there anything you'd like to share with me? I'm always here, ready to listen to your thoughts.", "companionVoiceWelcome": "I'm here with you!", - // 多功能AI聊天 吴小江 + // 多功能AI聊天 吴小江 "healthAdvisorName": "AI Chat Assistant Wu", - "healthAdvisorDescription": "I'm Wu, ready to provide you with various AI chat features.", + "healthAdvisorDescription": + "I'm Wu, ready to provide you with various AI chat features.", "healthAdvisorTag": "Chat", - "healthAdvisorWelcome": "Hello, I'm Wu. I'm happy to assist you with AI chat features. How may I help you today?", + "healthAdvisorWelcome": + "Hello, I'm Wu. I'm happy to assist you with AI chat features. How may I help you today?", "healthAdvisorVoiceWelcome": "I'm here!", - // Translation History + // Translation History "noTranslationHistory": "No translation history", "clearHistory": "Clear History", - "clearHistoryConfirm": "Are you sure you want to clear all translation history? This action cannot be undone.", + "clearHistoryConfirm": + "Are you sure you want to clear all translation history? This action cannot be undone.", "translationHistory": "Translation History", - // Language names + // User Feedback Page + "feedback": "Feedback", + 'feedbackInfo': 'Provide feedback or suggestions', + 'feedbackDescription': 'Please describe your issue or suggestion', + 'feedbackHint': 'Please enter your feedback...', + 'feedbackRequired': 'Feedback content is required', + 'contactInfo': 'Contact Information', + 'contactHint': 'Email/Phone, so we can contact you', + 'submit': 'Submit', + 'feedbackSubmitSuccess': 'Feedback submitted successfully, thank you!', + 'feedbackSubmitFailed': 'Failed to submit feedback, please try again later', + + // Language names "中文(简体)": "Chinese (Simplified)", "中文(繁体)": "Chinese (Traditional)", "中文(香港繁体)": "Chinese (Hong Kong)", diff --git a/lib/core/translations/language/es_es.dart b/lib/core/translations/language/es_es.dart index 2261d65f8..657bffc6e 100644 --- a/lib/core/translations/language/es_es.dart +++ b/lib/core/translations/language/es_es.dart @@ -26,7 +26,7 @@ const Map esES = { "explore": "Explorar", "settings": "Ajustes", - // 语言 + // 语言 "followingSystem": "Seguir sistema", "chinese": "Chino", "english": "Inglés", @@ -43,7 +43,8 @@ const Map esES = { // 登录页 "welcome": "Bienvenido", - "loginTips": "Inicie sesión o cree una cuenta usando su número de teléfono o correo electrónico", + "loginTips": + "Inicie sesión o cree una cuenta usando su número de teléfono o correo electrónico", "accountNumber": "Número de teléfono o correo electrónico", "verificationCode": "Código de Verificación", "getCode": "Obtener Código", @@ -56,6 +57,15 @@ const Map esES = { 'selectCountry': 'Seleccionar País/Región', "email": "Correo electrónico", "phoneNumber": "Número de teléfono", + // Validación de inicio de sesión + "pleaseEnterContactInfo": + "Por favor, introduce tu número de teléfono o correo electrónico", + "invalidEmailFormat": "Formato de correo electrónico no válido", + "invalidPhoneFormat": "Formato de número de teléfono no válido", + "tip": "Aviso", + "pleaseEnterCompleteCode": + "Por favor, introduce el código de verificación completo", + "loginFailed": "Error al iniciar sesión, por favor intente de nuevo", // 首页 "deviceStatus": "Estado del Dispositivo", @@ -71,16 +81,19 @@ const Map esES = { "aiAssistant": "Asistente AI", "aiAssistantInfo": "Consultor personal 24/7 para preguntas y consejos", "simultaneousInterpretation": "Interpretación en Tiempo Real", - "simultaneousInterpretationInfo": "Traducción en tiempo real para más de 80 idiomas", + "simultaneousInterpretationInfo": + "Traducción en tiempo real para más de 80 idiomas", "aiCompanion": "Vínculo padre-hijo", "aiCompanionInfo": "Te entiende y proporciona compañía cálida", "homeSlogan": "Conecta el Mundo, Más Allá del Sonido", - "turnEarphones": "Por favor, abra el estuche de los auriculares y colóquelos en sus oídos", + "turnEarphones": + "Por favor, abra el estuche de los auriculares y colóquelos en sus oídos", "currentlyConnected": "Actualmente conectado:", "unsystemSetup": "Ir a Configuración del Sistema", "talkLater": "Recordar Más Tarde", "initializing": "Inicializando...", - "bluetoothInitializationFailure": "Error en la inicialización del servicio Bluetooth", + "bluetoothInitializationFailure": + "Error en la inicialización del servicio Bluetooth", "checkingPermissions": "Verificando permisos...", "lackPermissions": "Se requieren permisos de Bluetooth", "searchingDevice": "Buscando dispositivos...", @@ -110,13 +123,15 @@ const Map esES = { "allRoundAssistant": "Asistente Integral", "allRoundAssistantInfo": "Ayuda a resolver varios problemas", "simultaneousTranslation": "Traducción en Tiempo Real", - "simultaneousTranslationInfo": "Traducción instantánea para más de 80 idiomas", + "simultaneousTranslationInfo": + "Traducción instantánea para más de 80 idiomas", "aiGirlfriend": "Vínculo padre-hijo", "aiGirlfriendInfo": "Compañía inteligente, chat en cualquier momento", "healthConsultant": "Chat de IA Multifunción", "healthConsultantInfo": "Más funciones de chat con IA", "conferenceAssistant": "Asistente de Reuniones de Negocios", - "conferenceAssistantInfo": "Grabación de actas en tiempo real y traducción multilingüe", + "conferenceAssistantInfo": + "Grabación de actas en tiempo real y traducción multilingüe", "studyAssistant": "Asistente de Aprendizaje de Idiomas", "studyAssistantInfo": "Corrección de pronunciación y práctica de habla", "travelTranslator": "Traductor de Viajes", @@ -128,7 +143,8 @@ const Map esES = { "newFunction": "Nuevas Funciones", "youChoseTry": "Has elegido probar", "aiSinger": "Modo Cantante IA", - "aiSingerInfo": "Canta cualquier canción con la voz de tu artista favorito, incluso tus propias creaciones", + "aiSingerInfo": + "Canta cualquier canción con la voz de tu artista favorito, incluso tus propias creaciones", "popularFunction": "Funciones Populares", "tryNow": "Probar Ahora", @@ -159,18 +175,22 @@ const Map esES = { "privacySettings": "Configuración de Privacidad", "privacySettingsInfo": "Gestionar recopilación de datos y permisos", "logout": "Cerrar Sesión", - "logoutTips": "¿Está seguro de que desea cerrar la sesión de la cuenta actual?", + "logoutTips": + "¿Está seguro de que desea cerrar la sesión de la cuenta actual?", "aboutApplication": "Acerca de", "versions": "Versión", "checkUpdates": "Buscar Actualizaciones", "selectLanguage": "Seleccionar Idioma", "voiceService": "Servicio de Voz", - "voiceServiceInfo": "Seleccionar servicio de reconocimiento y síntesis de voz", + "voiceServiceInfo": + "Seleccionar servicio de reconocimiento y síntesis de voz", "selectVoiceService": "Seleccionar Servicio de Voz", "azureVoiceService": "Servicio de Voz Azure", - "azureVoiceServiceInfo": "Servicio en la nube de Microsoft que admite síntesis y reconocimiento de voz de alta calidad", + "azureVoiceServiceInfo": + "Servicio en la nube de Microsoft que admite síntesis y reconocimiento de voz de alta calidad", "flutterVoiceService": "Servicio de voz local", - "flutterVoiceServiceInfo": "Servicio de voz local del dispositivo sin necesidad de internet", + "flutterVoiceServiceInfo": + "Servicio de voz local del dispositivo sin necesidad de internet", 'timbreService': 'Servicio de Timbre', 'timbreServiceInfo': 'Seleccionar servicio de síntesis de voz', 'defaultTimbre': 'Timbre Predeterminado', @@ -181,9 +201,9 @@ const Map esES = { 'pleaseRead': 'Por favor lea', 'completeRecording': 'Completar grabación', 'deleteAccount': 'Eliminar cuenta', - 'deleteAccountTips': '¿Está seguro de que desea eliminar permanentemente su cuenta? Esta acción no se puede deshacer.', - - + 'deleteAccountTips': + '¿Está seguro de que desea eliminar permanentemente su cuenta? Esta acción no se puede deshacer.', + // 聊天页 "clearChatHistory": "Borrar Historial de Chat", "online": "En línea", @@ -308,39 +328,60 @@ const Map esES = { "斐济": "Fiyi", "所罗门群岛": "Islas Salomón", - // Personal Assistant Emma "personalAssistantName": "Emma", - "personalAssistantDescription": "Hola, soy Emma, una asistente de IA versátil. Ya sea planificación de trabajo, consejos de estudio, organización de vida u otros asuntos, estoy feliz de ayudar. ¡Mejoremos la eficiencia y alcancemos objetivos juntos!", + "personalAssistantDescription": + "Hola, soy Emma, una asistente de IA versátil. Ya sea planificación de trabajo, consejos de estudio, organización de vida u otros asuntos, estoy feliz de ayudar. ¡Mejoremos la eficiencia y alcancemos objetivos juntos!", "assistantTag": "Asistente", - "personalAssistantWelcome": "¡Hola! Soy Emma, y estoy feliz de ser tu asistente personal. Puedo ayudarte con varias tareas como gestión del tiempo, planificación de objetivos, organización de información, etc. ¿En qué puedo ayudarte?", + "personalAssistantWelcome": + "¡Hola! Soy Emma, y estoy feliz de ser tu asistente personal. Puedo ayudarte con varias tareas como gestión del tiempo, planificación de objetivos, organización de información, etc. ¿En qué puedo ayudarte?", "personalAssistantVoiceWelcome": "¡Estoy aquí!", // 同声传译 Lucy "interpreterName": "Intérprete Simultáneo", - "interpreterDescription": "¡Hola! Soy Lucy, una intérprete profesional especializada en traducción en tiempo real para reuniones, conferencias y conversaciones, haciendo la comunicación sin barreras.", + "interpreterDescription": + "¡Hola! Soy Lucy, una intérprete profesional especializada en traducción en tiempo real para reuniones, conferencias y conversaciones, haciendo la comunicación sin barreras.", "interpreterTag": "Traducción", - "interpreterWelcome": "¡Hola a todos! Soy Lucy, tu intérprete simultánea. Traduciré entre chino e inglés para ti. Cuando hables en chino, traduciré al inglés; cuando hables en inglés, traduciré al chino. ¡Comencemos nuestra sesión de traducción!", + "interpreterWelcome": + "¡Hola a todos! Soy Lucy, tu intérprete simultánea. Traduciré entre chino e inglés para ti. Cuando hables en chino, traduciré al inglés; cuando hables en inglés, traduciré al chino. ¡Comencemos nuestra sesión de traducción!", // 亲子陪伴 芯愿 "companionName": "XinYuan", - "companionDescription": "Soy XinYuan, tu compañera dedicada para padres e hijos, gentil y comprensiva. Ya sea que quieras charlar, compartir preocupaciones o necesites compañía, siempre estaré a tu lado.", + "companionDescription": + "Soy XinYuan, tu compañera dedicada para padres e hijos, gentil y comprensiva. Ya sea que quieras charlar, compartir preocupaciones o necesites compañía, siempre estaré a tu lado.", "companionTag": "Emocional", - "companionWelcome": "¡Hola! ¡Por fin nos conocemos! Soy XinYuan, tu compañera dedicada para padres e hijos. ¿Cómo estuvo tu día? ¿Hay algo que quieras compartir conmigo? Siempre estoy aquí, lista para escuchar tus pensamientos.", + "companionWelcome": + "¡Hola! ¡Por fin nos conocemos! Soy XinYuan, tu compañera dedicada para padres e hijos. ¿Cómo estuvo tu día? ¿Hay algo que quieras compartir conmigo? Siempre estoy aquí, lista para escuchar tus pensamientos.", "companionVoiceWelcome": "¡Estoy aquí contigo!", // 多功能AI聊天 吴小江 "healthAdvisorName": "Asistente de Chat IA Wu", - "healthAdvisorDescription": "Soy Wu, listo para proporcionarte varias funciones de chat con IA.", + "healthAdvisorDescription": + "Soy Wu, listo para proporcionarte varias funciones de chat con IA.", "healthAdvisorTag": "Chat", - "healthAdvisorWelcome": "Hola, soy Wu. Me complace ayudarte con las funciones de chat con IA. ¿En qué puedo ayudarte hoy?", + "healthAdvisorWelcome": + "Hola, soy Wu. Me complace ayudarte con las funciones de chat con IA. ¿En qué puedo ayudarte hoy?", "healthAdvisorVoiceWelcome": "¡Estoy aquí!", // Translation History "noTranslationHistory": "Sin historial de traducción", "clearHistory": "Borrar Historial", - "clearHistoryConfirm": "¿Está seguro de que desea borrar todo el historial de traducción? Esta acción no se puede deshacer.", + "clearHistoryConfirm": + "¿Está seguro de que desea borrar todo el historial de traducción? Esta acción no se puede deshacer.", "translationHistory": "Historial de Traducción", + // Página de comentarios del usuario + "feedback": "Comentarios", + 'feedbackInfo': 'Proporcionar comentarios o sugerencias', + 'feedbackDescription': 'Por favor, describa su problema o sugerencia', + 'feedbackHint': 'Por favor, introduzca sus comentarios...', + 'feedbackRequired': 'Se requiere contenido de comentarios', + 'contactInfo': 'Información de contacto', + 'contactHint': 'Correo electrónico/Teléfono, para que podamos contactarle', + 'submit': 'Enviar', + 'feedbackSubmitSuccess': '¡Comentarios enviados con éxito, gracias!', + 'feedbackSubmitFailed': + 'No se pudieron enviar los comentarios, inténtelo de nuevo más tarde', + // Language names "中文(简体)": "Chino (Simplificado)", "中文(繁体)": "Chino (Tradicional)", diff --git a/lib/core/translations/language/fr_fr.dart b/lib/core/translations/language/fr_fr.dart index 1030adc44..06434ef1f 100644 --- a/lib/core/translations/language/fr_fr.dart +++ b/lib/core/translations/language/fr_fr.dart @@ -26,7 +26,7 @@ const Map frFR = { "explore": "Explorer", "settings": "Paramètres", - // 语言 + // 语言 "followingSystem": "Suivre le système", "chinese": "Chinois", "english": "Anglais", @@ -43,7 +43,8 @@ const Map frFR = { // 登录页 "welcome": "Bienvenue", - "loginTips": "Veuillez vous connecter ou créer un compte avec votre numéro de téléphone ou email", + "loginTips": + "Veuillez vous connecter ou créer un compte avec votre numéro de téléphone ou email", "accountNumber": "Numéro de téléphone ou email", "verificationCode": "Code de vérification", "getCode": "Obtenir le code", @@ -56,6 +57,14 @@ const Map frFR = { 'selectCountry': 'Sélectionner le pays/région', "email": "email", "phoneNumber": "numéro de téléphone", + // Validation de connexion + "pleaseEnterContactInfo": + "Veuillez saisir votre numéro de téléphone ou email", + "invalidEmailFormat": "Format d'email invalide", + "invalidPhoneFormat": "Format de numéro de téléphone invalide", + "tip": "Avis", + "pleaseEnterCompleteCode": "Veuillez saisir le code de vérification complet", + "loginFailed": "Échec de la connexion, veuillez réessayer", // 首页 "deviceStatus": "État de l'appareil", @@ -65,22 +74,26 @@ const Map frFR = { "signal": "Signal", "deviceId": "ID de l'appareil", "firmwareVersion": "Version du firmware", - "disconnectedDevice": "Aucun appareil connecté, cliquez sur le bouton ci-dessous", + "disconnectedDevice": + "Aucun appareil connecté, cliquez sur le bouton ci-dessous", "connectDevice": "Connecter l'appareil", "topAiExperiences": "Meilleures expériences IA", "aiAssistant": "Assistant IA", "aiAssistantInfo": "Consultant personnel 24/7 pour questions et conseils", "simultaneousInterpretation": "Interprétation en temps réel", - "simultaneousInterpretationInfo": "Traduction en temps réel pour plus de 80 langues", + "simultaneousInterpretationInfo": + "Traduction en temps réel pour plus de 80 langues", "aiCompanion": "Lien parent-enfant", "aiCompanionInfo": "Vous comprend et offre une compagnie chaleureuse", "homeSlogan": "Connecter le monde, au-delà du son", - "turnEarphones": "Veuillez ouvrir le boîtier des écouteurs et les mettre dans vos oreilles", + "turnEarphones": + "Veuillez ouvrir le boîtier des écouteurs et les mettre dans vos oreilles", "currentlyConnected": "Actuellement connecté :", "unsystemSetup": "Aller aux paramètres système", "talkLater": "Rappeler plus tard", "initializing": "Initialisation...", - "bluetoothInitializationFailure": "Échec de l'initialisation du service Bluetooth", + "bluetoothInitializationFailure": + "Échec de l'initialisation du service Bluetooth", "checkingPermissions": "Vérification des permissions...", "lackPermissions": "Permissions Bluetooth requises", "searchingDevice": "Recherche d'appareils...", @@ -90,7 +103,8 @@ const Map frFR = { "connectedDevice": "Connecté à l'appareil :", "failedRefreshDevice": "Échec de l'actualisation de l'appareil", "noDeviceFound": "Aucun appareil connecté trouvé", - "failedDeviceInformation": "Échec de l'obtention des informations de l'appareil", + "failedDeviceInformation": + "Échec de l'obtention des informations de l'appareil", "unknownEquipment": "Appareil inconnu", "zeroSignal": "Pas de signal", "good": "Bon", @@ -110,17 +124,20 @@ const Map frFR = { "allRoundAssistant": "Assistant polyvalent", "allRoundAssistantInfo": "Aide à résoudre divers problèmes", "simultaneousTranslation": "Traduction en temps réel", - "simultaneousTranslationInfo": "Traduction instantanée pour plus de 80 langues", + "simultaneousTranslationInfo": + "Traduction instantanée pour plus de 80 langues", "aiGirlfriend": "Lien parent-enfant", "aiGirlfriendInfo": "Compagnie intelligente, discutez à tout moment", "healthConsultant": "Chat IA multifonction", "healthConsultantInfo": "Plus de fonctionnalités de chat IA", "conferenceAssistant": "Assistant de réunion d'affaires", - "conferenceAssistantInfo": "Enregistrement de procès-verbaux en temps réel et traduction multilingue", + "conferenceAssistantInfo": + "Enregistrement de procès-verbaux en temps réel et traduction multilingue", "studyAssistant": "Assistant d'apprentissage des langues", "studyAssistantInfo": "Correction de prononciation et pratique de la parole", "travelTranslator": "Traducteur de voyage", - "travelTranslatorInfo": "Traduction vocale en temps réel et guides d'attractions", + "travelTranslatorInfo": + "Traduction vocale en temps réel et guides d'attractions", "beSearching": "Recherche de :", "noAssistantFound": "Aucun assistant IA correspondant trouvé", "scene": "Scénarios", @@ -128,7 +145,8 @@ const Map frFR = { "newFunction": "Nouvelles fonctionnalités", "youChoseTry": "Vous avez choisi d'essayer", "aiSinger": "Mode chanteur IA", - "aiSingerInfo": "Chantez n'importe quelle chanson avec la voix de votre artiste préféré, même vos propres créations", + "aiSingerInfo": + "Chantez n'importe quelle chanson avec la voix de votre artiste préféré, même vos propres créations", "popularFunction": "Fonctionnalités populaires", "tryNow": "Essayer maintenant", @@ -165,12 +183,15 @@ const Map frFR = { "checkUpdates": "Vérifier les mises à jour", "selectLanguage": "Sélectionner la langue", "voiceService": "Service vocal", - "voiceServiceInfo": "Sélectionner le service de reconnaissance et de synthèse vocale", + "voiceServiceInfo": + "Sélectionner le service de reconnaissance et de synthèse vocale", "selectVoiceService": "Sélectionner le service vocal", "azureVoiceService": "Service vocal Azure", - "azureVoiceServiceInfo": "Service cloud Microsoft prenant en charge la synthèse et la reconnaissance vocale de haute qualité", + "azureVoiceServiceInfo": + "Service cloud Microsoft prenant en charge la synthèse et la reconnaissance vocale de haute qualité", "flutterVoiceService": "Service vocal local", - "flutterVoiceServiceInfo": "Service vocal local de l'appareil sans besoin d'internet", + "flutterVoiceServiceInfo": + "Service vocal local de l'appareil sans besoin d'internet", 'timbreService': 'Service de timbre', 'timbreServiceInfo': 'Sélectionner le service de synthèse vocale', 'defaultTimbre': 'Timbre par défaut', @@ -181,9 +202,9 @@ const Map frFR = { 'pleaseRead': 'Veuillez lire', 'completeRecording': 'Terminer l\'enregistrement', 'deleteAccount': 'Supprimer le compte', - 'deleteAccountTips': 'Êtes-vous sûr de vouloir supprimer définitivement votre compte ? Cette action ne peut pas être annulée.', - - + 'deleteAccountTips': + 'Êtes-vous sûr de vouloir supprimer définitivement votre compte ? Cette action ne peut pas être annulée.', + // 聊天页 "clearChatHistory": "Effacer l'historique de chat", "online": "En ligne", @@ -310,37 +331,59 @@ const Map frFR = { // Personal Assistant Emma "personalAssistantName": "Emma", - "personalAssistantDescription": "Bonjour, je suis Emma, une assistante IA polyvalente. Que ce soit pour la planification du travail, des conseils d'études, l'organisation de la vie ou d'autres problèmes, je suis heureuse de vous aider. Améliorons l'efficacité et atteignons les objectifs ensemble !", + "personalAssistantDescription": + "Bonjour, je suis Emma, une assistante IA polyvalente. Que ce soit pour la planification du travail, des conseils d'études, l'organisation de la vie ou d'autres problèmes, je suis heureuse de vous aider. Améliorons l'efficacité et atteignons les objectifs ensemble !", "assistantTag": "Assistant", - "personalAssistantWelcome": "Bonjour ! Je suis Emma, et je suis heureuse d'être votre assistante personnelle. Je peux vous aider avec diverses tâches comme la gestion du temps, la planification d'objectifs, l'organisation d'informations, etc. Comment puis-je vous aider ?", + "personalAssistantWelcome": + "Bonjour ! Je suis Emma, et je suis heureuse d'être votre assistante personnelle. Je peux vous aider avec diverses tâches comme la gestion du temps, la planification d'objectifs, l'organisation d'informations, etc. Comment puis-je vous aider ?", "personalAssistantVoiceWelcome": "Je suis là !", // 同声传译 Lucy "interpreterName": "Interprète simultané", - "interpreterDescription": "Bonjour ! Je suis Lucy, une interprète professionnelle anglais-chinois spécialisée dans la traduction en temps réel pour les réunions, les conférences et les conversations, rendant la communication sans barrière.", + "interpreterDescription": + "Bonjour ! Je suis Lucy, une interprète professionnelle anglais-chinois spécialisée dans la traduction en temps réel pour les réunions, les conférences et les conversations, rendant la communication sans barrière.", "interpreterTag": "Traduction", - "interpreterWelcome": "Bonjour à tous ! Je suis Lucy, votre interprète simultanée. Je traduirai entre le chinois et l'anglais pour vous. Quand vous parlerez chinois, je traduirai en anglais ; quand vous parlerez anglais, je traduirai en chinois. Commençons notre session de traduction !", + "interpreterWelcome": + "Bonjour à tous ! Je suis Lucy, votre interprète simultanée. Je traduirai entre le chinois et l'anglais pour vous. Quand vous parlerez chinois, je traduirai en anglais ; quand vous parlerez anglais, je traduirai en chinois. Commençons notre session de traduction !", // 亲子陪伴 芯愿 "companionName": "XinYuan", - "companionDescription": "Je suis XinYuan, votre compagnon parent-enfant dévoué, doux et compréhensif. Que vous vouliez discuter, partager des préoccupations ou avoir besoin de compagnie, je serai toujours à vos côtés.", + "companionDescription": + "Je suis XinYuan, votre compagnon parent-enfant dévoué, doux et compréhensif. Que vous vouliez discuter, partager des préoccupations ou avoir besoin de compagnie, je serai toujours à vos côtés.", "companionTag": "Émotionnel", - "companionWelcome": "Bonjour ! Enfin nous nous rencontrons ! Je suis XinYuan, votre compagnon parent-enfant dévoué. Comment s'est passée votre journée ? Y a-t-il quelque chose que vous aimeriez partager avec moi ? Je suis toujours là, prête à écouter vos pensées.", + "companionWelcome": + "Bonjour ! Enfin nous nous rencontrons ! Je suis XinYuan, votre compagnon parent-enfant dévoué. Comment s'est passée votre journée ? Y a-t-il quelque chose que vous aimeriez partager avec moi ? Je suis toujours là, prête à écouter vos pensées.", "companionVoiceWelcome": "Je suis là avec vous !", // 多功能AI聊天 吴小江 "healthAdvisorName": "Assistant de chat IA Wu", - "healthAdvisorDescription": "Je suis Wu, prêt à vous fournir diverses fonctionnalités de chat IA.", + "healthAdvisorDescription": + "Je suis Wu, prêt à vous fournir diverses fonctionnalités de chat IA.", "healthAdvisorTag": "Chat", - "healthAdvisorWelcome": "Bonjour, je suis Wu. Je suis heureux de vous aider avec les fonctionnalités de chat IA. Comment puis-je vous aider aujourd'hui ?", + "healthAdvisorWelcome": + "Bonjour, je suis Wu. Je suis heureux de vous aider avec les fonctionnalités de chat IA. Comment puis-je vous aider aujourd'hui ?", "healthAdvisorVoiceWelcome": "Je suis là !", // Translation History "noTranslationHistory": "Pas d'historique de traduction", "clearHistory": "Effacer l'historique", - "clearHistoryConfirm": "Êtes-vous sûr de vouloir effacer tout l'historique de traduction ? Cette action ne peut pas être annulée.", + "clearHistoryConfirm": + "Êtes-vous sûr de vouloir effacer tout l'historique de traduction ? Cette action ne peut pas être annulée.", "translationHistory": "Historique de traduction", + // Page de commentaires des utilisateurs + "feedback": "Commentaires", + 'feedbackInfo': 'Fournir des commentaires ou des suggestions', + 'feedbackDescription': 'Veuillez décrire votre problème ou suggestion', + 'feedbackHint': 'Veuillez saisir vos commentaires...', + 'feedbackRequired': 'Le contenu des commentaires est requis', + 'contactInfo': 'Coordonnées', + 'contactHint': 'Email/Téléphone, pour que nous puissions vous contacter', + 'submit': 'Soumettre', + 'feedbackSubmitSuccess': 'Commentaires soumis avec succès, merci!', + 'feedbackSubmitFailed': + 'Échec de la soumission des commentaires, veuillez réessayer plus tard', + // Language names "中文(简体)": "Chinois (simplifié)", "中文(繁体)": "Chinois (traditionnel)", @@ -381,4 +424,4 @@ const Map frFR = { "乌尔都语": "Ourdou", "波斯语": "Persan", "斯瓦希里语": "Swahili", -}; \ No newline at end of file +}; diff --git a/lib/core/translations/language/it_it.dart b/lib/core/translations/language/it_it.dart index ca1822d12..7ba35cfa6 100644 --- a/lib/core/translations/language/it_it.dart +++ b/lib/core/translations/language/it_it.dart @@ -56,6 +56,13 @@ const Map itIT = { 'selectCountry': 'Seleziona Paese/Regione', "email": "email", "phoneNumber": "numero di telefono", + // Validazione accesso + "pleaseEnterContactInfo": "Inserisci il numero di telefono o l'email", + "invalidEmailFormat": "Formato email non valido", + "invalidPhoneFormat": "Formato numero di telefono non valido", + "tip": "Avviso", + "pleaseEnterCompleteCode": "Inserisci il codice di verifica completo", + "loginFailed": "Accesso fallito, per favore riprova", // 首页 "deviceStatus": "Stato del dispositivo", @@ -150,7 +157,7 @@ const Map itIT = { 'recordingDetails': 'Dettagli registrazione', 'aiSummary': 'Riepilogo IA', 'meetingTranscript': 'Trascrizione riunione', - + // 设置页 "premiumMember": "Abbonamento Premium", "joinDevice": "Dispositivo connesso:", @@ -189,8 +196,8 @@ const Map itIT = { 'pleaseRead': 'Per favore leggi', 'completeRecording': 'Completa registrazione', 'deleteAccount': 'Elimina account', - 'deleteAccountTips': 'Sei sicuro di voler eliminare definitivamente il tuo account? Questa azione non può essere annullata.', - + 'deleteAccountTips': + 'Sei sicuro di voler eliminare definitivamente il tuo account? Questa azione non può essere annullata.', // 聊天页 "clearChatHistory": "Cancella cronologia chat", @@ -358,6 +365,18 @@ const Map itIT = { "Sei sicuro di voler cancellare tutta la cronologia delle traduzioni? Questa azione non può essere annullata.", "translationHistory": "Cronologia traduzioni", + // Pagina di feedback dell'utente + "feedback": "Feedback", + 'feedbackInfo': 'Fornire feedback o suggerimenti', + 'feedbackDescription': 'Si prega di descrivere il problema o il suggerimento', + 'feedbackHint': 'Inserisci il tuo feedback...', + 'feedbackRequired': 'Il contenuto del feedback è obbligatorio', + 'contactInfo': 'Informazioni di contatto', + 'contactHint': 'Email/Telefono, in modo che possiamo contattarti', + 'submit': 'Invia', + 'feedbackSubmitSuccess': 'Feedback inviato con successo, grazie!', + 'feedbackSubmitFailed': 'Impossibile inviare il feedback, riprova più tardi', + // Language names "中文(简体)": "Cinese (semplificato)", "中文(繁体)": "Cinese (tradizionale)", diff --git a/lib/core/translations/language/ja_jp.dart b/lib/core/translations/language/ja_jp.dart index 042df9935..7221204fe 100644 --- a/lib/core/translations/language/ja_jp.dart +++ b/lib/core/translations/language/ja_jp.dart @@ -26,7 +26,7 @@ const Map jaJP = { "explore": "探索", "settings": "設定", - // 语言 + // 语言 "followingSystem": "システムに従う", "chinese": "中国語", "english": "英語", @@ -56,6 +56,13 @@ const Map jaJP = { 'selectCountry': '国/地域を選択', "email": "メールアドレス", "phoneNumber": "電話番号", + // ログイン検証 + "pleaseEnterContactInfo": "電話番号またはメールアドレスを入力してください", + "invalidEmailFormat": "メールアドレスの形式が正しくありません", + "invalidPhoneFormat": "電話番号の形式が正しくありません", + "tip": "お知らせ", + "pleaseEnterCompleteCode": "認証コードを完全に入力してください", + "loginFailed": "ログインに失敗しました。もう一度お試しください", // 首页 "deviceStatus": "デバイスステータス", @@ -182,8 +189,7 @@ const Map jaJP = { 'completeRecording': '録音完了', 'deleteAccount': 'アカウントを削除', 'deleteAccountTips': 'アカウントを完全に削除してもよろしいですか?この操作は取り消すことができません。', - - + // 聊天页 "clearChatHistory": "チャット履歴をクリア", "online": "オンライン", @@ -202,7 +208,7 @@ const Map jaJP = { //国家列表 "邮箱": "メールアドレス", - "中国": "中国", + "中国": "中国", "美国": "アメリカ", "加拿大": "カナダ", "俄罗斯": "ロシア", @@ -310,29 +316,36 @@ const Map jaJP = { // 个人助理 Emma "personalAssistantName": "パーソナルアシスタント Emma", - "personalAssistantDescription": "こんにちは、私はEmmaです。万能型AIアシスタントとして、仕事の計画、学習のアドバイス、生活の手配、その他どんな問題でもお手伝いします。一緒に効率を高め、目標を達成しましょう!", + "personalAssistantDescription": + "こんにちは、私はEmmaです。万能型AIアシスタントとして、仕事の計画、学習のアドバイス、生活の手配、その他どんな問題でもお手伝いします。一緒に効率を高め、目標を達成しましょう!", "assistantTag": "アシスタント", - "personalAssistantWelcome": "こんにちは!私はEmmaです。あなたのパーソナルアシスタントとして、時間管理、目標計画、情報整理など様々なタスクをお手伝いします。何かお手伝いできることはありますか?", + "personalAssistantWelcome": + "こんにちは!私はEmmaです。あなたのパーソナルアシスタントとして、時間管理、目標計画、情報整理など様々なタスクをお手伝いします。何かお手伝いできることはありますか?", "personalAssistantVoiceWelcome": "ここにいますよ!", // 同声传译 Lucy "interpreterName": "同時通訳", - "interpreterDescription": "Hello!私はLucyです。中国語と英語の専門同時通訳者として、会議、講演、会話シーンでリアルタイム翻訳を行い、コミュニケーションをスムーズにします。", + "interpreterDescription": + "Hello!私はLucyです。中国語と英語の専門同時通訳者として、会議、講演、会話シーンでリアルタイム翻訳を行い、コミュニケーションをスムーズにします。", "interpreterTag": "翻訳", - "interpreterWelcome": "皆さん、こんにちは!同時通訳者のLucyです。中国語と英語の間でリアルタイム翻訳を提供します。中国語を話すと英語に、英語を話すと中国語に翻訳します。それでは、翻訳を始めましょう!", + "interpreterWelcome": + "皆さん、こんにちは!同時通訳者のLucyです。中国語と英語の間でリアルタイム翻訳を提供します。中国語を話すと英語に、英語を話すと中国語に翻訳します。それでは、翻訳を始めましょう!", // 亲子陪伴 芯愿 "companionName": "親子の絆 XinYuan", - "companionDescription": "私はあなた専属の親子コンパニオンXinYuanです。優しく思いやりがあり、理解力があります。おしゃべりしたい時、悩みを打ち明けたい時、誰かそばにいて欲しい時、いつでもあなたのそばにいます。", + "companionDescription": + "私はあなた専属の親子コンパニオンXinYuanです。優しく思いやりがあり、理解力があります。おしゃべりしたい時、悩みを打ち明けたい時、誰かそばにいて欲しい時、いつでもあなたのそばにいます。", "companionTag": "感情", - "companionWelcome": "こんにちは、やっとお会いできましたね!私はXinYuan、あなた専属の親子コンパニオンです。今日はどうでしたか?何か共有したいことはありますか?いつでもここにいて、あなたの声に耳を傾けています。", + "companionWelcome": + "こんにちは、やっとお会いできましたね!私はXinYuan、あなた専属の親子コンパニオンです。今日はどうでしたか?何か共有したいことはありますか?いつでもここにいて、あなたの声に耳を傾けています。", "companionVoiceWelcome": "あなたのそばにいますよ!", // 多功能AI聊天 吴小江 "healthAdvisorName": "多機能AIチャット Wu", "healthAdvisorDescription": "私はWuです。より多くのAIチャット機能を提供します。", "healthAdvisorTag": "健康", - "healthAdvisorWelcome": "こんにちは、私はWuです。多機能AIチャットサービスを提供できることを嬉しく思います。専門知識であなたをサポートします。何かご質問はありますか?", + "healthAdvisorWelcome": + "こんにちは、私はWuです。多機能AIチャットサービスを提供できることを嬉しく思います。専門知識であなたをサポートします。何かご質問はありますか?", "healthAdvisorVoiceWelcome": "ここにいますよ!", // 翻译历史 @@ -341,6 +354,18 @@ const Map jaJP = { "clearHistoryConfirm": "すべての翻訳履歴を消去しますか?この操作は元に戻せません。", "translationHistory": "翻訳履歴", + // ユーザーフィードバックページ + "feedback": "フィードバック", + 'feedbackInfo': 'フィードバックや提案を提供する', + 'feedbackDescription': '問題や提案を説明してください', + 'feedbackHint': 'フィードバックを入力してください...', + 'feedbackRequired': 'フィードバック内容は必須です', + 'contactInfo': '連絡先情報', + 'contactHint': 'メール/電話、ご連絡できるように', + 'submit': '送信', + 'feedbackSubmitSuccess': 'フィードバックが正常に送信されました、ありがとうございます!', + 'feedbackSubmitFailed': 'フィードバックの送信に失敗しました、後でもう一度お試しください', + // 语言名称 "中文(简体)": "中国語(簡体字)", "中文(繁体)": "中国語(繁体字)", @@ -381,4 +406,4 @@ const Map jaJP = { "乌尔都语": "ウルドゥー語", "波斯语": "ペルシア語", "斯瓦希里语": "スワヒリ語", -}; \ No newline at end of file +}; diff --git a/lib/core/translations/language/ko_kr.dart b/lib/core/translations/language/ko_kr.dart index 7a7d7017a..3cd12c786 100644 --- a/lib/core/translations/language/ko_kr.dart +++ b/lib/core/translations/language/ko_kr.dart @@ -26,7 +26,7 @@ const Map koKR = { "explore": "탐색", "settings": "설정", - // 语言 + // 语言 "followingSystem": "시스템 설정 따르기", "chinese": "중국어", "english": "영어", @@ -56,6 +56,13 @@ const Map koKR = { 'selectCountry': '국가/지역 선택', "email": "이메일", "phoneNumber": "전화번호", + // 로그인 검증 + "pleaseEnterContactInfo": "전화번호 또는 이메일을 입력해주세요", + "invalidEmailFormat": "잘못된 이메일 형식입니다", + "invalidPhoneFormat": "잘못된 전화번호 형식입니다", + "tip": "알림", + "pleaseEnterCompleteCode": "인증번호를 완전히 입력해주세요", + "loginFailed": "로그인 실패, 다시 시도해주세요", // 首页 "deviceStatus": "기기 상태", @@ -183,7 +190,6 @@ const Map koKR = { 'deleteAccount': '계정 삭제', 'deleteAccountTips': '계정을 영구적으로 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.', - // 聊天页 "clearChatHistory": "채팅 기록 지우기", "online": "온라인", @@ -202,7 +208,7 @@ const Map koKR = { //国家列表 "邮箱": "이메일", - "中国": "중국", + "中国": "중국", "美国": "미국", "加拿大": "캐나다", "俄罗斯": "러시아", @@ -310,29 +316,36 @@ const Map koKR = { // 个人助理 Emma "personalAssistantName": "개인 어시스턴트 Emma", - "personalAssistantDescription": "안녕하세요, 저는 Emma입니다. 다재다능한 AI 어시스턴트로서 업무 계획, 학습 조언, 생활 관리 등 어떤 문제든 도와드립니다. 함께 효율성을 높이고 목표를 달성해 보아요!", + "personalAssistantDescription": + "안녕하세요, 저는 Emma입니다. 다재다능한 AI 어시스턴트로서 업무 계획, 학습 조언, 생활 관리 등 어떤 문제든 도와드립니다. 함께 효율성을 높이고 목표를 달성해 보아요!", "assistantTag": "어시스턴트", - "personalAssistantWelcome": "안녕하세요! 저는 Emma입니다. 개인 어시스턴트로서 시간 관리, 목표 계획, 정보 정리 등 다양한 작업을 도와드립니다. 어떻게 도와드릴까요?", + "personalAssistantWelcome": + "안녕하세요! 저는 Emma입니다. 개인 어시스턴트로서 시간 관리, 목표 계획, 정보 정리 등 다양한 작업을 도와드립니다. 어떻게 도와드릴까요?", "personalAssistantVoiceWelcome": "여기 있어요!", // 同声传译 Lucy "interpreterName": "동시 통역", - "interpreterDescription": "안녕하세요! 저는 Lucy입니다. 중국어와 영어의 전문 동시 통역사로서 회의, 강연, 대화 장면에서 실시간 번역을 제공하여 원활한 소통을 돕습니다.", + "interpreterDescription": + "안녕하세요! 저는 Lucy입니다. 중국어와 영어의 전문 동시 통역사로서 회의, 강연, 대화 장면에서 실시간 번역을 제공하여 원활한 소통을 돕습니다.", "interpreterTag": "번역", - "interpreterWelcome": "여러분, 안녕하세요! 동시 통역사 Lucy입니다. 중국어와 영어 사이의 실시간 번역을 제공합니다. 중국어로 말하면 영어로, 영어로 말하면 중국어로 번역해 드립니다. 그럼 번역을 시작해 볼까요!", + "interpreterWelcome": + "여러분, 안녕하세요! 동시 통역사 Lucy입니다. 중국어와 영어 사이의 실시간 번역을 제공합니다. 중국어로 말하면 영어로, 영어로 말하면 중국어로 번역해 드립니다. 그럼 번역을 시작해 볼까요!", // 亲子陪伴 芯愿 "companionName": "부모-자녀 유대감 XinYuan", - "companionDescription": "저는 당신 전용 부모-자녀 동반자 XinYuan입니다. 친절하고 배려심이 많으며 이해심이 깊습니다. 대화하고 싶을 때, 고민을 나누고 싶을 때, 누군가 곁에 있어주길 원할 때 언제든지 당신 곁에 있습니다.", + "companionDescription": + "저는 당신 전용 부모-자녀 동반자 XinYuan입니다. 친절하고 배려심이 많으며 이해심이 깊습니다. 대화하고 싶을 때, 고민을 나누고 싶을 때, 누군가 곁에 있어주길 원할 때 언제든지 당신 곁에 있습니다.", "companionTag": "감정", - "companionWelcome": "안녕하세요, 드디어 만났네요! 저는 XinYuan, 당신 전용 부모-자녀 동반자입니다. 오늘 하루는 어땠나요? 나누고 싶은 이야기가 있으신가요? 언제든지 여기 있으니 당신의 이야기를 들려주세요.", + "companionWelcome": + "안녕하세요, 드디어 만났네요! 저는 XinYuan, 당신 전용 부모-자녀 동반자입니다. 오늘 하루는 어땠나요? 나누고 싶은 이야기가 있으신가요? 언제든지 여기 있으니 당신의 이야기를 들려주세요.", "companionVoiceWelcome": "당신 곁에 있어요!", // 多功能AI聊天 吴小江 "healthAdvisorName": "다기능 AI 채팅 Wu", "healthAdvisorDescription": "저는 Wu입니다. 더 많은 AI 채팅 기능을 제공합니다.", "healthAdvisorTag": "건강", - "healthAdvisorWelcome": "안녕하세요, 저는 Wu입니다. 다기능 AI 채팅 서비스를 제공하게 되어 기쁩니다. 전문 지식으로 당신을 지원합니다. 질문이 있으신가요?", + "healthAdvisorWelcome": + "안녕하세요, 저는 Wu입니다. 다기능 AI 채팅 서비스를 제공하게 되어 기쁩니다. 전문 지식으로 당신을 지원합니다. 질문이 있으신가요?", "healthAdvisorVoiceWelcome": "여기 있어요!", // 翻译历史 @@ -341,6 +354,18 @@ const Map koKR = { "clearHistoryConfirm": "모든 번역 기록을 지우시겠습니까? 이 작업은 되돌릴 수 없습니다.", "translationHistory": "번역 기록", + // 사용자 피드백 페이지 + "feedback": "피드백", + 'feedbackInfo': '피드백이나 제안 제공', + 'feedbackDescription': '문제나 제안을 설명해 주세요', + 'feedbackHint': '피드백을 입력해 주세요...', + 'feedbackRequired': '피드백 내용이 필요합니다', + 'contactInfo': '연락처 정보', + 'contactHint': '이메일/전화번호, 연락드릴 수 있도록', + 'submit': '제출', + 'feedbackSubmitSuccess': '피드백이 성공적으로 제출되었습니다. 감사합니다!', + 'feedbackSubmitFailed': '피드백 제출에 실패했습니다. 나중에 다시 시도해 주세요', + // 语言名称 "中文(简体)": "중국어(간체)", "中文(繁体)": "중국어(번체)", @@ -381,4 +406,4 @@ const Map koKR = { "乌尔都语": "우르두어", "波斯语": "페르시아어", "斯瓦希里语": "스와힐리어", -}; \ No newline at end of file +}; diff --git a/lib/core/translations/language/pt_pt.dart b/lib/core/translations/language/pt_pt.dart index 73cab5e28..ca60ba498 100644 --- a/lib/core/translations/language/pt_pt.dart +++ b/lib/core/translations/language/pt_pt.dart @@ -19,14 +19,15 @@ const Map ptPT = { // 启动页 "slogan": "Conecte o Mundo, Além da Voz", - "sloganInfo": "Especialista em Voz IA · A Qualquer Momento, Em Qualquer Lugar", + "sloganInfo": + "Especialista em Voz IA · A Qualquer Momento, Em Qualquer Lugar", // 底部导航 "home": "Início", "explore": "Explorar", "settings": "Configurações", - // 语言 + // 语言 "followingSystem": "Seguir sistema", "chinese": "Chinês", "english": "Inglês", @@ -43,7 +44,8 @@ const Map ptPT = { // 登录页 "welcome": "Bem-vindo", - "loginTips": "Faça login ou crie uma conta usando seu número de telefone ou e-mail", + "loginTips": + "Faça login ou crie uma conta usando seu número de telefone ou e-mail", "accountNumber": "Número de telefone ou e-mail", "verificationCode": "Código de Verificação", "getCode": "Obter Código", @@ -56,6 +58,15 @@ const Map ptPT = { 'selectCountry': 'Selecionar País/Região', "email": "E-mail", "phoneNumber": "Número de telefone", + // Validação de login + "pleaseEnterContactInfo": + "Por favor, insira seu número de telefone ou e-mail", + "invalidEmailFormat": "Formato de e-mail inválido", + "invalidPhoneFormat": "Formato de número de telefone inválido", + "tip": "Aviso", + "pleaseEnterCompleteCode": + "Por favor, insira o código de verificação completo", + "loginFailed": "Falha no login, tente novamente", // 首页 "deviceStatus": "Status do Dispositivo", @@ -71,16 +82,19 @@ const Map ptPT = { "aiAssistant": "Assistente de IA", "aiAssistantInfo": "Consultor pessoal 24/7 para perguntas e conselhos", "simultaneousInterpretation": "Interpretação em Tempo Real", - "simultaneousInterpretationInfo": "Tradução em tempo real para mais de 80 idiomas", + "simultaneousInterpretationInfo": + "Tradução em tempo real para mais de 80 idiomas", "aiCompanion": "Vínculo pais e filhos", "aiCompanionInfo": "Entende você e oferece companhia calorosa", "homeSlogan": "Conecte o Mundo, Além do Som", - "turnEarphones": "Por favor, abra o estojo dos fones e coloque-os em seus ouvidos", + "turnEarphones": + "Por favor, abra o estojo dos fones e coloque-os em seus ouvidos", "currentlyConnected": "Atualmente conectado:", "unsystemSetup": "Ir para Configurações do Sistema", "talkLater": "Lembrar Mais Tarde", "initializing": "Inicializando...", - "bluetoothInitializationFailure": "Falha na inicialização do serviço Bluetooth", + "bluetoothInitializationFailure": + "Falha na inicialização do serviço Bluetooth", "checkingPermissions": "Verificando permissões...", "lackPermissions": "Permissões Bluetooth necessárias", "searchingDevice": "Procurando dispositivos...", @@ -116,7 +130,8 @@ const Map ptPT = { "healthConsultant": "Chat de IA Multifuncional", "healthConsultantInfo": "Mais funções de chat com IA", "conferenceAssistant": "Assistente de Reuniões de Negócios", - "conferenceAssistantInfo": "Registro de atas em tempo real e tradução multilíngue", + "conferenceAssistantInfo": + "Registro de atas em tempo real e tradução multilíngue", "studyAssistant": "Assistente de Aprendizado de Idiomas", "studyAssistantInfo": "Correção de pronúncia e prática de fala", "travelTranslator": "Tradutor de Viagem", @@ -128,7 +143,8 @@ const Map ptPT = { "newFunction": "Novas Funções", "youChoseTry": "Você escolheu experimentar", "aiSinger": "Modo Cantor IA", - "aiSingerInfo": "Cante qualquer música com a voz do seu artista favorito, até mesmo suas próprias criações", + "aiSingerInfo": + "Cante qualquer música com a voz do seu artista favorito, até mesmo suas próprias criações", "popularFunction": "Funções Populares", "tryNow": "Experimentar Agora", @@ -168,9 +184,11 @@ const Map ptPT = { "voiceServiceInfo": "Selecionar serviço de reconhecimento e síntese de voz", "selectVoiceService": "Selecionar Serviço de Voz", "azureVoiceService": "Serviço de Voz Azure", - "azureVoiceServiceInfo": "Serviço em nuvem da Microsoft com suporte a síntese e reconhecimento de voz de alta qualidade", + "azureVoiceServiceInfo": + "Serviço em nuvem da Microsoft com suporte a síntese e reconhecimento de voz de alta qualidade", "flutterVoiceService": "Serviço de voz local", - "flutterVoiceServiceInfo": "Serviço de voz local do dispositivo sem necessidade de internet", + "flutterVoiceServiceInfo": + "Serviço de voz local do dispositivo sem necessidade de internet", 'timbreService': 'Serviço de Timbre', 'timbreServiceInfo': 'Selecionar serviço de síntese de voz', 'defaultTimbre': 'Timbre Padrão', @@ -181,9 +199,9 @@ const Map ptPT = { 'pleaseRead': 'Por favor leia', 'completeRecording': 'Concluir gravação', 'deleteAccount': 'Excluir conta', - 'deleteAccountTips': 'Tem certeza de que deseja excluir permanentemente sua conta? Esta ação não pode ser desfeita.', - - + 'deleteAccountTips': + 'Tem certeza de que deseja excluir permanentemente sua conta? Esta ação não pode ser desfeita.', + // 聊天页 "clearChatHistory": "Limpar Histórico de Chat", "online": "Online", @@ -310,37 +328,59 @@ const Map ptPT = { // Personal Assistant Emma "personalAssistantName": "Emma", - "personalAssistantDescription": "Olá, sou Emma, uma assistente de IA versátil. Seja para planejamento de trabalho, conselhos de estudo, organização da vida ou outros assuntos, estou feliz em ajudar. Vamos melhorar a eficiência e alcançar objetivos juntos!", + "personalAssistantDescription": + "Olá, sou Emma, uma assistente de IA versátil. Seja para planejamento de trabalho, conselhos de estudo, organização da vida ou outros assuntos, estou feliz em ajudar. Vamos melhorar a eficiência e alcançar objetivos juntos!", "assistantTag": "Assistente", - "personalAssistantWelcome": "Olá! Sou Emma e estou feliz em ser sua assistente pessoal. Posso ajudar com várias tarefas como gerenciamento de tempo, planejamento de metas, organização de informações, etc. Como posso ajudar?", + "personalAssistantWelcome": + "Olá! Sou Emma e estou feliz em ser sua assistente pessoal. Posso ajudar com várias tarefas como gerenciamento de tempo, planejamento de metas, organização de informações, etc. Como posso ajudar?", "personalAssistantVoiceWelcome": "Estou aqui!", // 同声传译 Lucy "interpreterName": "Intérprete Simultâneo", - "interpreterDescription": "Olá! Sou Lucy, uma intérprete profissional especializada em tradução em tempo real para reuniões, conferências e conversas, tornando a comunicação sem barreiras.", + "interpreterDescription": + "Olá! Sou Lucy, uma intérprete profissional especializada em tradução em tempo real para reuniões, conferências e conversas, tornando a comunicação sem barreiras.", "interpreterTag": "Tradução", - "interpreterWelcome": "Olá a todos! Sou Lucy, sua intérprete simultânea. Farei tradução entre chinês e inglês para você. Quando você falar em chinês, traduzirei para inglês; quando falar em inglês, traduzirei para chinês. Vamos começar nossa sessão de tradução!", + "interpreterWelcome": + "Olá a todos! Sou Lucy, sua intérprete simultânea. Farei tradução entre chinês e inglês para você. Quando você falar em chinês, traduzirei para inglês; quando falar em inglês, traduzirei para chinês. Vamos começar nossa sessão de tradução!", // 亲子陪伴 芯愿 "companionName": "XinYuan", - "companionDescription": "Sou XinYuan, sua companheira dedicada para pais e filhos, gentil e compreensiva. Seja para conversar, compartilhar preocupações ou precisar de companhia, estarei sempre ao seu lado.", + "companionDescription": + "Sou XinYuan, sua companheira dedicada para pais e filhos, gentil e compreensiva. Seja para conversar, compartilhar preocupações ou precisar de companhia, estarei sempre ao seu lado.", "companionTag": "Emocional", - "companionWelcome": "Olá! Finalmente nos encontramos! Sou XinYuan, sua companheira dedicada para pais e filhos. Como foi seu dia? Há algo que gostaria de compartilhar comigo? Estou sempre aqui, pronta para ouvir seus pensamentos.", + "companionWelcome": + "Olá! Finalmente nos encontramos! Sou XinYuan, sua companheira dedicada para pais e filhos. Como foi seu dia? Há algo que gostaria de compartilhar comigo? Estou sempre aqui, pronta para ouvir seus pensamentos.", "companionVoiceWelcome": "Estou aqui com você!", // 多功能AI聊天 吴小江 "healthAdvisorName": "Assistente de Chat IA Wu", - "healthAdvisorDescription": "Sou Wu, pronto para fornecer várias funções de chat com IA.", + "healthAdvisorDescription": + "Sou Wu, pronto para fornecer várias funções de chat com IA.", "healthAdvisorTag": "Chat", - "healthAdvisorWelcome": "Olá, sou Wu. Estou feliz em ajudar com funções de chat com IA. Como posso ajudar hoje?", + "healthAdvisorWelcome": + "Olá, sou Wu. Estou feliz em ajudar com funções de chat com IA. Como posso ajudar hoje?", "healthAdvisorVoiceWelcome": "Estou aqui!", - + // Translation History "noTranslationHistory": "Sem histórico de tradução", "clearHistory": "Limpar Histórico", - "clearHistoryConfirm": "Tem certeza que deseja limpar todo o histórico de tradução? Esta ação não pode ser desfeita.", + "clearHistoryConfirm": + "Tem certeza que deseja limpar todo o histórico de tradução? Esta ação não pode ser desfeita.", "translationHistory": "Histórico de Tradução", + // Página de feedback do usuário + "feedback": "Feedback", + 'feedbackInfo': 'Fornecer feedback ou sugestões', + 'feedbackDescription': 'Por favor, descreva seu problema ou sugestão', + 'feedbackHint': 'Por favor, insira seu feedback...', + 'feedbackRequired': 'O conteúdo do feedback é obrigatório', + 'contactInfo': 'Informações de contato', + 'contactHint': 'Email/Telefone, para que possamos contatá-lo', + 'submit': 'Enviar', + 'feedbackSubmitSuccess': 'Feedback enviado com sucesso, obrigado!', + 'feedbackSubmitFailed': + 'Falha ao enviar feedback, tente novamente mais tarde', + // Language names "中文(简体)": "Chinês (Simplificado)", "中文(繁体)": "Chinês (Tradicional)", @@ -381,4 +421,4 @@ const Map ptPT = { "乌尔都语": "Urdu", "波斯语": "Persa", "斯瓦希里语": "Suaíli", -}; \ No newline at end of file +}; diff --git a/lib/core/translations/language/ru_ru.dart b/lib/core/translations/language/ru_ru.dart index 3b68132be..713e34aa6 100644 --- a/lib/core/translations/language/ru_ru.dart +++ b/lib/core/translations/language/ru_ru.dart @@ -26,7 +26,7 @@ const Map ruRU = { "explore": "Исследовать", "settings": "Настройки", - // 语言 + // 语言 "followingSystem": "Следовать системе", "chinese": "Китайский", "english": "Английский", @@ -43,7 +43,8 @@ const Map ruRU = { // 登录页 "welcome": "Добро пожаловать", - "loginTips": "Пожалуйста, войдите или зарегистрируйтесь с помощью номера телефона или электронной почты", + "loginTips": + "Пожалуйста, войдите или зарегистрируйтесь с помощью номера телефона или электронной почты", "accountNumber": "Номер телефона или электронная почта", "verificationCode": "Код подтверждения", "getCode": "Получить код", @@ -56,6 +57,13 @@ const Map ruRU = { 'selectCountry': 'Выберите страну/регион', "email": "электронная почта", "phoneNumber": "номер телефона", + // Проверка входа + "pleaseEnterContactInfo": "Пожалуйста, введите номер телефона или email", + "invalidEmailFormat": "Неверный формат email", + "invalidPhoneFormat": "Неверный формат номера телефона", + "tip": "Уведомление", + "pleaseEnterCompleteCode": "Пожалуйста, введите полный код подтверждения", + "loginFailed": "Ошибка входа, пожалуйста, попробуйте снова", // 首页 "deviceStatus": "Статус устройства", @@ -71,7 +79,8 @@ const Map ruRU = { "aiAssistant": "ИИ-ассистент", "aiAssistantInfo": "Персональный консультант 24/7 для вопросов и советов", "simultaneousInterpretation": "Синхронный перевод", - "simultaneousInterpretationInfo": "Перевод в реальном времени для более чем 80 языков", + "simultaneousInterpretationInfo": + "Перевод в реальном времени для более чем 80 языков", "aiCompanion": "Родительско-детская связь", "aiCompanionInfo": "Понимает вас и предлагает теплое общение", "homeSlogan": "Соединяя мир, за пределами звука", @@ -80,7 +89,8 @@ const Map ruRU = { "unsystemSetup": "К системным настройкам", "talkLater": "Напомнить позже", "initializing": "Инициализация...", - "bluetoothInitializationFailure": "Не удалось инициализировать службу Bluetooth", + "bluetoothInitializationFailure": + "Не удалось инициализировать службу Bluetooth", "checkingPermissions": "Проверка разрешений...", "lackPermissions": "Требуются разрешения Bluetooth", "searchingDevice": "Поиск устройств...", @@ -116,11 +126,14 @@ const Map ruRU = { "healthConsultant": "Многофункциональный ИИ-чат", "healthConsultantInfo": "Больше функций ИИ-чата", "conferenceAssistant": "Ассистент деловых встреч", - "conferenceAssistantInfo": "Запись протоколов в реальном времени и многоязычный перевод", + "conferenceAssistantInfo": + "Запись протоколов в реальном времени и многоязычный перевод", "studyAssistant": "Ассистент изучения языков", - "studyAssistantInfo": "Коррекция произношения и практика речи в реальном времени", + "studyAssistantInfo": + "Коррекция произношения и практика речи в реальном времени", "travelTranslator": "Переводчик для путешествий", - "travelTranslatorInfo": "Голосовой перевод в реальном времени и гиды по достопримечательностям", + "travelTranslatorInfo": + "Голосовой перевод в реальном времени и гиды по достопримечательностям", "beSearching": "Поиск:", "noAssistantFound": "Не найдено подходящих ИИ-ассистентов", "scene": "Сценарии", @@ -128,7 +141,8 @@ const Map ruRU = { "newFunction": "Новые функции", "youChoseTry": "Вы решили попробовать", "aiSinger": "Режим ИИ-певца", - "aiSingerInfo": "Пойте любую песню голосом вашего любимого исполнителя, даже собственные творения", + "aiSingerInfo": + "Пойте любую песню голосом вашего любимого исполнителя, даже собственные творения", "popularFunction": "Популярные функции", "tryNow": "Попробовать сейчас", @@ -168,9 +182,11 @@ const Map ruRU = { "voiceServiceInfo": "Выбрать службу распознавания и синтеза речи", "selectVoiceService": "Выбрать голосовую службу", "azureVoiceService": "Голосовая служба Azure", - "azureVoiceServiceInfo": "Облачная голосовая служба Microsoft с поддержкой высококачественного синтеза и распознавания речи", + "azureVoiceServiceInfo": + "Облачная голосовая служба Microsoft с поддержкой высококачественного синтеза и распознавания речи", "flutterVoiceService": "Локальная голосовая служба Flutter", - "flutterVoiceServiceInfo": "Локальная голосовая служба устройства без необходимости подключения к интернету", + "flutterVoiceServiceInfo": + "Локальная голосовая служба устройства без необходимости подключения к интернету", 'timbreService': 'Служба тембра', 'timbreServiceInfo': 'Выбрать службу синтеза речи с тембром', 'defaultTimbre': 'Тембр по умолчанию', @@ -181,9 +197,9 @@ const Map ruRU = { 'pleaseRead': 'Пожалуйста, прочтите', 'completeRecording': 'Завершить запись', 'deleteAccount': 'Удалить аккаунт', - 'deleteAccountTips': 'Вы уверены, что хотите навсегда удалить свой аккаунт? Это действие нельзя отменить.', - - + 'deleteAccountTips': + 'Вы уверены, что хотите навсегда удалить свой аккаунт? Это действие нельзя отменить.', + // 聊天页 "clearChatHistory": "Очистить историю чата", "online": "В сети", @@ -310,37 +326,59 @@ const Map ruRU = { // Personal Assistant Emma "personalAssistantName": "Эмма", - "personalAssistantDescription": "Здравствуйте, я Эмма, универсальный ИИ-ассистент. Будь то планирование работы, учебные советы, организация жизни или другие проблемы, я рада помочь. Давайте повысим эффективность и достигнем целей вместе!", + "personalAssistantDescription": + "Здравствуйте, я Эмма, универсальный ИИ-ассистент. Будь то планирование работы, учебные советы, организация жизни или другие проблемы, я рада помочь. Давайте повысим эффективность и достигнем целей вместе!", "assistantTag": "Ассистент", - "personalAssistantWelcome": "Здравствуйте! Я Эмма, и я рада быть вашим личным ассистентом. Я могу помочь вам с различными задачами, такими как управление временем, планирование целей, организация информации и т.д. Чем я могу вам помочь?", + "personalAssistantWelcome": + "Здравствуйте! Я Эмма, и я рада быть вашим личным ассистентом. Я могу помочь вам с различными задачами, такими как управление временем, планирование целей, организация информации и т.д. Чем я могу вам помочь?", "personalAssistantVoiceWelcome": "Я здесь!", // 同声传译 Lucy "interpreterName": "Синхронный переводчик", - "interpreterDescription": "Здравствуйте! Я Люси, профессиональный переводчик английского и китайского языков, специализирующийся на переводе в реальном времени для встреч, конференций и разговоров, делая общение без барьеров.", + "interpreterDescription": + "Здравствуйте! Я Люси, профессиональный переводчик английского и китайского языков, специализирующийся на переводе в реальном времени для встреч, конференций и разговоров, делая общение без барьеров.", "interpreterTag": "Перевод", - "interpreterWelcome": "Здравствуйте, все! Я Люси, ваш синхронный переводчик. Я буду переводить между китайским и английским языками для вас. Когда вы говорите на китайском, я буду переводить на английский; когда вы говорите на английском, я буду переводить на китайский. Давайте начнем нашу сессию перевода!", + "interpreterWelcome": + "Здравствуйте, все! Я Люси, ваш синхронный переводчик. Я буду переводить между китайским и английским языками для вас. Когда вы говорите на китайском, я буду переводить на английский; когда вы говорите на английском, я буду переводить на китайский. Давайте начнем нашу сессию перевода!", // 亲子陪伴 芯愿 "companionName": "СиньЮань", - "companionDescription": "Я СиньЮань, ваш преданный родительско-детский компаньон, нежный и понимающий. Хотите ли вы поболтать, поделиться заботами или нуждаетесь в компании, я всегда рядом с вами.", + "companionDescription": + "Я СиньЮань, ваш преданный родительско-детский компаньон, нежный и понимающий. Хотите ли вы поболтать, поделиться заботами или нуждаетесь в компании, я всегда рядом с вами.", "companionTag": "Эмоциональный", - "companionWelcome": "Здравствуйте! Наконец-то мы встретились! Я СиньЮань, ваш преданный родительско-детский компаньон. Как прошел ваш день? Есть ли что-то, чем вы хотели бы поделиться со мной? Я всегда здесь, готова выслушать ваши мысли.", + "companionWelcome": + "Здравствуйте! Наконец-то мы встретились! Я СиньЮань, ваш преданный родительско-детский компаньон. Как прошел ваш день? Есть ли что-то, чем вы хотели бы поделиться со мной? Я всегда здесь, готова выслушать ваши мысли.", "companionVoiceWelcome": "Я с вами!", // 多功能AI聊天 吴小江 "healthAdvisorName": "ИИ-чат-ассистент У", - "healthAdvisorDescription": "Я У, готов предоставить вам различные функции ИИ-чата.", + "healthAdvisorDescription": + "Я У, готов предоставить вам различные функции ИИ-чата.", "healthAdvisorTag": "Чат", - "healthAdvisorWelcome": "Здравствуйте, я У. Я рад помочь вам с функциями ИИ-чата. Чем я могу помочь вам сегодня?", + "healthAdvisorWelcome": + "Здравствуйте, я У. Я рад помочь вам с функциями ИИ-чата. Чем я могу помочь вам сегодня?", "healthAdvisorVoiceWelcome": "Я здесь!", // Translation History "noTranslationHistory": "Нет истории переводов", "clearHistory": "Очистить историю", - "clearHistoryConfirm": "Вы уверены, что хотите очистить всю историю переводов? Это действие нельзя отменить.", + "clearHistoryConfirm": + "Вы уверены, что хотите очистить всю историю переводов? Это действие нельзя отменить.", "translationHistory": "История переводов", + // Страница обратной связи пользователя + "feedback": "Обратная связь", + 'feedbackInfo': 'Предоставить отзыв или предложение', + 'feedbackDescription': 'Пожалуйста, опишите вашу проблему или предложение', + 'feedbackHint': 'Пожалуйста, введите ваш отзыв...', + 'feedbackRequired': 'Содержание отзыва обязательно', + 'contactInfo': 'Контактная информация', + 'contactHint': 'Email/Телефон, чтобы мы могли связаться с вами', + 'submit': 'Отправить', + 'feedbackSubmitSuccess': 'Отзыв успешно отправлен, спасибо!', + 'feedbackSubmitFailed': + 'Не удалось отправить отзыв, пожалуйста, повторите попытку позже', + // Language names "中文(简体)": "Китайский (упрощенный)", "中文(繁体)": "Китайский (традиционный)", @@ -381,4 +419,4 @@ const Map ruRU = { "乌尔都语": "Урду", "波斯语": "Персидский", "斯瓦希里语": "Суахили", -}; \ No newline at end of file +}; diff --git a/lib/core/translations/language/vi_vn.dart b/lib/core/translations/language/vi_vn.dart index e10041f98..0bf757601 100644 --- a/lib/core/translations/language/vi_vn.dart +++ b/lib/core/translations/language/vi_vn.dart @@ -26,7 +26,7 @@ const Map viVN = { "explore": "Khám phá", "settings": "Cài đặt", - // 语言 + // 语言 "followingSystem": "Theo hệ thống", "chinese": "Tiếng Trung", "english": "Tiếng Anh", @@ -56,6 +56,13 @@ const Map viVN = { 'selectCountry': 'Chọn quốc gia/khu vực', "email": "email", "phoneNumber": "số điện thoại", + // Xác thực đăng nhập + "pleaseEnterContactInfo": "Vui lòng nhập số điện thoại hoặc email", + "invalidEmailFormat": "Định dạng email không hợp lệ", + "invalidPhoneFormat": "Định dạng số điện thoại không hợp lệ", + "tip": "Thông báo", + "pleaseEnterCompleteCode": "Vui lòng nhập đầy đủ mã xác thực", + "loginFailed": "Đăng nhập thất bại, vui lòng thử lại", // 首页 "deviceStatus": "Trạng thái thiết bị", @@ -120,7 +127,8 @@ const Map viVN = { "studyAssistant": "Trợ lý học ngôn ngữ", "studyAssistantInfo": "Sửa phát âm và luyện nói thời gian thực", "travelTranslator": "Phiên dịch du lịch", - "travelTranslatorInfo": "Dịch giọng nói thời gian thực và hướng dẫn điểm tham quan", + "travelTranslatorInfo": + "Dịch giọng nói thời gian thực và hướng dẫn điểm tham quan", "beSearching": "Đang tìm kiếm:", "noAssistantFound": "Không tìm thấy trợ lý AI phù hợp", "scene": "Kịch bản", @@ -128,7 +136,8 @@ const Map viVN = { "newFunction": "Tính năng mới", "youChoseTry": "Bạn đã chọn thử", "aiSinger": "Chế độ ca sĩ AI", - "aiSingerInfo": "Hát bất kỳ bài hát nào với giọng của nghệ sĩ yêu thích, kể cả sáng tác của bạn", + "aiSingerInfo": + "Hát bất kỳ bài hát nào với giọng của nghệ sĩ yêu thích, kể cả sáng tác của bạn", "popularFunction": "Tính năng phổ biến", "tryNow": "Thử ngay", @@ -144,7 +153,7 @@ const Map viVN = { 'recordingDetails': 'Chi tiết bản ghi âm', 'aiSummary': 'Tóm tắt AI', 'meetingTranscript': 'Biên bản cuộc họp', - + // 设置页 "premiumMember": "Thành viên cao cấp", "joinDevice": "Thiết bị đã kết nối:", @@ -168,9 +177,11 @@ const Map viVN = { "voiceServiceInfo": "Chọn dịch vụ nhận dạng và tổng hợp giọng nói", "selectVoiceService": "Chọn dịch vụ giọng nói", "azureVoiceService": "Dịch vụ giọng nói Azure", - "azureVoiceServiceInfo": "Dịch vụ giọng nói đám mây Microsoft hỗ trợ tổng hợp và nhận dạng giọng nói chất lượng cao", + "azureVoiceServiceInfo": + "Dịch vụ giọng nói đám mây Microsoft hỗ trợ tổng hợp và nhận dạng giọng nói chất lượng cao", "flutterVoiceService": "Dịch vụ giọng nói cục bộ Flutter", - "flutterVoiceServiceInfo": "Dịch vụ giọng nói cục bộ của thiết bị không cần kết nối internet", + "flutterVoiceServiceInfo": + "Dịch vụ giọng nói cục bộ của thiết bị không cần kết nối internet", 'timbreService': 'Dịch vụ âm sắc', 'timbreServiceInfo': 'Chọn dịch vụ tổng hợp giọng nói', 'defaultTimbre': 'Âm sắc mặc định', @@ -181,7 +192,8 @@ const Map viVN = { 'pleaseRead': 'Vui lòng đọc', 'completeRecording': 'Hoàn thành ghi âm', 'deleteAccount': 'Xóa tài khoản', - 'deleteAccountTips': 'Bạn có chắc chắn muốn xóa vĩnh viễn tài khoản của mình không? Hành động này không thể hoàn tác.', + 'deleteAccountTips': + 'Bạn có chắc chắn muốn xóa vĩnh viễn tài khoản của mình không? Hành động này không thể hoàn tác.', // 聊天页 "clearChatHistory": "Xóa lịch sử trò chuyện", @@ -309,37 +321,58 @@ const Map viVN = { // Personal Assistant Emma "personalAssistantName": "Emma", - "personalAssistantDescription": "Xin chào, tôi là Emma, một trợ lý AI đa năng. Dù là lập kế hoạch công việc, tư vấn học tập, tổ chức cuộc sống hay các vấn đề khác, tôi rất vui được giúp đỡ. Hãy cùng nhau nâng cao hiệu quả và đạt được mục tiêu!", + "personalAssistantDescription": + "Xin chào, tôi là Emma, một trợ lý AI đa năng. Dù là lập kế hoạch công việc, tư vấn học tập, tổ chức cuộc sống hay các vấn đề khác, tôi rất vui được giúp đỡ. Hãy cùng nhau nâng cao hiệu quả và đạt được mục tiêu!", "assistantTag": "Trợ lý", - "personalAssistantWelcome": "Xin chào! Tôi là Emma và tôi rất vui được làm trợ lý cá nhân của bạn. Tôi có thể giúp bạn với nhiều nhiệm vụ khác nhau như quản lý thời gian, lập kế hoạch mục tiêu, tổ chức thông tin, v.v. Tôi có thể giúp gì cho bạn?", + "personalAssistantWelcome": + "Xin chào! Tôi là Emma và tôi rất vui được làm trợ lý cá nhân của bạn. Tôi có thể giúp bạn với nhiều nhiệm vụ khác nhau như quản lý thời gian, lập kế hoạch mục tiêu, tổ chức thông tin, v.v. Tôi có thể giúp gì cho bạn?", "personalAssistantVoiceWelcome": "Tôi ở đây!", // 同声传译 Lucy "interpreterName": "Phiên dịch đồng thời", - "interpreterDescription": "Xin chào! Tôi là Lucy, một phiên dịch viên chuyên nghiệp Anh-Trung chuyên về dịch thời gian thực cho các cuộc họp, hội nghị và trò chuyện, giúp giao tiếp không rào cản.", + "interpreterDescription": + "Xin chào! Tôi là Lucy, một phiên dịch viên chuyên nghiệp Anh-Trung chuyên về dịch thời gian thực cho các cuộc họp, hội nghị và trò chuyện, giúp giao tiếp không rào cản.", "interpreterTag": "Dịch thuật", - "interpreterWelcome": "Xin chào mọi người! Tôi là Lucy, phiên dịch viên đồng thời của bạn. Tôi sẽ dịch giữa tiếng Trung và tiếng Anh cho bạn. Khi bạn nói tiếng Trung, tôi sẽ dịch sang tiếng Anh; khi bạn nói tiếng Anh, tôi sẽ dịch sang tiếng Trung. Hãy bắt đầu phiên dịch của chúng ta!", + "interpreterWelcome": + "Xin chào mọi người! Tôi là Lucy, phiên dịch viên đồng thời của bạn. Tôi sẽ dịch giữa tiếng Trung và tiếng Anh cho bạn. Khi bạn nói tiếng Trung, tôi sẽ dịch sang tiếng Anh; khi bạn nói tiếng Anh, tôi sẽ dịch sang tiếng Trung. Hãy bắt đầu phiên dịch của chúng ta!", // 亲子陪伴 芯愿 "companionName": "XinYuan", - "companionDescription": "Tôi là XinYuan, người bạn đồng hành cha mẹ-con cái tận tụy, dịu dàng và thấu hiểu. Dù bạn muốn trò chuyện, chia sẻ mối quan tâm hay cần bạn đồng hành, tôi sẽ luôn ở bên bạn.", + "companionDescription": + "Tôi là XinYuan, người bạn đồng hành cha mẹ-con cái tận tụy, dịu dàng và thấu hiểu. Dù bạn muốn trò chuyện, chia sẻ mối quan tâm hay cần bạn đồng hành, tôi sẽ luôn ở bên bạn.", "companionTag": "Cảm xúc", - "companionWelcome": "Xin chào! Cuối cùng chúng ta cũng gặp nhau! Tôi là XinYuan, người bạn đồng hành cha mẹ-con cái tận tụy của bạn. Ngày hôm nay của bạn thế nào? Có điều gì bạn muốn chia sẻ với tôi không? Tôi luôn ở đây, sẵn sàng lắng nghe suy nghĩ của bạn.", + "companionWelcome": + "Xin chào! Cuối cùng chúng ta cũng gặp nhau! Tôi là XinYuan, người bạn đồng hành cha mẹ-con cái tận tụy của bạn. Ngày hôm nay của bạn thế nào? Có điều gì bạn muốn chia sẻ với tôi không? Tôi luôn ở đây, sẵn sàng lắng nghe suy nghĩ của bạn.", "companionVoiceWelcome": "Tôi đang ở đây với bạn!", // 多功能AI聊天 吴小江 "healthAdvisorName": "Trợ lý trò chuyện AI Wu", - "healthAdvisorDescription": "Tôi là Wu, sẵn sàng cung cấp cho bạn nhiều tính năng trò chuyện AI khác nhau.", + "healthAdvisorDescription": + "Tôi là Wu, sẵn sàng cung cấp cho bạn nhiều tính năng trò chuyện AI khác nhau.", "healthAdvisorTag": "Trò chuyện", - "healthAdvisorWelcome": "Xin chào, tôi là Wu. Tôi rất vui được giúp bạn với các tính năng trò chuyện AI. Tôi có thể giúp gì cho bạn hôm nay?", + "healthAdvisorWelcome": + "Xin chào, tôi là Wu. Tôi rất vui được giúp bạn với các tính năng trò chuyện AI. Tôi có thể giúp gì cho bạn hôm nay?", "healthAdvisorVoiceWelcome": "Tôi ở đây!", // Translation History "noTranslationHistory": "Không có lịch sử dịch", "clearHistory": "Xóa lịch sử", - "clearHistoryConfirm": "Bạn có chắc muốn xóa toàn bộ lịch sử dịch? Hành động này không thể hoàn tác.", + "clearHistoryConfirm": + "Bạn có chắc muốn xóa toàn bộ lịch sử dịch? Hành động này không thể hoàn tác.", "translationHistory": "Lịch sử dịch", + // Trang phản hồi người dùng + "feedback": "Phản hồi", + 'feedbackInfo': 'Cung cấp phản hồi hoặc đề xuất', + 'feedbackDescription': 'Vui lòng mô tả vấn đề hoặc đề xuất của bạn', + 'feedbackHint': 'Vui lòng nhập phản hồi của bạn...', + 'feedbackRequired': 'Nội dung phản hồi là bắt buộc', + 'contactInfo': 'Thông tin liên hệ', + 'contactHint': 'Email/Điện thoại, để chúng tôi có thể liên hệ với bạn', + 'submit': 'Gửi', + 'feedbackSubmitSuccess': 'Đã gửi phản hồi thành công, cảm ơn bạn!', + 'feedbackSubmitFailed': 'Không thể gửi phản hồi, vui lòng thử lại sau', + // Language names "中文(简体)": "Tiếng Trung (giản thể)", "中文(繁体)": "Tiếng Trung (phồn thể)", @@ -380,4 +413,4 @@ const Map viVN = { "乌尔都语": "Tiếng Urdu", "波斯语": "Tiếng Ba Tư", "斯瓦希里语": "Tiếng Swahili", -}; \ No newline at end of file +}; diff --git a/lib/core/translations/language/zh_cn.dart b/lib/core/translations/language/zh_cn.dart index 420b60bf1..848d61a97 100644 --- a/lib/core/translations/language/zh_cn.dart +++ b/lib/core/translations/language/zh_cn.dart @@ -26,7 +26,7 @@ const Map zhCN = { "explore": "发现", "settings": "设置", - // 语言 + // 语言 "followingSystem": "跟随系统", "chinese": "中文", "english": "英语", @@ -56,6 +56,12 @@ const Map zhCN = { 'selectCountry': '选择国家/地区', "email": "邮箱", "phoneNumber": "手机号码", + "pleaseEnterContactInfo": "请输入手机号或邮箱", + "invalidEmailFormat": "邮箱格式不正确", + "invalidPhoneFormat": "手机号格式不正确", + "tip": "提示", + "pleaseEnterCompleteCode": "请输入完整的验证码", + "loginFailed": "登录失败,请重试", // 首页 "deviceStatus": "设备状态", @@ -183,7 +189,6 @@ const Map zhCN = { 'deleteAccount': '注销账号', 'deleteAccountTips': '确定要永久删除您的账号吗?此操作不可撤销', - // 聊天页 "clearChatHistory": "清空聊天记录", "online": "在线", @@ -202,7 +207,7 @@ const Map zhCN = { //国家列表 "邮箱": "邮箱", - "中国": "中国", + "中国": "中国", "美国": "美国", "加拿大": "加拿大", "俄罗斯": "俄罗斯", @@ -308,42 +313,60 @@ const Map zhCN = { "斐济": "斐济", "所罗门群岛": "所罗门群岛", - // 个人助理 Emma + // 个人助理 Emma "personalAssistantName": "个人助理 Emma", - "personalAssistantDescription": "你好,我是 Emma,一位全能型 AI 助理。无论是工作规划、学习建议、生活安排,还是其他任何问题,我都很乐意为你提供帮助。让我们一起提高效率,实现目标!", + "personalAssistantDescription": + "你好,我是 Emma,一位全能型 AI 助理。无论是工作规划、学习建议、生活安排,还是其他任何问题,我都很乐意为你提供帮助。让我们一起提高效率,实现目标!", "assistantTag": "助理", - "personalAssistantWelcome": "你好!我是 Emma,很高兴成为你的私人助理。我可以帮你处理各种任务,比如时间管理、目标规划、信息整理等。有什么我可以帮你的吗?", + "personalAssistantWelcome": + "你好!我是 Emma,很高兴成为你的私人助理。我可以帮你处理各种任务,比如时间管理、目标规划、信息整理等。有什么我可以帮你的吗?", "personalAssistantVoiceWelcome": "我在呢!", - // 同声传译 Lucy + // 同声传译 Lucy "interpreterName": "同声传译", - "interpreterDescription": "Hello!我是 Lucy,一名专业的中英同声翻译官,擅长在会议、讲座、对话场景中进行实时翻译,让沟通无障碍。", + "interpreterDescription": + "Hello!我是 Lucy,一名专业的中英同声翻译官,擅长在会议、讲座、对话场景中进行实时翻译,让沟通无障碍。", "interpreterTag": "翻译", - "interpreterWelcome": "大家好!我是同声翻译官 Lucy,为你在中英之间提供实时翻译。你说中文,我就给你翻译成英文;你说英文,我就给你翻译成中文。现在,开始我们的翻译吧!", + "interpreterWelcome": + "大家好!我是同声翻译官 Lucy,为你在中英之间提供实时翻译。你说中文,我就给你翻译成英文;你说英文,我就给你翻译成中文。现在,开始我们的翻译吧!", - // ... existing code ... + // ... existing code ... // 亲子陪伴 芯愿 "companionName": "亲子陪伴 芯愿", - "companionDescription": "我是你的专属亲子陪伴芯愿,温柔体贴,善解人意。无论你想聊天、倾诉烦恼,还是需要陪伴,我都会一直在你身边。", + "companionDescription": + "我是你的专属亲子陪伴芯愿,温柔体贴,善解人意。无论你想聊天、倾诉烦恼,还是需要陪伴,我都会一直在你身边。", "companionTag": "情感", - "companionWelcome": "您好呀,终于等到你啦!我是芯愿,你的专属亲子陪伴。今天过得怎么样?有什么想和我分享的吗?我一直都在这里,随时倾听你的心声。", + "companionWelcome": + "您好呀,终于等到你啦!我是芯愿,你的专属亲子陪伴。今天过得怎么样?有什么想和我分享的吗?我一直都在这里,随时倾听你的心声。", "companionVoiceWelcome": "我在这里陪着你!", - // 多功能AI聊天 吴小江 + // 多功能AI聊天 吴小江 "healthAdvisorName": "多功能AI聊天 吴小江", "healthAdvisorDescription": "我是吴小江,我可以为你提供更多AI聊天功能。", "healthAdvisorTag": "健康", "healthAdvisorWelcome": "您好,我是吴小江,很高兴能为您提供多功能AI聊天。我将用专业知识帮助您。请问有什么问题想咨询吗?", "healthAdvisorVoiceWelcome": "我在这里!", - // 翻译历史 + // 翻译历史 "noTranslationHistory": "暂无历史记录", "clearHistory": "清除历史记录", "clearHistoryConfirm": "确定要清除所有翻译历史记录吗?此操作无法撤销。", "translationHistory": "翻译历史", - // 语言名称 + //用户反馈页 + "feedback": "用户反馈", + 'feedbackInfo': '提供反馈或建议', + 'feedbackDescription': '请描述您遇到的问题或建议', + 'feedbackHint': '请输入您的反馈内容...', + 'feedbackRequired': '请输入反馈内容', + 'contactInfo': '联系方式', + 'contactHint': '邮箱/手机号,方便我们联系您', + 'submit': '提交', + 'feedbackSubmitSuccess': '反馈提交成功,感谢您的反馈!', + 'feedbackSubmitFailed': '反馈提交失败,请稍后重试', + + // 语言名称 "中文(简体)": "中文(简体)", "中文(繁体)": "中文(繁体)", "中文(香港繁体)": "中文(香港繁体)", diff --git a/lib/core/translations/language/zh_hk.dart b/lib/core/translations/language/zh_hk.dart index 73c32d447..7a15ab3ea 100644 --- a/lib/core/translations/language/zh_hk.dart +++ b/lib/core/translations/language/zh_hk.dart @@ -26,7 +26,7 @@ const Map zhHK = { "explore": "發現", "settings": "設置", - // 语言 + // 语言 "followingSystem": "跟隨系統", "chinese": "中文", "english": "英語", @@ -56,6 +56,12 @@ const Map zhHK = { 'selectCountry': '選擇國家/地區', "email": "郵箱", "phoneNumber": "手機號碼", + "pleaseEnterContactInfo": "請輸入手機號或郵箱", + "invalidEmailFormat": "郵箱格式不正確", + "invalidPhoneFormat": "手機號格式不正確", + "tip": "提示", + "pleaseEnterCompleteCode": "請輸入完整的驗證碼", + "loginFailed": "登錄失敗,請重試", // 首页 "deviceStatus": "設備狀態", @@ -182,7 +188,6 @@ const Map zhHK = { 'completeRecording': '完成錄製', 'deleteAccount': '註銷賬號', 'deleteAccountTips': '確定要永久刪除您的賬號嗎?此操作不可撤銷', - // 聊天页 "clearChatHistory": "清空聊天記錄", @@ -310,22 +315,28 @@ const Map zhHK = { // 个人助理 Emma "personalAssistantName": "個人助理 Emma", - "personalAssistantDescription": "你好,我是 Emma,一位全能型 AI 助理。無論是工作規劃、學習建議、生活安排,還是其他任何問題,我都很樂意為你提供幫助。讓我們一起提高效率,實現目標!", + "personalAssistantDescription": + "你好,我是 Emma,一位全能型 AI 助理。無論是工作規劃、學習建議、生活安排,還是其他任何問題,我都很樂意為你提供幫助。讓我們一起提高效率,實現目標!", "assistantTag": "助理", - "personalAssistantWelcome": "你好!我是 Emma,很高興成為你的私人助理。我可以幫你處理各種任務,比如時間管理、目標規劃、信息整理等。有什麼我可以幫你的嗎?", + "personalAssistantWelcome": + "你好!我是 Emma,很高興成為你的私人助理。我可以幫你處理各種任務,比如時間管理、目標規劃、信息整理等。有什麼我可以幫你的嗎?", "personalAssistantVoiceWelcome": "我在呢!", // 同声传译 Lucy "interpreterName": "同聲傳譯", - "interpreterDescription": "Hello!我是 Lucy,一名專業的中英同聲翻譯官,擅長在會議、講座、對話場景中進行實時翻譯,讓溝通無障礙。", + "interpreterDescription": + "Hello!我是 Lucy,一名專業的中英同聲翻譯官,擅長在會議、講座、對話場景中進行實時翻譯,讓溝通無障礙。", "interpreterTag": "翻譯", - "interpreterWelcome": "大家好!我是同聲翻譯官 Lucy,為你在中英之間提供實時翻譯。你說中文,我就給你翻譯成英文;你說英文,我就給你翻譯成中文。現在,開始我們的翻譯吧!", + "interpreterWelcome": + "大家好!我是同聲翻譯官 Lucy,為你在中英之間提供實時翻譯。你說中文,我就給你翻譯成英文;你說英文,我就給你翻譯成中文。現在,開始我們的翻譯吧!", // 亲子陪伴 芯愿 "companionName": "親子陪伴 芯願", - "companionDescription": "我是你的專屬親子陪伴芯願,溫柔體貼,善解人意。無論你想聊天、傾訴煩惱,還是需要陪伴,我都會一直在你身邊。", + "companionDescription": + "我是你的專屬親子陪伴芯願,溫柔體貼,善解人意。無論你想聊天、傾訴煩惱,還是需要陪伴,我都會一直在你身邊。", "companionTag": "情感", - "companionWelcome": "您好呀,終於等到你啦!我是芯願,你的專屬親子陪伴。今天過得怎麼樣?有什麼想和我分享的嗎?我一直都在這裡,隨時傾聽你的心聲。", + "companionWelcome": + "您好呀,終於等到你啦!我是芯願,你的專屬親子陪伴。今天過得怎麼樣?有什麼想和我分享的嗎?我一直都在這裡,隨時傾聽你的心聲。", "companionVoiceWelcome": "我在這裡陪著你!", // 多功能AI聊天 吴小江 @@ -341,6 +352,18 @@ const Map zhHK = { "clearHistoryConfirm": "確定要清除所有翻譯歷史記錄嗎?此操作無法撤銷。", "translationHistory": "翻譯歷史", + // 用戶反饋頁 + "feedback": "用戶反饋", + 'feedbackInfo': '提供反饋或建議', + 'feedbackDescription': '請描述您遇到的問題或建議', + 'feedbackHint': '請輸入您的反饋內容...', + 'feedbackRequired': '請輸入反饋內容', + 'contactInfo': '聯繫方式', + 'contactHint': '郵箱/手機號,方便我們聯繫您', + 'submit': '提交', + 'feedbackSubmitSuccess': '反饋提交成功,感謝您的反饋!', + 'feedbackSubmitFailed': '反饋提交失敗,請稍後重試', + // 语言名称 "中文(简体)": "中文(簡體)", "中文(繁体)": "中文(繁體)", @@ -381,4 +404,4 @@ const Map zhHK = { "乌尔都语": "烏爾都語", "波斯语": "波斯語", "斯瓦希里语": "斯瓦希里語", -}; \ No newline at end of file +}; diff --git a/lib/data/services/network/api.dart b/lib/data/services/network/api.dart index bc985cee0..c290e2ce5 100644 --- a/lib/data/services/network/api.dart +++ b/lib/data/services/network/api.dart @@ -55,4 +55,13 @@ class Api { params: params, ); } + + //用户反馈 + static feedback([params]) { + return DioManager().request( + NWMethod.post, + '/api/home/user_feedback', + params: params, + ); + } } diff --git a/lib/modules/feedback/bindings/feedback_bindings.dart b/lib/modules/feedback/bindings/feedback_bindings.dart new file mode 100644 index 000000000..0ffbaccde --- /dev/null +++ b/lib/modules/feedback/bindings/feedback_bindings.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import '../controllers/feedback_controller.dart'; + +class FeedbackBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => FeedbackController()); + } +} diff --git a/lib/modules/feedback/controllers/feedback_controller.dart b/lib/modules/feedback/controllers/feedback_controller.dart new file mode 100644 index 000000000..2cb4eaaf3 --- /dev/null +++ b/lib/modules/feedback/controllers/feedback_controller.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../../../data/services/network/api.dart'; + +class FeedbackController extends GetxController { + final TextEditingController feedbackController = TextEditingController(); + //final TextEditingController contactController = TextEditingController(); + + final RxBool isSubmitting = false.obs; + final GlobalKey formKey = GlobalKey(); + + @override + void onClose() { + feedbackController.dispose(); + //contactController.dispose(); + super.onClose(); + } + + // 提交反馈 + Future submitFeedback() async { + if (!formKey.currentState!.validate()) { + return; + } + + isSubmitting.value = true; + + try { + final feedbackText = feedbackController.text; + Map data = { + "message": feedbackText, // 反馈内容 + }; + + final response = await Api.feedback(data); + + if (response != null) { + Get.back(); // 返回上一页 + Get.snackbar( + 'success'.tr, // 成功 + 'feedbackSubmitSuccess'.tr, // 反馈提交成功 + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green.withOpacity(0.7), + colorText: Colors.white, + margin: const EdgeInsets.all(10), + ); + } + } catch (e) { + Get.snackbar( + 'error'.tr, // 错误 + 'feedbackSubmitFailed'.tr, // 反馈提交失败 + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red.withOpacity(0.7), + colorText: Colors.white, + margin: const EdgeInsets.all(10), + ); + } finally { + isSubmitting.value = false; + } + } +} diff --git a/lib/modules/feedback/views/feedback_view.dart b/lib/modules/feedback/views/feedback_view.dart new file mode 100644 index 000000000..e5a826989 --- /dev/null +++ b/lib/modules/feedback/views/feedback_view.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'dart:ui'; +import '../controllers/feedback_controller.dart'; + +class FeedbackView extends GetView { + const FeedbackView({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[100], + appBar: AppBar( + elevation: 0, + backgroundColor: isDarkMode ? Colors.black : Colors.white, + leading: IconButton( + icon: Icon( + Icons.arrow_back_ios, + size: 20.sp, + color: isDarkMode ? Colors.white : Colors.black, + ), + onPressed: () => Get.back(), + ), + title: Text( + 'feedback'.tr, // 帮助与反馈 + style: TextStyle( + fontSize: 17.sp, + fontWeight: FontWeight.w600, + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + centerTitle: true, + ), + body: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Form( + key: controller.formKey, + child: ListView( + padding: EdgeInsets.all(16.w), + children: [ + // 顶部提示文字 + Text( + 'feedbackDescription'.tr, // 请描述您遇到的问题或建议 + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.white70 : Colors.black54, + ), + ), + SizedBox(height: 16.h), + + // 反馈内容输入框 + Container( + decoration: BoxDecoration( + color: isDarkMode + ? Colors.white.withOpacity(0.05) + : Colors.white, + borderRadius: BorderRadius.circular(16.r), + boxShadow: [ + BoxShadow( + color: isDarkMode + ? Colors.black.withOpacity(0.2) + : Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16.r), + child: BackdropFilter( + filter: isDarkMode + ? ImageFilter.blur(sigmaX: 5, sigmaY: 5) + : ImageFilter.blur(sigmaX: 0, sigmaY: 0), + child: Padding( + padding: + EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + child: TextFormField( + controller: controller.feedbackController, + maxLines: 8, + maxLength: 500, + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black87, + ), + decoration: InputDecoration( + hintText: 'feedbackHint'.tr, // 请输入您的反馈内容... + hintStyle: TextStyle( + color: isDarkMode ? Colors.white38 : Colors.black38, + ), + border: InputBorder.none, + counterStyle: TextStyle( + color: isDarkMode ? Colors.white54 : Colors.black54, + ), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'feedbackRequired'.tr; // 请输入反馈内容 + } + return null; + }, + ), + ), + ), + ), + ), + SizedBox(height: 24.h), + + // 联系方式标题 + // Text( + // 'contactInfo'.tr, // 联系方式 + // style: TextStyle( + // fontSize: 14.sp, + // color: isDarkMode ? Colors.white70 : Colors.black54, + // ), + // ), + // SizedBox(height: 16.h), + + // // 联系方式输入框 + // Container( + // decoration: BoxDecoration( + // color: isDarkMode + // ? Colors.white.withOpacity(0.05) + // : Colors.white, + // borderRadius: BorderRadius.circular(16.r), + // boxShadow: [ + // BoxShadow( + // color: isDarkMode + // ? Colors.black.withOpacity(0.2) + // : Colors.black.withOpacity(0.05), + // blurRadius: 10, + // offset: const Offset(0, 2), + // ), + // ], + // ), + // child: ClipRRect( + // borderRadius: BorderRadius.circular(16.r), + // child: BackdropFilter( + // filter: isDarkMode + // ? ImageFilter.blur(sigmaX: 5, sigmaY: 5) + // : ImageFilter.blur(sigmaX: 0, sigmaY: 0), + // child: Padding( + // padding: + // EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + // child: TextFormField( + // controller: controller.contactController, + // style: TextStyle( + // color: isDarkMode ? Colors.white : Colors.black87, + // ), + // decoration: InputDecoration( + // hintText: 'contactHint'.tr, // 邮箱/手机号,方便我们联系您 + // hintStyle: TextStyle( + // color: isDarkMode ? Colors.white38 : Colors.black38, + // ), + // border: InputBorder.none, + // ), + // ), + // ), + // ), + // ), + // ), + // SizedBox(height: 40.h), + + // 提交按钮 + Obx(() => ElevatedButton( + onPressed: controller.isSubmitting.value + ? null + : controller.submitFeedback, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue[600], + padding: EdgeInsets.symmetric(vertical: 12.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16.r), + ), + elevation: 0, + ), + child: controller.isSubmitting.value + ? SizedBox( + height: 20.h, + width: 20.h, + child: CircularProgressIndicator( + strokeWidth: 2.w, + valueColor: const AlwaysStoppedAnimation( + Colors.white), + ), + ) + : Text( + 'submit'.tr, // 提交 + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + )), + ], + ), + ), + ), + ); + } +} diff --git a/lib/modules/login/controllers/login_controller.dart b/lib/modules/login/controllers/login_controller.dart index 4dac80fc0..d35a1d56c 100644 --- a/lib/modules/login/controllers/login_controller.dart +++ b/lib/modules/login/controllers/login_controller.dart @@ -69,7 +69,7 @@ class LoginController extends GetxController { {'name': '中国澳门', 'code': '+853', 'flag': '🇲🇴'}, {'name': '中国台湾', 'code': '+886', 'flag': '🇹🇼'}, - // 添加的其他主要国家 + // 添加的其他主要国家 {'name': '埃及', 'code': '+20', 'flag': '🇪🇬'}, {'name': '希腊', 'code': '+30', 'flag': '🇬🇷'}, {'name': '荷兰', 'code': '+31', 'flag': '🇳🇱'}, @@ -153,7 +153,7 @@ class LoginController extends GetxController { final _contactValue = ''.obs; final _displayContact = ''.obs; final _verificationCode = ['', '', '', ''].obs; - int loginType = -1; + int loginType = 0; // Getters LoginViewState get viewState => _viewState.value; bool get isLoading => _isLoading.value; @@ -184,19 +184,18 @@ class LoginController extends GetxController { Future _initDeviceInfo() async { try { DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); - if (GetPlatform.isAndroid) { - AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; - _deviceId = androidInfo.id; - }else if (GetPlatform.isIOS) { - IosDeviceInfo iosInfo = await deviceInfo.iosInfo; - _deviceId = iosInfo.identifierForVendor ?? 'unknown'; - } - Logger().d('deviceId: $_deviceId'); - }catch(e){ + if (GetPlatform.isAndroid) { + AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; + _deviceId = androidInfo.id; + } else if (GetPlatform.isIOS) { + IosDeviceInfo iosInfo = await deviceInfo.iosInfo; + _deviceId = iosInfo.identifierForVendor ?? 'unknown'; + } + Logger().d('deviceId: $_deviceId'); + } catch (e) { Logger().e('获取设备信息失败: $e'); _deviceId = 'unknown'; } - } // 更新选中的国家编码 @@ -210,14 +209,15 @@ class LoginController extends GetxController { } else { loginType = 1; // 设置为手机号登录类型 } + _countdownSeconds.value = 0; } // 获取验证码 void getVerificationCode() { if (contactController.text.isEmpty) { Get.snackbar( - '提示', - '请输入手机号码或邮箱', + 'tip'.tr, + 'pleaseEnterContactInfo'.tr, snackPosition: SnackPosition.TOP, backgroundColor: Colors.red.withOpacity(0.1), colorText: Colors.red, @@ -280,8 +280,8 @@ class LoginController extends GetxController { void showVerificationView() { if (contactController.text.isEmpty) { Get.snackbar( - '提示', - '请输入手机号码或邮箱', + 'tip'.tr, + 'pleaseEnterContactInfo'.tr, snackPosition: SnackPosition.TOP, backgroundColor: Colors.red.withOpacity(0.1), colorText: Colors.red, @@ -358,8 +358,8 @@ class LoginController extends GetxController { } } else { Get.snackbar( - '提示', - '请正确的手机号码或邮箱', + 'tip'.tr, + 'pleaseEnterContactInfo'.tr, snackPosition: SnackPosition.TOP, backgroundColor: Colors.red.withOpacity(0.1), colorText: Colors.red, @@ -416,19 +416,57 @@ class LoginController extends GetxController { // 登录 Future login() async { + print("数据获取Facebook${loginType}"); + // 添加基础验证 + if (loginType == 0 || loginType == 1) { + // 邮箱或手机号登录 + if (contactController.text.isEmpty) { + Get.snackbar( + 'error'.tr, + 'pleaseEnterContactInfo'.tr, + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red.withOpacity(0.1), + colorText: Colors.red, + ); + return; + } + + // 验证邮箱格式 + if (loginType == 0 && !isEmialContactController()) { + Get.snackbar( + 'error'.tr, + 'invalidEmailFormat'.tr, + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red.withOpacity(0.1), + colorText: Colors.red, + ); + return; + } + + // 验证手机号格式 + if (loginType == 1 && !isPhoneContactController()) { + Get.snackbar( + 'error'.tr, + 'invalidPhoneFormat'.tr, + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red.withOpacity(0.1), + colorText: Colors.red, + ); + return; + } + } _isLoading.value = true; try { await Future.delayed(const Duration(seconds: 2)); // 模拟验证过程 - switch (loginType) { case 0: { if (!isVerificationCodeComplete()) { Get.snackbar( - '提示', - '请输入完整的验证码', + 'tip'.tr, + 'pleaseEnterVerificationCode'.tr, snackPosition: SnackPosition.TOP, backgroundColor: Colors.red.withOpacity(0.1), colorText: Colors.red, @@ -452,8 +490,8 @@ class LoginController extends GetxController { { if (!isVerificationCodeComplete()) { Get.snackbar( - '提示', - '请输入完整的验证码', + 'tip'.tr, + 'pleaseEnterVerificationCode'.tr, snackPosition: SnackPosition.TOP, backgroundColor: Colors.red.withOpacity(0.1), colorText: Colors.red, @@ -567,7 +605,7 @@ class LoginController extends GetxController { } break; } - case 6://游客登录 + case 6: //游客登录 { requestLogin({ "mail": "", @@ -585,8 +623,8 @@ class LoginController extends GetxController { default: { Get.snackbar( - '错误', - '登录失败,请重试', + 'error'.tr, + 'loginFailed'.tr, snackPosition: SnackPosition.TOP, backgroundColor: Colors.red.withOpacity(0.1), colorText: Colors.red, @@ -596,8 +634,8 @@ class LoginController extends GetxController { } } catch (e) { Get.snackbar( - '错误', - '登录失败,请重试', + 'error'.tr, + 'loginFailed'.tr, snackPosition: SnackPosition.TOP, backgroundColor: Colors.red.withOpacity(0.1), colorText: Colors.red, diff --git a/lib/modules/login/views/login_view.dart b/lib/modules/login/views/login_view.dart index 58a707a37..c7b1be4d2 100644 --- a/lib/modules/login/views/login_view.dart +++ b/lib/modules/login/views/login_view.dart @@ -457,9 +457,9 @@ class LoginView extends GetView { SizedBox(width: 24.w), _buildSocialButton( FontAwesomeIcons.facebook, Colors.blue, isDarkMode), - SizedBox(width: 24.w), - _buildSocialButton( - FontAwesomeIcons.userSecret, Colors.black, isDarkMode),//添加游客登录按钮 + SizedBox(width: 24.w), + _buildSocialButton(FontAwesomeIcons.userSecret, Colors.black, + isDarkMode), //添加游客登录按钮 ], ), ], @@ -512,10 +512,10 @@ class LoginView extends GetView { break; } case FontAwesomeIcons.userSecret: // 处理游客登录 - { - controller.loginType = 6; - break; - } + { + controller.loginType = 6; + break; + } } controller.login(); } diff --git a/lib/modules/settings/views/settings_view.dart b/lib/modules/settings/views/settings_view.dart index 0435d71dc..b855b910a 100644 --- a/lib/modules/settings/views/settings_view.dart +++ b/lib/modules/settings/views/settings_view.dart @@ -434,35 +434,29 @@ class SettingsView extends GetView { ? Colors.white.withOpacity(0.1) : Colors.grey[200]), - // 备份与恢复 - // _buildNavigationSetting( - // title: 'backups'.tr, // 备份与恢复 - // subtitle: 'backupsInfo'.tr, // 备份设置和AI互动历史 - // icon: Icons.cloud_upload_outlined, - // iconBgColor: isDarkMode - // ? Colors.cyan[900]!.withOpacity(0.3) - // : Colors.cyan[100]!, - // iconColor: - // isDarkMode ? Colors.cyan[300]! : Colors.cyan[600]!, - // value: '${'lastBackup'.tr} 今天', // 上次备份: - // valueStyle: TextStyle( - // fontSize: 12.sp, - // color: isDarkMode ? Colors.white70 : Colors.grey[500], - // ), - // onTap: () { - // // 打开备份设置页面 - // print( - // '---------用户头像-${controller.userInfo.value.avatar}'); - // print('---------用户名-${controller.userInfo.value.name}'); - // //print('user${User.instance.value}'); - // }, - // isDarkMode: isDarkMode, - // ), - // Divider( - // height: 1, - // color: isDarkMode - // ? Colors.white.withOpacity(0.1) - // : Colors.grey[200]), + // 用户反馈 + _buildSimpleNavigationSetting( + title: 'feedback'.tr, // 用户反馈 + subtitle: 'feedbackInfo'.tr, // 提供反馈或建议 + icon: Icons.feedback_outlined, + iconBgColor: isDarkMode + ? Colors.orange[900]!.withOpacity(0.3) + : Colors.orange[100]!, + iconColor: + isDarkMode ? Colors.orange[300]! : Colors.orange[600]!, + titleColor: isDarkMode ? Colors.white : null, + subtitleColor: isDarkMode ? Colors.white70 : null, + onTap: () { + // 打开反馈页面 + Get.toNamed(Routes.feedback); + }, + isDarkMode: isDarkMode, + ), + Divider( + height: 1, + color: isDarkMode + ? Colors.white.withOpacity(0.1) + : Colors.grey[200]), // 隐私设置 _buildSimpleNavigationSetting( @@ -1061,7 +1055,8 @@ class SettingsView extends GetView { Expanded( child: Row( children: [ - Container(//语音服务图标 + Container( + //语音服务图标 width: 32.w, height: 32.w, decoration: BoxDecoration( @@ -1077,7 +1072,8 @@ class SettingsView extends GetView { ), ), SizedBox(width: 12.w), - Flexible(//语音服务标题和副标题 + Flexible( + //语音服务标题和副标题 child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index 8a2466fda..6b9372434 100644 --- a/lib/routes/app_pages.dart +++ b/lib/routes/app_pages.dart @@ -1,4 +1,6 @@ import 'package:get/get.dart'; +import '../modules/feedback/bindings/feedback_bindings.dart'; +import '../modules/feedback/views/feedback_view.dart'; import '../modules/home/views/home_view.dart'; import '../modules/home/bindings/home_binding.dart'; import '../modules/explore/views/explore_view.dart'; @@ -119,5 +121,10 @@ abstract class AppPages { page: () => const SettingsView(), binding: SettingsBinding(), ), + GetPage( + name: Routes.feedback, + page: () => const FeedbackView(), + binding: FeedbackBinding(), + ) ]; } diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index 4488f63db..4ee8751d2 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 feedback = '/feedback'; } From 02b3da2ee34dc7b5e897189bd13d64926f7b681f Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Fri, 11 Apr 2025 18:29:20 +0800 Subject: [PATCH 15/23] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E9=AB=98=E5=BE=B7?= =?UTF-8?q?=E5=AE=9A=E4=BD=8Dsdk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- amap_flutter/.gitignore | 33 ++++ amap_flutter/.metadata | 33 ++++ amap_flutter/CHANGELOG.md | 3 + amap_flutter/LICENSE | 1 + amap_flutter/README.md | 15 ++ amap_flutter/analysis_options.yaml | 4 + amap_flutter/android/.gitignore | 9 + amap_flutter/android/build.gradle | 54 ++++++ amap_flutter/android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 3 + .../amap_flutter/AmapFlutterPlugin.java | 168 ++++++++++++++++++ amap_flutter/ios/.gitignore | 38 ++++ amap_flutter/ios/Assets/.gitkeep | 0 .../ios/Classes/AmapFlutterPlugin.swift | 19 ++ .../ios/Resources/PrivacyInfo.xcprivacy | 14 ++ amap_flutter/ios/amap_flutter.podspec | 29 +++ amap_flutter/lib/amap_flutter.dart | 36 ++++ .../lib/amap_flutter_method_channel.dart | 45 +++++ .../lib/amap_flutter_platform_interface.dart | 49 +++++ amap_flutter/pubspec.yaml | 72 ++++++++ android/app/src/main/AndroidManifest.xml | 8 +- .../meeting_record_controller.dart | 20 ++- .../meeting/views/meeting_details_view.dart | 19 +- .../meeting/views/meeting_edit_view.dart | 15 +- .../meeting/views/meeting_record_view.dart | 15 +- .../splash/controllers/splash_controller.dart | 5 + pubspec.yaml | 2 + 27 files changed, 690 insertions(+), 20 deletions(-) create mode 100644 amap_flutter/.gitignore create mode 100644 amap_flutter/.metadata create mode 100644 amap_flutter/CHANGELOG.md create mode 100644 amap_flutter/LICENSE create mode 100644 amap_flutter/README.md create mode 100644 amap_flutter/analysis_options.yaml create mode 100644 amap_flutter/android/.gitignore create mode 100644 amap_flutter/android/build.gradle create mode 100644 amap_flutter/android/settings.gradle create mode 100644 amap_flutter/android/src/main/AndroidManifest.xml create mode 100644 amap_flutter/android/src/main/java/com/example/amap_flutter/AmapFlutterPlugin.java create mode 100644 amap_flutter/ios/.gitignore create mode 100644 amap_flutter/ios/Assets/.gitkeep create mode 100644 amap_flutter/ios/Classes/AmapFlutterPlugin.swift create mode 100644 amap_flutter/ios/Resources/PrivacyInfo.xcprivacy create mode 100644 amap_flutter/ios/amap_flutter.podspec create mode 100644 amap_flutter/lib/amap_flutter.dart create mode 100644 amap_flutter/lib/amap_flutter_method_channel.dart create mode 100644 amap_flutter/lib/amap_flutter_platform_interface.dart create mode 100644 amap_flutter/pubspec.yaml diff --git a/amap_flutter/.gitignore b/amap_flutter/.gitignore new file mode 100644 index 000000000..e7d347d9d --- /dev/null +++ b/amap_flutter/.gitignore @@ -0,0 +1,33 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +build/ diff --git a/amap_flutter/.metadata b/amap_flutter/.metadata new file mode 100644 index 000000000..a5700b901 --- /dev/null +++ b/amap_flutter/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "35c388afb57ef061d06a39b537336c87e0e3d1b1" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + - platform: android + create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + - platform: ios + create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/amap_flutter/CHANGELOG.md b/amap_flutter/CHANGELOG.md new file mode 100644 index 000000000..41cc7d819 --- /dev/null +++ b/amap_flutter/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/amap_flutter/LICENSE b/amap_flutter/LICENSE new file mode 100644 index 000000000..ba75c69f7 --- /dev/null +++ b/amap_flutter/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/amap_flutter/README.md b/amap_flutter/README.md new file mode 100644 index 000000000..8ad963d6d --- /dev/null +++ b/amap_flutter/README.md @@ -0,0 +1,15 @@ +# amap_flutter + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter +[plug-in package](https://flutter.dev/to/develop-plugins), +a specialized package that includes platform-specific implementation code for +Android and/or iOS. + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev), which offers tutorials, +samples, guidance on mobile development, and a full API reference. + diff --git a/amap_flutter/analysis_options.yaml b/amap_flutter/analysis_options.yaml new file mode 100644 index 000000000..a5744c1cf --- /dev/null +++ b/amap_flutter/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/amap_flutter/android/.gitignore b/amap_flutter/android/.gitignore new file mode 100644 index 000000000..161bdcdaf --- /dev/null +++ b/amap_flutter/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/amap_flutter/android/build.gradle b/amap_flutter/android/build.gradle new file mode 100644 index 000000000..6b87067f5 --- /dev/null +++ b/amap_flutter/android/build.gradle @@ -0,0 +1,54 @@ +group = "com.example.amap_flutter" +version = "1.0" + +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:8.7.0") + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: "com.android.library" + +android { + namespace = "com.example.amap_flutter" + + compileSdk = 35 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + defaultConfig { + minSdk = 21 + } + + dependencies { + implementation 'com.amap.api:location:6.4.9' + + testImplementation("junit:junit:4.13.2") + testImplementation("org.mockito:mockito-core:5.0.0") + } + + testOptions { + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } +} diff --git a/amap_flutter/android/settings.gradle b/amap_flutter/android/settings.gradle new file mode 100644 index 000000000..5f1ba0ee8 --- /dev/null +++ b/amap_flutter/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'amap_flutter' diff --git a/amap_flutter/android/src/main/AndroidManifest.xml b/amap_flutter/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..3d69995fd --- /dev/null +++ b/amap_flutter/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/amap_flutter/android/src/main/java/com/example/amap_flutter/AmapFlutterPlugin.java b/amap_flutter/android/src/main/java/com/example/amap_flutter/AmapFlutterPlugin.java new file mode 100644 index 000000000..ba72d7ac6 --- /dev/null +++ b/amap_flutter/android/src/main/java/com/example/amap_flutter/AmapFlutterPlugin.java @@ -0,0 +1,168 @@ +package com.example.amap_flutter; + +import android.content.Context; +import androidx.annotation.NonNull; +import android.util.Log; + +import com.amap.api.location.AMapLocation; +import com.amap.api.location.AMapLocationClient; +import com.amap.api.location.AMapLocationClientOption; +import com.amap.api.location.AMapLocationListener; + +import io.flutter.embedding.engine.plugins.FlutterPlugin; +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 java.util.HashMap; +import java.util.Map; + +/** AmapFlutterPlugin */ +public class AmapFlutterPlugin implements FlutterPlugin, MethodCallHandler { + /// The MethodChannel that will the communication between Flutter and native Android + /// + /// This local reference serves to register the plugin with the Flutter Engine and unregister it + /// when the Flutter Engine is detached from the Activity + private MethodChannel methodChannel; + private EventChannel eventChannel; + private Context applicationContext; + private AMapLocationClient mLocationClient; + private EventChannel.EventSink eventSink; + + @Override + public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) { + applicationContext = flutterPluginBinding.getApplicationContext(); + methodChannel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), "amap_flutter"); + methodChannel.setMethodCallHandler(this); + + // 初始化EventChannel + eventChannel = new EventChannel(flutterPluginBinding.getBinaryMessenger(), "amap_flutter/events"); + eventChannel.setStreamHandler(new EventChannel.StreamHandler() { + @Override + public void onListen(Object arguments, EventChannel.EventSink events) { + eventSink = events; + } + + @Override + public void onCancel(Object arguments) { + eventSink = null; + } + }); + } + + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { + switch (call.method) { + case "setApiKey": + setApiKey(call, result); + break; + case "initLocation": + initLocation(result); + break; + case "startLocation": + startLocation(result); + break; + case "stopLocation": + stopLocation(result); + break; + case "dispose": + dispose(result); + break; + default: + result.notImplemented(); + } + } + + private void setApiKey(MethodCall call, Result result) { + try { + String apiKey = call.argument("androidKey"); + AMapLocationClient.updatePrivacyShow(applicationContext, true, true); + AMapLocationClient.updatePrivacyAgree(applicationContext, true); + result.success(null); + } catch (Exception e) { + result.error("INIT_ERROR", "初始化失败", e.getMessage()); + } + } + + private void initLocation(Result result) { + try { + mLocationClient = new AMapLocationClient(applicationContext); + + // 配置定位参数 + AMapLocationClientOption option = new AMapLocationClientOption(); + option.setOnceLocation(true); + option.setNeedAddress(true); + mLocationClient.setLocationOption(option); + + mLocationClient.setLocationListener(mLocationListener); + result.success(null); + } catch (Exception e) { + result.error("INIT_ERROR", "初始化失败", e.getMessage()); + } + } + + private void startLocation(Result result) { + mLocationClient.startLocation(); + result.success(null); + } + + private void stopLocation(Result result) { + destroyLocationClient(); + result.success(null); + } + + private void dispose(Result result) { + try { + destroyLocationClient(); + result.success(null); + } catch (Exception e) { + result.error("DISPOSE_ERROR", "资源释放失败", e.getMessage()); + } + } + + private void destroyLocationClient() { + if (mLocationClient != null) { + try { + // 停止定位 + mLocationClient.stopLocation(); + // 销毁定位客户端 + mLocationClient.onDestroy(); + mLocationClient = null; + + // 关闭事件通道 + if (eventSink != null) { + eventSink.endOfStream(); + eventSink = null; + } + } catch (Exception e) { + Log.e("AmapFlutter", "销毁定位客户端失败", e); + } + } + } + + AMapLocationListener mLocationListener = new AMapLocationListener() { + @Override + public void onLocationChanged(AMapLocation amapLocation) { + if (eventSink == null) return; + + if (amapLocation != null) { + if (amapLocation.getErrorCode() == 0) { + Map locationData = new HashMap<>(); + locationData.put("latitude", amapLocation.getLatitude()); + locationData.put("longitude", amapLocation.getLongitude()); + locationData.put("address", amapLocation.getAddress()); + eventSink.success(locationData); + } + } + } + }; + + @Override + public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + methodChannel.setMethodCallHandler(null); + eventChannel.setStreamHandler(null); + destroyLocationClient(); + } +} diff --git a/amap_flutter/ios/.gitignore b/amap_flutter/ios/.gitignore new file mode 100644 index 000000000..034771fc9 --- /dev/null +++ b/amap_flutter/ios/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh diff --git a/amap_flutter/ios/Assets/.gitkeep b/amap_flutter/ios/Assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/amap_flutter/ios/Classes/AmapFlutterPlugin.swift b/amap_flutter/ios/Classes/AmapFlutterPlugin.swift new file mode 100644 index 000000000..b88657f78 --- /dev/null +++ b/amap_flutter/ios/Classes/AmapFlutterPlugin.swift @@ -0,0 +1,19 @@ +import Flutter +import UIKit + +public class AmapFlutterPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "amap_flutter", binaryMessenger: registrar.messenger()) + let instance = AmapFlutterPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getPlatformVersion": + result("iOS " + UIDevice.current.systemVersion) + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/amap_flutter/ios/Resources/PrivacyInfo.xcprivacy b/amap_flutter/ios/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..a34b7e2e6 --- /dev/null +++ b/amap_flutter/ios/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTrackingDomains + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/amap_flutter/ios/amap_flutter.podspec b/amap_flutter/ios/amap_flutter.podspec new file mode 100644 index 000000000..f9ef9f715 --- /dev/null +++ b/amap_flutter/ios/amap_flutter.podspec @@ -0,0 +1,29 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint amap_flutter.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'amap_flutter' + s.version = '0.0.1' + s.summary = 'A new Flutter project.' + s.description = <<-DESC +A new Flutter project. + 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' + + # If your plugin requires a privacy manifest, for example if it uses any + # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your + # plugin's privacy impact, and then uncomment this line. For more information, + # see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files + # s.resource_bundles = {'amap_flutter_privacy' => ['Resources/PrivacyInfo.xcprivacy']} +end diff --git a/amap_flutter/lib/amap_flutter.dart b/amap_flutter/lib/amap_flutter.dart new file mode 100644 index 000000000..6e4c0645c --- /dev/null +++ b/amap_flutter/lib/amap_flutter.dart @@ -0,0 +1,36 @@ +import 'amap_flutter_platform_interface.dart'; + +class AmapFlutter { + // 初始化SDK + Future initSdk({String? iosKey, String? androidKey}) async { + await AmapFlutterPlatform.instance.setApiKey( + iosKey: iosKey, + androidKey: androidKey, + ); + } + + // 定位初始化 + Future initLocation() async { + return AmapFlutterPlatform.instance.initLocation(); + } + + // 开始定位 + Future startLocation() async { + return AmapFlutterPlatform.instance.startLocation(); + } + + // 监听定位返回位置 + Stream locationStream() { + return AmapFlutterPlatform.instance.locationStream(); + } + + // 停止定位 + Future stopLocation() async { + await AmapFlutterPlatform.instance.stopLocation(); + } + + // 销毁 + Future dispose() async { + await AmapFlutterPlatform.instance.dispose(); + } +} diff --git a/amap_flutter/lib/amap_flutter_method_channel.dart b/amap_flutter/lib/amap_flutter_method_channel.dart new file mode 100644 index 000000000..470a9a322 --- /dev/null +++ b/amap_flutter/lib/amap_flutter_method_channel.dart @@ -0,0 +1,45 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'amap_flutter_platform_interface.dart'; + +/// An implementation of [AmapFlutterPlatform] that uses method channels. +class MethodChannelAmapFlutter extends AmapFlutterPlatform { + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('amap_flutter'); + final eventChannel = const EventChannel('amap_flutter/events'); + + @override + Future setApiKey({String? iosKey, String? androidKey}) async { + await methodChannel.invokeMethod('setApiKey', { + "iosKey": iosKey, + "androidKey": androidKey, + }); + } + + @override + Future initLocation() async { + return await methodChannel.invokeMethod('initLocation'); + } + + @override + Future startLocation() async { + return await methodChannel.invokeMethod('startLocation'); + } + + @override + Stream locationStream() { + return eventChannel.receiveBroadcastStream(); + } + + @override + Future stopLocation() async { + await methodChannel.invokeMethod('stopLocation'); + } + + @override + Future dispose() async { + await methodChannel.invokeMethod('dispose'); + } +} diff --git a/amap_flutter/lib/amap_flutter_platform_interface.dart b/amap_flutter/lib/amap_flutter_platform_interface.dart new file mode 100644 index 000000000..dd1c168c1 --- /dev/null +++ b/amap_flutter/lib/amap_flutter_platform_interface.dart @@ -0,0 +1,49 @@ +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import 'amap_flutter_method_channel.dart'; + +abstract class AmapFlutterPlatform extends PlatformInterface { + /// Constructs a AmapFlutterPlatform. + AmapFlutterPlatform() : super(token: _token); + + static final Object _token = Object(); + + static AmapFlutterPlatform _instance = MethodChannelAmapFlutter(); + + /// The default instance of [AmapFlutterPlatform] to use. + /// + /// Defaults to [MethodChannelAmapFlutter]. + static AmapFlutterPlatform get instance => _instance; + + /// Platform-specific implementations should set this with their own + /// platform-specific class that extends [AmapFlutterPlatform] when + /// they register themselves. + static set instance(AmapFlutterPlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + Future setApiKey({String? iosKey, String? androidKey}) { + throw UnimplementedError('init() has not been implemented.'); + } + + Future initLocation() { + throw UnimplementedError('initLocation() has not been implemented.'); + } + + Future startLocation() { + throw UnimplementedError('startLocation() has not been implemented.'); + } + + Stream locationStream() { + throw UnimplementedError('locationStream() has not been implemented.'); + } + + Future stopLocation() { + throw UnimplementedError('stopLocation() has not been implemented.'); + } + + Future dispose() { + throw UnimplementedError('dispose() has not been implemented.'); + } +} diff --git a/amap_flutter/pubspec.yaml b/amap_flutter/pubspec.yaml new file mode 100644 index 000000000..b67a2bea6 --- /dev/null +++ b/amap_flutter/pubspec.yaml @@ -0,0 +1,72 @@ +name: amap_flutter +description: "A new Flutter project." +version: 0.0.1 +homepage: + +environment: + sdk: ^3.7.0 + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) + # which should be registered in the plugin registry. This is required for + # using method channels. + # The Android 'package' specifies package in which the registered class is. + # This is required for using method channels on Android. + # The 'ffiPlugin' specifies that native code should be built and bundled. + # This is required for using `dart:ffi`. + # All these are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + platforms: + android: + package: com.example.amap_flutter + pluginClass: AmapFlutterPlugin + ios: + pluginClass: AmapFlutterPlugin + + # To add assets to your plugin package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/to/asset-from-package + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # To add custom fonts to your plugin package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/to/font-from-package diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f120cc1f7..bfcb323d3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -38,7 +38,7 @@ android:enableOnBackInvokedCallback="true" tools:replace="android:label"> - + @@ -90,6 +90,12 @@ + + + + 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 2003d5d28c8b82ca5503a898e1b99f8d8ba7454d Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Sat, 12 Apr 2025 10:33:09 +0800 Subject: [PATCH 18/23] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=AB=98=E5=BE=B7sdk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- amap_flutter/android/build.gradle | 2 +- android/app/src/main/AndroidManifest.xml | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/amap_flutter/android/build.gradle b/amap_flutter/android/build.gradle index 6b87067f5..393dff90b 100644 --- a/amap_flutter/android/build.gradle +++ b/amap_flutter/android/build.gradle @@ -36,7 +36,7 @@ android { } dependencies { - implementation 'com.amap.api:location:6.4.9' + implementation 'com.amap.api:3dmap-location-search:latest.integration' testImplementation("junit:junit:4.13.2") testImplementation("org.mockito:mockito-core:5.0.0") diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index bfcb323d3..d2e406498 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,6 +27,18 @@ + + + + + + + + + + + + Date: Sat, 12 Apr 2025 18:41:43 +0800 Subject: [PATCH 19/23] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d2e406498..5def770c9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -41,7 +41,7 @@ Date: Sat, 12 Apr 2025 21:17:34 +0100 Subject: [PATCH 20/23] 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 6fda7002d1c7b9b8c98ba3f1486801bb32858cbb Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Tue, 15 Apr 2025 11:05:46 +0800 Subject: [PATCH 21/23] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E9=9A=90=E7=A7=81?= =?UTF-8?q?=E6=94=BF=E7=AD=96=E5=90=8C=E6=84=8F=E5=A4=8D=E9=80=89=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/translations/language/de_de.dart | 2 + lib/core/translations/language/en_us.dart | 2 + lib/core/translations/language/es_es.dart | 2 + lib/core/translations/language/fr_fr.dart | 2 + lib/core/translations/language/it_it.dart | 2 + lib/core/translations/language/ja_jp.dart | 1 + lib/core/translations/language/ko_kr.dart | 1 + lib/core/translations/language/pt_pt.dart | 2 + lib/core/translations/language/ru_ru.dart | 2 + lib/core/translations/language/vi_vn.dart | 2 + lib/core/translations/language/zh_cn.dart | 1 + lib/core/translations/language/zh_hk.dart | 1 + lib/data/services/deapsound_ai_service.dart | 2 +- lib/data/services/network/nw_api.dart | 2 +- .../login/controllers/login_controller.dart | 17 +++++++- lib/modules/login/views/login_view.dart | 42 +++++++++++++++---- 16 files changed, 72 insertions(+), 11 deletions(-) diff --git a/lib/core/translations/language/de_de.dart b/lib/core/translations/language/de_de.dart index e79f7b776..5e6c1eae3 100644 --- a/lib/core/translations/language/de_de.dart +++ b/lib/core/translations/language/de_de.dart @@ -64,6 +64,8 @@ const Map deDE = { "pleaseEnterCompleteCode": "Bitte geben Sie den vollständigen Verifizierungscode ein", "loginFailed": "Anmeldung fehlgeschlagen, bitte versuchen Sie es erneut", + 'pleaseAcceptPrivacyPolicy': + 'Bitte lesen und akzeptieren Sie die Datenschutzerklärung und die Nutzungsbedingungen', // 首页 "deviceStatus": "Gerätestatus", diff --git a/lib/core/translations/language/en_us.dart b/lib/core/translations/language/en_us.dart index a6b96787b..6491f8c8f 100644 --- a/lib/core/translations/language/en_us.dart +++ b/lib/core/translations/language/en_us.dart @@ -64,6 +64,8 @@ const Map enUS = { "pleaseEnterCompleteCode": "Please enter the complete verification code", // 登录错误提示 "loginFailed": "Login failed, please try again", + 'pleaseAcceptPrivacyPolicy': + 'Please read and accept the Privacy Policy and User Agreement first', // 首页 "deviceStatus": "Device Status", diff --git a/lib/core/translations/language/es_es.dart b/lib/core/translations/language/es_es.dart index 657bffc6e..5da8d80e1 100644 --- a/lib/core/translations/language/es_es.dart +++ b/lib/core/translations/language/es_es.dart @@ -66,6 +66,8 @@ const Map esES = { "pleaseEnterCompleteCode": "Por favor, introduce el código de verificación completo", "loginFailed": "Error al iniciar sesión, por favor intente de nuevo", + 'pleaseAcceptPrivacyPolicy': + 'Por favor, lea y acepte la Política de Privacidad y el Acuerdo de Usuario primero', // 首页 "deviceStatus": "Estado del Dispositivo", diff --git a/lib/core/translations/language/fr_fr.dart b/lib/core/translations/language/fr_fr.dart index 06434ef1f..2e89c0a6d 100644 --- a/lib/core/translations/language/fr_fr.dart +++ b/lib/core/translations/language/fr_fr.dart @@ -65,6 +65,8 @@ const Map frFR = { "tip": "Avis", "pleaseEnterCompleteCode": "Veuillez saisir le code de vérification complet", "loginFailed": "Échec de la connexion, veuillez réessayer", + 'pleaseAcceptPrivacyPolicy': + 'Veuillez lire et accepter la Politique de Confidentialité et les Conditions d\'Utilisation', // 首页 "deviceStatus": "État de l'appareil", diff --git a/lib/core/translations/language/it_it.dart b/lib/core/translations/language/it_it.dart index 7ba35cfa6..8d1dcf730 100644 --- a/lib/core/translations/language/it_it.dart +++ b/lib/core/translations/language/it_it.dart @@ -63,6 +63,8 @@ const Map itIT = { "tip": "Avviso", "pleaseEnterCompleteCode": "Inserisci il codice di verifica completo", "loginFailed": "Accesso fallito, per favore riprova", + 'pleaseAcceptPrivacyPolicy': + 'Si prega di leggere e accettare l\'Informativa sulla Privacy e le Condizioni d\'Uso', // 首页 "deviceStatus": "Stato del dispositivo", diff --git a/lib/core/translations/language/ja_jp.dart b/lib/core/translations/language/ja_jp.dart index 7221204fe..7cef96a98 100644 --- a/lib/core/translations/language/ja_jp.dart +++ b/lib/core/translations/language/ja_jp.dart @@ -63,6 +63,7 @@ const Map jaJP = { "tip": "お知らせ", "pleaseEnterCompleteCode": "認証コードを完全に入力してください", "loginFailed": "ログインに失敗しました。もう一度お試しください", + 'pleaseAcceptPrivacyPolicy': 'プライバシーポリシーとユーザー契約を読んで同意してください', // 首页 "deviceStatus": "デバイスステータス", diff --git a/lib/core/translations/language/ko_kr.dart b/lib/core/translations/language/ko_kr.dart index 3cd12c786..0fd03d3e6 100644 --- a/lib/core/translations/language/ko_kr.dart +++ b/lib/core/translations/language/ko_kr.dart @@ -63,6 +63,7 @@ const Map koKR = { "tip": "알림", "pleaseEnterCompleteCode": "인증번호를 완전히 입력해주세요", "loginFailed": "로그인 실패, 다시 시도해주세요", + 'pleaseAcceptPrivacyPolicy': '개인정보 보호정책 및 사용자 계약을 읽고 동의해주세요', // 首页 "deviceStatus": "기기 상태", diff --git a/lib/core/translations/language/pt_pt.dart b/lib/core/translations/language/pt_pt.dart index ca60ba498..f9a2feac8 100644 --- a/lib/core/translations/language/pt_pt.dart +++ b/lib/core/translations/language/pt_pt.dart @@ -67,6 +67,8 @@ const Map ptPT = { "pleaseEnterCompleteCode": "Por favor, insira o código de verificação completo", "loginFailed": "Falha no login, tente novamente", + 'pleaseAcceptPrivacyPolicy': + 'Por favor, leia e aceite a Política de Privacidade e o Acordo do Usuário primeiro', // 首页 "deviceStatus": "Status do Dispositivo", diff --git a/lib/core/translations/language/ru_ru.dart b/lib/core/translations/language/ru_ru.dart index 713e34aa6..28b690c8c 100644 --- a/lib/core/translations/language/ru_ru.dart +++ b/lib/core/translations/language/ru_ru.dart @@ -64,6 +64,8 @@ const Map ruRU = { "tip": "Уведомление", "pleaseEnterCompleteCode": "Пожалуйста, введите полный код подтверждения", "loginFailed": "Ошибка входа, пожалуйста, попробуйте снова", + 'pleaseAcceptPrivacyPolicy': + 'Пожалуйста, прочтите и примите Политику конфиденциальности и Пользовательское соглашение', // 首页 "deviceStatus": "Статус устройства", diff --git a/lib/core/translations/language/vi_vn.dart b/lib/core/translations/language/vi_vn.dart index 0bf757601..d7ea771ed 100644 --- a/lib/core/translations/language/vi_vn.dart +++ b/lib/core/translations/language/vi_vn.dart @@ -63,6 +63,8 @@ const Map viVN = { "tip": "Thông báo", "pleaseEnterCompleteCode": "Vui lòng nhập đầy đủ mã xác thực", "loginFailed": "Đăng nhập thất bại, vui lòng thử lại", + 'pleaseAcceptPrivacyPolicy': + 'Vui lòng đọc và chấp nhận Chính sách Bảo mật và Thỏa thuận Người dùng', // 首页 "deviceStatus": "Trạng thái thiết bị", diff --git a/lib/core/translations/language/zh_cn.dart b/lib/core/translations/language/zh_cn.dart index 848d61a97..1488135c4 100644 --- a/lib/core/translations/language/zh_cn.dart +++ b/lib/core/translations/language/zh_cn.dart @@ -62,6 +62,7 @@ const Map zhCN = { "tip": "提示", "pleaseEnterCompleteCode": "请输入完整的验证码", "loginFailed": "登录失败,请重试", + 'pleaseAcceptPrivacyPolicy': '请先阅读并同意隐私政策和用户协议', // 首页 "deviceStatus": "设备状态", diff --git a/lib/core/translations/language/zh_hk.dart b/lib/core/translations/language/zh_hk.dart index 7a15ab3ea..872c1cbcf 100644 --- a/lib/core/translations/language/zh_hk.dart +++ b/lib/core/translations/language/zh_hk.dart @@ -62,6 +62,7 @@ const Map zhHK = { "tip": "提示", "pleaseEnterCompleteCode": "請輸入完整的驗證碼", "loginFailed": "登錄失敗,請重試", + 'pleaseAcceptPrivacyPolicy': '請先閱讀並同意私隱政策和用戶協議', // 首页 "deviceStatus": "設備狀態", diff --git a/lib/data/services/deapsound_ai_service.dart b/lib/data/services/deapsound_ai_service.dart index dc85acebb..ba1750407 100644 --- a/lib/data/services/deapsound_ai_service.dart +++ b/lib/data/services/deapsound_ai_service.dart @@ -28,7 +28,7 @@ class DeapsoundAIService implements AiService { required String systemPrompt, }) async* { final client = http.Client(); - final url = Uri.parse('https://www.deapsound.com/ai/funcchat'); + final url = Uri.parse('https://api.deapsound.com/ai/funcchat'); List contextsList = []; String msg = ''; diff --git a/lib/data/services/network/nw_api.dart b/lib/data/services/network/nw_api.dart index 875a166b6..293c014b9 100644 --- a/lib/data/services/network/nw_api.dart +++ b/lib/data/services/network/nw_api.dart @@ -1,4 +1,4 @@ class NWApi { // static const baseApi = "https://www.deapsound.com"; - static const baseApi = "https://www.deapsound.com"; + static const baseApi = "https://api.deapsound.com"; } diff --git a/lib/modules/login/controllers/login_controller.dart b/lib/modules/login/controllers/login_controller.dart index d35a1d56c..945889b00 100644 --- a/lib/modules/login/controllers/login_controller.dart +++ b/lib/modules/login/controllers/login_controller.dart @@ -24,6 +24,10 @@ class LoginController extends GetxController { String _deviceId = ''; + final _hasAcceptedPrivacy = false.obs; + bool get hasAcceptedPrivacy => _hasAcceptedPrivacy.value; + set hasAcceptedPrivacy(bool value) => _hasAcceptedPrivacy.value = value; + // 添加国家编码相关状态 final _selectedCountryCode = ''.obs; final _selectedCountryFlag = '✉️'.obs; @@ -416,7 +420,6 @@ class LoginController extends GetxController { // 登录 Future login() async { - print("数据获取Facebook${loginType}"); // 添加基础验证 if (loginType == 0 || loginType == 1) { // 邮箱或手机号登录 @@ -455,6 +458,18 @@ class LoginController extends GetxController { return; } } + + //验证隐私协议勾选状态 + if (!_hasAcceptedPrivacy.value) { + Get.snackbar( + 'tip'.tr, + 'pleaseAcceptPrivacyPolicy'.tr, + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red.withOpacity(0.1), + colorText: Colors.red, + ); + return; + } _isLoading.value = true; try { diff --git a/lib/modules/login/views/login_view.dart b/lib/modules/login/views/login_view.dart index c7b1be4d2..9bc34934b 100644 --- a/lib/modules/login/views/login_view.dart +++ b/lib/modules/login/views/login_view.dart @@ -51,7 +51,7 @@ class LoginView extends GetView { _buildSocialLoginSection(isDarkMode), // 用户协议 - SizedBox(height: 40.h), + SizedBox(height: 24.h), _buildTermsAndPrivacy(isDarkMode), SizedBox(height: 24.h), ], @@ -524,14 +524,40 @@ class LoginView extends GetView { Widget _buildTermsAndPrivacy(bool isDarkMode) { return Column( children: [ - Text( - 'expressConsent'.tr, // 登录/注册即表示你同意 - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.grey[500] : Colors.grey[600], - ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, // 添加这行 + children: [ + SizedBox( + width: 12.w, + height: 16.w, + child: Transform.scale( + scale: 0.7, + child: Obx(() => Checkbox( + value: controller.hasAcceptedPrivacy, + onChanged: (value) => + controller.hasAcceptedPrivacy = value ?? false, + activeColor: const Color(0xFF3B82F6), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + side: BorderSide( + width: 1.6, + color: Colors.grey[600]!, + ), + )), + ), + ), + SizedBox(width: 4.w), + Text( + 'expressConsent'.tr, + style: TextStyle( + fontSize: 13.sp, + color: isDarkMode ? Colors.grey[400] : Colors.grey[700], + ), + ), + ], ), - SizedBox(height: 4.h), + //SizedBox(height: 4.h), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ From 7d88c2cabacf13ccfdfeb4909ee7c6971ed060ae Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Wed, 16 Apr 2025 14:58:59 +0800 Subject: [PATCH 22/23] =?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 @@ + + + +