10 changed files with 1066 additions and 19 deletions
@ -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) |
|||
} |
|||
} |
|||
@ -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) |
|||
@ -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<ConfigService> 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<bool> setString(String key, String value) { |
|||
return _prefs.setString(key, value); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue