Browse Source

Merge branch 'liwei)_yidong' of https://github.com/deepcloud2048/deep_voice into liwei

newdev_shunjiawei
liwei1dao 6 months ago
parent
commit
ea98609645
  1. 8
      lib/data/models/user_Info.dart
  2. 60
      lib/data/services/ble_manager.dart
  3. 463
      lib/modules/devices/views/devices_view.dart
  4. 8
      lib/modules/home/controllers/home_controller.dart
  5. 1
      lib/modules/home/views/home_view.dart
  6. 9
      lib/modules/mobile_elf/bindings/mobile_elf_binding.dart
  7. 459
      lib/modules/mobile_elf/controllers/mobile_elf_controller.dart
  8. 988
      lib/modules/mobile_elf/views/mobile_elf_view.dart
  9. 107
      lib/modules/pairing/controllers/pairing_controller.dart
  10. 9
      lib/routes/app_pages.dart
  11. 3
      lib/routes/app_routes.dart
  12. 97
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt
  13. 40
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt
  14. 31
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt
  15. 935
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/StsAgent.kt
  16. 75
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift
  17. 11
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift
  18. 613
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/StsAgent.swift
  19. 51
      local_plugins/agent_service/lib/agent_service.dart
  20. 2
      local_plugins/ble_service/lib/ble_service.dart
  21. 563
      preview/yidong.html
  22. 81
      test_mobile_elf.py

8
lib/data/models/user_Info.dart

