From d968cb65bfef3de255749ecbca45086c5ece4629 Mon Sep 17 00:00:00 2001 From: wolfplus Date: Sat, 8 Mar 2025 13:56:39 +0000 Subject: [PATCH] bg 001 --- .gitignore | 1 + android/app/build.gradle.kts | 12 +- android/app/src/main/AndroidManifest.xml | 28 +- .../com/example/deep_voice/AzureAsrHelper.kt | 29 + .../com/example/deep_voice/MainActivity.kt | 77 ++- .../deep_voice/VoiceInteractionService.kt | 551 ++++++++++++++++++ .../example/deep_voice/VolcanoAIService.kt | 324 ++++++++++ lib/core/bindings/initial_binding.dart | 5 +- lib/data/services/config_service.dart | 57 ++ pubspec.yaml | 1 + 10 files changed, 1066 insertions(+), 19 deletions(-) create mode 100644 android/app/src/main/kotlin/com/example/deep_voice/VoiceInteractionService.kt create mode 100644 android/app/src/main/kotlin/com/example/deep_voice/VolcanoAIService.kt create mode 100644 lib/data/services/config_service.dart diff --git a/.gitignore b/.gitignore index 252bf4002..887cf071e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ doc/api/ # dotenv environment variables file .env* +.env.example # Avoid committing generated Javascript files: *.dart.js diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 326fe0f55..9f7690714 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -50,11 +50,21 @@ android { } dependencies { - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") + // 添加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") + + // 添加legacy-support-v4依赖 + implementation("androidx.legacy:legacy-support-v4:1.0.0") + // // 添加火山语音合成SDK依赖 // implementation("com.bytedance.speechengine:speechengine_tts_tob:5.4.8") diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fb5dcdbaf..6122f521d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -33,32 +33,30 @@ android:enableOnBackInvokedCallback="true" tools:replace="android:label"> - - + - + + - - - + + + + - - - + when (call.method) { + "startVoiceInteractionService" -> { + startVoiceInteractionService() + result.success(true) + } + "stopVoiceInteractionService" -> { + stopVoiceInteractionService() + result.success(true) + } + "isVoiceInteractionServiceRunning" -> { + result.success(VoiceInteractionService.isServiceRunning()) + } + else -> { + result.notImplemented() + } + } + } + + // 设置 MethodChannel 处理程序 + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CONFIG_CHANNEL).setMethodCallHandler { call, result -> + when (call.method) { + "setConfig" -> { + val azureSpeechKey = call.argument("azure_speech_key") ?: "" + val azureSpeechRegion = call.argument("azure_speech_region") ?: "" + val volcanoAiApiKey = call.argument("volcano_ai_api_key") ?: "" + + // 保存到 SharedPreferences + val editor = sharedPreferences.edit() + editor.putString("flutter.azure_speech_key", azureSpeechKey) + editor.putString("flutter.azure_speech_region", azureSpeechRegion) + editor.putString("flutter.volcano_ai_api_key", volcanoAiApiKey) + editor.apply() + + // 打印日志 + println("已保存配置到原生 SharedPreferences:") + println("azure_speech_key: $azureSpeechKey") + println("azure_speech_region: $azureSpeechRegion") + println("volcano_ai_api_key: $volcanoAiApiKey") + + result.success(true) + } + else -> { + result.notImplemented() + } + } + } } // ASR 事件发送方法 @@ -295,6 +354,22 @@ class MainActivity: AudioServiceActivity() { return true } + /** + * 启动语音交互服务 + */ + private fun startVoiceInteractionService() { + val serviceIntent = Intent(this, VoiceInteractionService::class.java) + startService(serviceIntent) + } + + /** + * 停止语音交互服务 + */ + private fun stopVoiceInteractionService() { + val serviceIntent = Intent(this, VoiceInteractionService::class.java) + stopService(serviceIntent) + } + override fun onDestroy() { azureAsrHelper.dispose() azureTtsHelper.dispose() diff --git a/android/app/src/main/kotlin/com/example/deep_voice/VoiceInteractionService.kt b/android/app/src/main/kotlin/com/example/deep_voice/VoiceInteractionService.kt new file mode 100644 index 000000000..6e442c182 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/deep_voice/VoiceInteractionService.kt @@ -0,0 +1,551 @@ +package com.example.deep_voice + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.util.Log +import android.view.KeyEvent +import androidx.annotation.Nullable +import androidx.core.app.NotificationCompat +import androidx.media.session.MediaButtonReceiver +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import android.content.ComponentName +import android.media.AudioManager +import java.util.concurrent.atomic.AtomicBoolean +import org.json.JSONArray + +/** + * 后台语音交互 Service: + * 1) 前台服务,确保不会被系统轻易杀死 + * 2) MediaSession 捕获蓝牙耳机按键 + * 3) 处理录音/语音识别 + */ +class VoiceInteractionService : Service() { + + companion object { + private const val TAG = "VoiceInteractionService" + private const val NOTIFICATION_ID = 1001 + private const val CHANNEL_ID = "voice_interaction_channel" + + // 用于跟踪服务是否正在运行 + private val isRunning = AtomicBoolean(false) + + // 获取服务运行状态 + fun isServiceRunning(): Boolean { + return isRunning.get() + } + } + + private lateinit var mediaSession: MediaSessionCompat + private var isRecording = false + private var isProcessing = false + private var lastKeyEventTime = 0L + private var keyEventCount = 0 + private lateinit var audioManager: AudioManager + + // Azure服务 + private lateinit var azureAsrHelper: AzureAsrHelper + private lateinit var azureTtsHelper: AzureTtsHelper + + // 火山AI服务 + private lateinit var volcanoAIService: VolcanoAIService + + // 系统提示词 + private val systemPrompt = """ + 你是一个智能语音助手,能够简洁明了地回答用户的问题。 + 请保持回答简短、准确,避免过长的解释。 + 如果用户的问题不清楚,请礼貌地请求澄清。 + 不要使用复杂的术语,除非用户明确要求。 + """.trimIndent() + + override fun onCreate() { + super.onCreate() + Log.d(TAG, "onCreate") + isRunning.set(true) + + // 获取 AudioManager + audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + + // 1. 初始化Azure服务 + initAzureServices() + + // 2. 初始化火山AI服务 + volcanoAIService = VolcanoAIService(applicationContext) + + // 3. 初始化MediaSession + initMediaSession() + + // 4. 启动前台通知(Foreground Service) + startForegroundServiceWithNotification() + + // 5. 注册成为媒体按钮接收器 + registerMediaButtonReceiver() + + // 6. 测试按键事件 + testKeyEvent() + } + + /** + * 测试按键事件,模拟一个按键事件以验证处理逻辑 + */ + private fun testKeyEvent() { + Log.d(TAG, "测试按键事件处理") + val keyEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_HEADSETHOOK) + val mediaButtonIntent = Intent(Intent.ACTION_MEDIA_BUTTON).apply { + putExtra(Intent.EXTRA_KEY_EVENT, keyEvent) + component = ComponentName(packageName, VoiceInteractionService::class.java.name) + } + + // MediaButtonReceiver.handleIntent 返回的是 KeyEvent 类型,而不是 Boolean + MediaButtonReceiver.handleIntent(mediaSession, mediaButtonIntent) + Log.d(TAG, "测试按键事件已发送") + + // 直接处理按键事件 + handleKeyEvent(keyEvent) + } + + /** + * 处理按键事件 + */ + private fun handleKeyEvent(keyEvent: KeyEvent) { + if (keyEvent.action == KeyEvent.ACTION_DOWN) { + when (keyEvent.keyCode) { + KeyEvent.KEYCODE_MEDIA_PLAY, + KeyEvent.KEYCODE_MEDIA_PAUSE, + KeyEvent.KEYCODE_HEADSETHOOK, + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { + Log.d(TAG, "处理按键事件: ${keyEvent.keyCode}") + + // 检测双击 + val currentTime = System.currentTimeMillis() + if (currentTime - lastKeyEventTime < 500) { // 500毫秒内的按键视为连续按键 + keyEventCount++ + if (keyEventCount == 1) { // 这是第二次按键,即双击 + Log.d(TAG, "检测到双击,开始语音交互") + handleDoubleClick() + keyEventCount = 0 + } + } else { + keyEventCount = 0 + } + lastKeyEventTime = currentTime + } + } + } + } + + /** + * 注册成为媒体按钮接收器 + */ + private fun registerMediaButtonReceiver() { + val mediaButtonReceiver = ComponentName(packageName, MediaButtonReceiver::class.java.name) + audioManager.registerMediaButtonEventReceiver(mediaButtonReceiver) + Log.d(TAG, "已注册媒体按钮接收器: $mediaButtonReceiver") + } + + /** + * 初始化Azure服务 + */ + private fun initAzureServices() { + azureAsrHelper = AzureAsrHelper() + azureTtsHelper = AzureTtsHelper() + + // 从应用的SharedPreferences获取Azure配置 + val sharedPreferences = applicationContext.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) + val subscriptionKey = sharedPreferences.getString("flutter.azure_speech_key", "") ?: "" + val serviceRegion = sharedPreferences.getString("flutter.azure_speech_region", "") ?: "" + + Log.d(TAG, "Azure配置信息: subscriptionKey=$subscriptionKey, serviceRegion=$serviceRegion") + + if (subscriptionKey.isNotEmpty() && serviceRegion.isNotEmpty()) { + // 初始化ASR + azureAsrHelper.initialize(subscriptionKey, serviceRegion, "zh-CN") + + // 初始化TTS + azureTtsHelper.initialize(subscriptionKey, serviceRegion, "zh-CN") + } else { + Log.e(TAG, "Azure配置信息不完整,无法初始化Azure服务") + } + } + + /** + * 初始化 MediaSession 以捕获蓝牙耳机按键 + */ + private fun initMediaSession() { + mediaSession = MediaSessionCompat(applicationContext, "VoiceServiceSession") + + // 支持媒体按钮和传输控件 + mediaSession.setFlags( + MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or + MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS + ) + + // 设置播放状态 + val playbackStateBuilder = PlaybackStateCompat.Builder() + .setActions( + PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_PLAY_PAUSE or + PlaybackStateCompat.ACTION_SKIP_TO_NEXT or + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS + ) + .setState(PlaybackStateCompat.STATE_PAUSED, 0, 1.0f) + mediaSession.setPlaybackState(playbackStateBuilder.build()) + + // 监听回调 + mediaSession.setCallback(object : MediaSessionCompat.Callback() { + override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { + // 根据 Android 版本使用不同的方式获取 KeyEvent + val keyEvent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java) + } else { + @Suppress("DEPRECATION") + mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as? KeyEvent + } + + Log.d(TAG, "收到媒体按钮事件: $keyEvent") + + if (keyEvent != null) { + Log.d(TAG, "按键代码: ${keyEvent.keyCode}, 动作: ${keyEvent.action}") + + if (keyEvent.action == KeyEvent.ACTION_DOWN) { + when (keyEvent.keyCode) { + KeyEvent.KEYCODE_MEDIA_PLAY, + KeyEvent.KEYCODE_MEDIA_PAUSE, + KeyEvent.KEYCODE_HEADSETHOOK, + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { + Log.d(TAG, "蓝牙耳机按键被按下: ${keyEvent.keyCode}") + + // 检测双击 + val currentTime = System.currentTimeMillis() + if (currentTime - lastKeyEventTime < 500) { // 500毫秒内的按键视为连续按键 + keyEventCount++ + if (keyEventCount == 1) { // 这是第二次按键,即双击 + Log.d(TAG, "检测到双击,开始语音交互") + handleDoubleClick() + keyEventCount = 0 + return true + } + } else { + keyEventCount = 0 + } + lastKeyEventTime = currentTime + + // 单击也处理 + if (keyEventCount == 0) { + Log.d(TAG, "检测到单击,开始语音交互") + handleDoubleClick() + return true + } + } + } + } + } + return super.onMediaButtonEvent(mediaButtonEvent) + } + + override fun onPlay() { + Log.d(TAG, "MediaSession.onPlay()") + handleDoubleClick() + return super.onPlay() + } + + override fun onPause() { + Log.d(TAG, "MediaSession.onPause()") + handleDoubleClick() + return super.onPause() + } + }) + + // 激活 MediaSession + mediaSession.isActive = true + Log.d(TAG, "MediaSession 已激活: ${mediaSession.isActive}") + } + + /** + * 处理耳机双击事件 + */ + private fun handleDoubleClick() { + if (isProcessing) { + Log.d(TAG, "正在处理中,忽略此次点击") + return + } + + if (!isRecording) { + startVoiceInteraction() + } else { + stopVoiceInteraction() + } + } + + /** + * 开始语音交互 + */ + private fun startVoiceInteraction() { + if (isRecording || isProcessing) { + return + } + + isRecording = true + isProcessing = true + + // 播放提示音 + playStartPrompt() + + // 开始录音识别 + azureAsrHelper.startContinuousRecognition(null, object : AzureAsrHelper.ContinuousRecognizeCallback { + override fun onRecognizing(recognizing: String) { + // 实时识别结果,可以记录日志 + Log.d(TAG, "正在识别: $recognizing") + } + + override fun onResult(result: String) { + // 最终识别结果 + Log.d(TAG, "语音识别结果: $result") + + // 停止录音 + isRecording = false + + // 如果有识别结果,则发送到VolcanoAI处理 + if (result.isNotEmpty()) { + processWithVolcanoAI(result) + } else { + isProcessing = false + } + } + + override fun onSessionStarted() { + Log.d(TAG, "语音识别会话开始") + } + + override fun onSessionStopped() { + Log.d(TAG, "语音识别会话结束") + } + + override fun onCanceled(reason: String, errorDetails: String) { + Log.e(TAG, "语音识别取消: $reason, 详情: $errorDetails") + isRecording = false + isProcessing = false + } + + override fun onError(error: String) { + Log.e(TAG, "语音识别错误: $error") + isRecording = false + isProcessing = false + + // 播放错误提示音 + playErrorPrompt() + } + }) + } + + /** + * 停止语音交互 + */ + private fun stopVoiceInteraction() { + if (!isRecording) { + return + } + + // 停止录音 + azureAsrHelper.stopContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { + override fun onResult(result: String) { + // 不处理结果 + } + + override fun onRecognizing(recognizing: String) { + // 不处理中间结果 + } + + override fun onSessionStarted() { + // 不处理会话开始事件 + } + + override fun onSessionStopped() { + Log.d(TAG, "语音识别会话已停止") + } + + override fun onCanceled(reason: String, errorDetails: String) { + Log.d(TAG, "语音识别已取消: $reason") + } + + override fun onError(error: String) { + Log.e(TAG, "停止语音识别错误: $error") + } + }) + isRecording = false + } + + /** + * 播放开始提示音 + */ + private fun playStartPrompt() { + azureTtsHelper.speakText("我在听", object : AzureTtsHelper.TTSCallback { + override fun onSuccess(message: String) { + Log.d(TAG, "提示音播放完成") + } + + override fun onError(error: String) { + Log.e(TAG, "提示音播放错误: $error") + } + }) + } + + /** + * 播放错误提示音 + */ + private fun playErrorPrompt() { + azureTtsHelper.speakText("抱歉,我没有听清", object : AzureTtsHelper.TTSCallback { + override fun onSuccess(message: String) { + Log.d(TAG, "错误提示音播放完成") + } + + override fun onError(error: String) { + Log.e(TAG, "错误提示音播放错误: $error") + } + }) + } + + /** + * 使用VolcanoAI处理语音识别结果 + */ + private fun processWithVolcanoAI(text: String) { + // 使用原生VolcanoAIService处理 + Thread { + try { + // 创建用户消息 + val messages = JSONArray().apply { + put(volcanoAIService.createUserMessage(text)) + } + + // 发送请求到VolcanoAI + val response = volcanoAIService.sendMessage(messages, systemPrompt) + + // 播放AI回复 + speakAIResponse(response) + } catch (e: Exception) { + Log.e(TAG, "VolcanoAI处理错误: ${e.message}") + isProcessing = false + + // 播放错误提示音 + playErrorPrompt() + } + }.start() + } + + /** + * 播放AI回复 + */ + private fun speakAIResponse(text: String) { + azureTtsHelper.speakText(text, object : AzureTtsHelper.TTSCallback { + override fun onSuccess(message: String) { + Log.d(TAG, "AI回复播放完成") + isProcessing = false + } + + override fun onError(error: String) { + Log.e(TAG, "AI回复播放错误: $error") + isProcessing = false + } + }) + } + + /** + * 启动一个"前台通知",保证此Service在后台不会被系统回收 + */ + private fun startForegroundServiceWithNotification() { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Android 8.0+ 需要创建通知渠道 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "语音交互服务", + NotificationManager.IMPORTANCE_LOW + ) + manager.createNotificationChannel(channel) + } + + // 点击通知打开MainActivity + val intent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("语音助手") + .setContentText("双击蓝牙耳机按键即可开始语音交互") + .setSmallIcon(R.mipmap.ic_launcher) + .setContentIntent(pendingIntent) + .build() + + startForeground(NOTIFICATION_ID, notification) + } + + /** + * 当 Service 被启动时调用 + */ + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.d(TAG, "onStartCommand: $intent") + + // 处理媒体按键 Intent + if (intent != null) { + // 如果是媒体按钮事件,尝试手动处理 + if (Intent.ACTION_MEDIA_BUTTON == intent.action) { + Log.d(TAG, "收到媒体按钮事件: $intent") + + // 根据 Android 版本使用不同的方式获取 KeyEvent + val keyEvent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as? KeyEvent + } + + Log.d(TAG, "手动处理媒体按钮事件: $keyEvent") + if (keyEvent != null) { + handleKeyEvent(keyEvent) + } + } + + // 无论如何都要尝试让 MediaSession 处理 + MediaButtonReceiver.handleIntent(mediaSession, intent) + } + + return START_STICKY + } + + @Nullable + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + Log.d(TAG, "onDestroy") + super.onDestroy() + + // 取消注册媒体按钮接收器 + val mediaButtonReceiver = ComponentName(packageName, MediaButtonReceiver::class.java.name) + audioManager.unregisterMediaButtonEventReceiver(mediaButtonReceiver) + + // 释放资源 + mediaSession.isActive = false + mediaSession.release() + + // 释放Azure服务 + azureAsrHelper.dispose() + azureTtsHelper.dispose() + + isRunning.set(false) + } +} \ 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 new file mode 100644 index 000000000..b1591c1db --- /dev/null +++ b/android/app/src/main/kotlin/com/example/deep_voice/VolcanoAIService.kt @@ -0,0 +1,324 @@ +package com.example.deep_voice + +import android.content.Context +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 context: Context) { + 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 val apiKey: String + get() { + val sharedPreferences = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) + return sharedPreferences.getString("flutter.volcano_ai_api_key", "") ?: "" + } + + init { + if (apiKey.isEmpty()) { + Log.e(TAG, "火山AI配置信息不完整,请检查环境变量") + } + } + + /** + * 生成个性化问候语 + * + * @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 { + 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) { + 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/lib/core/bindings/initial_binding.dart b/lib/core/bindings/initial_binding.dart index 9687f3592..b0dd634b8 100644 --- a/lib/core/bindings/initial_binding.dart +++ b/lib/core/bindings/initial_binding.dart @@ -3,11 +3,14 @@ import '../../data/services/azure_asr_service.dart'; import '../../data/services/azure_tts_service.dart'; import '../../data/services/chat_history_service.dart'; import '../../data/services/volcano_translation_service.dart'; +import '../../data/services/config_service.dart'; /// 初始绑定,用于管理全局依赖 class InitialBinding extends Bindings { @override void dependencies() { + // 配置服务 - 需要首先初始化 + Get.putAsync(() => ConfigService().init()); // Azure 语音识别服务 Get.lazyPut(() => AzureAsrService(), fenix: true); @@ -20,8 +23,6 @@ class InitialBinding extends Bindings { // 火山翻译服务 Get.lazyPut(() => VolcanoTranslationService(), fenix: true); - - } } \ No newline at end of file diff --git a/lib/data/services/config_service.dart b/lib/data/services/config_service.dart new file mode 100644 index 000000000..e34a143be --- /dev/null +++ b/lib/data/services/config_service.dart @@ -0,0 +1,57 @@ +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:logger/logger.dart'; +import 'package:flutter/services.dart'; + +/// 配置服务,用于管理应用配置和环境变量 +class ConfigService extends GetxService { + final Logger _logger = Logger(); + late final SharedPreferences _prefs; + + // 创建一个 MethodChannel 用于与原生代码通信 + static const MethodChannel _channel = MethodChannel('com.example.deep_voice/config'); + + // 初始化服务 + Future init() async { + try { + _prefs = await SharedPreferences.getInstance(); + + // 从 .env 读取 Azure 语音服务配置 + final azureSpeechKey = dotenv.env['AZURE_ASR_SUBSCRIPTION_KEY']; + final azureSpeechRegion = dotenv.env['AZURE_ASR_SERVICE_REGION']; + + // 从 .env 读取火山 AI API Key + final volcanoAiApiKey = dotenv.env['VOLCANO_AI_API_KEY']; + + + // 通过 MethodChannel 向原生代码传递配置值 + try { + await _channel.invokeMethod('setConfig', { + 'azure_speech_key': azureSpeechKey ?? '', + 'azure_speech_region': azureSpeechRegion ?? '', + 'volcano_ai_api_key': volcanoAiApiKey ?? '', + }); + _logger.i('已通过 MethodChannel 向原生代码传递配置值'); + } catch (e) { + _logger.e('通过 MethodChannel 传递配置值失败: $e'); + } + + return this; + } catch (e) { + _logger.e('初始化配置服务失败: $e'); + rethrow; + } + } + + // 获取配置值 + String? getString(String key) { + return _prefs.getString(key); + } + + // 设置配置值 + Future setString(String key, String value) { + return _prefs.setString(key, value); + } +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index dc4fe6542..d50ddfacc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,6 +29,7 @@ dependencies: crypto: ^3.0.3 flutter_tts: ^4.2.2 logger: ^2.0.2+1 + shared_preferences: ^2.2.2 # azure_speech_recognition_null_safety: ^0.9.6 window_size: git: