Browse Source

移动版本的大模型接入成功

newdev_shunjiawei
liwei1dao 7 months ago
parent
commit
6c4e9e6e8b
  1. 36
      lib/data/services/ble_manager.dart
  2. 9
      lib/modules/home/controllers/home_controller.dart
  3. 23
      lib/modules/home/views/home_view.dart
  4. 1076
      lib/modules/mobile_elf/controllers/mobile_elf_controller.dart
  5. 199
      lib/modules/mobile_elf/views/mobile_elf_view.dart
  6. 70
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt
  7. 19
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt
  8. 38
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt
  9. 783
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/StsAgent.kt
  10. 72
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift
  11. 495
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/StsAgent.swift
  12. 22
      local_plugins/agent_service/lib/agent_service.dart
  13. 2
      local_plugins/ble_service/lib/ble_service.dart

36
lib/data/services/ble_manager.dart

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:get_storage/get_storage.dart';
import 'package:get/get.dart';
import 'package:ble_service/ble_service.dart';
@ -957,7 +958,27 @@ class BleManager extends GetxService {
supportedLanguages = ['zh-CN', 'en-US'];
}
// STS 模式参数(移动精灵)
final agentType = AppConfig.env('AGENT_TYPE') ?? 'yidong';
final agentMode = (agentType == 'yidong') ? 'sts' : 'stt_llm_tts';
final agentId = (agentType == 'yidong')
? (AppConfig.env('MOBILE_ELF_AGENT_ID') ?? 'mobile_elf')
: 'default_agent';
final stsProductId = AppConfig.env('MOBILE_ELF_PRODUCT_ID') ?? '';
final stsProductKey = AppConfig.env('MOBILE_ELF_PRODUCT_KEY') ?? '';
final stsDeviceNo = dotenv.env['MOBILE_ELF_DEVICE_NO'] ?? '';
final stsDeviceId = _storage.read<String>('sts_device_id') ?? '';
final stsDeviceSecret = _storage.read<String>('sts_device_secret') ?? '';
final config = <String, dynamic>{
// STS 模式路由
'agentMode': agentMode,
'agentId': agentId,
'stsProductId': stsProductId,
'stsProductKey': stsProductKey,
'stsDeviceNo': stsDeviceNo,
'stsDeviceId': stsDeviceId,
'stsDeviceSecret': stsDeviceSecret,
// 语音代理配置参数
'azureSpeechKey': azureSpeechKey,
'azureSpeechRegion': azureSpeechRegion,
@ -990,7 +1011,7 @@ class BleManager extends GetxService {
Logger.warning('XUNFEI_ASR_* 未完整配置,ASR 将不会切到讯飞');
}
if (azureSpeechKey.isEmpty || openaiApiKey.isEmpty) {
if (agentMode != 'sts' && (azureSpeechKey.isEmpty || openaiApiKey.isEmpty)) {
Logger.warning('缺少必要的API密钥,请检查.env文件配置');
// 检查.env中相关密钥是否存在
@ -1002,6 +1023,19 @@ class BleManager extends GetxService {
}
}
// STS 模式:监听凭证获取事件并缓存到 GetStorage
if (agentMode == 'sts') {
AgentService.events.where((e) => e.type == AgentServiceEventType.stsCredentials).listen((e) {
final deviceId = e.data['deviceId']?.toString() ?? '';
final deviceSecret = e.data['deviceSecret']?.toString() ?? '';
final deviceNo = e.data['deviceNo']?.toString() ?? '';
if (deviceId.isNotEmpty) _storage.write('sts_device_id', deviceId);
if (deviceSecret.isNotEmpty) _storage.write('sts_device_secret', deviceSecret);
if (deviceNo.isNotEmpty) _storage.write('sts_device_no', deviceNo);
Logger.info('STS 凭证已缓存: deviceId=$deviceId deviceNo=$deviceNo');
});
}
Logger.info('lxm--准备调用AgentService.startAgentService启动BLE服务');
final result = await AgentService.startAgentService(config);
Logger.info('lxm--AgentService.startAgentService调用结果: $result');

9
lib/modules/home/controllers/home_controller.dart

@ -256,8 +256,13 @@ class HomeController extends GetxController {
}
void exploreAIFeatures() {
Get.toNamed(Routes.agent);
AgentService.interruptCurrentResponse();
final agentType = AppConfig.env('AGENT_TYPE') ?? 'yidong';
if (agentType == 'yidong') {
Get.toNamed(Routes.mobileElf);
} else {
Get.toNamed(Routes.agent);
AgentService.interruptCurrentResponse();
}
}
Future<void> _checkConnectionStatus() async {

23
lib/modules/home/views/home_view.dart

@ -146,8 +146,6 @@ class HomeView extends GetView<HomeController> {
SizedBox(height: 16.h),
_buildPremiumAILayout(),
SizedBox(height: 16.h),
_buildMobileElfCard(isDarkMode),
],
),
),
@ -902,27 +900,6 @@ class HomeView extends GetView<HomeController> {
);
}
// 移动精灵卡片
Widget _buildMobileElfCard(bool isDarkMode) {
return _buildFeatureCard(
title: '移动精灵',
description: '智能语音助手,移动AI全场景对话',
icon: Icons.cell_tower,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
const Color(0xFF0091EA).withOpacity(0.3),
const Color(0xFF00BFA5).withOpacity(0.3),
],
),
onTap: () => Get.toNamed(Routes.mobileElf),
height: 70.h,
isDarkMode: isDarkMode,
iconColor: const Color(0xFF0091EA),
);
}
// 特性卡片构建方法
Widget _buildFeatureCard({
required String title,

1076
lib/modules/mobile_elf/controllers/mobile_elf_controller.dart

File diff suppressed because it is too large

199
lib/modules/mobile_elf/views/mobile_elf_view.dart

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart'; // 用于 DateFormat
import '../controllers/mobile_elf_controller.dart';
class MobileElfView extends GetView<MobileElfController> {
@ -71,6 +73,7 @@ class MobileElfView extends GetView<MobileElfController> {
size: 18.sp, color: isDark ? Colors.white : _textSecondary),
onPressed: () => Get.back(),
),
titleSpacing: 0,
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
@ -81,13 +84,59 @@ class MobileElfView extends GetView<MobileElfController> {
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : _textPrimary)),
SizedBox(width: 8.w),
Obx(() => _buildStatusPill()),
],
),
actions: [
Obx(() => Padding(
padding: EdgeInsets.only(right: 16.w),
child: _buildStatusPill(),
// 播报开关
Obx(() => IconButton(
icon: Icon(
controller.ttsEnabled.value
? Icons.volume_up_rounded
: Icons.volume_off_rounded,
size: 20.sp,
color: controller.ttsEnabled.value
? _blue
: (isDark ? Colors.grey[500] : _textSecondary),
),
onPressed: () => controller.ttsEnabled.toggle(),
tooltip: controller.ttsEnabled.value ? '关闭播报' : '开启播报',
)),
PopupMenuButton<String>(
icon: Icon(Icons.more_vert,
size: 20.sp, color: isDark ? Colors.white : _textSecondary),
onSelected: (value) {
if (value == 'clear') {
Get.dialog(
AlertDialog(
title: Text('clearChatHistory'.tr.isNotEmpty ? 'clearChatHistory'.tr : '清空记录'),
content: Text('confirmClearChat'.tr.isNotEmpty ? 'confirmClearChat'.tr : '确定清空所有聊天记录吗?'),
actions: [
TextButton(onPressed: () => Get.back(), child: Text('cancel'.tr.isNotEmpty ? 'cancel'.tr : '取消')),
TextButton(
onPressed: () {
controller.clearMessages();
Get.back();
},
child: Text('confirm'.tr.isNotEmpty ? 'confirm'.tr : '确定', style: const TextStyle(color: Colors.red)),
),
],
),
);
}
},
itemBuilder: (_) => [
PopupMenuItem(
value: 'clear',
child: Row(children: [
Icon(Icons.delete_outline, size: 18.sp, color: Colors.red),
SizedBox(width: 8.w),
Text('清空记录', style: TextStyle(fontSize: 14.sp, color: Colors.red)),
]),
),
],
),
],
);
}
@ -237,58 +286,138 @@ class MobileElfView extends GetView<MobileElfController> {
]),
SizedBox(height: 4.h),
],
// 气泡体
// 气泡体(长按弹出菜单)
Row(
mainAxisAlignment:
isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
children: [
if (!isUser) SizedBox(width: 34.w),
Flexible(
child: Container(
padding:
EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h),
decoration: BoxDecoration(
color: isUser
? _blue
: (isDark ? const Color(0xFF2A2A2A) : _surfaceLight),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(18.r),
topRight: Radius.circular(18.r),
bottomLeft: Radius.circular(isUser ? 18.r : 4.r),
bottomRight: Radius.circular(isUser ? 4.r : 18.r),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 6,
offset: const Offset(0, 2),
child: GestureDetector(
onLongPressStart: (details) => _showMessageMenu(details, msg, isDark),
child: Container(
padding:
EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h),
decoration: BoxDecoration(
color: isUser
? _blue
: (isDark ? const Color(0xFF2A2A2A) : _surfaceLight),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(18.r),
topRight: Radius.circular(18.r),
bottomLeft: Radius.circular(isUser ? 18.r : 4.r),
bottomRight: Radius.circular(isUser ? 4.r : 18.r),
),
],
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Text(msg.text,
style: TextStyle(
fontSize: 15.sp,
height: 1.55,
color: isUser
? Colors.white
: (isDark ? Colors.white : _textPrimary))),
),
child: Text(msg.text,
style: TextStyle(
fontSize: 15.sp,
height: 1.55,
color: isUser
? Colors.white
: (isDark ? Colors.white : _textPrimary))),
),
),
],
),
// 时间
// 时间 + AI 标签
SizedBox(height: 3.h),
Padding(
padding: EdgeInsets.only(left: isUser ? 0 : 34.w),
child: Text(_fmtTime(msg.time),
style: TextStyle(
fontSize: 11.sp, color: const Color(0xFFD0D5DD))),
child: Row(
mainAxisAlignment:
isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
children: [
Text(_formatTimestamp(msg.time),
style: TextStyle(
fontSize: 10.sp, color: isDark ? Colors.grey[500] : const Color(0xFFD0D5DD))),
if (!isUser) ...[
SizedBox(width: 8.w),
Text('generatedByAi'.tr.isNotEmpty ? 'generatedByAi'.tr : '由AI生成',
style: TextStyle(
fontSize: 10.sp,
color: Colors.blue[600],
fontWeight: FontWeight.bold,
fontStyle: FontStyle.italic)),
],
],
),
),
],
),
);
}
/// 长按消息弹出菜单
void _showMessageMenu(LongPressStartDetails details, MobileElfMessage msg, bool isDark) {
HapticFeedback.mediumImpact();
final overlay = Overlay.of(Get.context!).context.findRenderObject() as RenderBox?;
if (overlay == null) return;
final position = RelativeRect.fromRect(
Rect.fromLTWH(details.globalPosition.dx, details.globalPosition.dy, 0, 0),
Offset.zero & overlay.size,
);
showMenu(
context: Get.context!,
position: position,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.r)),
color: isDark ? const Color(0xFF2A2A2A) : Colors.white,
items: [
PopupMenuItem(
onTap: () {
Clipboard.setData(ClipboardData(text: msg.text));
Get.snackbar('success'.tr.isNotEmpty ? 'success'.tr : '成功',
'copySuccess'.tr.isNotEmpty ? 'copySuccess'.tr : '已复制',
backgroundColor: Colors.blue.withValues(alpha: 0.8), colorText: Colors.white);
},
child: Row(children: [
Icon(Icons.copy, size: 18.sp, color: isDark ? Colors.white : Colors.black87),
SizedBox(width: 8.w),
Text('copy'.tr.isNotEmpty ? 'copy'.tr : '复制',
style: TextStyle(fontSize: 14.sp, color: isDark ? Colors.white : Colors.black87)),
]),
),
],
);
}
/// 智能时间格式化(与 agent 模块一致)
String _formatTimestamp(DateTime t) {
final now = DateTime.now();
final diff = now.difference(t);
final today = DateTime(now.year, now.month, now.day);
final msgDate = DateTime(t.year, t.month, t.day);
if (diff.inMinutes < 1) return 'justNow'.tr.isNotEmpty ? 'justNow'.tr : '刚刚';
if (diff.inHours < 1) {
final m = diff.inMinutes.toString();
final text = 'minutesAgo'.trParams({'minutes': m});
return text.isNotEmpty && text != 'minutesAgo' ? text : '$m分钟前';
}
if (msgDate == today) return DateFormat('HH:mm').format(t);
if (msgDate == today.subtract(const Duration(days: 1))) {
return '${('yesterday'.tr.isNotEmpty ? 'yesterday'.tr : '昨天')} ${DateFormat('HH:mm').format(t)}';
}
if (diff.inDays < 7) {
return '${_weekdayName(t.weekday)} ${DateFormat('HH:mm').format(t)}';
}
if (t.year == now.year) return DateFormat('MM-dd HH:mm').format(t);
return DateFormat('yyyy-MM-dd HH:mm').format(t);
}
String _weekdayName(int w) {
const names = ['', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日'];
return names[w];
}
Widget _buildRealtimeBubble(String text) {
return Padding(
padding: EdgeInsets.only(bottom: 14.h),
@ -702,8 +831,6 @@ class MobileElfView extends GetView<MobileElfController> {
);
}
String _fmtTime(DateTime t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
String _fmtDuration(int secs) {
final m = (secs ~/ 60).toString().padLeft(2, '0');

70
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt

@ -100,8 +100,12 @@ object AgentService : CoroutineScope {
// 聊天存储服务
private lateinit var chatStorageHelper: ChatStorageHelper
// Agent模式: stt_llm_tts(默认)| sts(移动精灵)
private var agentMode = "stt_llm_tts"
private var stsAgent: StsAgent? = null
// 会话ID,用于区分不同聊天上下文
private val agentId = "default_agent"
private var agentId = "default_agent"
//工具调用提示
private var callingToolText = ""
private var insufficientIntegralText = "" // 积分不足提示
@ -215,6 +219,36 @@ object AgentService : CoroutineScope {
if (isInitialized) return true
try {
agentMode = config["agentMode"]?.toString() ?: "stt_llm_tts"
agentId = config["agentId"]?.toString()?.takeIf { it.isNotBlank() } ?: "default_agent"
// STS 模式:跳过 Azure/OpenAI 初始化,直接启动 StsAgent
if (agentMode == "sts") {
this.context = appContext.applicationContext
val stsConfig = StsConfig(
productId = config["stsProductId"]?.toString() ?: "",
productKey = config["stsProductKey"]?.toString() ?: "",
deviceNo = config["stsDeviceNo"]?.toString() ?: "",
deviceId = config["stsDeviceId"]?.toString() ?: "",
deviceSecret = config["stsDeviceSecret"]?.toString() ?: "",
agentId = agentId,
)
stsAgent = StsAgent(
context = context,
config = stsConfig,
onEvent = { name, data -> sendEvent(name, data) },
onCredentialsFetched = { deviceId, deviceSecret, deviceNo ->
sendEvent("sts_credentials", mapOf(
"deviceId" to deviceId,
"deviceSecret" to deviceSecret,
"deviceNo" to deviceNo,
))
}
)
_isInitialized.set(true)
return true
}
serverurl = config["serverurl"]?.toString() ?: "";
usertoken = config["usertoken"]?.toString() ?: "";
callingToolText = config["callingToolText"]?.toString() ?: "";
@ -341,6 +375,12 @@ object AgentService : CoroutineScope {
*/
fun dispose() {
try {
if (agentMode == "sts") {
stsAgent?.dispose()
stsAgent = null
_isInitialized.set(false)
return
}
BleService.closeCodec()
stopRecognition()
stopTts()
@ -644,6 +684,15 @@ object AgentService : CoroutineScope {
* @param mode 识别模式:normal, ble_wakeup, phone_call, push_to_talk
*/
fun startRecognition(isExternalActive: Boolean, mode: String = "normal"): Boolean {
if (agentMode == "sts") {
when (mode) {
"push_to_talk" -> stsAgent?.startPtt()
"phone_call" -> stsAgent?.startCall()
"ble_wakeup" -> stsAgent?.startCall() // BLE 唤醒:进入通话模式(VAD 自动检测)
else -> stsAgent?.startPtt() // 普通模式默认 PTT
}
return true
}
if(isExternalActive){
isKeepResult = false
BleService.openEncoder()//不发指令。只是录制opus
@ -902,6 +951,11 @@ object AgentService : CoroutineScope {
* 停止语音识别
*/
fun stopRecognition() {
if (agentMode == "sts") {
// StsAgent 内部根据 inCall / pttRecording 状态自行决定停止方式
stsAgent?.stop()
return
}
if (!isRecognitionActive) return
Log.d(TAG, "停止语音识别,当前模式: $currentRecognitionMode")
@ -928,7 +982,16 @@ object AgentService : CoroutineScope {
* 打断当前响应
* 停止TTS播放和AI流输出
*/
/** 设置 STS 模式 TTS 静音开关 */
fun setTtsMuted(muted: Boolean) {
stsAgent?.ttsMuted = muted
}
fun interruptCurrentResponse() {
if (agentMode == "sts") {
stsAgent?.interruptSpeaking()
return
}
if (isAiStreaming || isTtsSpeaking) {
val startTime = System.currentTimeMillis()
stopRecognition()
@ -1661,6 +1724,11 @@ object AgentService : CoroutineScope {
* 清除聊天历史
*/
fun clearChatHistory(callback: (Boolean) -> Unit) {
if (agentMode == "sts") {
stsAgent?.clearHistory()
callback(true)
return
}
launch {
try {
val success = chatStorageHelper.deleteMessages(agentId)

19
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt

@ -83,6 +83,14 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream
config["xunfeiAppId"]?.toString()?.let { intent.putExtra("xunfeiAppId", it) }
config["xunfeiAccessKeyId"]?.toString()?.let { intent.putExtra("xunfeiAccessKeyId", it) }
config["xunfeiAccessKeySecret"]?.toString()?.let { intent.putExtra("xunfeiAccessKeySecret", it) }
// STS 模式参数
config["agentMode"]?.toString()?.let { intent.putExtra("agentMode", it) }
config["agentId"]?.toString()?.let { intent.putExtra("agentId", it) }
config["stsProductId"]?.toString()?.let { intent.putExtra("stsProductId", it) }
config["stsProductKey"]?.toString()?.let { intent.putExtra("stsProductKey", it) }
config["stsDeviceNo"]?.toString()?.let { intent.putExtra("stsDeviceNo", it) }
config["stsDeviceId"]?.toString()?.let { intent.putExtra("stsDeviceId", it) }
config["stsDeviceSecret"]?.toString()?.let { intent.putExtra("stsDeviceSecret", it) }
config["vipexptime"]?.toString()?.let {
intent.putExtra("vipexptime", it)
//打印日志
@ -330,6 +338,17 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream
}
}
"setTtsMuted" -> {
try {
val muted = call.argument<Boolean>("muted") ?: false
AgentService.setTtsMuted(muted)
result.success(true)
} catch (e: Exception) {
Log.e(TAG, "设置TTS静音失败", e)
result.error("SET_TTS_MUTED_ERROR", "设置TTS静音失败: ${e.message}", null)
}
}
"setCanWakeUp" -> {
try {
val canWakeUp = call.argument<Boolean>("canWakeUp")

38
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt

@ -77,6 +77,14 @@ class BleMediaService : Service(), CoroutineScope {
private const val KEY_XUNFEI_APP_ID = "xunfeiAppId" // 讯飞AppID
private const val KEY_XUNFEI_ACCESS_KEY_ID = "xunfeiAccessKeyId" // 讯飞AccessKeyID
private const val KEY_XUNFEI_ACCESS_KEY_SECRET = "xunfeiAccessKeySecret" // 讯飞AccessKeySecret
// STS 模式参数键
private const val KEY_AGENT_MODE = "agentMode"
private const val KEY_AGENT_ID = "agentId"
private const val KEY_STS_PRODUCT_ID = "stsProductId"
private const val KEY_STS_PRODUCT_KEY = "stsProductKey"
private const val KEY_STS_DEVICE_NO = "stsDeviceNo"
private const val KEY_STS_DEVICE_ID = "stsDeviceId"
private const val KEY_STS_DEVICE_SECRET = "stsDeviceSecret"
// 用于跟踪服务是否正在运行
private val isRunning = AtomicBoolean(false)
@ -546,13 +554,15 @@ private var companionManager: BleCompanionManager? = null
val config = getConfigFromPreferences(prefs)
Log.d(TAG, "当前配置参数: $config")
// 检查必要的配置是否存在
val azureSpeechKey = config[KEY_AZURE_SPEECH_KEY]?.toString() ?: ""
val openaiApiKey = config[KEY_OPENAI_API_KEY]?.toString() ?: ""
if (azureSpeechKey.isEmpty() || openaiApiKey.isEmpty()) {
Log.w(TAG, "语音代理初始化失败: API密钥未配置")
return
// 检查必要的配置是否存在(STS 模式不需要 Azure/OpenAI 密钥)
val agentMode = config[KEY_AGENT_MODE]?.toString() ?: "stt_llm_tts"
if (agentMode != "sts") {
val azureSpeechKey = config[KEY_AZURE_SPEECH_KEY]?.toString() ?: ""
val openaiApiKey = config[KEY_OPENAI_API_KEY]?.toString() ?: ""
if (azureSpeechKey.isEmpty() || openaiApiKey.isEmpty()) {
Log.w(TAG, "语音代理初始化失败: API密钥未配置")
return
}
}
if (isChatAgentInitialized) {
@ -604,6 +614,13 @@ private var companionManager: BleCompanionManager? = null
it.getStringExtra(KEY_XUNFEI_APP_ID)?.let { value -> config[KEY_XUNFEI_APP_ID] = value }
it.getStringExtra(KEY_XUNFEI_ACCESS_KEY_ID)?.let { value -> config[KEY_XUNFEI_ACCESS_KEY_ID] = value }
it.getStringExtra(KEY_XUNFEI_ACCESS_KEY_SECRET)?.let { value -> config[KEY_XUNFEI_ACCESS_KEY_SECRET] = value }
it.getStringExtra(KEY_AGENT_MODE)?.let { value -> config[KEY_AGENT_MODE] = value }
it.getStringExtra(KEY_AGENT_ID)?.let { value -> config[KEY_AGENT_ID] = value }
it.getStringExtra(KEY_STS_PRODUCT_ID)?.let { value -> config[KEY_STS_PRODUCT_ID] = value }
it.getStringExtra(KEY_STS_PRODUCT_KEY)?.let { value -> config[KEY_STS_PRODUCT_KEY] = value }
it.getStringExtra(KEY_STS_DEVICE_NO)?.let { value -> config[KEY_STS_DEVICE_NO] = value }
it.getStringExtra(KEY_STS_DEVICE_ID)?.let { value -> config[KEY_STS_DEVICE_ID] = value }
it.getStringExtra(KEY_STS_DEVICE_SECRET)?.let { value -> config[KEY_STS_DEVICE_SECRET] = value }
Log.d(TAG, "liwei-------vipexptime 3-1 ${ config[KEY_VIP_EXPTIME]}")
// 提取 supportedLanguages 参数
it.getStringArrayListExtra(KEY_SUPPORTED_LANGUAGES)?.let { languages ->
@ -671,6 +688,13 @@ private var companionManager: BleCompanionManager? = null
config[KEY_XUNFEI_APP_ID] = getConfigString(prefs, KEY_XUNFEI_APP_ID, "")
config[KEY_XUNFEI_ACCESS_KEY_ID] = getConfigString(prefs, KEY_XUNFEI_ACCESS_KEY_ID, "")
config[KEY_XUNFEI_ACCESS_KEY_SECRET] = getConfigString(prefs, KEY_XUNFEI_ACCESS_KEY_SECRET, "")
config[KEY_AGENT_MODE] = getConfigString(prefs, KEY_AGENT_MODE, "stt_llm_tts")
config[KEY_AGENT_ID] = getConfigString(prefs, KEY_AGENT_ID, "default_agent")
config[KEY_STS_PRODUCT_ID] = getConfigString(prefs, KEY_STS_PRODUCT_ID, "")
config[KEY_STS_PRODUCT_KEY] = getConfigString(prefs, KEY_STS_PRODUCT_KEY, "")
config[KEY_STS_DEVICE_NO] = getConfigString(prefs, KEY_STS_DEVICE_NO, "")
config[KEY_STS_DEVICE_ID] = getConfigString(prefs, KEY_STS_DEVICE_ID, "")
config[KEY_STS_DEVICE_SECRET] = getConfigString(prefs, KEY_STS_DEVICE_SECRET, "")
Log.d(TAG, "liwei-------vipexptime 3-0 ${ config[KEY_VIP_EXPTIME]}")
// 添加 supportedLanguages 参数处理
val supportedLanguagesStr = getConfigString(prefs, KEY_SUPPORTED_LANGUAGES, "")

783
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/StsAgent.kt

@ -0,0 +1,783 @@
package com.yunqiinnovation.agent_service
import android.content.Context
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaPlayer
import android.media.MediaRecorder
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.yunqiinnovation.chat_storage.ChatStorageHelper
import kotlinx.coroutines.*
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okio.ByteString.Companion.toByteString
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.cert.X509Certificate
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
import kotlin.coroutines.resumeWithException
import kotlin.math.sqrt
/**
* STS 配置参数(移动精灵 A80 平台)
*/
data class StsConfig(
val productId: String,
val productKey: String,
var deviceNo: String,
var deviceId: String,
var deviceSecret: String,
val agentId: String,
)
/**
* StsAgent — 中国移动 A80 WS 一体化语音对话实现
*
* 通过一条 WSS 连接完成 ASR + AI + TTS,事件以与标准 stt_llm_tts 相同的格式
* 通过 [onEvent] 回调发出,供 AgentService 转发到 Flutter EventChannel。
*/
class StsAgent(
private val context: Context,
private var config: StsConfig,
private val onEvent: (eventName: String, data: Map<String, Any>) -> Unit,
private val onCredentialsFetched: ((deviceId: String, deviceSecret: String, deviceNo: String) -> Unit)? = null,
) : CoroutineScope {
private val TAG = "StsAgent"
private val job = SupervisorJob()
override val coroutineContext = Dispatchers.IO + job
init {
fetchCredentials()
}
// ── WebSocket ─────────────────────────────────────────────────────────────
private val okHttpClient: OkHttpClient = buildTrustAllClient()
private var webSocket: WebSocket? = null
private var readyReceived = false
private var sessionEnded = true
private var dialogRequestId = ""
// ── 录音 ──────────────────────────────────────────────────────────────────
private val SAMPLE_RATE = 16000
private val FRAME_SIZE = 5120 // 160ms @ 16kHz 16bit mono
private val RECORD_CHUNK = FRAME_SIZE / 2 // 读取块大小
private val RECORD_BUFFER = maxOf(
AudioRecord.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT
), FRAME_SIZE * 2
)
private var audioRecord: AudioRecord? = null
private var recordingJob: Job? = null
private var micChunkCount = 0
// ── 发送缓冲 ──────────────────────────────────────────────────────────────
private val sendBuffer = ArrayDeque<Byte>()
private var sendBufferOffset = 0
// ── 预缓冲(VAD 触发前 ~1.5s)────────────────────────────────────────────
private val preBuffer = ArrayDeque<ByteArray>()
private val PRE_BUFFER_MAX_CHUNKS = 75
// ── 本地 VAD ──────────────────────────────────────────────────────────────
private var speechDetected = false
private val VAD_THRESHOLD = 800.0
private val VAD_ONSET_CHUNKS = 3
private var vadOnsetCount = 0
// ── 状态标记 ──────────────────────────────────────────────────────────────
private var forwardingAudio = false
private var wsConnecting = false
private var inCall = false
private var pttRecording = false
// ── TTS 播放队列 ──────────────────────────────────────────────────────────
private val ttsQueue = ArrayDeque<String>()
private var ttsPlaying = false
private var ttsInterrupted = false
private var currentPlayer: MediaPlayer? = null
private val mainHandler = Handler(Looper.getMainLooper())
/** 静音开关:设为 true 时停止当前播放、跳过后续 TTS,文本接收完后主动关闭连接 */
var ttsMuted = false
set(value) {
field = value
if (value) {
// 立即停止当前播放
ttsQueue.clear()
ttsPlaying = false
mainHandler.post {
try { currentPlayer?.stop(); currentPlayer?.release() } catch (_: Exception) {}
currentPlayer = null
}
emit("tts_canceled", emptyMap())
// 如果会话已结束(文本已全部接收),主动关闭连接
if (sessionEnded) {
closeWs()
if (inCall) { resetVad(); startIdleTimer() }
}
}
}
// ── 消息历史(WS 上下文) ──────────────────────────────────────────────────
private var messages = JSONArray()
private val chatStorageHelper = ChatStorageHelper(context)
private var currentSessionId = ""
private var pendingAnswer = "" // 累积最完整的回复文本,会话结束时统一发送
// ── 计时器 ────────────────────────────────────────────────────────────────
private var micWatchdogJob: Job? = null
private var lastWatchdogChunkCount = 0
private var idleTimerJob: Job? = null
private val IDLE_TIMEOUT_MS = 15_000L
// ── 凭证就绪 ──────────────────────────────────────────────────────────────
var credentialReady = false
private set
// ═════════════════════════════════════════════════════════════════════════
// 凭证获取
// ═════════════════════════════════════════════════════════════════════════
fun fetchCredentials() {
if (config.deviceSecret.isNotEmpty()) {
credentialReady = true
return
}
launch {
try {
val body = JSONObject().apply {
put("deviceNoType", "SN")
put("deviceNo", config.deviceNo)
put("productId", config.productId)
put("productKey", config.productKey)
}.toString().toRequestBody("application/json".toMediaType())
val req = Request.Builder()
.url("https://ivs.chinamobiledevice.com:11443/v2/customer/device/secret/info")
.post(body)
.build()
okHttpClient.newCall(req).execute().use { resp ->
val json = JSONObject(resp.body!!.string())
if (json.getString("code") == "200" && json.getBoolean("success")) {
val d = json.getJSONObject("data")
config = config.copy(
deviceNo = d.optString("deviceNo", config.deviceNo),
deviceId = d.optString("deviceId", ""),
deviceSecret = d.optString("deviceSecret", ""),
)
credentialReady = config.deviceSecret.isNotEmpty()
if (credentialReady) {
onCredentialsFetched?.invoke(config.deviceId, config.deviceSecret, config.deviceNo)
}
} else {
Log.e(TAG, "凭证获取失败: ${json.optString("message")}")
}
}
} catch (e: Exception) {
Log.e(TAG, "凭证获取异常: ${e.message}")
}
}
}
// ═════════════════════════════════════════════════════════════════════════
// 公开接口
// ═════════════════════════════════════════════════════════════════════════
/** 通话模式:开启麦克风,VAD 自动触发 WS */
fun startCall() {
if (inCall) return
if (ttsPlaying) interruptTts()
if (pttRecording) stopPtt(cancel = true)
if (!credentialReady) { emitError("移动精灵凭证未就绪,请稍后重试"); return }
openMic()
startMicWatchdog()
inCall = true
startIdleTimer()
}
/** PTT 模式:立即连 WS 并开始录音 */
fun startPtt() {
if (pttRecording) return
if (ttsPlaying) interruptTts()
if (!credentialReady) { emitError("移动精灵凭证未就绪"); return }
pttRecording = true
openMic()
startMicWatchdog()
ttsInterrupted = false
closeWs()
sessionEnded = false
wsConnecting = true
dialogRequestId = UUID.randomUUID().toString()
clearSendBuffer()
pendingAnswer = ""
launch {
try {
connectWs()
forwardingAudio = true
wsConnecting = false
emit("recognition_started", emptyMap())
} catch (e: Exception) {
wsConnecting = false
pttRecording = false
stopMicWatchdog()
emitError("连接失败: ${e.message}")
}
}
}
/** PTT 松开:发送剩余音频 + finish */
fun stopPtt(cancel: Boolean = false) {
pttRecording = false
stopMicWatchdog()
if (cancel) {
forwardingAudio = false
closeWs()
stopMicHard()
return
}
if (forwardingAudio) {
forwardingAudio = false
synchronized(sendBufferLock) {
val remain = sendBuffer.size - sendBufferOffset
if (remain > 0) {
sendAudioChunk(sendBuffer.toMutableList().subList(sendBufferOffset, sendBuffer.size).toByteArray())
}
sendBuffer.clear(); sendBufferOffset = 0
}
sendFinish()
emit("recognition_stopped", emptyMap())
}
stopMicHard()
}
/** 统一停止:根据当前状态自动选择 stopPtt 或 disconnect */
fun stop() {
if (pttRecording) stopPtt()
else disconnect()
}
/** 挂断通话 */
fun disconnect() {
stopMicWatchdog()
cancelIdleTimer()
forwardingAudio = false
wsConnecting = false
speechDetected = false
stopMicHard()
ttsInterrupted = true
ttsQueue.clear()
ttsPlaying = false
mainHandler.post {
try { currentPlayer?.stop(); currentPlayer?.release() } catch (_: Exception) {}
currentPlayer = null
}
closeWs()
preBuffer.clear()
clearSendBuffer()
flushPendingAnswer()
inCall = false
pttRecording = false
}
/** 打断 AI 播报 */
fun interruptSpeaking() {
interruptTts()
ttsInterrupted = false
if (inCall) resetVad()
}
/** 清空消息历史 */
fun clearHistory() {
messages = JSONArray()
}
/** 释放资源 */
fun dispose() {
disconnect()
job.cancel()
}
// ═════════════════════════════════════════════════════════════════════════
// 麦克风 & 本地 VAD
// ═════════════════════════════════════════════════════════════════════════
private fun openMic() {
stopMicHard()
speechDetected = false; vadOnsetCount = 0; micChunkCount = 0
try {
audioRecord = AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
RECORD_BUFFER
)
audioRecord?.startRecording()
recordingJob = launch {
val buf = ByteArray(RECORD_CHUNK)
while (isActive && (inCall || pttRecording)) {
val read = audioRecord?.read(buf, 0, buf.size) ?: break
if (read > 0) { micChunkCount++; onMicAudio(buf.copyOf(read)) }
}
}
} catch (e: Exception) {
Log.e(TAG, "打开麦克风失败: ${e.message}")
}
}
private fun stopMicHard() {
recordingJob?.cancel(); recordingJob = null
try { audioRecord?.stop(); audioRecord?.release() } catch (_: Exception) {}
audioRecord = null
}
private fun startMicWatchdog() {
stopMicWatchdog()
lastWatchdogChunkCount = micChunkCount
micWatchdogJob = launch {
while (isActive && (inCall || pttRecording)) {
delay(1500)
if (micChunkCount == lastWatchdogChunkCount && !ttsPlaying) openMic()
lastWatchdogChunkCount = micChunkCount
}
}
}
private fun stopMicWatchdog() { micWatchdogJob?.cancel(); micWatchdogJob = null }
private fun onMicAudio(chunk: ByteArray) {
if (!inCall && !pttRecording) return
if (forwardingAudio && webSocket != null) {
if (micChunkCount % 3 == 0) emit("mic_rms", mapOf("rms" to calcRms(chunk)))
feedSendBuffer(chunk); return
}
if (wsConnecting) { addToPreBuffer(chunk); return }
if (ttsPlaying) return
addToPreBuffer(chunk)
val rms = calcRms(chunk)
// 发送麦克风音量给 Dart 层
if (micChunkCount % 3 == 0) {
emit("mic_rms", mapOf("rms" to rms))
}
if (rms >= VAD_THRESHOLD) {
vadOnsetCount++
if (vadOnsetCount >= VAD_ONSET_CHUNKS && !speechDetected) {
speechDetected = true
cancelIdleTimer()
launch { onSpeechDetected() }
}
} else {
vadOnsetCount = 0
}
}
private fun calcRms(pcm: ByteArray): Double {
if (pcm.size < 2) return 0.0
val samples = pcm.size / 2
var sum = 0.0
for (i in 0 until pcm.size - 1 step 2) {
var s = (pcm[i].toInt() and 0xFF) or (pcm[i + 1].toInt() shl 8)
if (s >= 32768) s -= 65536
sum += s.toLong() * s.toLong()
}
return sqrt(sum / samples)
}
private fun addToPreBuffer(chunk: ByteArray) {
preBuffer.addLast(chunk.copyOf())
while (preBuffer.size > PRE_BUFFER_MAX_CHUNKS) preBuffer.removeFirst()
}
// ═════════════════════════════════════════════════════════════════════════
// VAD 触发 → WS 连接
// ═════════════════════════════════════════════════════════════════════════
private suspend fun onSpeechDetected() {
if (wsConnecting) return
ttsInterrupted = false
closeWs()
sessionEnded = false
wsConnecting = true
dialogRequestId = UUID.randomUUID().toString()
clearSendBuffer()
pendingAnswer = ""
try {
connectWs()
} catch (e: Exception) {
wsConnecting = false; speechDetected = false
emitError("连接失败: ${e.message}")
return
}
forwardingAudio = true; wsConnecting = false
emit("recognition_started", emptyMap())
val cached = preBuffer.toList(); preBuffer.clear()
for (c in cached) feedSendBuffer(c)
}
// ═════════════════════════════════════════════════════════════════════════
// WebSocket
// ═════════════════════════════════════════════════════════════════════════
private suspend fun connectWs(): Unit = suspendCancellableCoroutine { cont ->
val ts = System.currentTimeMillis()
val sign = calcSign(config.deviceSecret, ts.toString())
val sn = UUID.randomUUID().toString()
val url = "wss://ivs.chinamobiledevice.com:11443/app-ws/v2/asr" +
"?deviceNo=${config.deviceNo}&deviceId=${config.deviceId}" +
"&productId=${config.productId}&productKey=${config.productKey}" +
"&sign=$sign&sn=$sn&ts=$ts"
val req = Request.Builder().url(url)
.header("Origin", "https://ivs.chinamobiledevice.com:11443")
.header("Host", "ivs.chinamobiledevice.com:11443")
.build()
var resolved = false
webSocket = okHttpClient.newWebSocket(req, object : WebSocketListener() {
override fun onOpen(ws: WebSocket, response: Response) {
sendStartFrame()
}
override fun onMessage(ws: WebSocket, text: String) {
try {
val msg = JSONObject(text)
if (msg.optString("type") == "ready") {
if (!resolved) { resolved = true; cont.resume(Unit) {} }
} else {
launch { handleMessage(msg) }
}
} catch (_: Exception) {}
}
override fun onMessage(ws: WebSocket, bytes: okio.ByteString) {}
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) {
webSocket = null
if (!resolved) { resolved = true; cont.resumeWithException(t) }
else launch { handleWsDone() }
}
override fun onClosing(ws: WebSocket, code: Int, reason: String) { ws.close(1000, null) }
override fun onClosed(ws: WebSocket, code: Int, reason: String) {
webSocket = null
if (!resolved) { resolved = true; cont.resumeWithException(IOException("WS closed")) }
else launch { handleWsDone() }
}
})
cont.invokeOnCancellation { closeWs() }
// 15s 超时
launch {
delay(15_000)
if (!resolved) {
resolved = true; closeWs()
cont.resumeWithException(IOException("等待 ready 超时"))
}
}
}
private fun handleWsDone() {
webSocket = null; forwardingAudio = false; wsConnecting = false
when {
sessionEnded && inCall -> if (!ttsPlaying) { resetVad(); startIdleTimer() }
sessionEnded -> { /* PTT 结束,等 TTS 播完 */ }
inCall -> resetVad()
}
}
private fun resetVad() {
speechDetected = false; vadOnsetCount = 0; preBuffer.clear(); openMic()
}
private fun closeWs() {
try { webSocket?.cancel() } catch (_: Exception) {}
webSocket = null
}
// ═════════════════════════════════════════════════════════════════════════
// 发送帧
// ═════════════════════════════════════════════════════════════════════════
private fun sendStartFrame() {
currentSessionId = UUID.randomUUID().toString()
val payload = JSONObject().apply {
put("type", "start")
put("data", JSONObject().apply {
put("cuid", config.deviceId)
put("format", "pcm")
put("sample", 16000)
put("support_dcs", 2)
put("chunk_size", 10240)
put("support_tts", true)
put("support_text2dcs", true)
put("user_agent", "voitrans/1.0")
put("dialog_request_id", dialogRequestId)
put("access_rc", true)
put("support_part_tts", true)
put("need_stoplisten", true)
put("need_dialogue_finish", true)
put("result_trans2directive", false)
put("rc_version", "5")
put("messages", messages)
put("client_context", JSONArray())
})
}
webSocket?.send(payload.toString())
}
private val sendBufferLock = Any()
private fun clearSendBuffer() {
synchronized(sendBufferLock) {
sendBuffer.clear(); sendBufferOffset = 0
}
}
private fun feedSendBuffer(chunk: ByteArray) {
synchronized(sendBufferLock) {
sendBuffer.addAll(chunk.toList())
while (sendBuffer.size - sendBufferOffset >= FRAME_SIZE) {
val frame = ByteArray(FRAME_SIZE) { i -> sendBuffer[sendBufferOffset + i] }
sendBufferOffset += FRAME_SIZE
sendAudioChunk(frame)
}
// 收缩缓冲
if (sendBufferOffset >= FRAME_SIZE * 8) {
val toRemove = sendBufferOffset
repeat(toRemove) { if (sendBuffer.isNotEmpty()) sendBuffer.removeFirst() }
sendBufferOffset = 0
}
}
}
private fun sendAudioChunk(data: ByteArray) {
try { webSocket?.send(data.toByteString()) } catch (_: Exception) {}
}
private fun sendFinish() {
try { webSocket?.send("""{"type":"finish"}""") } catch (_: Exception) {}
}
// ═════════════════════════════════════════════════════════════════════════
// 消息处理
// ═════════════════════════════════════════════════════════════════════════
private suspend fun handleMessage(msg: JSONObject) {
when (msg.optString("type")) {
"stoplisten" -> {
forwardingAudio = false; speechDetected = false; vadOnsetCount = 0
synchronized(sendBufferLock) {
val remain = sendBuffer.size - sendBufferOffset
if (remain > 0) {
sendAudioChunk(ByteArray(remain) { i -> sendBuffer[sendBufferOffset + i] })
}
sendBuffer.clear(); sendBufferOffset = 0
}
sendFinish()
emit("recognition_stopped", emptyMap())
}
"mid_result" -> {
val text = msg.optString("result", "")
if (text.isNotEmpty()) emit("recognizing", mapOf("text" to text))
}
"fin_result" -> {
val result = msg.optString("result", "")
if (result.isNotEmpty()) {
emit("recognition_result", mapOf("text" to result))
messages.put(JSONObject(mapOf("role" to "user", "content" to result)))
withContext(Dispatchers.IO) {
try {
chatStorageHelper.saveMessage(config.agentId, currentSessionId, result, "user")
} catch (_: Exception) {}
}
}
if (msg.optInt("end", 0) == 1) markSessionEnded()
}
"inside_rc" -> handleInsideRc(msg)
"dcs_decide" -> if (msg.optInt("end", 0) == 1) markSessionEnded()
}
}
private suspend fun handleInsideRc(msg: JSONObject) {
val data = msg.optJSONObject("data") ?: return
if (data.optInt("code", -1) != 0) return
val rawAnswer = data.optString("assistant_answer", "")
val cleanAnswer = try {
JSONObject(rawAnswer).optString("content", rawAnswer)
} catch (_: Exception) { rawAnswer }
var ttsUrl: String? = null
val items = data.optJSONArray("data")
if (items != null) {
for (i in 0 until items.length()) {
val item = items.optJSONObject(i) ?: continue
val h = item.optJSONObject("header") ?: continue
if (h.optString("namespace") == "ai.fxzsos.device_interface.voice_output" &&
h.optString("name") == "Speak"
) {
ttsUrl = item.optJSONObject("payload")?.optString("url")
break
}
}
}
// 每次取更完整的回复文本,立即发送给 UI
if (cleanAnswer.isNotEmpty() && cleanAnswer.length > pendingAnswer.length) {
pendingAnswer = cleanAnswer
emit("assistant_response", mapOf("response" to cleanAnswer, "sessionid" to currentSessionId))
}
ttsUrl?.takeIf { it.isNotEmpty() }?.let { enqueueTts(it) }
if (msg.optInt("end", 0) == 1) markSessionEnded()
}
/** 将 pendingAnswer 保存到历史和存储,并清空 */
private fun flushPendingAnswer() {
if (pendingAnswer.isEmpty()) return
val answer = pendingAnswer
val sid = currentSessionId
messages.put(JSONObject(mapOf("role" to "assistant", "content" to answer)))
launch(Dispatchers.IO) {
try {
chatStorageHelper.saveMessage(config.agentId, sid, answer, "assistant")
} catch (_: Exception) {}
}
pendingAnswer = ""
}
private fun markSessionEnded() {
if (sessionEnded) return
sessionEnded = true; forwardingAudio = false; speechDetected = false; vadOnsetCount = 0
clearSendBuffer()
flushPendingAnswer()
// 静音模式下文本接收完毕,发送完成事件并关闭连接
if (ttsMuted) {
emit("tts_completed", emptyMap())
closeWs()
if (inCall) { resetVad(); startIdleTimer() }
}
}
// ═════════════════════════════════════════════════════════════════════════
// TTS 播放队列
// ═════════════════════════════════════════════════════════════════════════
private fun enqueueTts(url: String) {
if (ttsMuted) return // 静音模式跳过 TTS
ttsQueue.addLast(url)
if (!ttsPlaying) {
stopMicHard()
mainHandler.post { playNextTts() }
}
}
private fun playNextTts() {
if (ttsQueue.isEmpty() || ttsInterrupted) {
ttsPlaying = false
if (!ttsInterrupted) {
emit("tts_completed", emptyMap())
if (inCall) { resetVad(); startIdleTimer() }
}
return
}
ttsPlaying = true
emit("tts_started", emptyMap())
val url = ttsQueue.removeFirst()
try {
currentPlayer?.stop(); currentPlayer?.release()
currentPlayer = MediaPlayer().apply {
setDataSource(url)
setOnCompletionListener {
if (!ttsInterrupted) mainHandler.post { playNextTts() }
}
setOnErrorListener { _, _, _ ->
if (!ttsInterrupted) mainHandler.post { playNextTts() }
true
}
prepare()
start()
}
} catch (e: Exception) {
Log.e(TAG, "TTS 播放失败: ${e.message}")
if (!ttsInterrupted) mainHandler.post { playNextTts() }
}
}
private fun interruptTts() {
ttsInterrupted = true; ttsQueue.clear(); ttsPlaying = false
mainHandler.post {
try { currentPlayer?.stop(); currentPlayer?.release() } catch (_: Exception) {}
currentPlayer = null
}
emit("tts_canceled", emptyMap())
}
// ═════════════════════════════════════════════════════════════════════════
// 超时 & 工具
// ═════════════════════════════════════════════════════════════════════════
private fun startIdleTimer() {
cancelIdleTimer()
idleTimerJob = launch {
delay(IDLE_TIMEOUT_MS)
if (inCall && !speechDetected) disconnect()
}
}
private fun cancelIdleTimer() { idleTimerJob?.cancel(); idleTimerJob = null }
private fun calcSign(secret: String, ts: String): String {
val digest = MessageDigest.getInstance("MD5").digest("$secret$ts".toByteArray())
return digest.joinToString("") { "%02x".format(it) }
}
private fun emit(eventName: String, data: Map<String, Any>) {
launch(Dispatchers.Main) { onEvent(eventName, data) }
}
private fun emitError(msg: String) = emit("error", mapOf("message" to msg))
private fun buildTrustAllClient(): OkHttpClient {
return try {
val trustAll = arrayOf<TrustManager>(object : X509TrustManager {
override fun checkClientTrusted(c: Array<X509Certificate>, t: String) {}
override fun checkServerTrusted(c: Array<X509Certificate>, t: String) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
})
val sc = SSLContext.getInstance("TLS")
sc.init(null, trustAll, SecureRandom())
OkHttpClient.Builder()
.sslSocketFactory(sc.socketFactory, trustAll[0] as X509TrustManager)
.hostnameVerifier { _, _ -> true }
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS)
.writeTimeout(90, TimeUnit.SECONDS)
.build()
} catch (e: Exception) {
Log.e(TAG, "buildTrustAllClient 失败,使用默认客户端", e)
OkHttpClient()
}
}
}

72
local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift

@ -66,8 +66,12 @@ class AgentServiceImpl: NSObject {
return ChatStorageHelper.shared
}
// Agent 模式
private var agentMode = "stt_llm_tts"
private var stsAgent: StsAgent?
// 会话ID,用于区分不同聊天上下文
private let agentId = "default_agent"
private var agentId = "default_agent"
//工具调用提示
public var callingToolText = "" // 工具调用提示
public var insufficientIntegralText = "" // 积分不足提示
@ -194,9 +198,38 @@ class AgentServiceImpl: NSObject {
func initialize(config: [String: Any]) -> Bool {
// if isInitialized { return true }
// os_log("initialize: config=%{public}@", log: logger, type: .info, config)
agentMode = config["agentMode"] as? String ?? "stt_llm_tts"
agentId = (config["agentId"] as? String)?.isEmpty == false
? config["agentId"] as! String : "default_agent"
// STS 模式:跳过 Azure/OpenAI 初始化
if agentMode == "sts" {
let stsConfig = StsConfig(
productId: config["stsProductId"] as? String ?? "",
productKey: config["stsProductKey"] as? String ?? "",
deviceNo: config["stsDeviceNo"] as? String ?? "",
deviceId: config["stsDeviceId"] as? String ?? "",
deviceSecret: config["stsDeviceSecret"] as? String ?? "",
agentId: agentId
)
stsAgent = StsAgent(
config: stsConfig,
onEvent: { [weak self] name, data in self?.sendEvent(name: name, data: data) },
onCredentialsFetched: { [weak self] deviceId, deviceSecret, deviceNo in
self?.sendEvent(name: "sts_credentials", data: [
"deviceId": deviceId,
"deviceSecret": deviceSecret,
"deviceNo": deviceNo,
])
}
)
isInitialized = true
return true
}
self.config = config
if let navigationMode = config["navigationMode"] as? String {
self.navigationMode = navigationMode
}
@ -490,6 +523,17 @@ class AgentServiceImpl: NSObject {
}
}
func startRecognition(useBle: Bool = false, mode: String = "normal") -> Bool {
if agentMode == "sts" {
switch mode {
case "push_to_talk":
stsAgent?.startPtt()
case "phone_call", "ble_wakeup":
stsAgent?.startCall()
default:
stsAgent?.startPtt()
}
return true
}
if !isInitialized {
sendError("服务未初始化", code: "NOT_INITIALIZED")
return false
@ -575,6 +619,10 @@ class AgentServiceImpl: NSObject {
}
func stopRecognition() -> Bool {
if agentMode == "sts" {
stsAgent?.stop()
return true
}
/// 函数功能:停止语音识别
/// 特性:
/// 1) 即便处于"启动中"(isStartingRecognition)也会打印日志并尝试停止
@ -679,8 +727,12 @@ class AgentServiceImpl: NSObject {
}
func interruptCurrentResponse() -> Bool {
if agentMode == "sts" {
stsAgent?.interruptSpeaking()
return true
}
var interrupted = false
if isSpeaking {
interrupted = stopTts() || interrupted
}
@ -1490,8 +1542,12 @@ private func jsonToString(_ json: [String: Any]) -> String? {
return result
}
func clearChatHistory() -> Bool {
if agentMode == "sts" {
stsAgent?.clearHistory()
return true
}
chatHistory.removeAll()
DispatchQueue.global(qos: .utility).async {
let success = self.chatStorageHelper.deleteMessages(agentId:self.agentId, messageIds: nil as [Int]?)
if !success {
@ -1738,6 +1794,12 @@ private func jsonToString(_ json: [String: Any]) -> String? {
func dispose() -> Bool {
print("ai释放资源")
if agentMode == "sts" {
stsAgent?.dispose()
stsAgent = nil
isInitialized = false
return true
}
if isRecognizing {
// 关闭编码器 (设备侧)
BleService.shared.closeCodec()

495
local_plugins/agent_service/ios/agent_service/Sources/agent_service/StsAgent.swift

@ -0,0 +1,495 @@
import Foundation
import AVFoundation
import CryptoKit
import os.log
import chat_storage
// MARK: - StsConfig
struct StsConfig {
let productId: String
let productKey: String
var deviceNo: String
var deviceId: String
var deviceSecret: String
let agentId: String
}
// MARK: - StsAgent
/// StsAgent — 中国移动 A80 WS 一体化语音对话实现(iOS)
///
/// 通过一条 WSS 连接完成 ASR + AI + TTS,事件格式与标准 stt_llm_tts 完全一致,
/// 通过 onEvent 回调发出,供 AgentServiceImpl 转发到 Flutter EventChannel。
class StsAgent: NSObject {
private let TAG = "StsAgent"
private let logger = OSLog(subsystem: "com.yunqiinnovation.agent_service", category: "StsAgent")
private var config: StsConfig
private let onEvent: (String, [String: Any]) -> Void
private let onCredentialsFetched: ((String, String, String) -> Void)?
// MARK: WebSocket
private var webSocketTask: URLSessionWebSocketTask?
private var urlSession: URLSession!
private var readyReceived = false
private var sessionEnded = true
private var dialogRequestId = ""
// MARK: 录音
private let SAMPLE_RATE: Double = 16000
private let FRAME_SIZE = 5120 // 160ms @ 16kHz 16bit mono
private var audioEngine: AVAudioEngine?
private var inputNode: AVAudioInputNode?
private var recordingActive = false
private var sendBuffer = Data()
private var micChunkCount = 0
// MARK: VAD
private let VAD_THRESHOLD: Float = 800
private let VAD_ONSET_CHUNKS = 3
private var vadOnsetCount = 0
private var vadActive = false
private var preBuffer: [[UInt8]] = []
private let PRE_BUFFER_MAX = 75
// MARK: TTS
private var ttsQueue: [String] = []
private var ttsPlayer: AVPlayer?
private var ttsPlaying = false
private var ttsPlayerObserver: Any?
// MARK: 历史
private var messages: [[String: String]] = []
private var currentSessionId = ""
private let chatStorage = ChatStorageHelper.shared
// MARK: 模式
private var isPttMode = false
// MARK: 凭证请求
private var credentialsFetched = false
init(config: StsConfig,
onEvent: @escaping (String, [String: Any]) -> Void,
onCredentialsFetched: ((String, String, String) -> Void)? = nil) {
self.config = config
self.onEvent = onEvent
self.onCredentialsFetched = onCredentialsFetched
super.init()
let sessionConfig = URLSessionConfiguration.default
urlSession = URLSession(configuration: sessionConfig, delegate: nil, delegateQueue: OperationQueue())
}
// MARK: - 公共接口
func startCall() {
isPttMode = false
os_log("StsAgent startCall", log: logger, type: .info)
fetchCredentialsIfNeeded { [weak self] in
self?.connect()
}
}
func startPtt() {
isPttMode = true
os_log("StsAgent startPtt", log: logger, type: .info)
fetchCredentialsIfNeeded { [weak self] in
self?.connect()
}
}
func stopPtt() {
os_log("StsAgent stopPtt", log: logger, type: .info)
guard isPttMode else { return }
sendFinishFrame()
}
/// 统一停止:根据当前模式自动选择 stopPtt 或 dispose
func stop() {
if isPttMode {
stopPtt()
} else {
dispose()
}
}
func interruptSpeaking() {
os_log("StsAgent interruptSpeaking", log: logger, type: .info)
stopTts()
emit("tts_canceled")
}
func clearHistory() {
messages.removeAll()
DispatchQueue.global().async { [weak self] in
guard let self = self else { return }
_ = self.chatStorage.deleteMessages(agentId: self.config.agentId, messageIds: nil as [Int]?)
}
}
func dispose() {
os_log("StsAgent dispose", log: logger, type: .info)
stopRecording()
webSocketTask?.cancel(with: .normalClosure, reason: nil)
webSocketTask = nil
stopTts()
}
// MARK: - 凭证获取
private func fetchCredentialsIfNeeded(completion: @escaping () -> Void) {
if credentialsFetched && !config.deviceId.isEmpty && !config.deviceSecret.isEmpty {
completion()
return
}
let ts = Int64(Date().timeIntervalSince1970 * 1000)
let sign = calcSign(secret: config.productKey, ts: ts)
let urlStr = "https://ivs.chinamobiledevice.com:11443/v2/customer/device/secret/info"
guard var components = URLComponents(string: urlStr) else { completion(); return }
components.queryItems = [
URLQueryItem(name: "productId", value: config.productId),
URLQueryItem(name: "deviceNo", value: config.deviceNo),
URLQueryItem(name: "ts", value: "\(ts)"),
URLQueryItem(name: "sign", value: sign),
]
guard let url = components.url else { completion(); return }
var req = URLRequest(url: url)
req.timeoutInterval = 10
URLSession.shared.dataTask(with: req) { [weak self] data, _, error in
guard let self = self else { return }
if let error = error {
os_log("凭证请求失败: %{public}@", log: self.logger, type: .error, error.localizedDescription)
completion()
return
}
guard let data = data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let body = json["body"] as? [String: Any] else {
completion()
return
}
if let deviceId = body["deviceId"] as? String { self.config.deviceId = deviceId }
if let deviceSecret = body["deviceSecret"] as? String { self.config.deviceSecret = deviceSecret }
if let deviceNo = body["deviceNo"] as? String { self.config.deviceNo = deviceNo }
self.credentialsFetched = true
self.onCredentialsFetched?(self.config.deviceId, self.config.deviceSecret, self.config.deviceNo)
completion()
}.resume()
}
// MARK: - WebSocket 连接
private func connect() {
let ts = Int64(Date().timeIntervalSince1970 * 1000)
let sign = calcSign(secret: config.deviceSecret, ts: ts)
let urlStr = "wss://ivs.chinamobiledevice.com:11443/app-ws/v2/asr"
guard var components = URLComponents(string: urlStr) else { return }
components.queryItems = [
URLQueryItem(name: "productId", value: config.productId),
URLQueryItem(name: "deviceId", value: config.deviceId),
URLQueryItem(name: "ts", value: "\(ts)"),
URLQueryItem(name: "sign", value: sign),
]
guard let url = components.url else { return }
webSocketTask = urlSession.webSocketTask(with: url)
webSocketTask?.resume()
receiveMessages()
// 发送 start 帧
sendStartFrame()
}
private func sendStartFrame() {
dialogRequestId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
currentSessionId = dialogRequestId
let payload: [String: Any] = [
"msgType": "start",
"dialogRequestId": dialogRequestId,
"params": [
"asrParams": [
"format": "pcm",
"sampleRate": 16000,
"lang": "zh-CN",
"vadEnable": false,
],
"contextParams": [
"history": messages.map { ["role": $0["role"] ?? "", "content": $0["content"] ?? ""] }
]
]
]
sendJson(payload)
}
private func receiveMessages() {
webSocketTask?.receive { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let msg):
switch msg {
case .string(let text):
self.handleMessage(text)
default:
break
}
self.receiveMessages()
case .failure(let err):
os_log("WS error: %{public}@", log: self.logger, type: .error, err.localizedDescription)
self.emit("error", data: ["message": err.localizedDescription])
}
}
}
// MARK: - 消息处理
private func handleMessage(_ text: String) {
guard let data = text.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
let msgType = json["msgType"] as? String ?? ""
os_log("WS recv: %{public}@", log: logger, type: .debug, msgType)
switch msgType {
case "ready":
readyReceived = true
sessionEnded = false
emit("recognition_started")
startRecording()
if isPttMode {
// PTT 模式等待 stopPtt() 调用发送 finish
}
case "mid_result":
let result = (json["data"] as? [String: Any])?["result"] as? String ?? ""
if !result.isEmpty { emit("recognizing", data: ["text": result]) }
case "fin_result":
let result = (json["data"] as? [String: Any])?["result"] as? String ?? ""
emit("recognition_result", data: ["text": result])
emit("recognition_stopped")
stopRecording()
if !result.isEmpty {
messages.append(["role": "user", "content": result])
saveToChatStorage(text: result, role: "user")
}
case "stoplisten":
stopRecording()
emit("recognition_stopped")
case "inside_rc":
handleInsideRc(json["data"] as? [String: Any])
case "dcs_decide":
// 语义决策,忽略
break
default:
os_log("未处理的消息类型: %{public}@", log: logger, type: .info, msgType)
}
}
private func handleInsideRc(_ data: [String: Any]?) {
guard let data = data else { return }
guard let rcType = data["rcType"] as? String else { return }
if rcType == "assistant_answer" {
let answer = (data["answer"] as? [String: Any])?["text"] as? String ?? ""
let cleanAnswer = answer.trimmingCharacters(in: .whitespacesAndNewlines)
let sid = data["sessionId"] as? String ?? currentSessionId
if !cleanAnswer.isEmpty {
messages.append(["role": "assistant", "content": cleanAnswer])
saveToChatStorage(text: cleanAnswer, role: "assistant")
emit("assistant_response", data: ["response": cleanAnswer, "sessionid": sid])
}
// TTS URLs
if let ttsData = data["tts"] as? [[String: Any]] {
for item in ttsData {
if let url = item["url"] as? String, !url.isEmpty {
ttsQueue.append(url)
}
}
if !ttsPlaying { playNextTts() }
}
}
}
// MARK: - TTS 播放
private func playNextTts() {
guard !ttsQueue.isEmpty else {
ttsPlaying = false
emit("tts_completed")
return
}
let urlStr = ttsQueue.removeFirst()
guard let url = URL(string: urlStr) else { playNextTts(); return }
ttsPlaying = true
if ttsQueue.isEmpty && !ttsPlaying { emit("tts_started") }
emit("tts_started")
let item = AVPlayerItem(url: url)
ttsPlayer = AVPlayer(playerItem: item)
if let observer = ttsPlayerObserver {
NotificationCenter.default.removeObserver(observer)
}
ttsPlayerObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main
) { [weak self] _ in
self?.playNextTts()
}
ttsPlayer?.play()
}
private func stopTts() {
ttsPlayer?.pause()
ttsPlayer = nil
ttsQueue.removeAll()
ttsPlaying = false
if let observer = ttsPlayerObserver {
NotificationCenter.default.removeObserver(observer)
ttsPlayerObserver = nil
}
}
// MARK: - 录音
private func startRecording() {
guard !recordingActive else { return }
let engine = AVAudioEngine()
audioEngine = engine
inputNode = engine.inputNode
let fmt = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: SAMPLE_RATE, channels: 1, interleaved: true)!
let hwFmt = inputNode!.outputFormat(forBus: 0)
let converterFmt = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: SAMPLE_RATE, channels: 1, interleaved: true)!
inputNode!.installTap(onBus: 0, bufferSize: 4096, format: hwFmt) { [weak self] buffer, _ in
guard let self = self, self.recordingActive else { return }
// 转换为 16kHz PCM16
guard let cvt = AVAudioConverter(from: hwFmt, to: converterFmt) else { return }
let frameCount = AVAudioFrameCount(Double(buffer.frameLength) * self.SAMPLE_RATE / hwFmt.sampleRate)
guard let outBuf = AVAudioPCMBuffer(pcmFormat: converterFmt, frameCapacity: frameCount) else { return }
var error: NSError?
cvt.convert(to: outBuf, error: &error) { _, outStatus in
outStatus.pointee = .haveData
return buffer
}
if error != nil { return }
let byteCount = Int(outBuf.frameLength) * 2
let ptr = outBuf.int16ChannelData![0]
let data = Data(bytes: ptr, count: byteCount)
self.onMicAudio(data)
}
do {
try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker])
try AVAudioSession.sharedInstance().setActive(true)
try engine.start()
recordingActive = true
} catch {
os_log("录音启动失败: %{public}@", log: logger, type: .error, error.localizedDescription)
}
}
private func stopRecording() {
guard recordingActive else { return }
recordingActive = false
inputNode?.removeTap(onBus: 0)
audioEngine?.stop()
audioEngine = nil
inputNode = nil
sendBuffer.removeAll()
micChunkCount = 0
vadActive = false
vadOnsetCount = 0
preBuffer.removeAll()
}
private func onMicAudio(_ pcm: Data) {
micChunkCount += 1
let rms = calcRms(pcm)
let chunk = [UInt8](pcm)
if !vadActive {
if preBuffer.count >= PRE_BUFFER_MAX { preBuffer.removeFirst() }
preBuffer.append(chunk)
}
if !vadActive && rms > VAD_THRESHOLD {
vadOnsetCount += 1
if vadOnsetCount >= VAD_ONSET_CHUNKS {
vadActive = true
// 先发预缓存
for c in preBuffer { feedSendBuffer(Data(c)) }
preBuffer.removeAll()
}
} else if vadActive {
feedSendBuffer(pcm)
}
}
private func calcRms(_ pcm: Data) -> Float {
let count = pcm.count / 2
guard count > 0 else { return 0 }
var sum: Float = 0
pcm.withUnsafeBytes { ptr in
let samples = ptr.bindMemory(to: Int16.self)
for i in 0..<count {
let s = Float(samples[i])
sum += s * s
}
}
return sqrtf(sum / Float(count))
}
private func feedSendBuffer(_ chunk: Data) {
sendBuffer.append(chunk)
while sendBuffer.count >= FRAME_SIZE {
let frame = sendBuffer.prefix(FRAME_SIZE)
sendBuffer.removeFirst(FRAME_SIZE)
webSocketTask?.send(.data(Data(frame))) { _ in }
}
}
private func sendFinishFrame() {
let payload: [String: Any] = [
"msgType": "finish",
"dialogRequestId": dialogRequestId,
]
sendJson(payload)
}
// MARK: - 工具
private func sendJson(_ dict: [String: Any]) {
guard let data = try? JSONSerialization.data(withJSONObject: dict),
let str = String(data: data, encoding: .utf8) else { return }
webSocketTask?.send(.string(str)) { _ in }
}
private func calcSign(secret: String, ts: Int64) -> String {
let raw = "\(secret)\(ts)"
let data = Data(raw.utf8)
let digest = Insecure.MD5.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
private func emit(_ name: String, data: [String: Any] = [:]) {
DispatchQueue.main.async { [weak self] in
self?.onEvent(name, data)
}
}
private func saveToChatStorage(text: String, role: String) {
DispatchQueue.global().async { [weak self] in
guard let self = self else { return }
_ = self.chatStorage.saveMessage(
agentId: self.config.agentId,
sessionId: self.currentSessionId,
message: text,
sender: role,
metadata: nil
)
}
}
}

22
local_plugins/agent_service/lib/agent_service.dart

@ -71,6 +71,12 @@ enum AgentServiceEventType {
/// 错误
error,
/// STS 凭证获取成功(设备 deviceId/deviceSecret)
stsCredentials,
/// 麦克风音量(RMS)
micRms,
/// 未知事件
unknown
}
@ -176,6 +182,10 @@ class AgentService {
return AgentServiceEventType.imageProcessing;
case 'image_ready':
return AgentServiceEventType.imageReady;
case 'sts_credentials':
return AgentServiceEventType.stsCredentials;
case 'mic_rms':
return AgentServiceEventType.micRms;
default:
return AgentServiceEventType.unknown;
}
@ -446,6 +456,18 @@ class AgentService {
}
}
/// 设置 TTS 静音开关(STS 模式)
static Future<bool> setTtsMuted(bool muted) async {
try {
final bool result = await _channel.invokeMethod('setTtsMuted', {
'muted': muted,
});
return result;
} on PlatformException catch (e) {
throw AgentServiceException(e.code, e.message ?? '设置TTS静音失败', e.details);
}
}
/// 中断当前响应
///
/// 停止语音合成和AI流输出

2
local_plugins/ble_service/lib/ble_service.dart

@ -329,7 +329,7 @@ class BleService {
/// 断开连接
Future<bool> stopScan() async {
try {
final result = await _methodChannel.invokeMethod<bool>('stopscan');
final result = await _methodChannel.invokeMethod<bool>('stopScan');
return result ?? false;
} catch (e) {
print('开启扫描ble设备失败: $e');

Loading…
Cancel
Save