@ -218,34 +218,42 @@ class User {
class UserDevice { class UserDevice {
int id; int id;
String uid; String uid;
int productid;
int devicetype; int devicetype;
String devicename; String devicename;
String devicemac; String devicemac;
String license; String license;
String cmei;
UserDevice({ UserDevice({
required this.id, required this.id,
required this.uid, required this.uid,
required this.productid,
required this.devicetype, required this.devicetype,
required this.devicename, required this.devicename,
required this.devicemac, required this.devicemac,
required this.license, required this.license,
this.cmei = '',
}); });
factory UserDevice.fromJson(Map<String, dynamic> json) => UserDevice( factory UserDevice.fromJson(Map<String, dynamic> json) => UserDevice(
id: json['id'] as int, id: json['id'] as int,
uid: json['uid'] as String, uid: json['uid'] as String,
productid: (json['productid'] as num).toInt(),
devicetype: (json['devicetype'] as num).toInt(), devicetype: (json['devicetype'] as num).toInt(),
devicename: json['devicename'] as String, devicename: json['devicename'] as String,
devicemac: json['devicemac'] as String, devicemac: json['devicemac'] as String,
license: json['license'] as String, license: json['license'] as String,
cmei: json['cmei'] as String? ?? '',
); );
toJson() => { toJson() => {
'id': id, 'id': id,
'uid': uid, 'uid': uid,
'productid': productid,
'devicetype': devicetype, 'devicetype': devicetype,
'devicename': devicename, 'devicename': devicename,
'devicemac': devicemac, 'devicemac': devicemac,
'license': license, 'license': license,
'cmei': cmei,
}; };
} }

60
lib/data/services/ble_manager.dart

@ -601,23 +601,17 @@ class BleManager extends GetxService {
} }
} }
/// 更新设备id /// 更新设备id,并检测是否为移动精灵设备
void _updateProductIdFromData(Map<dynamic, dynamic> data) { void _updateProductIdFromData(Map<dynamic, dynamic> data) {
try { try {
// 确保数据中包含版本信息
if (!data.containsKey('productId')) { if (!data.containsKey('productId')) {
Logger.warning('版本信息数据不完整'); Logger.warning('版本信息数据不完整');
return; return;
} }
// 提取版本信息
final String productId = data['productId'] as String? ?? ''; final String productId = data['productId'] as String? ?? '';
// 更新设备信息 _updateDeviceInfoIfNeeded(firmwareProductId: productId);
_updateDeviceInfoIfNeeded(
firmwareProductId: productId,
);
Logger.info('设备设备id已更新: 设备id=$productId'); Logger.info('设备设备id已更新: 设备id=$productId');
} catch (e) { } catch (e) {
Logger.error('更新设备id失败: ${e.toString()}'); Logger.error('更新设备id失败: ${e.toString()}');
@ -1035,7 +1029,42 @@ class BleManager extends GetxService {
supportedLanguages = ['zh-CN', 'en-US']; supportedLanguages = ['zh-CN', 'en-US'];
} }
// STS 模式参数(移动精灵)
// 优先使用设备检测结果(YIDONG_PID 匹配时写入),否则用服务端 AGENT_TYPE
final agentType = _storage.read<String>('device_agent_type')
?? AppConfig.env('AGENT_TYPE')
?? 'stt_llm_tts';
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') ?? '';
// 使用当前连接设备的 SN(配对时写入),认证失败由原生层上报 credential_failed 事件
final stsDeviceNo = _storage.read<String>('device_sts_code') ?? '';
final stsDeviceId = _storage.read<String>('sts_device_id') ?? '';
final stsDeviceSecret = _storage.read<String>('sts_device_secret') ?? '';
// 从 UserDevice 获取当前连接设备的 CMEI
final mac = currentDeviceAddress?.toUpperCase() ?? '';
String stsCmei = '';
try {
final matchedDevice = userInfo.User.instance.devices.firstWhere(
(d) => d.devicemac.toUpperCase() == mac,
);
stsCmei = matchedDevice.cmei;
} catch (_) {}
Logger.info('lxm--当前设备 CMEI: $stsCmei (MAC=$mac)');
final config = <String, dynamic>{ final config = <String, dynamic>{
// STS 模式路由
'agentMode': agentMode,
'agentId': agentId,
'stsProductId': stsProductId,
'stsProductKey': stsProductKey,
'stsDeviceNo': stsDeviceNo,
'stsDeviceId': stsDeviceId,
'stsDeviceSecret': stsDeviceSecret,
'stsCmei': stsCmei,
// 语音代理配置参数 // 语音代理配置参数
'azureSpeechKey': azureSpeechKey, 'azureSpeechKey': azureSpeechKey,
'azureSpeechRegion': azureSpeechRegion, 'azureSpeechRegion': azureSpeechRegion,
@ -1068,7 +1097,7 @@ class BleManager extends GetxService {
Logger.warning('XUNFEI_ASR_* 未完整配置,ASR 将不会切到讯飞'); Logger.warning('XUNFEI_ASR_* 未完整配置,ASR 将不会切到讯飞');
} }
if (azureSpeechKey.isEmpty || openaiApiKey.isEmpty) { if (agentMode != 'sts' && (azureSpeechKey.isEmpty || openaiApiKey.isEmpty)) {
Logger.warning('缺少必要的API密钥,请检查.env文件配置'); Logger.warning('缺少必要的API密钥,请检查.env文件配置');
// 检查.env中相关密钥是否存在 // 检查.env中相关密钥是否存在
@ -1097,6 +1126,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服务'); Logger.info('lxm--准备调用AgentService.startAgentService启动BLE服务');
final result = await AgentService.startAgentService(config); final result = await AgentService.startAgentService(config);
Logger.info('lxm--AgentService.startAgentService调用结果: $result'); Logger.info('lxm--AgentService.startAgentService调用结果: $result');

463
lib/modules/devices/views/devices_view.dart

@ -1,12 +1,10 @@
import 'dart:ui';
import '../../../routes/app_routes.dart'; import '../../../routes/app_routes.dart';
import '/modules/devices/controllers/devices_controller.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '/core/theme/app_colors.dart'; import '/core/theme/app_colors.dart';
import '/data/models/user_Info.dart'; import '/data/models/user_Info.dart';
import '/data/services/ble_manager.dart';
import '../controllers/devices_controller.dart'; import '../controllers/devices_controller.dart';
// 设备类型枚举 // 设备类型枚举
@ -20,7 +18,6 @@ enum DeviceType {
const DeviceType(this.value); const DeviceType(this.value);
final int value; final int value;
// 使用getter获取多语言显示名称
String get displayName { String get displayName {
switch (this) { switch (this) {
case DeviceType.Unknown: case DeviceType.Unknown:
@ -61,112 +58,240 @@ enum DeviceType {
class DevicesView extends GetView<DevicesController> { class DevicesView extends GetView<DevicesController> {
const DevicesView({super.key}); const DevicesView({super.key});
bool _isConnected(UserDevice device) {
try {
final bleManager = Get.find<BleManager>();
return bleManager.currentDeviceAddress != null &&
bleManager.currentDeviceAddress == device.devicemac;
} catch (_) {
return false;
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final bg = isDark ? const Color(0xFF0A0A0F) : const Color(0xFFF5F6FA);
final topBg = isDark ? const Color(0xFF0F1018) : const Color(0xFFF0F1F6);
return Scaffold( return Scaffold(
backgroundColor: isDarkMode ? AppColors.darkBackground : Colors.white, backgroundColor: bg,
body: Column( body: Column(
children: [ children: [
// 顶部标题栏 // 顶部区域
Container( _buildTopArea(context, isDark, bg, topBg),
// 设备列表
Expanded(child: _buildDevicesList(isDark, topBg)),
],
),
);
}
Widget _buildTopArea(
BuildContext context, bool isDark, Color bg, Color topBg) {
return Container(
padding: EdgeInsets.only( padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 10.h, top: MediaQuery.of(context).padding.top + 8.h,
left: 20.w, left: 20.w,
right: 20.w, right: 20.w,
bottom: 20.h, bottom: 24.h,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(color: bg),
color: isDarkMode ? Colors.black : Colors.white, child: Column(
boxShadow: [ crossAxisAlignment: CrossAxisAlignment.start,
BoxShadow( children: [
color: Colors.black.withOpacity(0.05), // 导航栏
blurRadius: 10, Row(
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [ children: [
GestureDetector( GestureDetector(
onTap: () => Get.back(), onTap: () => Get.back(),
child: Container(
width: 40.w,
height: 40.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isDark
? Colors.white.withOpacity(0.06)
: Colors.black.withOpacity(0.04),
),
child: Icon( child: Icon(
Icons.arrow_back_ios, Icons.arrow_back_ios_new,
color: isDarkMode ? Colors.white : Colors.black, size: 18.sp,
size: 24.sp, color: isDark ? Colors.white : const Color(0xFF222222),
),
), ),
), ),
SizedBox(width: 16.w), SizedBox(width: 16.w),
Text( Text(
"deviceManagement".tr, "deviceManagement".tr,
style: TextStyle( style: TextStyle(
fontSize: 20.sp, fontSize: 26.sp,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w800,
color: isDarkMode ? Colors.white : Colors.black, color: isDark ? Colors.white : const Color(0xFF111111),
letterSpacing: -0.5,
), ),
), ),
], ],
), ),
SizedBox(height: 20.h),
// 摘要卡片
Obx(() {
final totalDevices = controller.devices.values
.fold<int>(0, (sum, list) => sum + list.length);
final connectedCount = controller.devices.values
.expand((list) => list)
.where((d) => _isConnected(d))
.length;
return Row(
children: [
_buildSummaryCard(
number: totalDevices.toString(),
label: "boundDevices".tr,
isActive: true,
isDark: isDark,
),
SizedBox(width: 12.w),
_buildSummaryCard(
number: connectedCount.toString(),
label: "currentConnection".tr,
isActive: false,
isDark: isDark,
),
],
);
}),
],
),
);
}
Widget _buildSummaryCard({
required String number,
required String label,
required bool isActive,
required bool isDark,
}) {
final activeGradient = isDark
? [
const Color(0xFF6366F1).withOpacity(0.15),
const Color(0xFF8B5CF6).withOpacity(0.1),
]
: [const Color(0xFFEEF2FF), const Color(0xFFF5F3FF)];
return Expanded(
child: Container(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 14.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.r),
gradient: isActive ? LinearGradient(colors: activeGradient) : null,
color: isActive
? null
: (isDark
? Colors.white.withOpacity(0.04)
: Colors.white),
border: Border.all(
color: isActive
? (isDark
? const Color(0xFF6366F1).withOpacity(0.2)
: const Color(0xFF6366F1).withOpacity(0.15))
: (isDark
? Colors.white.withOpacity(0.06)
: Colors.black.withOpacity(0.04)),
),
boxShadow: isDark
? null
: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
number,
style: TextStyle(
fontSize: 24.sp,
fontWeight: FontWeight.w700,
color: isActive
? (isDark
? const Color(0xFFA5B4FC)
: const Color(0xFF6366F1))
: (isDark ? Colors.white : const Color(0xFF222222)),
),
),
SizedBox(height: 2.h),
Text(
label,
style: TextStyle(
fontSize: 11.sp,
fontWeight: FontWeight.w500,
color: isActive
? (isDark
? const Color(0xFFA5B4FC).withOpacity(0.6)
: const Color(0xFF818CF8))
: (isDark
? Colors.white.withOpacity(0.35)
: const Color(0xFF999999)),
), ),
// 设备列表内容
Expanded(
child: _buildDevicesList(isDarkMode),
), ),
], ],
), ),
),
); );
} }
Widget _buildDevicesList(bool isDarkMode) { Widget _buildDevicesList(bool isDark, Color topBg) {
return Obx(() { return Obx(() {
// 使用控制器的响应式数据,这会触发控制器的实例化
final groupedDevices = controller.devices; final groupedDevices = controller.devices;
if (groupedDevices.isEmpty) { if (groupedDevices.isEmpty) {
return _buildEmptyState(isDarkMode); return _buildEmptyState(isDark);
} }
return ListView( return ListView(
padding: EdgeInsets.all(20.w), padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 20.h),
children: [ children: [
// 遍历每个设备类型
...groupedDevices.entries.map((entry) { ...groupedDevices.entries.map((entry) {
final deviceTypeValue = entry.key; final deviceType = DeviceType.fromValue(entry.key);
final deviceList = entry.value; final deviceList = entry.value;
final deviceType = DeviceType.fromValue(deviceTypeValue);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 设备类型标题 // 分类标题
Padding( Padding(
padding: EdgeInsets.only(bottom: 12.h), padding: EdgeInsets.only(left: 8.w, bottom: 12.h),
child: Text( child: Text(
deviceType.displayName, deviceType.displayName.toUpperCase(),
style: TextStyle( style: TextStyle(
fontSize: 16.sp, fontSize: 12.sp,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black87, color: isDark
? Colors.white.withOpacity(0.25)
: const Color(0xFFAAAAAA),
letterSpacing: 1.5,
), ),
), ),
), ),
// 该类型下的设备列表
...deviceList.map((device) => _buildDeviceCard( ...deviceList.map((device) => _buildDeviceCard(
device: device, device: device,
deviceType: deviceType, deviceType: deviceType,
isDarkMode: isDarkMode, isDark: isDark,
)), )),
SizedBox(height: 20.h), SizedBox(height: 16.h),
], ],
); );
}).toList(), }),
], ],
); );
}); });
} }
Widget _buildEmptyState(bool isDarkMode) { Widget _buildEmptyState(bool isDark) {
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -174,14 +299,14 @@ class DevicesView extends GetView<DevicesController> {
Icon( Icon(
Icons.devices_other, Icons.devices_other,
size: 80.sp, size: 80.sp,
color: isDarkMode ? Colors.white54 : Colors.grey[400], color: isDark ? Colors.white54 : Colors.grey[400],
), ),
SizedBox(height: 20.h), SizedBox(height: 20.h),
Text( Text(
"noDevicesBound".tr, "noDevicesBound".tr,
style: TextStyle( style: TextStyle(
fontSize: 16.sp, fontSize: 16.sp,
color: isDarkMode ? Colors.white70 : Colors.grey[600], color: isDark ? Colors.white70 : Colors.grey[600],
), ),
), ),
SizedBox(height: 12.h), SizedBox(height: 12.h),
@ -189,7 +314,7 @@ class DevicesView extends GetView<DevicesController> {
"pleaseBindDeviceFirst".tr, "pleaseBindDeviceFirst".tr,
style: TextStyle( style: TextStyle(
fontSize: 14.sp, fontSize: 14.sp,
color: isDarkMode ? Colors.white54 : Colors.grey[500], color: isDark ? Colors.white54 : Colors.grey[500],
), ),
), ),
], ],
@ -200,47 +325,82 @@ class DevicesView extends GetView<DevicesController> {
Widget _buildDeviceCard({ Widget _buildDeviceCard({
required UserDevice device, required UserDevice device,
required DeviceType deviceType, required DeviceType deviceType,
required bool isDarkMode, required bool isDark,
}) { }) {
final connected = _isConnected(device);
final connectedColor = const Color(0xFF22C55E);
return GestureDetector( return GestureDetector(
onTap: () => Get.toNamed(Routes.eqSettings), onTap: () => Get.toNamed(Routes.eqSettings),
child: Container( child: Container(
margin: EdgeInsets.only(bottom: 12.h), margin: EdgeInsets.only(bottom: 10.h),
padding: EdgeInsets.all(16.w), padding: EdgeInsets.all(20.w),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isDarkMode ? AppColors.cardBackground : Colors.white, borderRadius: BorderRadius.circular(20.r),
borderRadius: BorderRadius.circular(12.r), color: isDark
boxShadow: [ ? (connected
? connectedColor.withOpacity(0.04)
: Colors.white.withOpacity(0.03))
: (connected
? connectedColor.withOpacity(0.03)
: Colors.white),
border: Border.all(
color: connected
? connectedColor.withOpacity(0.2)
: (isDark
? Colors.white.withOpacity(0.05)
: Colors.black.withOpacity(0.04)),
),
boxShadow: isDark
? null
: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.05), color: Colors.black.withOpacity(0.03),
blurRadius: 8, blurRadius: 12,
offset: const Offset(0, 2), offset: const Offset(0, 2),
), ),
], ],
), ),
child: Row( child: Column(
children: [
// 上部:图标 + 名称/MAC + 解绑
Row(
children: [ children: [
// 设备图标 // 设备图标
Container( Container(
width: 48.w, width: 46.w,
height: 48.w, height: 46.w,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isDarkMode borderRadius: BorderRadius.circular(14.r),
? Colors.white.withOpacity(0.1) gradient: LinearGradient(
: Colors.grey[100], begin: Alignment.topLeft,
borderRadius: BorderRadius.circular(12.r), end: Alignment.bottomRight,
colors: connected
? [connectedColor, const Color(0xFF16A34A)]
: [const Color(0xFF6366F1), const Color(0xFF8B5CF6)],
),
boxShadow: [
BoxShadow(
color: (connected ? connectedColor : const Color(0xFF6366F1))
.withOpacity(0.3),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
), ),
child: Icon( child: Icon(
deviceType.icon, deviceType.icon,
color: isDarkMode ? Colors.white : Colors.black87, color: Colors.white,
size: 24.sp, size: 22.sp,
), ),
), ),
SizedBox(width: 16.w), SizedBox(width: 14.w),
// 设备信息 // 设备名 + MAC
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [ children: [
Text( Text(
device.devicename.isNotEmpty device.devicename.isNotEmpty
@ -248,73 +408,180 @@ class DevicesView extends GetView<DevicesController> {
: "deviceTypeUnknown".tr, : "deviceTypeUnknown".tr,
style: TextStyle( style: TextStyle(
fontSize: 16.sp, fontSize: 16.sp,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w700,
color: isDarkMode ? Colors.white : Colors.black87, color: isDark
? Colors.white
: const Color(0xFF1A1A2E),
), ),
), ),
SizedBox(height: 4.h), if (connected) ...[
Text( SizedBox(width: 8.w),
device.devicemac.isNotEmpty Container(
? device.devicemac width: 6.w,
: "noMacAddress".tr, height: 6.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: connectedColor,
boxShadow: [
BoxShadow(
color: connectedColor.withOpacity(0.6),
blurRadius: 8,
),
],
),
),
SizedBox(width: 6.w),
Container(
padding: EdgeInsets.symmetric(
horizontal: 8.w, vertical: 3.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6.r),
color: connectedColor.withOpacity(0.1),
),
child: Text(
"connected".tr,
style: TextStyle( style: TextStyle(
fontSize: 14.sp, fontSize: 10.sp,
color: isDarkMode ? Colors.white70 : Colors.grey[600], fontWeight: FontWeight.w600,
color: connectedColor,
),
), ),
), ),
if (device.license.isNotEmpty) ...[ ],
SizedBox(height: 4.h), ],
),
SizedBox(height: 3.h),
Text( Text(
"${"licenseCode".tr}: ${device.license}", device.devicemac.isNotEmpty
? device.devicemac
: "noMacAddress".tr,
style: TextStyle( style: TextStyle(
fontSize: 12.sp, fontSize: 12.sp,
color: isDarkMode ? Colors.white54 : Colors.grey[500], fontWeight: FontWeight.w500,
color: isDark
? Colors.white.withOpacity(0.3)
: const Color(0xFF999999),
fontFamily: 'Menlo',
letterSpacing: 0.5,
), ),
), ),
], ],
],
), ),
), ),
// 解绑按钮 // 解绑按钮
GestureDetector( GestureDetector(
onTap: () => _showUnbindDialog(device, isDarkMode), onTap: () => _showUnbindDialog(device, isDark),
child: Container( child: Container(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: 12.w, horizontal: 14.w, vertical: 8.h),
vertical: 6.h,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1), borderRadius: BorderRadius.circular(10.r),
borderRadius: BorderRadius.circular(6.r),
border: Border.all( border: Border.all(
color: Colors.red.withOpacity(0.3), color: isDark
width: 1, ? Colors.white.withOpacity(0.08)
: Colors.black.withOpacity(0.08),
), ),
), ),
child: Text( child: Text(
"unbind".tr, "unbind".tr,
style: TextStyle( style: TextStyle(
fontSize: 12.sp, fontSize: 12.sp,
color: Colors.red, fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500, color: isDark
? Colors.white.withOpacity(0.4)
: const Color(0xFF999999),
),
),
),
),
],
),
// 下部:授权码 / CMEI
if (device.license.isNotEmpty || device.cmei.isNotEmpty) ...[
SizedBox(height: 16.h),
Container(
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.r),
color: isDark
? Colors.white.withOpacity(0.03)
: const Color(0xFFF7F7FA),
), ),
child: Column(
children: [
if (device.license.isNotEmpty)
_buildFieldRow(
label: "licenseCode".tr,
value: device.license,
isDark: isDark,
),
if (device.license.isNotEmpty && device.cmei.isNotEmpty)
SizedBox(height: 8.h),
if (device.cmei.isNotEmpty)
_buildFieldRow(
label: "CMEI",
value: device.cmei,
isDark: isDark,
), ),
],
), ),
), ),
], ],
],
), ),
), ),
); );
} }
void _showUnbindDialog(UserDevice device, bool isDarkMode) { Widget _buildFieldRow({
required String label,
required String value,
required bool isDark,
}) {
return Row(
children: [
SizedBox(
width: 44.w,
child: Text(
label,
style: TextStyle(
fontSize: 10.sp,
fontWeight: FontWeight.w600,
color: isDark
? Colors.white.withOpacity(0.2)
: const Color(0xFFBBBBBB),
letterSpacing: 0.5,
),
),
),
SizedBox(width: 8.w),
Expanded(
child: Text(
value,
style: TextStyle(
fontSize: 11.sp,
fontWeight: FontWeight.w500,
color: isDark
? Colors.white.withOpacity(0.5)
: const Color(0xFF666666),
fontFamily: 'Menlo',
letterSpacing: 0.3,
),
overflow: TextOverflow.ellipsis,
),
),
],
);
}
void _showUnbindDialog(UserDevice device, bool isDark) {
Get.dialog( Get.dialog(
AlertDialog( AlertDialog(
backgroundColor: isDarkMode ? AppColors.cardBackground : Colors.white, backgroundColor: isDark ? AppColors.cardBackground : Colors.white,
title: Text( title: Text(
"confirmUnbind".tr, "confirmUnbind".tr,
style: TextStyle( style: TextStyle(
color: isDarkMode ? Colors.white : Colors.black87, color: isDark ? Colors.white : Colors.black87,
), ),
), ),
content: Text( content: Text(
@ -322,7 +589,7 @@ class DevicesView extends GetView<DevicesController> {
.tr .tr
.replaceAll('{deviceName}', device.devicename), .replaceAll('{deviceName}', device.devicename),
style: TextStyle( style: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black54, color: isDark ? Colors.white70 : Colors.black54,
), ),
), ),
actions: [ actions: [
@ -331,7 +598,7 @@ class DevicesView extends GetView<DevicesController> {
child: Text( child: Text(
"cancel".tr, "cancel".tr,
style: TextStyle( style: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.grey[600], color: isDark ? Colors.white70 : Colors.grey[600],
), ),
), ),
), ),

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

@ -256,9 +256,17 @@ class HomeController extends GetxController {
} }
void exploreAIFeatures() { void exploreAIFeatures() {
// 优先读设备检测写入的 storage(YIDONG_PID 匹配时),否则降级到服务端 AGENT_TYPE
final agentType = GetStorage().read<String>('device_agent_type')
?? AppConfig.env('AGENT_TYPE')
?? 'stt_llm_tts';
if (agentType == 'yidong') {
Get.toNamed(Routes.mobileElf);
} else {
Get.toNamed(Routes.agent); Get.toNamed(Routes.agent);
AgentService.interruptCurrentResponse(); AgentService.interruptCurrentResponse();
} }
}
Future<void> _checkConnectionStatus() async { Future<void> _checkConnectionStatus() async {
try { try {

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

@ -145,6 +145,7 @@ class HomeView extends GetView<HomeController> {
_buildSectionTitle(isDarkMode), _buildSectionTitle(isDarkMode),
SizedBox(height: 16.h), SizedBox(height: 16.h),
_buildPremiumAILayout(), _buildPremiumAILayout(),
], ],
), ),
), ),

9
lib/modules/mobile_elf/bindings/mobile_elf_binding.dart

@ -0,0 +1,9 @@
import 'package:get/get.dart';
import '../controllers/mobile_elf_controller.dart';
class MobileElfBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<MobileElfController>(() => MobileElfController());
}
}

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

@ -0,0 +1,459 @@
import 'dart:async';
import 'package:agent_service/agent_service.dart';
import 'package:chat_storage/chat_storage.dart';
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../../data/models/appconfig.dart';
import '../../../core/utils/logger.dart';
import '../../../core/utils/permission_util.dart';
enum MobileElfStatus { idle, connecting, recording, thinking, speaking, error }
class MobileElfMessage {
final bool isUser;
final String text;
final DateTime time;
MobileElfMessage(
{required this.isUser, required this.text, required this.time});
}
class MobileElfController extends GetxController {
static const _tag = 'MobileElfCtrl';
// ── 可观察状态 ─────────────────────────────────────────────────────────────
final status = MobileElfStatus.idle.obs;
final messages = <MobileElfMessage>[].obs;
final realtimeText = ''.obs;
final errorMsg = ''.obs;
final inCall = false.obs;
final callSeconds = 0.obs;
final recordingSeconds = 0.obs;
final micRms = 0.0.obs;
final micAlive = false.obs;
final pttRecording = false.obs;
final pttSeconds = 0.obs;
final ttsEnabled = true.obs; // 是否播报 AI 回复
final ScrollController scrollController = ScrollController();
// 凭证状态(原生层负责,Dart 层始终无错)
final credentialReady = true.obs;
final credentialError = ''.obs;
// 设备认证失败(STS credential_failed 事件触发)
final credentialFailed = false.obs;
final credentialFailedReason = ''.obs;
// ── 内部 ───────────────────────────────────────────────────────────────────
StreamSubscription<AgentServiceEvent>? _eventSub;
Timer? _callTimer;
Timer? _pttTimer;
Timer? _pttSafetyTimer;
bool _inCallMode = false;
bool _callStarting = false; // 防止并发 startCall
// PTT 松开后仍有异步事件待处理(recognition_result / assistant_response / tts)
bool _pttResultPending = false;
// PTT 正在异步启动(await 权限期间用户已松手)
bool _pttStarting = false;
// BLE/AI 唤醒触发的会话(非通话、非PTT模式下的唤醒)
bool _bleWakeupActive = false;
// PTT 被中止(轻点松手),用于忽略原生层的残留事件
bool _pttAborted = false;
final _storage = GetStorage();
// 滚动:layout 变化前是否已在底部
bool _atBottom = true;
// ═══════════════════════════════════════════════════════════════════════════
// 生命周期
// ═══════════════════════════════════════════════════════════════════════════
String get _agentId => AppConfig.env('MOBILE_ELF_AGENT_ID') ?? 'mobile_elf';
@override
void onInit() {
super.onInit();
_subscribeEvents();
_loadChatHistory();
// 同步播报开关到原生层
ever(ttsEnabled, (bool enabled) {
AgentService.setTtsMuted(!enabled);
});
// 主动触发认证检查(页面打开时,确保 credential_failed 事件不被错过)
AgentService.fetchCredentials();
}
Future<void> _loadChatHistory() async {
try {
final result = await ChatStorage.getMessages(
agentId: _agentId,
page: 1,
pageSize: 50,
);
if (result.messages.isNotEmpty) {
final history = result.messages.map((m) => MobileElfMessage(
isUser: m.sender == 'user',
text: m.message,
time: DateTime.tryParse(m.timestamp) ?? DateTime.now(),
)).toList();
// 按时间正序排列
history.sort((a, b) => a.time.compareTo(b.time));
messages.addAll(history);
_scrollToBottom();
}
} catch (e) {
Logger.error('$_tag 加载聊天记录失败: $e');
}
}
@override
void onClose() {
_eventSub?.cancel();
_callTimer?.cancel();
_pttTimer?.cancel();
_pttSafetyTimer?.cancel();
AgentService.stopConversation();
scrollController.dispose();
super.onClose();
}
// ═══════════════════════════════════════════════════════════════════════════
// 事件订阅(来自原生 StsAgent)
// ═══════════════════════════════════════════════════════════════════════════
void _subscribeEvents() {
_eventSub = AgentService.events.listen(_onAgentEvent);
}
void _onAgentEvent(AgentServiceEvent event) {
Logger.info('$_tag event: ${event.type} data: ${event.data}');
// PTT 松开后 pttRecording=false,但 fin_result/assistant_response/tts 仍异步到达
// 用 _pttResultPending 保持会话激活,直到 ttsCompleted/error 才关闭
final sessionActive = _inCallMode || pttRecording.value || _pttResultPending || _bleWakeupActive;
switch (event.type) {
case AgentServiceEventType.recognitionStarted:
// 忽略刚刚中止的 PTT 会话的残留事件,避免误触发 BLE 唤醒
if (_pttAborted && !sessionActive) {
_pttAborted = false;
break;
}
// BLE/AI 唤醒:即使没有主动会话也激活
if (!sessionActive) _bleWakeupActive = true;
status.value = MobileElfStatus.recording;
realtimeText.value = '';
micAlive.value = true;
break;
case AgentServiceEventType.recognizing:
if (!sessionActive) break;
realtimeText.value = event.data['text']?.toString() ?? '';
status.value = MobileElfStatus.recording;
break;
case AgentServiceEventType.recognitionResult:
if (!sessionActive) break;
final text = event.data['text']?.toString() ?? '';
realtimeText.value = '';
micAlive.value = false;
if (text.isNotEmpty) _addMessage(isUser: true, text: text);
status.value = MobileElfStatus.thinking;
break;
case AgentServiceEventType.recognitionStopped:
case AgentServiceEventType.recognitionCanceled:
if (!sessionActive) break;
realtimeText.value = '';
micAlive.value = false;
break;
case AgentServiceEventType.assistantResponse:
if (!sessionActive) break;
final text = event.data['response']?.toString() ?? '';
if (text.isNotEmpty) _updateOrAddAssistantMessage(text);
if (!ttsEnabled.value) {
status.value =
_inCallMode ? MobileElfStatus.recording : MobileElfStatus.idle;
micAlive.value = _inCallMode;
}
break;
case AgentServiceEventType.ttsStarted:
if (!sessionActive) break;
status.value = MobileElfStatus.speaking;
break;
case AgentServiceEventType.ttsCompleted:
if (!sessionActive) break;
_pttResultPending = false;
_pttSafetyTimer?.cancel();
_endBleWakeup(); // BLE 唤醒一轮结束,清理 native PTT 状态
if (_inCallMode) {
status.value = MobileElfStatus.recording;
micAlive.value = true;
} else {
status.value = MobileElfStatus.idle;
micAlive.value = false;
}
break;
case AgentServiceEventType.ttsCanceled:
case AgentServiceEventType.ttsStopped:
if (!sessionActive) break;
_pttResultPending = false;
_pttSafetyTimer?.cancel();
_endBleWakeup();
status.value = _inCallMode ? MobileElfStatus.recording : MobileElfStatus.idle;
micAlive.value = _inCallMode;
break;
case AgentServiceEventType.error:
if (!sessionActive) break;
_pttResultPending = false;
_pttSafetyTimer?.cancel();
_endBleWakeup();
final msg = event.data['message']?.toString() ?? '未知错误';
status.value = MobileElfStatus.error;
errorMsg.value = msg;
Logger.error('$_tag error: $msg');
break;
case AgentServiceEventType.micRms:
final rms = (event.data['rms'] as num?)?.toDouble() ?? 0.0;
micRms.value = rms;
break;
case AgentServiceEventType.heartbeatReported:
// 记录心跳上报时间
final t = (event.data['time'] as num?)?.toInt() ?? DateTime.now().millisecondsSinceEpoch;
_storage.write('sts_heartbeat_time', t);
Logger.info('$_tag 心跳上报成功,时间: $t');
break;
case AgentServiceEventType.credentialFailed:
final reason = event.data['reason']?.toString() ?? '设备未认证';
Logger.error('$_tag 设备认证失败: $reason');
credentialFailed.value = true;
credentialFailedReason.value = reason;
// 确保停止任何进行中的会话
if (_inCallMode || pttRecording.value) {
disconnect();
}
break;
default:
break;
}
_scrollToBottom();
}
// ═══════════════════════════════════════════════════════════════════════════
// 公共接口(View 调用)
// ═══════════════════════════════════════════════════════════════════════════
Future<void> startCall() async {
if (inCall.value || _callStarting) return;
_callStarting = true;
_pttAborted = false;
_checkHeartbeat();
try {
// 若有正在播报的 PTT/BLE 唤醒会话,先强制中断 TTS/AI 流,
// 使原生层立刻进入可停止状态,避免 stopConversation 长时间等待
if (_pttResultPending || _bleWakeupActive) {
_pttResultPending = false;
_bleWakeupActive = false;
await AgentService.interruptCurrentResponse();
}
// 再确保原生层完全停止
await AgentService.stopConversation();
final granted = await PermissionUtil.instance.requestPermission(
permissionType: Permission.microphone,
permissionName: '麦克风',
explanationText: '移动精灵需要麦克风权限才能进行语音对话',
permanentDenialText: '请在设置中开启麦克风权限以使用语音功能',
);
if (!granted || inCall.value) return; // 权限等待期间可能已被挂断或重复调用
_inCallMode = true;
inCall.value = true;
callSeconds.value = 0;
micAlive.value = true;
status.value = MobileElfStatus.connecting;
_callTimer?.cancel();
_callTimer = Timer.periodic(
const Duration(seconds: 1), (_) => callSeconds.value++);
await AgentService.startConversation(mode: 'phone_call');
// startCall 返回即表示麦克风已打开、VAD 已在监听,直接显示聆听状态
// (call 模式下 recognition_started 只在 VAD 检测到语音时才发出,不能等它来转状态)
if (_inCallMode) status.value = MobileElfStatus.recording;
} finally {
_callStarting = false;
}
}
Future<void> disconnect() async {
_callStarting = false;
_inCallMode = false;
_pttStarting = false;
_pttResultPending = false; // 强制结束所有等待
_pttSafetyTimer?.cancel();
_bleWakeupActive = false;
inCall.value = false;
_callTimer?.cancel();
_callTimer = null;
callSeconds.value = 0;
pttRecording.value = false;
_pttTimer?.cancel();
_pttTimer = null;
status.value = MobileElfStatus.idle;
micAlive.value = false;
realtimeText.value = '';
await AgentService.stopConversation();
}
/// 超过1小时未上报心跳则补报一次
void _checkHeartbeat() {
final last = _storage.read<int>('sts_heartbeat_time') ?? 0;
if (DateTime.now().millisecondsSinceEpoch - last > 3600000) {
AgentService.reportHeartbeat().catchError((_) {});
}
}
Future<void> startPtt() async {
if (pttRecording.value || _pttStarting) return;
_pttStarting = true;
_pttAborted = false;
_checkHeartbeat();
// BLE 唤醒/PTT 等待结果期间用户按下 PTT,先中断当前会话(同 startCall 逻辑)
if (_bleWakeupActive || _pttResultPending) {
_pttResultPending = false;
_bleWakeupActive = false;
await AgentService.interruptCurrentResponse();
await AgentService.stopConversation();
}
final granted = await PermissionUtil.instance.requestPermission(
permissionType: Permission.microphone,
permissionName: '麦克风',
explanationText: '移动精灵需要麦克风权限才能进行语音对话',
permanentDenialText: '请在设置中开启麦克风权限以使用语音功能',
);
// 权限等待期间用户已松手(stopPtt 将 _pttStarting 置 false),直接放弃
if (!granted || !_pttStarting) {
_pttStarting = false;
return;
}
_pttStarting = false;
pttRecording.value = true;
pttSeconds.value = 0;
status.value = MobileElfStatus.connecting;
_pttTimer?.cancel();
_pttTimer = Timer.periodic(
const Duration(seconds: 1), (_) => pttSeconds.value++);
AgentService.startConversation(mode: 'push_to_talk');
}
void stopPtt() {
// 若仍在等待权限授予,取消本次启动
if (_pttStarting) {
_pttStarting = false;
return;
}
if (!pttRecording.value) return;
pttRecording.value = false;
_pttTimer?.cancel();
_pttTimer = null;
// 如果还在连接中(还未开始识别),说明用户轻点了一下就松手,
// 直接恢复空闲,不需要等待异步事件回来
if (status.value == MobileElfStatus.connecting) {
_pttAborted = true; // 标记中止,忽略原生层的残留事件
_pttResultPending = false;
status.value = MobileElfStatus.idle;
micAlive.value = false;
AgentService.stopConversation();
return;
}
_pttResultPending = true; // 等待 fin_result/assistant_response/tts 异步回来
AgentService.stopConversation();
// 安全超时:防止原生层未返回事件导致状态永久卡住
_pttSafetyTimer?.cancel();
_pttSafetyTimer = Timer(const Duration(seconds: 8), () {
if (_pttResultPending && !_inCallMode) {
Logger.info('$_tag PTT safety timeout — force reset to idle');
_pttResultPending = false;
status.value = MobileElfStatus.idle;
micAlive.value = false;
}
});
}
void interruptSpeaking() {
AgentService.interruptCurrentResponse();
status.value =
_inCallMode ? MobileElfStatus.recording : MobileElfStatus.idle;
micAlive.value = _inCallMode;
}
/// 原生层已自动获取凭证,此处保留方法供 View 调用不报错
Future<void> retryCredentials() async {
credentialError.value = '';
}
/// 清空聊天记录
Future<void> clearMessages() async {
messages.clear();
try {
await AgentService.clearChatHistory();
} catch (e) {
Logger.error('$_tag 清空聊天记录失败: $e');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 工具
// ═══════════════════════════════════════════════════════════════════════════
void _addMessage({required bool isUser, required String text}) {
messages.add(MobileElfMessage(
isUser: isUser, text: text, time: DateTime.now()));
_scrollToBottom();
}
/// 更新最后一条 AI 消息(如果存在且更短),否则添加新消息
void _updateOrAddAssistantMessage(String text) {
final lastIdx = messages.length - 1;
if (lastIdx >= 0 && !messages[lastIdx].isUser) {
// 用更完整的文本替换
messages[lastIdx] = MobileElfMessage(
isUser: false, text: text, time: messages[lastIdx].time);
messages.refresh();
} else {
_addMessage(isUser: false, text: text);
}
_scrollToBottom();
}
/// BLE 唤醒会话标志清除
/// Native 状态(pttRecording 等)由 StsAgent.startPtt() 的重入逻辑懒清理,
/// 不在此调用 stopConversation(),避免与新会话产生竞态。
void _endBleWakeup() {
_bleWakeupActive = false;
}
void _scrollToBottom() {
// layout 变化前捕获是否在底部,避免新内容撑开后差值变大误判为"已滚离底部"
if (scrollController.hasClients) {
final pos = scrollController.position;
_atBottom = pos.maxScrollExtent - pos.pixels <= 80;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients && _atBottom) {
scrollController.animateTo(
scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
}

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

@ -0,0 +1,988 @@
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> {
const MobileElfView({Key? key}) : super(key: key);
// ── 颜色常量 ──────────────────────────────────────────────────────────────
static const _blue = Color(0xFF2E7CF6);
static const _green = Color(0xFF34C759);
static const _greenDark = Color(0xFF16A34A);
static const _red = Color(0xFFEF4444);
static const _orange = Color(0xFFF79009);
static const _bgLight = Color(0xFFF4F6F9);
static const _surfaceLight = Colors.white;
static const _textPrimary = Color(0xFF101828);
static const _textSecondary = Color(0xFF667085);
static const _border = Color(0xFFEAECF0);
// 渐变复用
static const _avatarGradient = LinearGradient(
colors: [Color(0xFF667EEA), Color(0xFF764BA2)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark ? const Color(0xFF0D0D0D) : _bgLight,
appBar: _buildAppBar(isDark),
// 顶层 Listener 捕获 PTT 松开事件(确保 widget 树重建后仍能接收 pointerUp)
body: Listener(
behavior: HitTestBehavior.translucent,
onPointerUp: (_) {
if (controller.pttRecording.value) controller.stopPtt();
},
child: Obx(() {
if (controller.credentialError.value.isNotEmpty) {
return _buildCredentialError();
}
return Column(
children: [
Expanded(child: _buildMessageList(isDark)),
_buildBottomBar(isDark),
],
);
}),
),
);
}
// ══════════════════════════════════════════════════════════════════════════
// AppBar
// ══════════════════════════════════════════════════════════════════════════
PreferredSizeWidget _buildAppBar(bool isDark) {
return AppBar(
backgroundColor: isDark ? const Color(0xFF1A1A1A) : _surfaceLight,
elevation: 0,
surfaceTintColor: Colors.transparent,
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: Container(
color: isDark ? const Color(0xFF222222) : _border, height: 1),
),
leading: IconButton(
icon: Icon(Icons.arrow_back_ios,
size: 18.sp, color: isDark ? Colors.white : _textSecondary),
onPressed: () => Get.back(),
),
titleSpacing: 0,
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
_gradientCircle(32.w, '晓', 13.sp),
SizedBox(width: 10.w),
Text('移动精灵',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : _textPrimary)),
SizedBox(width: 8.w),
Obx(() => _buildStatusPill()),
],
),
actions: [
// 播报开关
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)),
]),
),
],
),
],
);
}
/// 右上角状态胶囊 + 通话计时
Widget _buildStatusPill() {
if (controller.credentialFailed.value) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 3.h),
decoration: BoxDecoration(color: const Color(0xFFFEF2F2), borderRadius: BorderRadius.circular(20.r)),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.lock_outline_rounded, size: 11.sp, color: _red),
SizedBox(width: 4.w),
Text('认证失败', style: TextStyle(fontSize: 11.sp, color: _red, fontWeight: FontWeight.w500)),
]),
);
}
final s = controller.status.value;
final inCall = controller.inCall.value;
final cfg = _pillConfig(s, inCall);
return Container(
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 3.h),
decoration: BoxDecoration(
color: cfg.bg,
borderRadius: BorderRadius.circular(20.r),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(cfg.label,
style: TextStyle(
fontSize: 11.sp,
color: cfg.fg,
fontWeight: FontWeight.w500)),
if (inCall) ...[
SizedBox(width: 4.w),
Obx(() {
final sec = controller.callSeconds.value;
return Text(_fmtDuration(sec),
style: TextStyle(
fontSize: 11.sp,
color: cfg.fg,
fontWeight: FontWeight.w400));
}),
],
],
),
);
}
_PillCfg _pillConfig(MobileElfStatus s, bool inCall) {
switch (s) {
case MobileElfStatus.connecting:
return const _PillCfg(Color(0xFFFFFAEB), Color(0xFFB54708), '连接中…');
case MobileElfStatus.recording:
return const _PillCfg(Color(0xFFECFDF3), _greenDark, '聆听中');
case MobileElfStatus.thinking:
return const _PillCfg(Color(0xFFEFF8FF), Color(0xFF175CD3), '处理中');
case MobileElfStatus.speaking:
return const _PillCfg(Color(0xFFECFDF3), _greenDark, '朗读中');
case MobileElfStatus.error:
return const _PillCfg(Color(0xFFFEF2F2), _red, '错误');
case MobileElfStatus.idle:
return inCall
? const _PillCfg(Color(0xFFECFDF3), _greenDark, '通话中')
: const _PillCfg(Color(0xFFF2F4F7), _textSecondary, '空闲');
}
}
// ══════════════════════════════════════════════════════════════════════════
// 凭证错误
// ══════════════════════════════════════════════════════════════════════════
Widget _buildCredentialError() {
return Center(
child: Padding(
padding: EdgeInsets.all(32.w),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.wifi_off_rounded, size: 56.sp, color: Colors.red[300]),
SizedBox(height: 16.h),
Obx(() => Text(controller.credentialError.value,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13.sp, color: Colors.red[400]))),
SizedBox(height: 20.h),
ElevatedButton.icon(
onPressed: controller.retryCredentials,
icon: const Icon(Icons.refresh),
label: const Text('重试'),
),
],
),
),
);
}
// ══════════════════════════════════════════════════════════════════════════
// 消息列表
// ══════════════════════════════════════════════════════════════════════════
Widget _buildMessageList(bool isDark) {
return Obx(() {
final msgs = controller.messages;
final rt = controller.realtimeText.value;
if (msgs.isEmpty && rt.isEmpty) return _buildEmptyState(isDark);
return ListView.builder(
controller: controller.scrollController,
padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 8.h),
itemCount: msgs.length + (rt.isNotEmpty ? 1 : 0),
itemBuilder: (_, i) {
if (i < msgs.length) return _buildBubble(msgs[i], isDark);
return _buildRealtimeBubble(rt);
},
);
});
}
Widget _buildEmptyState(bool isDark) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_gradientCircle(80.w, '晓', 32.sp),
SizedBox(height: 20.h),
Text('移动精灵',
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : _textPrimary)),
SizedBox(height: 8.h),
Text('点击右下角按钮,即可与晓晓对话',
style: TextStyle(
fontSize: 13.sp,
color: isDark ? Colors.grey[500] : _textSecondary)),
],
),
);
}
// ── 消息气泡 ──────────────────────────────────────────────────────────────
Widget _buildBubble(MobileElfMessage msg, bool isDark) {
final isUser = msg.isUser;
return Padding(
padding: EdgeInsets.only(bottom: 14.h),
child: Column(
crossAxisAlignment:
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
// AI 头像 + 名字
if (!isUser) ...[
Row(children: [
_gradientCircle(28.w, '晓', 11.sp),
SizedBox(width: 6.w),
Text('晓晓',
style: TextStyle(fontSize: 11.sp, color: _textSecondary)),
]),
SizedBox(height: 4.h),
],
// 气泡体(长按弹出菜单)
Row(
mainAxisAlignment:
isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
children: [
if (!isUser) SizedBox(width: 34.w),
Flexible(
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))),
),
),
),
],
),
// 时间 + AI 标签
SizedBox(height: 3.h),
Padding(
padding: EdgeInsets.only(left: isUser ? 0 : 34.w),
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),
child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [
Flexible(
child: Container(
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h),
decoration: BoxDecoration(
color: _blue.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(18.r),
),
child: Text(text,
style: TextStyle(
fontSize: 15.sp, color: Colors.white, height: 1.55)),
),
),
]),
);
}
// ══════════════════════════════════════════════════════════════════════════
// 底部控制栏
// ══════════════════════════════════════════════════════════════════════════
Widget _buildBottomBar(bool isDark) {
return Obx(() {
final inCall = controller.inCall.value;
final s = controller.status.value;
final ptt = controller.pttRecording.value;
// 状态栏显示条件:
// - 通话模式:recording/thinking/speaking/connecting 都显示
// - 非通话非PTT:thinking/speaking 显示
// - PTT 中:不显示(状态在中间按钮体现)
final showStatusBar = !ptt && (
(inCall && s != MobileElfStatus.idle && s != MobileElfStatus.error) ||
(!inCall && (s == MobileElfStatus.speaking || s == MobileElfStatus.thinking))
);
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1A1A1A) : _surfaceLight,
border: Border(top: BorderSide(color: isDark ? const Color(0xFF222222) : _border)),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 12, offset: const Offset(0, -2)),
],
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (showStatusBar) _buildStatusBar(s, inCall, isDark),
if (inCall)
_buildInCallInputBar(isDark)
else
_buildNormalInputBar(isDark),
],
),
),
);
});
}
// ── 状态提示栏 ────────────────────────────────────────────────────────────
Widget _buildStatusBar(MobileElfStatus s, bool inCall, bool isDark) {
Widget? content;
switch (s) {
case MobileElfStatus.speaking:
content = GestureDetector(
onTap: controller.interruptSpeaking,
behavior: HitTestBehavior.opaque,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
color: const Color(0xFFECFDF3),
child: Row(
children: [
_MiniWave(color: _greenDark),
SizedBox(width: 8.w),
Expanded(child: Text('晓晓正在朗读...', style: TextStyle(fontSize: 13.sp, color: _greenDark, fontWeight: FontWeight.w500))),
Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h),
decoration: BoxDecoration(
border: Border.all(color: _red),
borderRadius: BorderRadius.circular(16.r),
),
child: Text('× 打断', style: TextStyle(fontSize: 12.sp, color: _red, fontWeight: FontWeight.w500)),
),
],
),
),
);
break;
case MobileElfStatus.recording:
content = Container(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
color: const Color(0xFFECFDF3),
child: Row(
children: [
_MiniWave(color: _greenDark),
SizedBox(width: 8.w),
Expanded(child: Obx(() {
final rt = controller.realtimeText.value;
return Text(rt.isNotEmpty ? rt : '聆听中...', style: TextStyle(fontSize: 13.sp, color: _greenDark), maxLines: 1, overflow: TextOverflow.ellipsis);
})),
],
),
);
break;
case MobileElfStatus.connecting:
content = Container(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
color: const Color(0xFFFFFAEB),
child: Row(
children: [
SizedBox(width: 14.w, height: 14.w, child: CircularProgressIndicator(strokeWidth: 2, color: _orange)),
SizedBox(width: 8.w),
Text('正在连接...', style: TextStyle(fontSize: 13.sp, color: const Color(0xFFB54708))),
],
),
);
break;
case MobileElfStatus.thinking:
content = Container(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
color: const Color(0xFFEFF8FF),
child: Row(
children: [
SizedBox(width: 14.w, height: 14.w, child: CircularProgressIndicator(strokeWidth: 2, color: const Color(0xFF175CD3))),
SizedBox(width: 8.w),
Text('AI 思考中...', style: TextStyle(fontSize: 13.sp, color: const Color(0xFF175CD3))),
],
),
);
break;
default:
return const SizedBox.shrink();
}
return content;
}
// ── 通话模式底栏 ──────────────────────────────────────────────────────────
Widget _buildInCallInputBar(bool isDark) {
return Padding(
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 14.h),
child: Row(
children: [
// 通话状态 + 计时
Expanded(
child: Obx(() {
final s = controller.status.value;
final idle = s == MobileElfStatus.idle;
return Row(
children: [
Container(
width: 8.w, height: 8.w,
decoration: BoxDecoration(shape: BoxShape.circle, color: idle ? _green : _orange),
),
SizedBox(width: 6.w),
Text('通话中', style: TextStyle(fontSize: 13.sp, color: isDark ? Colors.white70 : _textSecondary)),
SizedBox(width: 4.w),
Obx(() => Text(_fmtDuration(controller.callSeconds.value),
style: TextStyle(fontSize: 13.sp, color: isDark ? Colors.white70 : _textSecondary))),
],
);
}),
),
// 音量指示器
Obx(() => _VolumeIndicator(
rms: controller.micRms.value,
alive: controller.micAlive.value,
isDark: isDark,
)),
SizedBox(width: 12.w),
// 挂断按钮
GestureDetector(
onTap: controller.disconnect,
child: Container(
width: 52.w, height: 52.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _red,
boxShadow: [BoxShadow(color: _red.withValues(alpha: 0.3), blurRadius: 14, offset: const Offset(0, 4))],
),
child: Icon(Icons.call_end_rounded, color: Colors.white, size: 26.sp),
),
),
],
),
);
}
// ── 普通输入栏 (按住说话) ──────────────────────────────────────────────────
Widget _buildNormalInputBar(bool isDark) {
return Obx(() {
final s = controller.status.value;
final ptt = controller.pttRecording.value;
final locked = controller.credentialFailed.value;
final disabled = locked || (!ptt && s != MobileElfStatus.idle && s != MobileElfStatus.error);
final pttClickable = !locked && s == MobileElfStatus.speaking;
final sideDisabled = locked || ptt || (disabled && !pttClickable);
return Padding(
padding: EdgeInsets.fromLTRB(12.w, 6.h, 12.w, 14.h),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── PTT 实时识别文字(居中 + 渐变背景) ──
if (ptt)
Obx(() {
final rt = controller.realtimeText.value;
if (rt.isEmpty) return const SizedBox.shrink();
return Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h),
margin: EdgeInsets.only(bottom: 8.h),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDark
? [const Color(0xFF2A2A2A), const Color(0xFF1A2332)]
: [const Color(0xFFF0F4FF), const Color(0xFFE8F5E9)],
),
borderRadius: BorderRadius.circular(16.r),
border: Border.all(
color: isDark ? const Color(0xFF334155) : const Color(0xFFE2E8F0),
width: 0.5,
),
),
child: Text(rt,
style: TextStyle(fontSize: 15.sp, color: isDark ? Colors.white : _textPrimary, height: 1.4),
textAlign: TextAlign.center,
maxLines: 3, overflow: TextOverflow.ellipsis,
),
);
}),
// ── 按钮行 ──
Row(
children: [
// 中间:PTT 按钮
Expanded(
child: ptt
? _buildPttRecordingCenter(isDark)
: IgnorePointer(
ignoring: disabled && !pttClickable,
child: Opacity(
opacity: (disabled && !pttClickable) ? 0.5 : 1.0,
child: _buildPttIdleButton(isDark),
),
),
),
SizedBox(width: 8.w),
// 右侧:电话按钮
IgnorePointer(
ignoring: sideDisabled,
child: Opacity(
opacity: sideDisabled ? 0.4 : 1.0,
child: _roundButton(
onTap: controller.startCall,
gradient: const LinearGradient(
colors: [Color(0xFF34C759), Color(0xFF28A745)],
begin: Alignment.topLeft, end: Alignment.bottomRight,
),
shadow: _green.withValues(alpha: 0.4),
size: 44,
child: Icon(Icons.phone, color: Colors.white, size: 20.sp),
),
),
),
],
),
],
),
);
});
}
/// 按住说话按钮(空闲状态)— 参考微信风格
Widget _buildPttIdleButton(bool isDark) {
return Listener(
onPointerDown: (_) => controller.startPtt(),
child: Container(
height: 42.h,
decoration: BoxDecoration(
color: isDark ? const Color(0xFF2A2A2A) : Colors.white,
borderRadius: BorderRadius.circular(21.r),
border: Border.all(color: isDark ? const Color(0xFF444444) : const Color(0xFFD0D5DD), width: 1),
),
child: Center(
child: Text('按住 说话',
style: TextStyle(
fontSize: 15.sp,
color: isDark ? Colors.white70 : _textPrimary,
fontWeight: FontWeight.w600,
letterSpacing: 2.w,
),
),
),
),
);
}
/// PTT 录音中:仅中间部分
/// 连接中=与按住说话相同样式,录音中=橙色
Widget _buildPttRecordingCenter(bool isDark) {
return Obx(() {
final s = controller.status.value;
final isConnecting = s == MobileElfStatus.connecting;
if (isConnecting) {
// 连接中:与按住说话空闲状态一样的外观 + 加载指示
return Container(
height: 42.h,
decoration: BoxDecoration(
color: isDark ? const Color(0xFF2A2A2A) : Colors.white,
borderRadius: BorderRadius.circular(21.r),
border: Border.all(color: isDark ? const Color(0xFF444444) : const Color(0xFFD0D5DD), width: 1),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(width: 14.w, height: 14.w,
child: CircularProgressIndicator(strokeWidth: 2, color: isDark ? Colors.white70 : _textSecondary)),
SizedBox(width: 8.w),
Text('连接中...', style: TextStyle(fontSize: 14.sp, color: isDark ? Colors.white70 : _textSecondary, fontWeight: FontWeight.w500)),
],
),
);
}
// 录音中:橙色
return Container(
height: 42.h,
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0xFFF79009), Color(0xFFDC6803)]),
borderRadius: BorderRadius.circular(21.r),
boxShadow: [BoxShadow(
color: _orange.withValues(alpha: 0.3),
blurRadius: 8, offset: const Offset(0, 2),
)],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Obx(() => _RmsBars(rms: controller.micRms.value, color: Colors.white)),
SizedBox(width: 8.w),
Obx(() => Text('松开发送 · ${controller.pttSeconds.value}s',
style: TextStyle(fontSize: 14.sp, color: Colors.white, fontWeight: FontWeight.w500))),
],
),
);
});
}
// ── 通用圆形按钮 ──────────────────────────────────────────────────────────
Widget _roundButton({
required VoidCallback onTap,
required Widget child,
Color? color,
Gradient? gradient,
Color? shadow,
Color? border,
double size = 56,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: size.w,
height: size.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: gradient == null ? color : null,
gradient: gradient,
border: border != null ? Border.all(color: border, width: 2) : null,
boxShadow: shadow != null
? [BoxShadow(color: shadow, blurRadius: 14, offset: const Offset(0, 4))]
: null,
),
child: Center(child: child),
),
);
}
// ══════════════════════════════════════════════════════════════════════════
// 工具方法
// ══════════════════════════════════════════════════════════════════════════
/// 渐变圆形头像
Widget _gradientCircle(double size, String text, double fontSize) {
return Container(
width: size,
height: size,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: _avatarGradient,
),
child: Center(
child: Text(text,
style: TextStyle(
color: Colors.white,
fontSize: fontSize,
fontWeight: FontWeight.w600)),
),
);
}
String _fmtDuration(int secs) {
final m = (secs ~/ 60).toString().padLeft(2, '0');
final s = (secs % 60).toString().padLeft(2, '0');
return '$m:$s';
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 辅助类
// ═══════════════════════════════════════════════════════════════════════════
class _PillCfg {
final Color bg, fg;
final String label;
const _PillCfg(this.bg, this.fg, this.label);
}
/// 3 条小波浪动画
class _MiniWave extends StatefulWidget {
final Color color;
const _MiniWave({required this.color});
@override
State<_MiniWave> createState() => _MiniWaveState();
}
class _MiniWaveState extends State<_MiniWave>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this, duration: const Duration(milliseconds: 800))
..repeat();
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _ctrl,
builder: (_, __) => Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: List.generate(3, (i) {
final phase = (_ctrl.value + i * 0.2) % 1.0;
final h = 3.0 + 7.0 * (0.5 - (phase - 0.5).abs()) * 2;
return Container(
width: 2.5,
height: h.clamp(3.0, 10.0),
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: widget.color,
borderRadius: BorderRadius.circular(2),
),
);
}),
),
);
}
}
/// 麦克风音量指示器(通话模式用)
class _VolumeIndicator extends StatelessWidget {
final double rms;
final bool alive;
final bool isDark;
const _VolumeIndicator({required this.rms, required this.alive, required this.isDark});
@override
Widget build(BuildContext context) {
// rms 范围大约 0~6000,归一化到 0~1
final level = alive ? (rms / 5000).clamp(0.0, 1.0) : 0.0;
const barCount = 5;
return Container(
width: 44.w, height: 44.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isDark ? const Color(0xFF2A2A2A) : const Color(0xFFF2F4F7),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: List.generate(barCount, (i) {
final threshold = (i + 1) / barCount;
final active = level >= threshold;
return Container(
width: 3.w,
height: (8 + i * 4).h,
margin: EdgeInsets.symmetric(horizontal: 1.w),
decoration: BoxDecoration(
color: active
? const Color(0xFF34C759)
: (isDark ? const Color(0xFF444444) : const Color(0xFFD0D5DD)),
borderRadius: BorderRadius.circular(1.5.r),
),
);
}),
),
);
}
}
/// PTT 录音时响应麦克风音量的波形条
class _RmsBars extends StatelessWidget {
final double rms;
final Color color;
const _RmsBars({required this.rms, required this.color});
@override
Widget build(BuildContext context) {
// rms 范围约 0~6000,归一化到 0~1
final level = (rms / 5000).clamp(0.0, 1.0);
const barCount = 3;
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: List.generate(barCount, (i) {
final threshold = (i + 1) / barCount;
final h = level >= threshold ? 10.0 : 3.0;
return AnimatedContainer(
duration: const Duration(milliseconds: 80),
width: 2.5,
height: h,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
);
}),
);
}
}

107
lib/modules/pairing/controllers/pairing_controller.dart

@ -810,6 +810,7 @@ class PairingController extends GetxController with WidgetsBindingObserver {
orElse: () => UserDevice( orElse: () => UserDevice(
id: -1, id: -1,
uid: '', uid: '',
productid: -1,
devicetype: -1, devicetype: -1,
devicename: '', devicename: '',
devicemac: '', devicemac: '',
@ -840,15 +841,21 @@ class PairingController extends GetxController with WidgetsBindingObserver {
orElse: () => UserDevice( orElse: () => UserDevice(
id: -1, id: -1,
uid: '', uid: '',
productid: -1,
devicetype: -1, devicetype: -1,
devicename: '', devicename: '',
devicemac: '', devicemac: '',
license: ''), license: ''),
); );
if (existingDevice.id != -1) { if (existingDevice.id != -1) {
_verifiedDevicePid = existingDevice.productid;
_verifiedDeviceName = device.name; _verifiedDeviceName = device.name;
_verifiedDeviceAddress = device.address; _verifiedDeviceAddress = device.address;
// 设备时ble 蓝牙 且已绑定 // 已绑定设备直接用 license 作为 deviceCode(即绑定时扫到的 SN)
if (existingDevice.license.isNotEmpty) {
deviceCode.value = existingDevice.license;
Logger.i(_tag, '[YIDONG] 已绑定设备,从license提取 deviceCode=${existingDevice.license}');
}
return 10; return 10;
} }
} }
@ -885,14 +892,15 @@ class PairingController extends GetxController with WidgetsBindingObserver {
code = extractAndReverseBytes(hexData, 13, 22); code = extractAndReverseBytes(hexData, 13, 22);
_verifiedDevicePid = _verifiedDevicePid =
extractAndReverseBytesToInt(hexData, 4, 5); extractAndReverseBytesToInt(hexData, 4, 5);
print('找到匹配设备,获取到 code: $code'); final pidHexIos = _verifiedDevicePid?.toRadixString(16).toUpperCase() ?? '';
Logger.i(_tag, '[YIDONG] iOS BLE扫描 MAC匹配 device=${device.name} pidHex=$pidHexIos code=$code');
if (code != "") { if (code != "") {
_bleManager.stopScan(); _bleManager.stopScan();
deviceCode.value = code; deviceCode.value = code;
String? uuids = deviceInfo["id"]; String? uuids = deviceInfo["id"];
_verifiedDeviceName = device.name; _verifiedDeviceName = device.name;
_verifiedDeviceAddress = uuids; _verifiedDeviceAddress = uuids;
print('找到匹配设备,获取到 uuids: $uuids'); Logger.i(_tag, '[YIDONG] iOS deviceCode提取成功 code=$code uuid=$uuids');
//检查是否已经绑定 //检查是否已经绑定
final existingDevice = User.instance.devices.firstWhere( final existingDevice = User.instance.devices.firstWhere(
(d) => (d) =>
@ -902,6 +910,7 @@ class PairingController extends GetxController with WidgetsBindingObserver {
orElse: () => UserDevice( orElse: () => UserDevice(
id: -1, id: -1,
uid: '', uid: '',
productid: -1,
devicetype: -1, devicetype: -1,
devicename: '', devicename: '',
devicemac: '', devicemac: '',
@ -921,12 +930,14 @@ class PairingController extends GetxController with WidgetsBindingObserver {
code = extractAndReverseBytes(hexData, 15, 24); code = extractAndReverseBytes(hexData, 15, 24);
_verifiedDevicePid = _verifiedDevicePid =
extractAndReverseBytesToInt(hexData, 6, 7); extractAndReverseBytesToInt(hexData, 6, 7);
print('找到匹配设备,获取到 code: $code'); final pidHexAndroid = _verifiedDevicePid?.toRadixString(16).toUpperCase() ?? '';
Logger.i(_tag, '[YIDONG] Android BLE扫描 MAC匹配 device=${device.name} mac=${device.address} pidHex=$pidHexAndroid code=$code');
if (code != "") { if (code != "") {
_bleManager.stopScan(); _bleManager.stopScan();
deviceCode.value = code; deviceCode.value = code;
_verifiedDeviceName = device.name; _verifiedDeviceName = device.name;
_verifiedDeviceAddress = device.address; _verifiedDeviceAddress = device.address;
Logger.i(_tag, '[YIDONG] Android deviceCode提取成功 code=$code mac=${device.address}');
//检查是否已经绑定 //检查是否已经绑定
final existingDevice = User.instance.devices.firstWhere( final existingDevice = User.instance.devices.firstWhere(
(d) => (d) =>
@ -936,6 +947,7 @@ class PairingController extends GetxController with WidgetsBindingObserver {
orElse: () => UserDevice( orElse: () => UserDevice(
id: -1, id: -1,
uid: '', uid: '',
productid: -1,
devicetype: -1, devicetype: -1,
devicename: '', devicename: '',
devicemac: '', devicemac: '',
@ -1178,26 +1190,92 @@ class PairingController extends GetxController with WidgetsBindingObserver {
} }
} }
/// 检测是否为移动精灵(YIDONG)设备,并写入 GetStorage
void _detectAndSaveYidongDevice() {
final yidongPid = AppConfig.env('YIDONG_PID') ?? '';
if (yidongPid.isEmpty) {
Logger.i(_tag, '[YIDONG] 未配置 YIDONG_PID,跳过检测');
return;
}
// 优先使用本次扫描解析的 pid,否则从已绑定设备列表中查找
int? pid = _verifiedDevicePid;
String pidSource = 'BLE扫描';
if (pid == null) {
final mac = _verifiedDeviceAddress ?? _currentClassicDevice?.address ?? '';
Logger.i(_tag, '[YIDONG] 本次未扫描到pid,从已绑定设备查找 mac=$mac');
if (mac.isNotEmpty) {
final matched = User.instance.devices.firstWhere(
(d) =>
d.devicemac.toUpperCase().replaceAll(':', '') ==
mac.toUpperCase().replaceAll(':', ''),
orElse: () => UserDevice(
id: -1, uid: '', productid: -1,
devicetype: -1, devicename: '', devicemac: '', license: ''),
);
if (matched.id != -1) {
pid = matched.productid;
pidSource = '已绑定设备';
}
}
}
if (pid == null) {
Logger.i(_tag, '[YIDONG] 无法获取设备pid,跳过检测');
return;
}
// 将 int pid 转为十六进制字符串(不补零,大写)与 YIDONG_PID 比对
final pidHex = pid.toRadixString(16).toUpperCase();
final isYidong = pidHex == yidongPid.toUpperCase();
Logger.i(_tag, '[YIDONG] 设备检测: pidHex=$pidHex yidongPid=${yidongPid.toUpperCase()} source=$pidSource matched=$isYidong');
if (isYidong) {
_storage.write('device_agent_type', 'yidong');
// deviceCode 来源:
// 新设备 → BLE 扫描从广播包提取
// 已绑定设备 → authbindingDevice 从 existingDevice.license 赋值
if (deviceCode.value.isNotEmpty) {
final prevCode = _storage.read<String>('device_sts_code') ?? '';
// SN 变了说明换了设备,清除旧凭证,强制重新向移动平台认证
if (prevCode != deviceCode.value) {
_storage.remove('sts_device_id');
_storage.remove('sts_device_secret');
Logger.i(_tag, '[YIDONG] SN 变更($prevCode → ${deviceCode.value}),已清除旧凭证,强制重新认证');
}
_storage.write('device_sts_code', deviceCode.value);
Logger.i(_tag, '[YIDONG] 识别成功,写入 device_sts_code=${deviceCode.value}');
} else {
// 既无扫描结果也无 license,清除所有凭证,认证失败会提示用户
_storage.remove('device_sts_code');
_storage.remove('sts_device_id');
_storage.remove('sts_device_secret');
Logger.i(_tag, '[YIDONG] deviceCode 为空,已清除所有凭证');
}
} else {
_storage.remove('device_agent_type');
_storage.remove('device_sts_code');
_storage.remove('sts_device_id');
_storage.remove('sts_device_secret');
Logger.i(_tag, '[YIDONG] 非移动精灵设备,已清除相关存储');
}
}
/// 导航到主页 /// 导航到主页
void _navigateToHome() { void _navigateToHome() {
// Future.delayed(const Duration(milliseconds: 500), () async { // 检测是否为移动精灵(YIDONG)设备
_detectAndSaveYidongDevice();
// 启动AgentService服务 // 启动AgentService服务
try { try {
Logger.i(_tag, '启动AgentService服务'); Logger.i(_tag, '启动AgentService服务');
_bleManager.startAgentService(); _bleManager.startAgentService();
// _bleManager.registerBackgroundScan(macAddress: _verifiedDeviceAddress);
} catch (e) { } catch (e) {
Logger.e(_tag, '启动AgentService失败: $e'); Logger.e(_tag, '启动AgentService失败: $e');
} }
//print('lxm-上一个路由: ${Get.previousRoute.toString()}'); // 已在 Home 页时不重复导航,避免两个 HomeView 同时存在导致 Duplicate GlobalKey
// if (Get.previousRoute == Routes.home) { if (Get.currentRoute == Routes.home) return;
// 如果是从home页过来的,则直接返回
// Get.back();
// } else {
// 否则,跳转到home页并清空之前的路由栈
Get.offAllNamed(Routes.home); Get.offAllNamed(Routes.home);
// }
// });
} }
// ==================== 用户操作 ==================== // ==================== 用户操作 ====================
@ -1259,6 +1337,7 @@ class PairingController extends GetxController with WidgetsBindingObserver {
UserDevice( UserDevice(
id: 0, // 临时设备ID id: 0, // 临时设备ID
uid: User.instance.uid, uid: User.instance.uid,
productid: _verifiedDevicePid ?? -1,
devicetype: 1, devicetype: 1,
devicemac: _currentClassicDevice?.address ?? '', devicemac: _currentClassicDevice?.address ?? '',
devicename: _currentClassicDevice?.name ?? 'Unknown Device', devicename: _currentClassicDevice?.name ?? 'Unknown Device',

9
lib/routes/app_pages.dart

@ -82,6 +82,8 @@ import '../modules/goods/bindings/goodsmeet_binding.dart';
import '../modules/goods/views/goodsmeet_view.dart'; import '../modules/goods/views/goodsmeet_view.dart';
import '../modules/goods/bindings/goodsai_binding.dart'; import '../modules/goods/bindings/goodsai_binding.dart';
import '../modules/goods/views/goodsai_view.dart'; import '../modules/goods/views/goodsai_view.dart';
import '../modules/mobile_elf/bindings/mobile_elf_binding.dart';
import '../modules/mobile_elf/views/mobile_elf_view.dart';
abstract class AppPages { abstract class AppPages {
static final pages = [ static final pages = [
@ -378,5 +380,12 @@ abstract class AppPages {
transition: Transition.rightToLeft, transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 250), transitionDuration: Duration(milliseconds: 250),
), ),
GetPage(
name: Routes.mobileElf,
page: () => const MobileElfView(),
binding: MobileElfBinding(),
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 250),
),
]; ];
} }

3
lib/routes/app_routes.dart

@ -61,6 +61,9 @@ abstract class Routes {
// 实时语音对话 // 实时语音对话
static const realtime = '/realtime'; static const realtime = '/realtime';
// 移动精灵
static const mobileElf = '/mobile_elf';
// 设备管理 // 设备管理
static const devices = '/devices'; static const devices = '/devices';

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

@ -131,8 +131,12 @@ object AgentService : CoroutineScope {
// 聊天存储服务 // 聊天存储服务
private lateinit var chatStorageHelper: ChatStorageHelper private lateinit var chatStorageHelper: ChatStorageHelper
// Agent模式: stt_llm_tts(默认)| sts(移动精灵)
private var agentMode = "stt_llm_tts"
private var stsAgent: StsAgent? = null
// 会话ID,用于区分不同聊天上下文 // 会话ID,用于区分不同聊天上下文
private val agentId = "default_agent" private var agentId = "default_agent"
//工具调用提示 //工具调用提示
private var callingToolText = "" private var callingToolText = ""
private var insufficientIntegralText = "" // 积分不足提示 private var insufficientIntegralText = "" // 积分不足提示
@ -246,6 +250,44 @@ object AgentService : CoroutineScope {
if (isInitialized) return true if (isInitialized) return true
try { 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
audioPlayer = AudioPlayer(context) // STS 模式也需要音频播放器(提示音等)
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,
cmei = config["stsCmei"]?.toString() ?: "",
)
stsAgent = StsAgent(
context = context,
config = stsConfig,
onEvent = { name, data ->
// BLE 唤醒:AI 开始回复时关闭 BLE 编解码器(说明用户已说完)
if (currentRecognitionMode == "ble_wakeup" && name == "assistant_response") {
BleService.closeCodec()
}
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() ?: ""; serverurl = config["serverurl"]?.toString() ?: "";
usertoken = config["usertoken"]?.toString() ?: ""; usertoken = config["usertoken"]?.toString() ?: "";
callingToolText = config["callingToolText"]?.toString() ?: ""; callingToolText = config["callingToolText"]?.toString() ?: "";
@ -358,6 +400,11 @@ object AgentService : CoroutineScope {
config["mcpServer"]?.toString() ?: "", config["mcpServer"]?.toString() ?: "",
config["musicServiceType"]?.toString() ?: "" config["musicServiceType"]?.toString() ?: ""
) )
// 更新 STS 模式参数(cmei 等可能在首次初始化时尚未获取到)
val newCmei = config["stsCmei"]?.toString() ?: ""
if (newCmei.isNotEmpty()) {
stsAgent?.updateCmei(newCmei)
}
} catch (e: Exception) { } catch (e: Exception) {
_log("ERROR", "[Agent] 应用配置更新失败: ${e.message}") _log("ERROR", "[Agent] 应用配置更新失败: ${e.message}")
} }
@ -369,6 +416,12 @@ object AgentService : CoroutineScope {
*/ */
fun dispose() { fun dispose() {
try { try {
if (agentMode == "sts") {
stsAgent?.dispose()
stsAgent = null
_isInitialized.set(false)
return
}
BleService.closeCodec() BleService.closeCodec()
stopRecognition() stopRecognition()
stopTts() stopTts()
@ -670,6 +723,19 @@ object AgentService : CoroutineScope {
* @param mode 识别模式:normal, ble_wakeup, phone_call, push_to_talk * @param mode 识别模式:normal, ble_wakeup, phone_call, push_to_talk
*/ */
fun startRecognition(isExternalActive: Boolean, mode: String = "normal"): Boolean { fun startRecognition(isExternalActive: Boolean, mode: String = "normal"): Boolean {
if (agentMode == "sts") {
currentRecognitionMode = mode // 记录模式供 onEvent 回调使用
when (mode) {
"push_to_talk" -> stsAgent?.startPtt()
"phone_call" -> stsAgent?.startCall()
"ble_wakeup" -> {
audioPlayer?.playAudio(R.raw.start) // 立即播提示音,不等 WS 连接完成
stsAgent?.startPtt()
}
else -> stsAgent?.startPtt() // 普通模式默认 PTT
}
return true
}
if(isExternalActive){ if(isExternalActive){
isKeepResult = false isKeepResult = false
BleService.openEncoder()//不发指令。只是录制opus BleService.openEncoder()//不发指令。只是录制opus
@ -928,6 +994,11 @@ object AgentService : CoroutineScope {
* 停止语音识别 * 停止语音识别
*/ */
fun stopRecognition() { fun stopRecognition() {
if (agentMode == "sts") {
// StsAgent 内部根据 inCall / pttRecording 状态自行决定停止方式
stsAgent?.stop()
return
}
if (!isRecognitionActive) return if (!isRecognitionActive) return
_log("INFO", "[Agent] 停止语音识别,模式: $currentRecognitionMode") _log("INFO", "[Agent] 停止语音识别,模式: $currentRecognitionMode")
@ -954,7 +1025,26 @@ object AgentService : CoroutineScope {
* 打断当前响应 * 打断当前响应
* 停止TTS播放和AI流输出 * 停止TTS播放和AI流输出
*/ */
/** 设置 STS 模式 TTS 静音开关 */
fun setTtsMuted(muted: Boolean) {
stsAgent?.ttsMuted = muted
}
/** 手动触发心跳上报(Flutter 层调用) */
fun reportHeartbeat() {
stsAgent?.reportHeartbeat()
}
/** 主动触发认证检查(移动精灵页面打开时调用,确保认证事件被 Flutter 层接收) */
fun fetchCredentials() {
stsAgent?.fetchCredentials()
}
fun interruptCurrentResponse() { fun interruptCurrentResponse() {
if (agentMode == "sts") {
stsAgent?.interruptSpeaking()
return
}
if (isAiStreaming || isTtsSpeaking) { if (isAiStreaming || isTtsSpeaking) {
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
stopRecognition() stopRecognition()
@ -1689,6 +1779,11 @@ object AgentService : CoroutineScope {
* 清除聊天历史 * 清除聊天历史
*/ */
fun clearChatHistory(callback: (Boolean) -> Unit) { fun clearChatHistory(callback: (Boolean) -> Unit) {
if (agentMode == "sts") {
stsAgent?.clearHistory()
callback(true)
return
}
launch { launch {
try { try {
val success = chatStorageHelper.deleteMessages(agentId) val success = chatStorageHelper.deleteMessages(agentId)

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

@ -97,6 +97,15 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream
config["xunfeiAppId"]?.toString()?.let { intent.putExtra("xunfeiAppId", it) } config["xunfeiAppId"]?.toString()?.let { intent.putExtra("xunfeiAppId", it) }
config["xunfeiAccessKeyId"]?.toString()?.let { intent.putExtra("xunfeiAccessKeyId", it) } config["xunfeiAccessKeyId"]?.toString()?.let { intent.putExtra("xunfeiAccessKeyId", it) }
config["xunfeiAccessKeySecret"]?.toString()?.let { intent.putExtra("xunfeiAccessKeySecret", 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["stsCmei"]?.toString()?.let { intent.putExtra("stsCmei", it) }
config["vipexptime"]?.toString()?.let { config["vipexptime"]?.toString()?.let {
intent.putExtra("vipexptime", it) intent.putExtra("vipexptime", it)
//打印日志 //打印日志
@ -344,6 +353,37 @@ 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)
}
}
"reportHeartbeat" -> {
try {
AgentService.reportHeartbeat()
result.success(true)
} catch (e: Exception) {
Log.e(TAG, "心跳上报失败", e)
result.error("HEARTBEAT_ERROR", "心跳上报失败: ${e.message}", null)
}
}
"fetchCredentials" -> {
try {
AgentService.fetchCredentials()
result.success(true)
} catch (e: Exception) {
Log.e(TAG, "触发认证检查失败", e)
result.error("FETCH_CREDENTIALS_ERROR", "触发认证检查失败: ${e.message}", null)
}
}
"setCanWakeUp" -> { "setCanWakeUp" -> {
try { try {
val canWakeUp = call.argument<Boolean>("canWakeUp") val canWakeUp = call.argument<Boolean>("canWakeUp")

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

@ -77,6 +77,15 @@ class BleMediaService : Service(), CoroutineScope {
private const val KEY_XUNFEI_APP_ID = "xunfeiAppId" // 讯飞AppID 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_ID = "xunfeiAccessKeyId" // 讯飞AccessKeyID
private const val KEY_XUNFEI_ACCESS_KEY_SECRET = "xunfeiAccessKeySecret" // 讯飞AccessKeySecret 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 const val KEY_STS_CMEI = "stsCmei"
// 用于跟踪服务是否正在运行 // 用于跟踪服务是否正在运行
private val isRunning = AtomicBoolean(false) private val isRunning = AtomicBoolean(false)
@ -546,14 +555,16 @@ private var companionManager: BleCompanionManager? = null
val config = getConfigFromPreferences(prefs) val config = getConfigFromPreferences(prefs)
Log.d(TAG, "当前配置参数: $config") Log.d(TAG, "当前配置参数: $config")
// 检查必要的配置是否存在 // 检查必要的配置是否存在(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 azureSpeechKey = config[KEY_AZURE_SPEECH_KEY]?.toString() ?: ""
val openaiApiKey = config[KEY_OPENAI_API_KEY]?.toString() ?: "" val openaiApiKey = config[KEY_OPENAI_API_KEY]?.toString() ?: ""
if (azureSpeechKey.isEmpty() || openaiApiKey.isEmpty()) { if (azureSpeechKey.isEmpty() || openaiApiKey.isEmpty()) {
Log.w(TAG, "语音代理初始化失败: API密钥未配置") Log.w(TAG, "语音代理初始化失败: API密钥未配置")
return return
} }
}
if (isChatAgentInitialized) { if (isChatAgentInitialized) {
Log.d(TAG, "ChatAgent已经初始化,跳过") Log.d(TAG, "ChatAgent已经初始化,跳过")
@ -604,6 +615,14 @@ private var companionManager: BleCompanionManager? = null
it.getStringExtra(KEY_XUNFEI_APP_ID)?.let { value -> config[KEY_XUNFEI_APP_ID] = value } 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_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_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 }
it.getStringExtra(KEY_STS_CMEI)?.let { value -> config[KEY_STS_CMEI] = value }
Log.d(TAG, "liwei-------vipexptime 3-1 ${ config[KEY_VIP_EXPTIME]}") Log.d(TAG, "liwei-------vipexptime 3-1 ${ config[KEY_VIP_EXPTIME]}")
// 提取 supportedLanguages 参数 // 提取 supportedLanguages 参数
it.getStringArrayListExtra(KEY_SUPPORTED_LANGUAGES)?.let { languages -> it.getStringArrayListExtra(KEY_SUPPORTED_LANGUAGES)?.let { languages ->
@ -671,6 +690,14 @@ private var companionManager: BleCompanionManager? = null
config[KEY_XUNFEI_APP_ID] = getConfigString(prefs, KEY_XUNFEI_APP_ID, "") 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_ID] = getConfigString(prefs, KEY_XUNFEI_ACCESS_KEY_ID, "")
config[KEY_XUNFEI_ACCESS_KEY_SECRET] = getConfigString(prefs, KEY_XUNFEI_ACCESS_KEY_SECRET, "") 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, "")
config[KEY_STS_CMEI] = getConfigString(prefs, KEY_STS_CMEI, "")
Log.d(TAG, "liwei-------vipexptime 3-0 ${ config[KEY_VIP_EXPTIME]}") Log.d(TAG, "liwei-------vipexptime 3-0 ${ config[KEY_VIP_EXPTIME]}")
// 添加 supportedLanguages 参数处理 // 添加 supportedLanguages 参数处理
val supportedLanguagesStr = getConfigString(prefs, KEY_SUPPORTED_LANGUAGES, "") val supportedLanguagesStr = getConfigString(prefs, KEY_SUPPORTED_LANGUAGES, "")

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

@ -0,0 +1,935 @@
package com.yunqiinnovation.agent_service
import android.content.Context
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.MediaPlayer
import android.media.MediaRecorder
import android.media.ToneGenerator
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,
val cmei: 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 {
Log.i(TAG, "[YIDONG] StsAgent初始化: productId=${config.productId} deviceNo=${config.deviceNo} deviceId=${config.deviceId} agentId=${config.agentId} cmei=${config.cmei} hasSecret=${config.deviceSecret.isNotEmpty()}")
fetchCredentials()
}
/** 动态更新 CMEI(首次初始化时可能尚未获取到) */
fun updateCmei(cmei: String) {
if (cmei.isNotEmpty() && cmei != config.cmei) {
config = config.copy(cmei = cmei)
Log.i(TAG, "[YIDONG] updateCmei: cmei已更新为 $cmei")
}
}
// ── 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 preBufferLock = Any()
// ── 麦克风读写锁(防止 read() 与 stop()/release() 并发导致 native SIGABRT)──
private val micLock = Any()
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
// ── 心跳冷却 ──────────────────────────────────────────────────────────────
private val HEARTBEAT_COOLDOWN_MS = 3_600_000L // 1 小时
// ═════════════════════════════════════════════════════════════════════════
// 凭证获取
// ═════════════════════════════════════════════════════════════════════════
fun fetchCredentials() {
if (config.deviceSecret.isNotEmpty()) {
credentialReady = true
Log.i(TAG, "[YIDONG] fetchCredentials: 凭证已存在,跳过请求 deviceNo=${config.deviceNo} deviceId=${config.deviceId}")
reportHeartbeat() // 凭证已有,直接上报心跳
return
}
Log.i(TAG, "[YIDONG] fetchCredentials: 开始请求密钥 productId=${config.productId} deviceNo=${config.deviceNo}")
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) {
Log.i(TAG, "[YIDONG] fetchCredentials: 密钥获取成功 deviceId=${config.deviceId} deviceNo=${config.deviceNo} cmei=${config.cmei}")
onCredentialsFetched?.invoke(config.deviceId, config.deviceSecret, config.deviceNo)
reportHeartbeat() // 凭证获取成功后立即上报心跳
} else {
Log.e(TAG, "[YIDONG] fetchCredentials: 响应成功但deviceSecret为空")
emit("credential_failed", mapOf("reason" to "设备未认证,deviceSecret为空"))
}
} else {
val msg = json.optString("message", "未知错误")
Log.e(TAG, "[YIDONG] fetchCredentials: 密钥获取失败 code=${json.optString("code")} msg=$msg")
emit("credential_failed", mapOf("reason" to "设备未认证:$msg"))
}
}
} catch (e: Exception) {
Log.e(TAG, "[YIDONG] fetchCredentials: 请求异常 ${e.message}")
emit("credential_failed", mapOf("reason" to "设备认证请求失败:${e.message}"))
}
}
}
/** 向中国移动平台上报心跳 */
fun reportHeartbeat() {
if (!credentialReady) {
Log.w(TAG, "[YIDONG] reportHeartbeat: 凭证未就绪,跳过上报")
return
}
// 1 小时冷却:StsAgent 会被反复重建,init/fetchCredentials 都会调到这里,
// 必须在原生层拦,否则每次重建都会打一发心跳
val prefs = context.getSharedPreferences("sts_agent", Context.MODE_PRIVATE)
val last = prefs.getLong("last_heartbeat_ts", 0L)
val now = System.currentTimeMillis()
if (last > 0L && now - last < HEARTBEAT_COOLDOWN_MS) {
Log.i(TAG, "[YIDONG] reportHeartbeat: 距上次 ${(now - last) / 1000}s,冷却中跳过")
return
}
prefs.edit().putLong("last_heartbeat_ts", now).apply()
Log.i(TAG, "[YIDONG] reportHeartbeat: 开始上报 deviceId=${config.deviceId} deviceNo=${config.deviceNo} cmei=${config.cmei}")
launch {
try {
val netType = try {
val cm = context.getSystemService(android.content.Context.CONNECTIVITY_SERVICE)
as android.net.ConnectivityManager
val net = cm.activeNetworkInfo
when (net?.type) {
android.net.ConnectivityManager.TYPE_WIFI -> "WiFi"
android.net.ConnectivityManager.TYPE_MOBILE -> "5G"
else -> "WiFi"
}
} catch (_: Exception) { "WiFi" }
val body = JSONObject().apply {
put("deviceId", config.deviceId)
put("deviceSecret", config.deviceSecret)
put("productId", config.productId)
put("productKey", config.productKey)
put("params", JSONObject().apply {
put("innerIp", JSONArray().put("127.0.0.1"))
put("netSpeed", "0Mbps")
put("netType", netType)
put("platform", "Android${android.os.Build.VERSION.RELEASE}")
put("sdkVersion", "ai_http_1.0")
put("firmwareVersion", "1.0.0")
put("imei", "")
put("cmei", config.cmei) // 优先使用 UserDevice 的 CMEI
put("mac", "")
})
}.toString().toRequestBody("application/json".toMediaType())
val req = Request.Builder()
.url("https://ivs.chinamobiledevice.com:11443/v2/customer/device/report")
.post(body)
.build()
okHttpClient.newCall(req).execute().use { resp ->
val json = JSONObject(resp.body!!.string())
val code = json.optString("code")
val msg = json.optString("message")
if (code == "200") {
Log.i(TAG, "[YIDONG] reportHeartbeat: 上报成功 code=$code msg=$msg")
emit("heartbeat_reported", mapOf("time" to System.currentTimeMillis()))
} else {
Log.e(TAG, "[YIDONG] reportHeartbeat: 上报失败 code=$code msg=$msg")
// 上报失败回滚时间戳,避免卡 1 小时无法重试
prefs.edit().putLong("last_heartbeat_ts", last).apply()
}
}
} catch (e: Exception) {
Log.e(TAG, "[YIDONG] reportHeartbeat: 请求异常 ${e.message}")
// 异常同样回滚时间戳
prefs.edit().putLong("last_heartbeat_ts", last).apply()
}
}
}
// ═════════════════════════════════════════════════════════════════════════
// 公开接口
// ═════════════════════════════════════════════════════════════════════════
/** 通话模式:开启麦克风,VAD 自动触发 WS */
fun startCall() {
if (inCall) return
if (ttsPlaying) interruptTts()
if (pttRecording) stopPtt(cancel = true)
if (!credentialReady) { emitError("移动精灵凭证未就绪,请稍后重试"); return }
Log.i(TAG, "[YIDONG] startCall: 启动通话模式 deviceNo=${config.deviceNo} agentId=${config.agentId}")
openMic()
startMicWatchdog()
inCall = true
startIdleTimer()
}
/** PTT 模式:立即连 WS 并开始录音 */
fun startPtt() {
if (pttRecording) {
if (forwardingAudio) return // 真正在录音中,忽略重复调用
// forwardingAudio=false:WS 已关闭,正处于 TTS 播报等待阶段(如 BLE 唤醒等待 AI 回复期间)
// 调用方(BleAgent)已通过 interruptCurrentResponse() 中断了 TTS,
// 此处只做静默状态重置,不再重复发出 tts_canceled 事件
Log.i(TAG, "[YIDONG] startPtt: 静默重置旧会话,重新启动")
stopMicHard()
sessionEnded = false
pttRecording = false
}
if (ttsPlaying) interruptTts()
if (!credentialReady) { emitError("移动精灵凭证未就绪"); return }
Log.i(TAG, "[YIDONG] startPtt: 启动PTT模式 deviceNo=${config.deviceNo} agentId=${config.agentId}")
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()
synchronized(preBufferLock) { 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)) {
// 使用非阻塞读 + micLock,确保与 stopMicHard() 中的 stop()/release() 互斥。
// READ_NON_BLOCKING 让 read() 立即返回(微秒级),锁持有时间极短,
// 彻底消除 releaseBuffer 并发导致的 native SIGABRT。
val read = synchronized(micLock) {
audioRecord?.read(buf, 0, buf.size, AudioRecord.READ_NON_BLOCKING) ?: -1
}
if (read < 0) break
if (read > 0) { micChunkCount++; onMicAudio(buf.copyOf(read)) }
else delay(5) // 无数据时释放协程让出线程,避免忙等
}
}
} catch (e: Exception) {
Log.e(TAG, "打开麦克风失败: ${e.message}")
}
}
private fun stopMicHard() {
recordingJob?.cancel(); recordingJob = null
// 拿 micLock 后再 stop()/release(),与录音循环的 read() 互斥,
// 保证 stop() 永远不会在 read() 内部 releaseBuffer 期间被调用。
synchronized(micLock) {
val ar = audioRecord
audioRecord = null
try { ar?.stop(); ar?.release() } catch (_: Exception) {}
}
}
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) {
synchronized(preBufferLock) {
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
playReadyBeep()
emit("recognition_started", emptyMap())
val cached = synchronized(preBufferLock) { preBuffer.toList().also { preBuffer.clear() } }
for (c in cached) feedSendBuffer(c)
}
/** 唤醒成功提示音:通知用户可以说话了 */
private fun playReadyBeep() {
try {
val toneGen = ToneGenerator(AudioManager.STREAM_MUSIC, 60)
toneGen.startTone(ToneGenerator.TONE_PROP_BEEP, 180)
mainHandler.postDelayed({ toneGen.release() }, 300)
} catch (e: Exception) {
Log.w(TAG, "playReadyBeep failed: ${e.message}")
}
}
// ═════════════════════════════════════════════════════════════════════════
// 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"
Log.i(TAG, "[YIDONG] connectWs: 连接WSS deviceNo=${config.deviceNo} deviceId=${config.deviceId} sn=$sn")
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") {
Log.i(TAG, "[YIDONG] connectWs: 收到ready,WSS连接成功")
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?) {
Log.e(TAG, "[YIDONG] connectWs: WSS连接失败 ${t.message}")
if (webSocket === ws) webSocket = null // 只清除自己的引用,避免覆盖新 WS
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) {
if (webSocket === ws) webSocket = null // 只清除自己的引用,避免覆盖新 WS
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() {
forwardingAudio = false; wsConnecting = false
when {
sessionEnded && inCall -> if (!ttsPlaying) { resetVad(); startIdleTimer() }
sessionEnded -> {
// PTT 结束;若无 TTS 排队或播放(如无语音输入场景),立即补发 tts_completed
// 避免 Flutter 侧 _pttResultPending 永久无法清除导致按钮锁死
if (!ttsPlaying && ttsQueue.isEmpty()) emit("tts_completed", emptyMap())
}
inCall -> resetVad()
// WS 意外关闭(session 未正常结束,如极速松手音频未发出),同样补发保底
else -> if (!ttsPlaying && ttsQueue.isEmpty() && !ttsInterrupted) emit("tts_completed", emptyMap())
}
}
private fun resetVad() {
speechDetected = false; vadOnsetCount = 0
synchronized(preBufferLock) { 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()
}
}
}

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

@ -101,8 +101,12 @@ class AgentServiceImpl: NSObject {
return ChatStorageHelper.shared return ChatStorageHelper.shared
} }
// Agent 模式
private var agentMode = "stt_llm_tts"
private var stsAgent: StsAgent?
// 会话ID,用于区分不同聊天上下文 // 会话ID,用于区分不同聊天上下文
private let agentId = "default_agent" private var agentId = "default_agent"
//工具调用提示 //工具调用提示
public var callingToolText = "" // 工具调用提示 public var callingToolText = "" // 工具调用提示
public var insufficientIntegralText = "" // 积分不足提示 public var insufficientIntegralText = "" // 积分不足提示
@ -230,6 +234,36 @@ class AgentServiceImpl: NSObject {
// if isInitialized { return true } // if isInitialized { return true }
// os_log("initialize: config=%{public}@", log: logger, type: .info, config) // 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,
cmei: config["stsCmei"] as? String ?? ""
)
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 self.config = config
if let navigationMode = config["navigationMode"] as? String { if let navigationMode = config["navigationMode"] as? String {
@ -522,6 +556,17 @@ class AgentServiceImpl: NSObject {
} }
} }
func startRecognition(useBle: Bool = false, mode: String = "normal") -> Bool { 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 { if !isInitialized {
sendError("服务未初始化", code: "NOT_INITIALIZED") sendError("服务未初始化", code: "NOT_INITIALIZED")
return false return false
@ -607,6 +652,10 @@ class AgentServiceImpl: NSObject {
} }
func stopRecognition() -> Bool { func stopRecognition() -> Bool {
if agentMode == "sts" {
stsAgent?.stop()
return true
}
/// 函数功能:停止语音识别 /// 函数功能:停止语音识别
/// 特性: /// 特性:
/// 1) 即便处于"启动中"(isStartingRecognition)也会打印日志并尝试停止 /// 1) 即便处于"启动中"(isStartingRecognition)也会打印日志并尝试停止
@ -710,7 +759,21 @@ class AgentServiceImpl: NSObject {
return success return success
} }
/// STS 模式下主动触发中国移动平台的凭证获取(Dart AgentService.fetchCredentials 会调到这里)
func fetchCredentials() {
stsAgent?.fetchCredentials()
}
/// STS 模式下主动上报一次心跳(1 小时冷却在原生层拦截)
func reportHeartbeat() {
stsAgent?.reportHeartbeat()
}
func interruptCurrentResponse() -> Bool { func interruptCurrentResponse() -> Bool {
if agentMode == "sts" {
stsAgent?.interruptSpeaking()
return true
}
var interrupted = false var interrupted = false
if isSpeaking { if isSpeaking {
@ -1530,6 +1593,10 @@ private func jsonToString(_ json: [String: Any]) -> String? {
return result return result
} }
func clearChatHistory() -> Bool { func clearChatHistory() -> Bool {
if agentMode == "sts" {
stsAgent?.clearHistory()
return true
}
chatHistory.removeAll() chatHistory.removeAll()
DispatchQueue.global(qos: .utility).async { DispatchQueue.global(qos: .utility).async {
@ -1778,6 +1845,12 @@ private func jsonToString(_ json: [String: Any]) -> String? {
func dispose() -> Bool { func dispose() -> Bool {
_log("INFO", "[Agent] dispose") _log("INFO", "[Agent] dispose")
if agentMode == "sts" {
stsAgent?.dispose()
stsAgent = nil
isInitialized = false
return true
}
if isRecognizing { if isRecognizing {
// 关闭编码器 (设备侧) // 关闭编码器 (设备侧)
BleService.shared.closeCodec() BleService.shared.closeCodec()

11
local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift

@ -203,6 +203,17 @@ public class AgentServicePlugin: NSObject, FlutterPlugin {
} }
impl.setCanWakeUp(canWakeUp) impl.setCanWakeUp(canWakeUp)
result(true) result(true)
case "reportHeartbeat":
// 中国移动精灵心跳上报,原生层持久化 1 小时冷却
impl.reportHeartbeat()
result(nil)
case "fetchCredentials":
// 中国移动精灵凭证拉取,成功后会自动触发心跳(受冷却保护)
impl.fetchCredentials()
result(nil)
default: default:
result(FlutterMethodNotImplemented) result(FlutterMethodNotImplemented)
} }

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

@ -0,0 +1,613 @@
import Foundation
import UIKit
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
var cmei: 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
// MARK: 心跳冷却
/// 1 小时冷却(毫秒),避免 StsAgent 反复重建 / fetchCredentials 反复触发时频繁上报
private let HEARTBEAT_COOLDOWN_MS: Int64 = 3_600_000
private let HEARTBEAT_PREFS_KEY = "sts_agent_last_heartbeat_ts"
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())
os_log("StsAgent 初始化: productId=%{public}@ deviceNo=%{public}@ deviceId=%{public}@ cmei=%{public}@ hasSecret=%{public}@",
log: logger, type: .info,
config.productId, config.deviceNo, config.deviceId, config.cmei,
config.deviceSecret.isEmpty ? "false" : "true")
// 与 Android 行为对齐:凭证已有时立即尝试上报一次心跳(受 1 小时冷却保护)
if !config.deviceSecret.isEmpty && !config.deviceId.isEmpty {
credentialsFetched = true
reportHeartbeat()
}
}
/// 动态更新 CMEI(首次初始化时可能尚未获取到)
func updateCmei(_ cmei: String) {
guard !cmei.isEmpty, cmei != config.cmei else { return }
config.cmei = cmei
os_log("updateCmei: cmei 已更新为 %{public}@", log: logger, type: .info, cmei)
}
// 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: - 凭证获取
/// 公开入口:主动触发认证检查(Dart 层 AgentService.fetchCredentials 会调用)
func fetchCredentials() {
fetchCredentialsIfNeeded { [weak self] in
// 成功与否都不做额外处理;fetchCredentialsIfNeeded 内部会在命中凭证时触发心跳
_ = self
}
}
private func fetchCredentialsIfNeeded(completion: @escaping () -> Void) {
if credentialsFetched && !config.deviceId.isEmpty && !config.deviceSecret.isEmpty {
// 凭证已有,直接上报心跳(受冷却保护)
reportHeartbeat()
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)
// 凭证获取成功后立即上报心跳(受冷却保护)
self.reportHeartbeat()
completion()
}.resume()
}
// MARK: - 心跳上报
/// 向中国移动平台上报心跳;1 小时冷却通过 UserDefaults 持久化,所有调用路径共享同一把闸门
func reportHeartbeat() {
guard credentialsFetched,
!config.deviceId.isEmpty,
!config.deviceSecret.isEmpty else {
os_log("reportHeartbeat: 凭证未就绪,跳过上报", log: logger, type: .info)
return
}
let defaults = UserDefaults.standard
// 用 Double 存毫秒时间戳,避开 Int64 的 UserDefaults 桥接问题
let last = defaults.double(forKey: HEARTBEAT_PREFS_KEY)
let now = Date().timeIntervalSince1970 * 1000
if last > 0, now - last < Double(HEARTBEAT_COOLDOWN_MS) {
os_log("reportHeartbeat: 距上次 %{public}ds,冷却中跳过", log: logger, type: .info, Int((now - last) / 1000))
return
}
defaults.set(now, forKey: HEARTBEAT_PREFS_KEY)
os_log("reportHeartbeat: 开始上报 deviceId=%{public}@ deviceNo=%{public}@ cmei=%{public}@",
log: logger, type: .info, config.deviceId, config.deviceNo, config.cmei)
let urlStr = "https://ivs.chinamobiledevice.com:11443/v2/customer/device/report"
guard let url = URL(string: urlStr) else { return }
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.timeoutInterval = 10
let systemVersion = UIDevice.current.systemVersion
let params: [String: Any] = [
"innerIp": ["127.0.0.1"],
"netSpeed": "0Mbps",
"netType": "WiFi",
"platform": "iOS\(systemVersion)",
"sdkVersion": "ai_http_1.0",
"firmwareVersion": "1.0.0",
"imei": "",
"cmei": config.cmei,
"mac": "",
]
let bodyDict: [String: Any] = [
"deviceId": config.deviceId,
"deviceSecret": config.deviceSecret,
"productId": config.productId,
"productKey": config.productKey,
"params": params,
]
guard let bodyData = try? JSONSerialization.data(withJSONObject: bodyDict, options: []) else { return }
req.httpBody = bodyData
URLSession.shared.dataTask(with: req) { [weak self] data, _, error in
guard let self = self else { return }
if let error = error {
os_log("reportHeartbeat: 请求异常 %{public}@", log: self.logger, type: .error, error.localizedDescription)
// 异常回滚时间戳,避免卡 1 小时无法重试
UserDefaults.standard.set(last, forKey: self.HEARTBEAT_PREFS_KEY)
return
}
guard let data = data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
os_log("reportHeartbeat: 响应解析失败", log: self.logger, type: .error)
UserDefaults.standard.set(last, forKey: self.HEARTBEAT_PREFS_KEY)
return
}
// code 可能是 String 或 NSNumber,统一转成字符串
let code: String = {
if let s = json["code"] as? String { return s }
if let n = json["code"] as? NSNumber { return n.stringValue }
return ""
}()
let msg = (json["message"] as? String) ?? ""
if code == "200" {
os_log("reportHeartbeat: 上报成功 code=%{public}@ msg=%{public}@", log: self.logger, type: .info, code, msg)
self.emit("heartbeat_reported", data: ["time": Int64(now)])
} else {
os_log("reportHeartbeat: 上报失败 code=%{public}@ msg=%{public}@", log: self.logger, type: .error, code, msg)
// 上报失败回滚时间戳
UserDefaults.standard.set(last, forKey: self.HEARTBEAT_PREFS_KEY)
}
}.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
)
}
}
}

51
local_plugins/agent_service/lib/agent_service.dart

@ -71,6 +71,18 @@ enum AgentServiceEventType {
/// 错误 /// 错误
error, error,
/// STS 凭证获取成功(设备 deviceId/deviceSecret)
stsCredentials,
/// 麦克风音量(RMS)
micRms,
/// 心跳上报成功
heartbeatReported,
/// STS 设备认证失败
credentialFailed,
/// 未知事件 /// 未知事件
unknown unknown
} }
@ -193,6 +205,14 @@ class AgentService {
return AgentServiceEventType.imageProcessing; return AgentServiceEventType.imageProcessing;
case 'image_ready': case 'image_ready':
return AgentServiceEventType.imageReady; return AgentServiceEventType.imageReady;
case 'sts_credentials':
return AgentServiceEventType.stsCredentials;
case 'mic_rms':
return AgentServiceEventType.micRms;
case 'heartbeat_reported':
return AgentServiceEventType.heartbeatReported;
case 'credential_failed':
return AgentServiceEventType.credentialFailed;
default: default:
return AgentServiceEventType.unknown; return AgentServiceEventType.unknown;
} }
@ -463,6 +483,37 @@ 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);
}
}
/// 手动触发心跳上报(移动精灵)
static Future<void> reportHeartbeat() async {
try {
await _channel.invokeMethod('reportHeartbeat');
} on PlatformException catch (e) {
throw AgentServiceException(e.code, e.message ?? '心跳上报失败', e.details);
}
}
/// 主动触发认证检查(移动精灵页面打开时调用)
/// 若凭证不存在则重新请求,结果通过事件流返回(credentialFailed / heartbeatReported)
static Future<void> fetchCredentials() async {
try {
await _channel.invokeMethod('fetchCredentials');
} on PlatformException catch (_) {
// 忽略异常,认证结果通过事件流反馈
}
}
/// 中断当前响应 /// 中断当前响应
/// ///
/// 停止语音合成和AI流输出 /// 停止语音合成和AI流输出

2
local_plugins/ble_service/lib/ble_service.dart

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

563
preview/yidong.html

@ -0,0 +1,563 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=375, initial-scale=1">
<title>移动精灵 UI 预览</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #e8edf2;
font-family: -apple-system, 'PingFang SC', 'Helvetica Neue', sans-serif;
display: flex; gap: 20px; padding: 24px;
flex-wrap: wrap; justify-content: center; align-items: flex-start;
}
/* ── Phone shell ── */
.phone {
width: 375px; height: 700px;
background: #f4f6f9;
border-radius: 44px;
border: 1px solid #d0d5dd;
overflow: hidden;
display: flex; flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06);
}
/* ── AppBar ── */
.appbar {
height: 56px; flex-shrink: 0;
background: #fff;
border-bottom: 1px solid #eaecf0;
display: flex; align-items: center;
padding: 0 16px; gap: 8px;
}
.appbar-back { color: #667085; font-size: 22px; line-height: 1; cursor: pointer; }
.appbar-avatar {
width: 32px; height: 32px; border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 14px; font-weight: 600; flex-shrink: 0;
}
.appbar-name { font-size: 16px; font-weight: 600; color: #101828; flex: 1; }
.status-pill {
font-size: 11px; font-weight: 500;
padding: 3px 10px; border-radius: 20px;
display: flex; align-items: center; gap: 4px;
}
.pill-idle { background: #f2f4f7; color: #667085; }
.pill-connecting { background: #fffaeb; color: #b54708; }
.pill-recording { background: #ecfdf3; color: #027a48; }
.pill-thinking { background: #eff8ff; color: #175cd3; }
.pill-speaking { background: #ecfdf3; color: #027a48; }
.pill-timer { font-weight: 400; }
/* ── Chat area ── */
.chat-area {
flex: 1; overflow-y: auto;
padding: 16px 16px 8px;
display: flex; flex-direction: column; gap: 14px;
}
.empty-state {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 8px;
}
.empty-avatar {
width: 80px; height: 80px; border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 32px; font-weight: 600; margin-bottom: 12px;
}
.empty-title { font-size: 20px; font-weight: bold; color: #101828; }
.empty-sub { font-size: 13px; color: #667085; }
.msg-row { display: flex; flex-direction: column; }
.msg-row.user { align-items: flex-end; }
.msg-row.ai { align-items: flex-start; gap: 4px; }
.ai-label { display: flex; align-items: center; gap: 6px; }
.ai-dot {
width: 22px; height: 22px; border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 10px;
}
.ai-name { font-size: 11px; color: #98a2b3; }
.bubble {
max-width: 72%; padding: 10px 14px;
font-size: 15px; line-height: 1.55;
word-break: break-all;
}
.bubble-user {
background: #2e7cf6; color: #fff;
border-radius: 18px 18px 4px 18px;
}
.bubble-ai {
background: #fff; color: #1d2939;
border-radius: 4px 18px 18px 18px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.msg-time { font-size: 11px; color: #d0d5dd; margin-top: 3px; }
/* ── Bottom bar ── */
.bottom-bar {
flex-shrink: 0;
background: #fff;
border-top: 1px solid #eaecf0;
box-shadow: 0 -2px 12px rgba(0,0,0,0.05);
}
/* ── 输入区域 ── */
.input-row {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px 18px;
}
.mode-toggle {
width: 38px; height: 38px; border-radius: 50%;
background: #f2f4f7; border: none;
display: flex; align-items: center; justify-content: center;
cursor: pointer; flex-shrink: 0; color: #667085; font-size: 18px;
}
/* 文本输入框(内含发送按钮) */
.text-input-wrap {
flex: 1; height: 42px; border-radius: 21px;
background: #f2f4f7;
display: flex; align-items: center;
padding: 0 4px 0 16px;
}
.text-input-wrap input {
flex: 1; height: 100%; border: none; background: transparent;
font-size: 14px; color: #101828; outline: none;
}
.text-input-wrap input::placeholder { color: #98a2b3; }
.send-btn-inner {
width: 32px; height: 32px; border-radius: 50%;
background: linear-gradient(135deg, #667eea, #5a67d8);
border: none; cursor: pointer; color: #fff; font-size: 16px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.call-btn {
width: 44px; height: 44px; border-radius: 50%;
background: linear-gradient(135deg, #34c759, #28a745);
border: none; cursor: pointer; color: #fff; font-size: 18px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
box-shadow: 0 4px 14px rgba(52,199,89,.4);
}
/* 按住说话按钮(微信风格) */
.ptt-btn {
flex: 1; height: 42px; border-radius: 21px;
background: #fff; border: 1px solid #d0d5dd;
display: flex; align-items: center; justify-content: center;
cursor: pointer; font-size: 15px; color: #101828; font-weight: 600;
letter-spacing: 2px;
transition: all .15s;
user-select: none;
}
.ptt-btn:active {
background: #f2f4f7;
border-color: #98a2b3;
}
/* 禁用状态 */
.ptt-btn.disabled {
opacity: 0.5;
pointer-events: none;
}
/* PTT 录音中 - 中间按钮(橙色) */
.ptt-record-center {
flex: 1; height: 42px; border-radius: 21px;
background: linear-gradient(135deg, #f79009, #dc6803);
display: flex; align-items: center; justify-content: center; gap: 8px;
box-shadow: 0 2px 8px rgba(247,144,9,.3);
color: #fff; font-size: 14px; font-weight: 500;
}
/* PTT 连接中 - 与按住说话空闲状态相同 */
.ptt-connecting-center {
flex: 1; height: 42px; border-radius: 21px;
background: #fff; border: 1px solid #d0d5dd;
display: flex; align-items: center; justify-content: center; gap: 8px;
color: #667085; font-size: 14px; font-weight: 500;
}
/* 取消按钮(替换左侧切换按钮位置) */
.cancel-toggle {
width: 38px; height: 38px; border-radius: 50%;
background: #f2f4f7; border: 1px solid #eaecf0;
display: flex; align-items: center; justify-content: center;
cursor: pointer; flex-shrink: 0; color: #ef4444; font-size: 16px;
}
/* PTT 实时识别文字(渐变背景) */
.ptt-realtime {
padding: 12px 20px; margin: 0 12px 4px;
background: linear-gradient(135deg, #f0f4ff, #e8f5e9);
border: 0.5px solid #e2e8f0;
border-radius: 16px;
font-size: 15px; color: #101828;
text-align: center;
line-height: 1.4;
}
/* ── 状态提示条 ── */
.status-strip {
padding: 8px 16px;
display: flex; align-items: center; gap: 8px;
font-size: 13px; font-weight: 500;
}
.strip-speaking { background: #ecfdf3; color: #16a34a; }
.strip-thinking { background: #eff8ff; color: #175cd3; }
.strip-connecting { background: #fffaeb; color: #b54708; }
.strip-recording { background: #ecfdf3; color: #16a34a; }
.interrupt-btn {
margin-left: auto;
padding: 4px 12px; border-radius: 16px;
border: 1px solid #ef4444; background: transparent;
color: #ef4444; font-size: 12px; font-weight: 500;
cursor: pointer;
}
/* ── 通话底栏 ── */
.call-bottom {
padding: 12px 20px 18px;
display: flex; align-items: center; gap: 12px;
}
.call-status {
display: flex; align-items: center; gap: 6px; flex: 1;
}
.call-dot { width: 8px; height: 8px; border-radius: 50%; }
.call-label { font-size: 13px; color: #667085; }
/* 音量指示器 */
.vol-indicator {
width: 44px; height: 44px; border-radius: 50%;
background: #f2f4f7;
display: flex; align-items: center; justify-content: center; gap: 2px;
flex-shrink: 0;
}
.vol-bar {
width: 3px; border-radius: 1.5px; background: #d0d5dd;
transition: background .15s;
}
.vol-bar.active { background: #34c759; }
.hangup-btn {
width: 52px; height: 52px; border-radius: 50%;
background: #ef4444; border: none; color: #fff;
display: flex; align-items: center; justify-content: center;
cursor: pointer; flex-shrink: 0;
box-shadow: 0 4px 14px rgba(239,68,68,.3);
}
/* Spinner */
.spinner {
width: 22px; height: 22px;
border: 2.5px solid currentColor; border-top-color: transparent;
border-radius: 50%; animation: spin .7s linear infinite;
}
.spinner-sm { width: 14px; height: 14px; border-width: 2px; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Mini wave */
.mini-wave { display: flex; gap: 2px; align-items: center; }
.mini-wave span {
display: block; width: 2.5px; border-radius: 2px;
animation: mwave .8s ease-in-out infinite;
}
.mini-wave span:nth-child(1) { animation-delay: 0s; }
.mini-wave span:nth-child(2) { animation-delay: .13s; }
.mini-wave span:nth-child(3) { animation-delay: .26s; }
@keyframes mwave {
0%,100% { height: 3px; }
50% { height: 10px; }
}
.label {
font-size: 11px; color: #98a2b3; text-align: center;
padding: 6px 0 0; letter-spacing: .3px; font-weight: 500;
}
</style>
</head>
<body>
<!-- ① 文本输入模式(空闲) -->
<div>
<p class="label">① 文本输入模式</p>
<div class="phone">
<div class="appbar">
<span class="appbar-back">‹</span>
<div class="appbar-avatar">晓</div>
<span class="appbar-name">移动精灵</span>
<span class="status-pill pill-idle">空闲</span>
</div>
<div class="chat-area">
<div class="empty-state">
<div class="empty-avatar">晓</div>
<div class="empty-title">移动精灵</div>
<div class="empty-sub">点击右下角按钮,即可与晓晓对话</div>
</div>
</div>
<div class="bottom-bar">
<div class="input-row">
<button class="mode-toggle" title="切换到语音">🎤</button>
<div class="text-input-wrap">
<input placeholder="继续提问..." />
<button class="send-btn-inner">↑</button>
</div>
<button class="call-btn">📞</button>
</div>
</div>
</div>
</div>
<!-- ② 语音输入模式(空闲) -->
<div>
<p class="label">② 语音模式 - 按住说话</p>
<div class="phone">
<div class="appbar">
<span class="appbar-back">‹</span>
<div class="appbar-avatar">晓</div>
<span class="appbar-name">移动精灵</span>
<span class="status-pill pill-idle">空闲</span>
</div>
<div class="chat-area">
<div class="msg-row ai">
<div class="ai-label"><div class="ai-dot">晓</div><span class="ai-name">晓晓</span></div>
<div class="bubble bubble-ai">你好!我是晓晓,有什么可以帮你的吗?</div>
<span class="msg-time">14:23</span>
</div>
</div>
<div class="bottom-bar">
<div class="input-row">
<button class="mode-toggle" title="切换到文本">⌨</button>
<div class="ptt-btn">按住 说话</div>
<button class="call-btn">📞</button>
</div>
</div>
</div>
</div>
<!-- ③ PTT 按住中 - 连接阶段(灰色,保留两侧按钮) -->
<div>
<p class="label">③ 按住说话 - 连接中(同空闲样式)</p>
<div class="phone">
<div class="appbar">
<span class="appbar-back">‹</span>
<div class="appbar-avatar">晓</div>
<span class="appbar-name">移动精灵</span>
<span class="status-pill pill-connecting">连接中…</span>
</div>
<div class="chat-area">
<div class="msg-row ai">
<div class="ai-label"><div class="ai-dot">晓</div><span class="ai-name">晓晓</span></div>
<div class="bubble bubble-ai">你好!我是晓晓,有什么可以帮你的吗?</div>
<span class="msg-time">14:23</span>
</div>
</div>
<div class="bottom-bar">
<div class="input-row">
<button class="mode-toggle" style="opacity:0.4;pointer-events:none;">⌨</button>
<div class="ptt-connecting-center">
<div class="spinner spinner-sm" style="color:#667085;"></div>
<span>连接中...</span>
</div>
<button class="call-btn" style="opacity:0.4;pointer-events:none;">📞</button>
</div>
</div>
</div>
</div>
<!-- ④ PTT 按住中 - 录音中 + 实时识别(绿色,渐变识别背景) -->
<div>
<p class="label">④ 按住说话 - 录音中(橙色)</p>
<div class="phone">
<div class="appbar">
<span class="appbar-back">‹</span>
<div class="appbar-avatar">晓</div>
<span class="appbar-name">移动精灵</span>
<span class="status-pill pill-recording">识别中</span>
</div>
<div class="chat-area">
<div class="msg-row ai">
<div class="ai-label"><div class="ai-dot">晓</div><span class="ai-name">晓晓</span></div>
<div class="bubble bubble-ai">你好!我是晓晓,有什么可以帮你的吗?</div>
<span class="msg-time">14:23</span>
</div>
</div>
<div class="bottom-bar">
<!-- 居中实时识别文字(渐变背景) -->
<div class="ptt-realtime">今天天气怎么样?</div>
<div class="input-row" style="padding-top:4px;">
<button class="mode-toggle" style="opacity:0.4;pointer-events:none;">⌨</button>
<div class="ptt-record-center">
<div class="mini-wave"><span style="background:#fff;height:6px;"></span><span style="background:#fff;height:10px;"></span><span style="background:#fff;height:4px;"></span></div>
<span>松开发送 · 2s</span>
</div>
<button class="call-btn" style="opacity:0.4;pointer-events:none;">📞</button>
</div>
</div>
</div>
</div>
<!-- ⑤ AI 思考中(输入栏保留,半透明禁用) -->
<div>
<p class="label">⑤ AI 思考中(输入栏禁用)</p>
<div class="phone">
<div class="appbar">
<span class="appbar-back">‹</span>
<div class="appbar-avatar">晓</div>
<span class="appbar-name">移动精灵</span>
<span class="status-pill pill-thinking">处理中</span>
</div>
<div class="chat-area">
<div class="msg-row ai">
<div class="ai-label"><div class="ai-dot">晓</div><span class="ai-name">晓晓</span></div>
<div class="bubble bubble-ai">你好!我是晓晓,有什么可以帮你的吗?</div>
<span class="msg-time">14:23</span>
</div>
<div class="msg-row user">
<div class="bubble bubble-user">今天天气怎么样?</div>
<span class="msg-time">14:24</span>
</div>
</div>
<div class="bottom-bar">
<div class="status-strip strip-thinking">
<div class="spinner spinner-sm" style="color:#175cd3;"></div>
<span>AI 思考中...</span>
</div>
<div class="input-row" style="padding-top:4px; opacity:0.5; pointer-events:none;">
<button class="mode-toggle">⌨</button>
<div class="ptt-btn">按住 说话</div>
<button class="call-btn">📞</button>
</div>
</div>
</div>
</div>
<!-- ⑥ AI 播报中(输入栏保留,可打断) -->
<div>
<p class="label">⑥ AI 播报中(可打断)</p>
<div class="phone">
<div class="appbar">
<span class="appbar-back">‹</span>
<div class="appbar-avatar">晓</div>
<span class="appbar-name">移动精灵</span>
<span class="status-pill pill-speaking">朗读中</span>
</div>
<div class="chat-area">
<div class="msg-row user">
<div class="bubble bubble-user">今天天气怎么样?</div>
<span class="msg-time">14:24</span>
</div>
<div class="msg-row ai">
<div class="ai-label"><div class="ai-dot">晓</div><span class="ai-name">晓晓</span></div>
<div class="bubble bubble-ai">今天晴天,气温 18°C,微风,空气质量优,很适合出行哦</div>
<span class="msg-time">14:24</span>
</div>
</div>
<div class="bottom-bar">
<div class="status-strip strip-speaking">
<div class="mini-wave"><span style="background:#16a34a;"></span><span style="background:#16a34a;"></span><span style="background:#16a34a;"></span></div>
<span>晓晓正在朗读...</span>
<button class="interrupt-btn">✕ 打断</button>
</div>
<div class="input-row" style="padding-top:4px; opacity:0.5; pointer-events:none;">
<button class="mode-toggle">⌨</button>
<div class="ptt-btn">按住 说话</div>
<button class="call-btn">📞</button>
</div>
</div>
</div>
</div>
<!-- ⑦ 通话模式 - 等待说话(音量指示器 + 挂断) -->
<div>
<p class="label">⑦ 通话模式 - 等待说话</p>
<div class="phone">
<div class="appbar">
<span class="appbar-back">‹</span>
<div class="appbar-avatar">晓</div>
<span class="appbar-name">移动精灵</span>
<span class="status-pill pill-recording">通话中 <span class="pill-timer">01:23</span></span>
</div>
<div class="chat-area">
<div class="msg-row user">
<div class="bubble bubble-user">今天天气怎么样?</div>
<span class="msg-time">14:24</span>
</div>
<div class="msg-row ai">
<div class="ai-label"><div class="ai-dot">晓</div><span class="ai-name">晓晓</span></div>
<div class="bubble bubble-ai">今天晴天,气温 18°C,微风,空气质量优</div>
<span class="msg-time">14:24</span>
</div>
</div>
<div class="bottom-bar">
<div class="call-bottom">
<div class="call-status">
<div class="call-dot" style="background:#34c759;"></div>
<span class="call-label">通话中</span>
<span class="call-label">01:23</span>
</div>
<!-- 音量指示器 -->
<div class="vol-indicator">
<div class="vol-bar active" style="height:8px;"></div>
<div class="vol-bar active" style="height:12px;"></div>
<div class="vol-bar active" style="height:16px;"></div>
<div class="vol-bar" style="height:20px;"></div>
<div class="vol-bar" style="height:24px;"></div>
</div>
<!-- 挂断按钮 -->
<button class="hangup-btn">
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor"><path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08a.956.956 0 0 1 0-1.36C3.36 8.85 7.47 7 12 7s8.64 1.85 11.71 4.72c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.1-.7-.28-.79-.73-1.68-1.36-2.66-1.85-.33-.16-.56-.5-.56-.9v-3.1C14.15 9.25 12.6 9 12 9z"/></svg>
</button>
</div>
</div>
</div>
</div>
<!-- ⑧ 通话模式 - AI 播报中 -->
<div>
<p class="label">⑧ 通话模式 - AI 播报</p>
<div class="phone">
<div class="appbar">
<span class="appbar-back">‹</span>
<div class="appbar-avatar">晓</div>
<span class="appbar-name">移动精灵</span>
<span class="status-pill pill-speaking">朗读中 <span class="pill-timer">01:48</span></span>
</div>
<div class="chat-area">
<div class="msg-row user">
<div class="bubble bubble-user">Flutter 有什么优势?</div>
<span class="msg-time">14:41</span>
</div>
<div class="msg-row ai">
<div class="ai-label"><div class="ai-dot">晓</div><span class="ai-name">晓晓</span></div>
<div class="bubble bubble-ai">Flutter 的核心优势在于跨平台能力强、渲染性能高,以及 Dart 语言的高效开发体验...</div>
<span class="msg-time">14:41</span>
</div>
</div>
<div class="bottom-bar">
<div class="status-strip strip-speaking">
<div class="mini-wave"><span style="background:#16a34a;"></span><span style="background:#16a34a;"></span><span style="background:#16a34a;"></span></div>
<span>晓晓正在朗读...</span>
<button class="interrupt-btn">✕ 打断</button>
</div>
<div class="call-bottom" style="padding-top:6px;">
<div class="call-status">
<div class="call-dot" style="background:#34c759;"></div>
<span class="call-label">通话中</span>
<span class="call-label">01:48</span>
</div>
<div class="vol-indicator">
<div class="vol-bar" style="height:8px;"></div>
<div class="vol-bar" style="height:12px;"></div>
<div class="vol-bar" style="height:16px;"></div>
<div class="vol-bar" style="height:20px;"></div>
<div class="vol-bar" style="height:24px;"></div>
</div>
<button class="hangup-btn">
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor"><path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08a.956.956 0 0 1 0-1.36C3.36 8.85 7.47 7 12 7s8.64 1.85 11.71 4.72c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.1-.7-.28-.79-.73-1.68-1.36-2.66-1.85-.33-.16-.56-.5-.56-.9v-3.1C14.15 9.25 12.6 9 12 9z"/></svg>
</button>
</div>
</div>
</div>
</div>
</body>
</html>

81
test_mobile_elf.py

@ -0,0 +1,81 @@
#!/usr/bin/env python3
import ssl, time, hashlib, json, websocket
DEVICE_NO = '199839C0AAAE99653D2E'
DEVICE_ID = '2032013791868043266'
PRODUCT_ID = '2031271967792472065'
PRODUCT_KEY = 'hotepZZwvouWQjHz'
DEVICE_SECRET = 'ijdthZGQELhkmigf'
ts = str(int(time.time() * 1000))
sign = hashlib.md5((DEVICE_SECRET + ts).encode()).hexdigest()
url = (f'wss://ivs.chinamobiledevice.com:11443/app-ws/v2/asr'
f'?deviceNo={DEVICE_NO}&deviceId={DEVICE_ID}'
f'&productId={PRODUCT_ID}&productKey={PRODUCT_KEY}'
f'&sign={sign}&sn=py-test&ts={ts}')
print(f'connecting...\n{url}')
ws = websocket.WebSocket(sslopt={'cert_reqs': ssl.CERT_NONE})
ws.settimeout(15)
ws.connect(url)
print('connected! sending start frame immediately...')
import uuid
start_frame = json.dumps({
'type': 'start',
'data': {
'cuid': DEVICE_ID,
'format': 'pcm',
'sample': 16000,
'support_dcs': 2,
'chunk_size': 10240,
'support_tts': True,
'support_text2dcs': True,
'user_agent': 'voitrans/1.0',
'dialog_request_id': str(uuid.uuid4()),
'access_rc': True,
'support_part_tts': True,
'need_stoplisten': True,
'need_dialogue_finish': True,
'result_trans2directive': False,
'rc_version': '5',
'messages': [],
'client_context': [],
}
})
ws.send(start_frame)
print('start frame sent, waiting for ready...')
# 等待 ready
while True:
msg = ws.recv()
print('received:', msg)
data = json.loads(msg)
if data.get('type') == 'ready':
print('--- ready received, sending text "你好" ---')
break
# 发送文本
text_frame = json.dumps({'type': 'text', 'data': {'text': '你好'}})
ws.send(text_frame)
print('text frame sent')
# 发送 finish
finish_frame = json.dumps({'type': 'finish'})
ws.send(finish_frame)
print('finish frame sent, waiting for AI response...')
# 接收所有回包直到 end=1
while True:
msg = ws.recv()
print('received:', msg)
try:
data = json.loads(msg)
if data.get('end') == 1:
print('--- session end (end=1), closing ---')
break
except Exception:
pass
ws.close()
print('done.')
Loading…
Cancel
Save