57 changed files with 5303 additions and 1474 deletions
@ -0,0 +1,10 @@ |
|||
{ |
|||
"mcpServers": { |
|||
"web-search": { |
|||
"url": "http://mcp.ideapsound.com:8000/sse" |
|||
}, |
|||
"amap-amap-sse": { |
|||
"url": "https://mcp.amap.com/sse?key=df7b0b6747c632fc38ddad22e7176646" |
|||
} |
|||
} |
|||
} |
|||
@ -1,13 +1,10 @@ |
|||
{ |
|||
"mcpServers": { |
|||
"deepsound-mcp": { |
|||
"url": "https://api.deapsound.com/sse" |
|||
}, |
|||
"voitrans-mcp": { |
|||
"url": "https://www.voitrans.net/sse" |
|||
}, |
|||
"web-search": { |
|||
"url": "http://mcp.ideapsound.com:8000/sse" |
|||
}, |
|||
"mp3": { |
|||
"url": "https://api.deapsound.com/sse" |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,554 @@ |
|||
import 'dart:async'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:ble_service/ble_service.dart'; |
|||
import 'package:deep_voice/core/utils/logger.dart'; |
|||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|||
import 'package:get_storage/get_storage.dart'; |
|||
import 'package:deep_voice/data/models/appconfig_model.dart'; |
|||
import 'dart:convert'; |
|||
import 'package:flutter/services.dart' show rootBundle; |
|||
|
|||
/// 命令类型常量(与原生层保持一致) |
|||
class BleCommands { |
|||
static const int GET_VERSION = 0x01; |
|||
static const int GET_BATTERY_INFO = 0x02; |
|||
static const int CONTROL_CODEC = 0x03; |
|||
static const int PLAY_A2DP = 0x04; |
|||
} |
|||
|
|||
/// 蓝牙设备信息缓存模型 |
|||
class DeviceInfo { |
|||
final String address; |
|||
final String name; |
|||
|
|||
// 整体电池电量 |
|||
final int batteryLevel; |
|||
|
|||
// 详细电池信息 |
|||
final int leftEarBattery; |
|||
final int rightEarBattery; |
|||
final int caseBattery; |
|||
|
|||
// 充电状态 |
|||
final bool leftEarCharging; |
|||
final bool rightEarCharging; |
|||
final bool caseCharging; |
|||
|
|||
final String firmwareVersion; |
|||
final DateTime lastUpdated; |
|||
|
|||
DeviceInfo({ |
|||
required this.address, |
|||
required this.name, |
|||
this.batteryLevel = 0, |
|||
this.leftEarBattery = 0, |
|||
this.rightEarBattery = 0, |
|||
this.caseBattery = 0, |
|||
this.leftEarCharging = false, |
|||
this.rightEarCharging = false, |
|||
this.caseCharging = false, |
|||
this.firmwareVersion = '', |
|||
DateTime? lastUpdated, |
|||
}) : lastUpdated = lastUpdated ?? DateTime.now(); |
|||
|
|||
/// 创建更新后的设备信息副本 |
|||
DeviceInfo copyWith({ |
|||
String? address, |
|||
String? name, |
|||
int? batteryLevel, |
|||
int? leftEarBattery, |
|||
int? rightEarBattery, |
|||
int? caseBattery, |
|||
bool? leftEarCharging, |
|||
bool? rightEarCharging, |
|||
bool? caseCharging, |
|||
String? firmwareVersion, |
|||
}) { |
|||
return DeviceInfo( |
|||
address: address ?? this.address, |
|||
name: name ?? this.name, |
|||
batteryLevel: batteryLevel ?? this.batteryLevel, |
|||
leftEarBattery: leftEarBattery ?? this.leftEarBattery, |
|||
rightEarBattery: rightEarBattery ?? this.rightEarBattery, |
|||
caseBattery: caseBattery ?? this.caseBattery, |
|||
leftEarCharging: leftEarCharging ?? this.leftEarCharging, |
|||
rightEarCharging: rightEarCharging ?? this.rightEarCharging, |
|||
caseCharging: caseCharging ?? this.caseCharging, |
|||
firmwareVersion: firmwareVersion ?? this.firmwareVersion, |
|||
lastUpdated: DateTime.now(), |
|||
); |
|||
} |
|||
|
|||
@override |
|||
String toString() { |
|||
return 'DeviceInfo(address: $address, name: $name, ' |
|||
'battery: $batteryLevel%, L: $leftEarBattery%, R: $rightEarBattery%, Case: $caseBattery%, ' |
|||
'charging: L:$leftEarCharging, R:$rightEarCharging, C:$caseCharging, ' |
|||
'version: $firmwareVersion)'; |
|||
} |
|||
} |
|||
|
|||
/// BLE管理器 - 专门处理BLE设备连接和信息管理 |
|||
class BleManager extends GetxService { |
|||
static BleManager get to => Get.find<BleManager>(); |
|||
|
|||
// BLE服务实例 |
|||
final BleService _bleService = BleService(); |
|||
|
|||
// BLE连接状态 |
|||
final _connectionState = BleConnectionState.disconnected.obs; |
|||
int get connectionState => _connectionState.value; |
|||
|
|||
// 连接状态监听 |
|||
final _isConnected = false.obs; |
|||
bool get isConnected => _isConnected.value; |
|||
|
|||
// 设备信息缓存 |
|||
final Rx<DeviceInfo?> _deviceInfo = Rx<DeviceInfo?>(null); |
|||
DeviceInfo? get deviceInfo => _deviceInfo.value; |
|||
|
|||
// 订阅 |
|||
StreamSubscription<dynamic>? _statusSubscription; |
|||
StreamSubscription<dynamic>? _dataSubscription; |
|||
final GetStorage _storage = GetStorage(); |
|||
|
|||
/// 初始化 |
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
Logger.info('BLE管理器初始化'); |
|||
|
|||
// 设置BLE事件监听 |
|||
_setupBleListener(); |
|||
} |
|||
|
|||
/// 释放资源 |
|||
@override |
|||
void onClose() { |
|||
_statusSubscription?.cancel(); |
|||
_dataSubscription?.cancel(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
/// 设置BLE事件监听 |
|||
void _setupBleListener() { |
|||
_statusSubscription?.cancel(); |
|||
_dataSubscription?.cancel(); |
|||
|
|||
try { |
|||
// 监听BLE状态更新 |
|||
_statusSubscription = _bleService.onStatusUpdated.listen( |
|||
(event) { |
|||
Logger.info('接收到BLE状态更新: $event'); |
|||
|
|||
if (event is Map) { |
|||
// 处理连接状态更新 |
|||
if (event.containsKey('connectionState')) { |
|||
final state = event['connectionState'] as int; |
|||
_connectionState.value = state; |
|||
|
|||
// 连接状态转换 |
|||
if (state == BleConnectionState.connected) { |
|||
Logger.info('BLE设备已连接'); |
|||
_isConnected.value = true; |
|||
|
|||
// 在连接成功后获取设备信息 |
|||
_fetchDeviceInfo(); |
|||
} else if (state == BleConnectionState.disconnected || |
|||
state == BleConnectionState.error) { |
|||
Logger.info('BLE设备已断开或发生错误'); |
|||
_isConnected.value = false; |
|||
} |
|||
} |
|||
|
|||
// 处理设备信息 |
|||
_processDeviceInfo(event); |
|||
} |
|||
}, |
|||
onError: (error) { |
|||
Logger.error('BLE状态事件流错误: $error'); |
|||
}, |
|||
); |
|||
|
|||
// 监听BLE数据流 |
|||
_dataSubscription = _bleService.onDataReceived.listen( |
|||
(data) { |
|||
Logger.info('接收到BLE数据: $data'); |
|||
// 在这里处理来自设备的数据 |
|||
}, |
|||
onError: (error) { |
|||
Logger.error('BLE数据流错误: $error'); |
|||
}, |
|||
); |
|||
} catch (e) { |
|||
Logger.error('设置BLE事件监听器失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 处理来自原生层的设备信息 |
|||
void _processDeviceInfo(Map<dynamic, dynamic> event) { |
|||
try { |
|||
// 检查是否是设备信息响应 |
|||
if (event.containsKey('command') && event.containsKey('success')) { |
|||
final bool success = event['success'] as bool; |
|||
if (!success) { |
|||
Logger.warning('设备命令执行失败: ${event['command']}'); |
|||
return; |
|||
} |
|||
|
|||
// 根据命令类型处理不同信息 |
|||
final command = event['command']; |
|||
|
|||
if (command is int) { |
|||
// 基于命令ID处理 |
|||
_processDeviceInfoByCommandId(command, event); |
|||
} else if (command is String) { |
|||
// 基于命令名称处理 |
|||
_processDeviceInfoByCommandName(command, event); |
|||
} |
|||
} |
|||
} catch (e) { |
|||
Logger.error('处理设备信息异常: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 根据命令ID处理设备信息 |
|||
void _processDeviceInfoByCommandId( |
|||
int commandId, Map<dynamic, dynamic> data) { |
|||
switch (commandId) { |
|||
case BleCommands.GET_BATTERY_INFO: |
|||
_updateBatteryInfoFromData(data); |
|||
break; |
|||
case BleCommands.GET_VERSION: |
|||
_updateVersionInfoFromData(data); |
|||
break; |
|||
default: |
|||
Logger.info('未处理的命令ID: $commandId'); |
|||
} |
|||
} |
|||
|
|||
/// 根据命令名称处理设备信息 |
|||
void _processDeviceInfoByCommandName( |
|||
String commandName, Map<dynamic, dynamic> data) { |
|||
switch (commandName) { |
|||
case 'batteryInfo': |
|||
_updateBatteryInfoFromData(data); |
|||
break; |
|||
case 'versionInfo': |
|||
_updateVersionInfoFromData(data); |
|||
break; |
|||
default: |
|||
Logger.info('未处理的命令名称: $commandName'); |
|||
} |
|||
} |
|||
|
|||
/// 更新电池信息 |
|||
void _updateBatteryInfoFromData(Map<dynamic, dynamic> data) { |
|||
try { |
|||
// 确保数据中包含电池信息 |
|||
if (!data.containsKey('leftEarBattery') || |
|||
!data.containsKey('rightEarBattery') || |
|||
!data.containsKey('caseBattery')) { |
|||
Logger.warning('电池信息数据不完整'); |
|||
return; |
|||
} |
|||
|
|||
// 提取电池信息 |
|||
final int leftBattery = data['leftEarBattery'] as int? ?? 0; |
|||
final int rightBattery = data['rightEarBattery'] as int? ?? 0; |
|||
final int caseBattery = data['caseBattery'] as int? ?? 0; |
|||
final bool leftCharging = data['leftEarCharging'] as bool? ?? false; |
|||
final bool rightCharging = data['rightEarCharging'] as bool? ?? false; |
|||
final bool caseCharging = data['caseCharging'] as bool? ?? false; |
|||
|
|||
// 获取整体电量(原生层使用左耳电量作为主要指示) |
|||
final int overallBattery = data['batteryLevel'] as int? ?? leftBattery; |
|||
|
|||
// 更新设备信息 |
|||
_updateDeviceInfoIfNeeded( |
|||
batteryLevel: overallBattery, |
|||
leftEarBattery: leftBattery, |
|||
rightEarBattery: rightBattery, |
|||
caseBattery: caseBattery, |
|||
leftEarCharging: leftCharging, |
|||
rightEarCharging: rightCharging, |
|||
caseCharging: caseCharging, |
|||
); |
|||
|
|||
Logger.info( |
|||
'设备电池信息已更新: 电量=$overallBattery%, 左耳=$leftBattery%, 右耳=$rightBattery%, 充电仓=$caseBattery%'); |
|||
} catch (e) { |
|||
Logger.error('更新电池信息失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 更新版本信息 |
|||
void _updateVersionInfoFromData(Map<dynamic, dynamic> data) { |
|||
try { |
|||
// 确保数据中包含版本信息 |
|||
if (!data.containsKey('version')) { |
|||
Logger.warning('版本信息数据不完整'); |
|||
return; |
|||
} |
|||
|
|||
// 提取版本信息 |
|||
final String version = data['version'] as String? ?? ''; |
|||
|
|||
// 更新设备信息 |
|||
_updateDeviceInfoIfNeeded( |
|||
firmwareVersion: version, |
|||
); |
|||
|
|||
Logger.info('设备版本信息已更新: 版本=$version'); |
|||
} catch (e) { |
|||
Logger.error('更新版本信息失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 更新设备信息(如果已存在设备) |
|||
void _updateDeviceInfoIfNeeded({ |
|||
int? batteryLevel, |
|||
int? leftEarBattery, |
|||
int? rightEarBattery, |
|||
int? caseBattery, |
|||
bool? leftEarCharging, |
|||
bool? rightEarCharging, |
|||
bool? caseCharging, |
|||
String? firmwareVersion, |
|||
}) { |
|||
// 如果设备信息已存在,则更新 |
|||
if (_deviceInfo.value != null) { |
|||
_deviceInfo.value = _deviceInfo.value!.copyWith( |
|||
batteryLevel: batteryLevel, |
|||
leftEarBattery: leftEarBattery, |
|||
rightEarBattery: rightEarBattery, |
|||
caseBattery: caseBattery, |
|||
leftEarCharging: leftEarCharging, |
|||
rightEarCharging: rightEarCharging, |
|||
caseCharging: caseCharging, |
|||
firmwareVersion: firmwareVersion, |
|||
); |
|||
} |
|||
// 如果设备信息不存在,但有地址和名称,则创建新的设备信息 |
|||
else if (_deviceInfo.value == null && |
|||
_isConnected.value && |
|||
(firmwareVersion != null || batteryLevel != null)) { |
|||
// 提示尝试创建设备信息但缺少必要信息 |
|||
Logger.warning('尝试更新设备信息,但设备基本信息不存在'); |
|||
} |
|||
} |
|||
|
|||
/// 连接成功后获取设备信息 |
|||
Future<void> _fetchDeviceInfo() async { |
|||
Logger.info('开始获取BLE设备详细信息'); |
|||
|
|||
try { |
|||
// 获取电池信息 |
|||
final batteryResult = await _bleService.getBatteryInfo(); |
|||
if (batteryResult) { |
|||
Logger.info('电池信息获取指令已发送'); |
|||
} else { |
|||
Logger.warning('电池信息获取指令发送失败'); |
|||
} |
|||
|
|||
// 获取设备版本信息 |
|||
final versionResult = await _bleService.getDeviceVersion(); |
|||
if (versionResult) { |
|||
Logger.info('版本信息获取指令已发送'); |
|||
} else { |
|||
Logger.warning('版本信息获取指令发送失败'); |
|||
} |
|||
} catch (e) { |
|||
Logger.error('获取BLE设备信息失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 检查BLE连接状态 |
|||
Future<int> getConnectionState() async { |
|||
try { |
|||
final isConnected = await _bleService.isConnected(); |
|||
_connectionState.value = isConnected |
|||
? BleConnectionState.connected |
|||
: BleConnectionState.disconnected; |
|||
_isConnected.value = isConnected; |
|||
|
|||
return _connectionState.value; |
|||
} catch (e) { |
|||
Logger.error('获取BLE连接状态失败: $e'); |
|||
return BleConnectionState.error; |
|||
} |
|||
} |
|||
|
|||
/// 启动BLE服务 |
|||
Future<bool> startBleService() async { |
|||
try { |
|||
// 从.env文件直接读取环境变量 |
|||
final azureSpeechKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; |
|||
final azureSpeechRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? 'eastasia'; |
|||
final openaiApiKey = dotenv.env['OPENAI_API_KEY'] ?? ''; |
|||
final openaiBaseUrl = |
|||
dotenv.env['OPENAI_BASE_URL'] ?? 'https://api.openai.com/v1'; |
|||
final openaiModel = dotenv.env['OPENAI_MODEL'] ?? 'gpt-3.5-turbo'; |
|||
// 从Flutter资源包中加载.mcp.json |
|||
final mcpConfig = await rootBundle.loadString('.mcp.json'); |
|||
|
|||
// 验证JSON格式 |
|||
final jsonData = jsonDecode(mcpConfig); |
|||
final config = <String, dynamic>{ |
|||
// 语音代理配置参数 |
|||
'azureSpeechKey': azureSpeechKey, |
|||
'azureSpeechRegion': azureSpeechRegion, |
|||
'openaiApiKey': openaiApiKey, |
|||
'openaiBaseUrl': openaiBaseUrl, |
|||
'openaiModel': openaiModel, |
|||
'mcpServer': jsonEncode(jsonData), |
|||
}; |
|||
|
|||
// 记录日志(不记录敏感信息) |
|||
Logger.info('启动BLE服务,配置参数键名: ${config}'); |
|||
|
|||
if (azureSpeechKey.isEmpty || openaiApiKey.isEmpty) { |
|||
Logger.warning('缺少必要的API密钥,请检查.env文件配置'); |
|||
|
|||
// 检查.env中相关密钥是否存在 |
|||
if (azureSpeechKey.isEmpty) { |
|||
Logger.warning('AZURE_SPEECH_KEY未配置在.env文件中'); |
|||
} |
|||
if (openaiApiKey.isEmpty) { |
|||
Logger.warning('OPENAI_API_KEY未配置在.env文件中'); |
|||
} |
|||
} |
|||
|
|||
final result = await _bleService.startBleService(config); |
|||
|
|||
if (result) { |
|||
Logger.info('BLE服务启动成功'); |
|||
|
|||
// 添加延迟以等待设备连接 |
|||
await Future.delayed(const Duration(seconds: 2)); |
|||
|
|||
// 检查连接状态 |
|||
final isConnected = await _bleService.isConnected(); |
|||
if (isConnected) { |
|||
_isConnected.value = true; |
|||
_connectionState.value = BleConnectionState.connected; |
|||
|
|||
// 如果设备信息存在,则获取详细信息 |
|||
if (_deviceInfo.value != null) { |
|||
// 获取详细设备信息 |
|||
_fetchDeviceInfo(); |
|||
} |
|||
} |
|||
} else { |
|||
Logger.error('BLE服务启动失败'); |
|||
} |
|||
|
|||
return result; |
|||
} catch (e) { |
|||
Logger.error('启动BLE服务失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 停止BLE服务 |
|||
Future<bool> stopBleService() async { |
|||
try { |
|||
final result = await _bleService.stopBleService(); |
|||
|
|||
if (result) { |
|||
Logger.info('BLE服务停止成功'); |
|||
_isConnected.value = false; |
|||
_connectionState.value = BleConnectionState.disconnected; |
|||
} else { |
|||
Logger.error('BLE服务停止失败'); |
|||
} |
|||
|
|||
return result; |
|||
} catch (e) { |
|||
Logger.error('停止BLE服务失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
registerBackgroundScan() { |
|||
_bleService.registerBackgroundScan(); |
|||
} |
|||
|
|||
/// 获取电池电量 |
|||
Future<int> getBatteryLevel() async { |
|||
try { |
|||
return await _bleService.getBatteryLevel(); |
|||
} catch (e) { |
|||
Logger.error('获取电池电量失败: $e'); |
|||
return 0; |
|||
} |
|||
} |
|||
|
|||
/// 主动获取电池信息 |
|||
Future<bool> fetchBatteryInfo() async { |
|||
try { |
|||
return await _bleService.getBatteryInfo(); |
|||
} catch (e) { |
|||
Logger.error('获取电池信息失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 主动获取设备版本信息 |
|||
Future<bool> fetchDeviceVersion() async { |
|||
try { |
|||
return await _bleService.getDeviceVersion(); |
|||
} catch (e) { |
|||
Logger.error('获取设备版本失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 打开编码器 |
|||
Future<bool> openEncoder() async { |
|||
try { |
|||
return await _bleService.openEncoder(); |
|||
} catch (e) { |
|||
Logger.error('打开编码器失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 打开解码器 |
|||
Future<bool> openDecoder() async { |
|||
try { |
|||
return await _bleService.openDecoder(); |
|||
} catch (e) { |
|||
Logger.error('打开解码器失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 关闭编解码器 |
|||
Future<bool> closeCodec() async { |
|||
try { |
|||
return await _bleService.closeCodec(); |
|||
} catch (e) { |
|||
Logger.error('关闭编解码器失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 播放A2DP |
|||
Future<bool> playA2DP() async { |
|||
try { |
|||
return await _bleService.playA2DP(); |
|||
} catch (e) { |
|||
Logger.error('播放A2DP失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 注册服务 |
|||
static Future<BleManager> init() async { |
|||
if (!Get.isRegistered<BleManager>()) { |
|||
Get.put(BleManager(), permanent: true); |
|||
} |
|||
return Future.value(Get.find<BleManager>()); |
|||
} |
|||
} |
|||
@ -0,0 +1,125 @@ |
|||
import 'dart:async'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:classic_bluetooth/classic_bluetooth.dart'; |
|||
import 'package:deep_voice/core/utils/logger.dart'; |
|||
|
|||
|
|||
/// 蓝牙服务 - 管理经典蓝牙服务 |
|||
class BluetoothManager extends GetxService { |
|||
static BluetoothManager get to => Get.find<BluetoothManager>(); |
|||
|
|||
// 蓝牙状态 |
|||
final _bluetoothState = 'unknown'.obs; |
|||
String get bluetoothState => _bluetoothState.value; |
|||
|
|||
// 当前经典蓝牙设备 |
|||
final Rx<BluetoothDevice?> currentDeviceRx = Rx<BluetoothDevice?>(null); |
|||
BluetoothDevice? get currentDevice => currentDeviceRx.value; |
|||
|
|||
// 订阅 |
|||
StreamSubscription<BluetoothEvent>? _bluetoothSubscription; |
|||
|
|||
|
|||
/// 初始化 |
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
Logger.info('蓝牙服务初始化'); |
|||
|
|||
|
|||
// 设置蓝牙事件监听 |
|||
_setupBluetoothListener(); |
|||
} |
|||
|
|||
/// 释放资源 |
|||
@override |
|||
void onClose() { |
|||
_bluetoothSubscription?.cancel(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
/// 设置经典蓝牙事件监听 |
|||
void _setupBluetoothListener() { |
|||
_bluetoothSubscription?.cancel(); |
|||
|
|||
try { |
|||
_bluetoothSubscription = ClassicBluetooth.events.listen( |
|||
(event) { |
|||
if (event.type == 'stateChanged' && event.state != null) { |
|||
_bluetoothState.value = event.state!; |
|||
Logger.info('蓝牙状态变更: ${event.state}'); |
|||
|
|||
// 如果蓝牙关闭,清除设备 |
|||
if (event.state == 'off') { |
|||
currentDeviceRx.value = null; |
|||
} |
|||
} else if (event.type == 'deviceConnected' && event.device != null) { |
|||
// 直接从事件中获取设备信息 |
|||
final newDevice = event.device!; |
|||
|
|||
// 更新当前设备 |
|||
currentDeviceRx.value = newDevice; |
|||
|
|||
Logger.info('经典蓝牙设备已连接: ${newDevice.name} (${newDevice.address})'); |
|||
} else if (event.type == 'deviceDisconnected' && event.device != null) { |
|||
// 直接从事件中获取设备信息 |
|||
final disconnectedDevice = event.device!; |
|||
|
|||
Logger.info('经典蓝牙设备已断开: ${disconnectedDevice.name} (${disconnectedDevice.address})'); |
|||
|
|||
// 如果断开的是当前设备,清除当前设备 |
|||
if (currentDeviceRx.value?.address == disconnectedDevice.address) { |
|||
currentDeviceRx.value = null; |
|||
Logger.info('已清除当前设备引用'); |
|||
} |
|||
} |
|||
}, |
|||
onError: (error) { |
|||
Logger.error('蓝牙事件流错误: $error'); |
|||
}, |
|||
); |
|||
} catch (e) { |
|||
Logger.error('设置蓝牙事件监听器失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 刷新设备连接状态 |
|||
Future<void> refreshDeviceConnection() async { |
|||
try { |
|||
|
|||
|
|||
// 如果BLE未连接,则检查经典蓝牙连接 |
|||
final devices = await ClassicBluetooth.getConnectedDevices(); |
|||
|
|||
if (devices.isNotEmpty) { |
|||
final newDevice = devices.first; |
|||
|
|||
// 更新当前设备 |
|||
currentDeviceRx.value = newDevice; |
|||
|
|||
Logger.info('已连接经典蓝牙设备: ${newDevice.name} (${newDevice.address})'); |
|||
return; |
|||
} |
|||
|
|||
// 没有任何连接 |
|||
currentDeviceRx.value = null; |
|||
Logger.info('未找到已连接的蓝牙设备'); |
|||
|
|||
} catch (e) { |
|||
Logger.error('刷新设备连接失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 检查蓝牙是否已启用 |
|||
Future<bool> isBluetoothEnabled() async { |
|||
try { |
|||
final isEnabled = await ClassicBluetooth.isBluetoothEnabled(); |
|||
_bluetoothState.value = isEnabled ? 'on' : 'off'; |
|||
return isEnabled; |
|||
} catch (e) { |
|||
Logger.error('检查蓝牙状态失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -1,249 +0,0 @@ |
|||
import 'dart:async'; |
|||
import 'dart:math' as math; |
|||
import 'package:get/get.dart'; |
|||
import 'package:classic_bluetooth/classic_bluetooth.dart'; |
|||
import 'package:deep_voice/core/utils/logger.dart'; |
|||
import 'package:flutter/services.dart'; |
|||
|
|||
/// 蓝牙设备信息缓存模型 |
|||
class DeviceInfo { |
|||
final String address; |
|||
final String name; |
|||
|
|||
// 整体电池电量 |
|||
final int batteryLevel; |
|||
|
|||
// 详细电池信息 |
|||
final int leftEarBattery; |
|||
final int rightEarBattery; |
|||
final int caseBattery; |
|||
|
|||
// 充电状态 |
|||
final bool leftEarCharging; |
|||
final bool rightEarCharging; |
|||
final bool caseCharging; |
|||
|
|||
final String firmwareVersion; |
|||
final String productId; |
|||
final String colorId; |
|||
final DateTime lastUpdated; |
|||
|
|||
DeviceInfo({ |
|||
required this.address, |
|||
required this.name, |
|||
this.batteryLevel = 0, |
|||
this.leftEarBattery = 0, |
|||
this.rightEarBattery = 0, |
|||
this.caseBattery = 0, |
|||
this.leftEarCharging = false, |
|||
this.rightEarCharging = false, |
|||
this.caseCharging = false, |
|||
this.firmwareVersion = '', |
|||
this.productId = '', |
|||
this.colorId = '', |
|||
DateTime? lastUpdated, |
|||
}) : lastUpdated = lastUpdated ?? DateTime.now(); |
|||
|
|||
/// 创建更新后的设备信息副本 |
|||
DeviceInfo copyWith({ |
|||
String? address, |
|||
String? name, |
|||
int? batteryLevel, |
|||
int? leftEarBattery, |
|||
int? rightEarBattery, |
|||
int? caseBattery, |
|||
bool? leftEarCharging, |
|||
bool? rightEarCharging, |
|||
bool? caseCharging, |
|||
String? firmwareVersion, |
|||
String? productId, |
|||
String? colorId, |
|||
}) { |
|||
return DeviceInfo( |
|||
address: address ?? this.address, |
|||
name: name ?? this.name, |
|||
batteryLevel: batteryLevel ?? this.batteryLevel, |
|||
leftEarBattery: leftEarBattery ?? this.leftEarBattery, |
|||
rightEarBattery: rightEarBattery ?? this.rightEarBattery, |
|||
caseBattery: caseBattery ?? this.caseBattery, |
|||
leftEarCharging: leftEarCharging ?? this.leftEarCharging, |
|||
rightEarCharging: rightEarCharging ?? this.rightEarCharging, |
|||
caseCharging: caseCharging ?? this.caseCharging, |
|||
firmwareVersion: firmwareVersion ?? this.firmwareVersion, |
|||
productId: productId ?? this.productId, |
|||
colorId: colorId ?? this.colorId, |
|||
lastUpdated: DateTime.now(), |
|||
); |
|||
} |
|||
|
|||
@override |
|||
String toString() { |
|||
return 'DeviceInfo(address: $address, name: $name, ' |
|||
'battery: $batteryLevel%, L: $leftEarBattery%, R: $rightEarBattery%, Case: $caseBattery%, ' |
|||
'charging: L:$leftEarCharging, R:$rightEarCharging, C:$caseCharging, ' |
|||
'version: $firmwareVersion, productId: $productId, colorId: $colorId)'; |
|||
} |
|||
} |
|||
|
|||
/// 蓝牙服务 - 管理经典蓝牙服务,提供设备信息缓存 |
|||
class BluetoothService extends GetxService { |
|||
static BluetoothService get to => Get.find<BluetoothService>(); |
|||
|
|||
// 蓝牙状态 |
|||
final _bluetoothState = 'unknown'.obs; |
|||
String get bluetoothState => _bluetoothState.value; |
|||
|
|||
// 当前经典蓝牙设备 |
|||
final Rx<BluetoothDevice?> currentDeviceRx = Rx<BluetoothDevice?>(null); |
|||
BluetoothDevice? get currentDevice => currentDeviceRx.value; |
|||
|
|||
// 设备信息缓存 |
|||
final Rx<DeviceInfo?> _deviceInfo = Rx<DeviceInfo?>(null); |
|||
DeviceInfo? get deviceInfo => _deviceInfo.value; |
|||
|
|||
// 表明当前连接类型,本服务只处理经典蓝牙 |
|||
bool get isBleConnection => false; |
|||
|
|||
// 订阅 |
|||
StreamSubscription<BluetoothEvent>? _bluetoothSubscription; |
|||
|
|||
/// 初始化 |
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
Logger.info('蓝牙服务初始化'); |
|||
|
|||
// 设置蓝牙事件监听 |
|||
_setupBluetoothListener(); |
|||
} |
|||
|
|||
/// 释放资源 |
|||
@override |
|||
void onClose() { |
|||
_bluetoothSubscription?.cancel(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
/// 设置经典蓝牙事件监听 |
|||
void _setupBluetoothListener() { |
|||
_bluetoothSubscription?.cancel(); |
|||
|
|||
try { |
|||
_bluetoothSubscription = ClassicBluetooth.events.listen( |
|||
(event) { |
|||
if (event.type == 'stateChanged' && event.state != null) { |
|||
_bluetoothState.value = event.state!; |
|||
Logger.info('蓝牙状态变更: ${event.state}'); |
|||
|
|||
// 如果蓝牙关闭,清除设备 |
|||
if (event.state == 'off') { |
|||
currentDeviceRx.value = null; |
|||
_deviceInfo.value = null; |
|||
} |
|||
} else if (event.type == 'deviceConnected' && event.device != null) { |
|||
// 直接从事件中获取设备信息 |
|||
final newDevice = event.device!; |
|||
|
|||
// 更新当前设备 |
|||
currentDeviceRx.value = newDevice; |
|||
|
|||
Logger.info('经典蓝牙设备已连接: ${newDevice.name} (${newDevice.address})'); |
|||
|
|||
// 创建基本设备信息 |
|||
if (_deviceInfo.value == null || _deviceInfo.value!.address != newDevice.address) { |
|||
_deviceInfo.value = DeviceInfo( |
|||
address: newDevice.address, |
|||
name: newDevice.name, |
|||
); |
|||
Logger.info('创建设备信息缓存: ${_deviceInfo.value}'); |
|||
} |
|||
} else if (event.type == 'deviceDisconnected' && event.device != null) { |
|||
// 直接从事件中获取设备信息 |
|||
final disconnectedDevice = event.device!; |
|||
|
|||
Logger.info('经典蓝牙设备已断开: ${disconnectedDevice.name} (${disconnectedDevice.address})'); |
|||
|
|||
// 如果断开的是当前设备,清除当前设备 |
|||
if (currentDeviceRx.value?.address == disconnectedDevice.address) { |
|||
currentDeviceRx.value = null; |
|||
// 不清除设备信息缓存,保留最后状态 |
|||
Logger.info('已清除当前设备引用,保留最后设备信息缓存'); |
|||
} |
|||
} |
|||
}, |
|||
onError: (error) { |
|||
Logger.error('蓝牙事件流错误: $error'); |
|||
}, |
|||
); |
|||
} catch (e) { |
|||
Logger.error('设置蓝牙事件监听器失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 刷新设备连接状态 |
|||
Future<void> refreshDeviceConnection() async { |
|||
try { |
|||
// 检查经典蓝牙连接 |
|||
final devices = await ClassicBluetooth.getConnectedDevices(); |
|||
|
|||
if (devices.isNotEmpty) { |
|||
final newDevice = devices.first; |
|||
|
|||
// 更新当前设备 |
|||
currentDeviceRx.value = newDevice; |
|||
|
|||
Logger.info('已连接经典蓝牙设备: ${newDevice.name} (${newDevice.address})'); |
|||
|
|||
// 创建基本设备信息 |
|||
if (_deviceInfo.value == null || _deviceInfo.value!.address != newDevice.address) { |
|||
_deviceInfo.value = DeviceInfo( |
|||
address: newDevice.address, |
|||
name: newDevice.name, |
|||
); |
|||
Logger.info('创建设备信息缓存: ${_deviceInfo.value}'); |
|||
} |
|||
|
|||
return; |
|||
} |
|||
|
|||
// 没有任何连接 |
|||
currentDeviceRx.value = null; |
|||
Logger.info('未找到已连接的蓝牙设备'); |
|||
|
|||
} catch (e) { |
|||
Logger.error('刷新设备连接失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 检查蓝牙是否已启用 |
|||
Future<bool> isBluetoothEnabled() async { |
|||
try { |
|||
final isEnabled = await ClassicBluetooth.isBluetoothEnabled(); |
|||
_bluetoothState.value = isEnabled ? 'on' : 'off'; |
|||
return isEnabled; |
|||
} catch (e) { |
|||
Logger.error('检查蓝牙状态失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 启动BLE服务(不适用于此服务,仅为接口兼容) |
|||
Future<bool> startBleService(Map<String, dynamic> config) async { |
|||
Logger.warning('BluetoothService不支持BLE服务,请使用BleService'); |
|||
return false; |
|||
} |
|||
|
|||
/// 停止BLE服务(不适用于此服务,仅为接口兼容) |
|||
Future<bool> stopBleService() async { |
|||
Logger.warning('BluetoothService不支持BLE服务,请使用BleService'); |
|||
return false; |
|||
} |
|||
|
|||
/// 注册服务 |
|||
static Future<BluetoothService> init() async { |
|||
if (!Get.isRegistered<BluetoothService>()) { |
|||
Get.put(BluetoothService(), permanent: true); |
|||
} |
|||
return Future.value(Get.find<BluetoothService>()); |
|||
} |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/agent_controller.dart'; |
|||
|
|||
class AgentBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
Get.lazyPut<AgentController>(() => AgentController()); |
|||
} |
|||
} |
|||
@ -0,0 +1,409 @@ |
|||
import 'dart:async'; |
|||
import 'dart:convert'; |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:agent_service/agent_service.dart'; |
|||
import 'package:logger/logger.dart'; |
|||
import 'package:chat_storage/chat_storage.dart'; |
|||
|
|||
class Message { |
|||
final bool isUser; |
|||
final String text; |
|||
final DateTime timestamp; |
|||
final bool isRecognizing; // 是否为语音识别中的临时消息 |
|||
|
|||
Message({ |
|||
required this.isUser, |
|||
required this.text, |
|||
DateTime? timestamp, |
|||
this.isRecognizing = false, |
|||
}) : this.timestamp = timestamp ?? DateTime.now(); |
|||
} |
|||
|
|||
class AgentController extends GetxController { |
|||
static AgentController get to => Get.find(); |
|||
|
|||
final logger = Logger(); |
|||
|
|||
// 聊天消息列表 |
|||
final messages = <Message>[].obs; |
|||
|
|||
// 控制器 |
|||
final TextEditingController textController = TextEditingController(); |
|||
final ScrollController scrollController = ScrollController(); |
|||
|
|||
// 状态标志 |
|||
final isInitialized = false.obs; |
|||
final isListening = false.obs; |
|||
final isSpeaking = false.obs; |
|||
final isProcessing = false.obs; |
|||
|
|||
// 输入模式控制 |
|||
final isTextInputMode = true.obs; |
|||
|
|||
// 当前输入的文本内容 |
|||
final currentText = ''.obs; |
|||
|
|||
// 流事件订阅 |
|||
StreamSubscription<AgentServiceEvent>? _eventSubscription; |
|||
|
|||
// 用于跟踪AI回复状态 |
|||
bool _isNewAssistantResponse = true; |
|||
String _currentResponseId = ''; |
|||
|
|||
// 会话ID |
|||
final String sessionId = 'default_agent'; |
|||
|
|||
// 是否已加载聊天记录 |
|||
final isHistoryLoaded = false.obs; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
_loadChatHistory(); |
|||
_subscribeToEvents(); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
AgentService.stopConversation(); |
|||
AgentService.stopTts(); |
|||
textController.dispose(); |
|||
scrollController.dispose(); |
|||
_eventSubscription?.cancel(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
// 加载聊天历史记录 |
|||
Future<void> _loadChatHistory() async { |
|||
try { |
|||
logger.i('正在加载聊天历史...'); |
|||
final result = await ChatStorage.getMessages( |
|||
sessionId: sessionId, |
|||
page: 1, |
|||
pageSize: 20, |
|||
); |
|||
|
|||
if (result.messages.isNotEmpty) { |
|||
// 转换并添加消息到列表 |
|||
final historyMessages = result.messages.map((msg) => Message( |
|||
isUser: msg.sender == 'user', |
|||
text: msg.message, |
|||
timestamp: DateTime.parse(msg.timestamp), |
|||
)).toList(); |
|||
|
|||
// 消息按时间排序,最新的消息在底部 |
|||
historyMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp)); |
|||
messages.assignAll(historyMessages); |
|||
|
|||
logger.i('成功加载 ${historyMessages.length} 条聊天记录'); |
|||
} else { |
|||
// 没有历史记录时添加欢迎消息 |
|||
_addWelcomeMessage(); |
|||
logger.i('没有历史记录,添加欢迎消息'); |
|||
} |
|||
} catch (e) { |
|||
logger.e('加载聊天历史失败: $e'); |
|||
_addWelcomeMessage(); |
|||
} finally { |
|||
isHistoryLoaded.value = true; |
|||
|
|||
messages.refresh(); |
|||
// 在加载完成后直接定位到底部,无需动画 |
|||
WidgetsBinding.instance.addPostFrameCallback((_) { |
|||
scrollController.jumpTo(scrollController.position.maxScrollExtent); |
|||
}); |
|||
|
|||
} |
|||
} |
|||
|
|||
// 添加欢迎消息 |
|||
void _addWelcomeMessage() { |
|||
messages.add(Message( |
|||
isUser: false, |
|||
text: '你好!我是DeepVoice AI助手,我能帮你:\n\n' |
|||
'• 回答问题和提供信息\n' |
|||
'• 实时语音翻译\n' |
|||
'• 推荐音乐和播客\n' |
|||
'• 语音备忘录和提醒\n' |
|||
'• 控制耳机功能\n\n' |
|||
'有什么我可以帮你的吗?', |
|||
)); |
|||
} |
|||
|
|||
// 订阅代理服务事件 |
|||
void _subscribeToEvents() { |
|||
_eventSubscription = AgentService.events.listen((event) { |
|||
switch (event.type) { |
|||
case AgentServiceEventType.recognitionStarted: |
|||
isListening.value = true; |
|||
isTextInputMode.value = false; |
|||
isTextInputMode.refresh(); |
|||
// 只标记识别开始,不添加消息 |
|||
break; |
|||
case AgentServiceEventType.recognitionStopped: |
|||
isListening.value = false; |
|||
isTextInputMode.value = true; |
|||
isTextInputMode.refresh(); |
|||
break; |
|||
|
|||
case AgentServiceEventType.recognizing: |
|||
final text = event.data['text'] ?? ''; |
|||
currentText.value = text; // 保留当前文本,以便其他地方使用 |
|||
|
|||
if (text.isNotEmpty) { |
|||
// 查找是否有正在识别中的消息 |
|||
final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); |
|||
|
|||
if (index >= 0) { |
|||
// 更新已有的识别中消息 |
|||
messages[index] = Message( |
|||
isUser: true, |
|||
text: text, |
|||
isRecognizing: true, |
|||
timestamp: messages[index].timestamp, |
|||
); |
|||
} else { |
|||
// 没有识别中的消息,创建新消息 |
|||
messages.add(Message( |
|||
isUser: true, |
|||
text: text, |
|||
isRecognizing: true, |
|||
)); |
|||
} |
|||
messages.refresh(); |
|||
_scrollToBottom(); |
|||
} |
|||
break; |
|||
|
|||
case AgentServiceEventType.recognitionResult: |
|||
final text = event.data['text'] ?? ''; |
|||
|
|||
// 查找是否有正在识别中的消息 |
|||
final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); |
|||
|
|||
if (text.isNotEmpty) { |
|||
if (index >= 0) { |
|||
// 将识别中的消息转换为最终消息 |
|||
messages[index] = Message( |
|||
isUser: true, |
|||
text: text, |
|||
timestamp: messages[index].timestamp, |
|||
); |
|||
messages.refresh(); |
|||
} else { |
|||
// 如果没有找到识别中的消息,添加新消息 |
|||
messages.add(Message(isUser: true, text: text)); |
|||
} |
|||
|
|||
isProcessing.value = true; |
|||
// 标记为新的AI回复 |
|||
_isNewAssistantResponse = true; |
|||
// 滚动到底部 |
|||
_scrollToBottom(); |
|||
} else if (index >= 0) { |
|||
// 如果识别结果为空,移除临时的识别消息 |
|||
messages.removeAt(index); |
|||
messages.refresh(); |
|||
} |
|||
break; |
|||
|
|||
case AgentServiceEventType.ttsStarted: |
|||
isSpeaking.value = true; |
|||
break; |
|||
|
|||
case AgentServiceEventType.ttsCompleted: |
|||
case AgentServiceEventType.ttsStopped: |
|||
case AgentServiceEventType.ttsCanceled: |
|||
isSpeaking.value = false; |
|||
break; |
|||
|
|||
case AgentServiceEventType.assistantToken: |
|||
if (!isProcessing.value) isProcessing.value = true; |
|||
|
|||
final token = event.data['token'] ?? ''; |
|||
final responseId = event.data['responseId'] ?? ''; |
|||
|
|||
if (token.isNotEmpty) { |
|||
// 如果是新的回复或者响应ID改变,创建新消息 |
|||
if (_isNewAssistantResponse || (_currentResponseId != responseId && responseId.isNotEmpty)) { |
|||
messages.add(Message(isUser: false, text: token)); |
|||
_isNewAssistantResponse = false; |
|||
_currentResponseId = responseId; |
|||
} else { |
|||
// 否则更新最后一条消息 |
|||
final lastIndex = messages.length - 1; |
|||
|
|||
if (lastIndex >= 0 && !messages[lastIndex].isUser) { |
|||
messages[lastIndex] = Message( |
|||
isUser: false, |
|||
text: messages[lastIndex].text + token, |
|||
timestamp: messages[lastIndex].timestamp, |
|||
); |
|||
} |
|||
} |
|||
// 滚动到底部 |
|||
_scrollToBottom(); |
|||
} |
|||
break; |
|||
|
|||
case AgentServiceEventType.assistantResponse: |
|||
isProcessing.value = false; |
|||
// 标记当前回复完成 |
|||
_isNewAssistantResponse = true; |
|||
break; |
|||
|
|||
case AgentServiceEventType.error: |
|||
isListening.value = false; |
|||
isSpeaking.value = false; |
|||
isProcessing.value = false; |
|||
|
|||
// 移除临时的识别消息 |
|||
final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); |
|||
if (index >= 0) { |
|||
messages.removeAt(index); |
|||
messages.refresh(); |
|||
} |
|||
|
|||
logger.e('代理服务错误: ${event.data['message']}'); |
|||
break; |
|||
|
|||
case AgentServiceEventType.autoStop: |
|||
isListening.value = false; |
|||
|
|||
// 移除临时的识别消息 |
|||
final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); |
|||
if (index >= 0) { |
|||
// 只有当消息为空或文本太短时才移除 |
|||
if (messages[index].text.trim().length < 2) { |
|||
messages.removeAt(index); |
|||
messages.refresh(); |
|||
} else { |
|||
// 否则转换为正式消息 |
|||
messages[index] = Message( |
|||
isUser: true, |
|||
text: messages[index].text, |
|||
timestamp: messages[index].timestamp, |
|||
); |
|||
messages.refresh(); |
|||
|
|||
// 发送此消息到AI处理 |
|||
isProcessing.value = true; |
|||
_isNewAssistantResponse = true; |
|||
AgentService.processTextInput(messages[index].text, speakResponse: true); |
|||
} |
|||
} |
|||
break; |
|||
|
|||
case AgentServiceEventType.functionCall: |
|||
// final functionName = event.data['name'] ?? ''; |
|||
// final arguments = event.data['arguments'] ?? ''; |
|||
// logger.i('mcp: $functionName, $arguments'); |
|||
break; |
|||
|
|||
case AgentServiceEventType.functionCallResult: |
|||
final result = event.data['result'] ?? ''; |
|||
final functionCall = event.data['function_call'] ?? ''; |
|||
break; |
|||
|
|||
default: |
|||
break; |
|||
} |
|||
}); |
|||
} |
|||
|
|||
// 滚动到底部 |
|||
void _scrollToBottom() { |
|||
WidgetsBinding.instance.addPostFrameCallback((_) { |
|||
if (scrollController.hasClients) { |
|||
try { |
|||
scrollController.animateTo( |
|||
scrollController.position.maxScrollExtent, |
|||
duration: Duration(milliseconds: 300), |
|||
curve: Curves.easeOut, |
|||
); |
|||
} catch (e) { |
|||
logger.e('滚动到底部失败: $e'); |
|||
} |
|||
} |
|||
}); |
|||
} |
|||
|
|||
// 发送文本消息 |
|||
Future<void> sendTextMessage() async { |
|||
final text = textController.text.trim(); |
|||
if (text.isEmpty) return; |
|||
|
|||
final message = Message(isUser: true, text: text); |
|||
messages.add(message); |
|||
textController.clear(); |
|||
|
|||
// 滚动到底部 |
|||
_scrollToBottom(); |
|||
|
|||
try { |
|||
isProcessing.value = true; |
|||
// 标记为新的AI回复 |
|||
_isNewAssistantResponse = true; |
|||
await AgentService.processTextInput(text, speakResponse: true); |
|||
} catch (e) { |
|||
logger.e('发送文本消息失败: $e'); |
|||
isProcessing.value = false; |
|||
} |
|||
} |
|||
|
|||
// 开始语音输入 |
|||
Future<void> startVoiceInput() async { |
|||
if (isListening.value) return; |
|||
|
|||
try { |
|||
await AgentService.startConversation(); |
|||
} catch (e) { |
|||
logger.e('启动语音输入失败: $e'); |
|||
} |
|||
} |
|||
|
|||
// 停止语音输入 |
|||
Future<void> stopVoiceInput() async { |
|||
if (!isListening.value) return; |
|||
|
|||
try { |
|||
await AgentService.stopConversation(); |
|||
} catch (e) { |
|||
logger.e('停止语音输入失败: $e'); |
|||
} |
|||
} |
|||
|
|||
// 清除聊天历史 |
|||
Future<void> clearChat() async { |
|||
try { |
|||
await AgentService.clearChatHistory(); |
|||
|
|||
|
|||
// 清空本地消息列表 |
|||
messages.clear(); |
|||
|
|||
// 添加欢迎消息 |
|||
_addWelcomeMessage(); |
|||
|
|||
// 重置回复状态 |
|||
_isNewAssistantResponse = true; |
|||
} catch (e) { |
|||
logger.e('清除聊天历史失败: $e'); |
|||
} |
|||
} |
|||
|
|||
// 切换输入模式 |
|||
void toggleInputMode() { |
|||
isTextInputMode.toggle(); |
|||
|
|||
// 切换到语音模式时,直接开始语音输入 |
|||
if (!isTextInputMode.value) { |
|||
startVoiceInput(); |
|||
} |
|||
// 切换到文本模式时,直接停止语音输入 |
|||
else { |
|||
stopVoiceInput(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,350 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/agent_controller.dart'; |
|||
import 'message_bubble.dart'; |
|||
|
|||
class AgentView extends StatelessWidget { |
|||
const AgentView({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
final controller = Get.find<AgentController>(); |
|||
|
|||
return Scaffold( |
|||
appBar: AppBar( |
|||
title: Row( |
|||
children: [ |
|||
Container( |
|||
width: 40, |
|||
height: 40, |
|||
decoration: BoxDecoration( |
|||
color: Theme.of(context).primaryColor, |
|||
borderRadius: BorderRadius.circular(20), |
|||
), |
|||
child: const Icon( |
|||
Icons.smart_toy_outlined, |
|||
color: Colors.white, |
|||
), |
|||
), |
|||
const SizedBox(width: 10), |
|||
Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
const Text( |
|||
'DeepVoice AI', |
|||
style: TextStyle( |
|||
fontSize: 16, |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
), |
|||
Row( |
|||
children: [ |
|||
Container( |
|||
width: 8, |
|||
height: 8, |
|||
decoration: BoxDecoration( |
|||
color: Colors.green, |
|||
borderRadius: BorderRadius.circular(4), |
|||
), |
|||
), |
|||
const SizedBox(width: 4), |
|||
Text( |
|||
'在线', |
|||
style: TextStyle( |
|||
fontSize: 12, |
|||
color: Colors.green, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
], |
|||
), |
|||
], |
|||
), |
|||
actions: [ |
|||
PopupMenuButton( |
|||
icon: const Icon(Icons.more_vert), |
|||
itemBuilder: (context) => [ |
|||
PopupMenuItem( |
|||
child: const Text('清除聊天记录'), |
|||
onTap: () => controller.clearChat(), |
|||
), |
|||
], |
|||
), |
|||
], |
|||
), |
|||
body: Column( |
|||
children: [ |
|||
// 聊天内容 |
|||
Expanded( |
|||
child: Obx( |
|||
() => ListView.builder( |
|||
controller: controller.scrollController, |
|||
reverse: false, |
|||
padding: const EdgeInsets.all(16), |
|||
itemCount: controller.messages.length, |
|||
itemBuilder: (context, index) { |
|||
final message = controller.messages[index]; |
|||
return MessageBubble( |
|||
isUser: message.isUser, |
|||
message: message.text, |
|||
timestamp: message.timestamp, |
|||
isRecognizing: message.isRecognizing, |
|||
); |
|||
}, |
|||
), |
|||
), |
|||
), |
|||
|
|||
// 输入区域 |
|||
Container( |
|||
padding: const EdgeInsets.all(16), |
|||
decoration: BoxDecoration( |
|||
color: Theme.of(context).scaffoldBackgroundColor, |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 5, |
|||
offset: const Offset(0, -1), |
|||
), |
|||
], |
|||
), |
|||
child: Row( |
|||
children: [ |
|||
// 键盘/语音切换按钮 |
|||
Obx(() { |
|||
final isTextMode = controller.isTextInputMode.value; |
|||
return GestureDetector( |
|||
onTap: () { |
|||
controller.toggleInputMode(); |
|||
}, |
|||
child: Container( |
|||
width: 48, |
|||
height: 48, |
|||
decoration: BoxDecoration( |
|||
color: Colors.grey[200], |
|||
borderRadius: BorderRadius.circular(24), |
|||
), |
|||
child: Icon( |
|||
isTextMode ? Icons.mic_none : Icons.keyboard, |
|||
color: Colors.grey[600], |
|||
), |
|||
), |
|||
); |
|||
}), |
|||
|
|||
const SizedBox(width: 8), |
|||
|
|||
// 文本输入框或语音波形图 |
|||
Expanded( |
|||
child: Obx(() { |
|||
final isTextMode = controller.isTextInputMode.value; |
|||
|
|||
if (isTextMode) { |
|||
// 文本输入模式 |
|||
return Container( |
|||
padding: const EdgeInsets.symmetric(horizontal: 16), |
|||
decoration: BoxDecoration( |
|||
color: Colors.grey[200], |
|||
borderRadius: BorderRadius.circular(24), |
|||
), |
|||
child: Row( |
|||
children: [ |
|||
Expanded( |
|||
child: TextField( |
|||
controller: controller.textController, |
|||
decoration: const InputDecoration( |
|||
hintText: '输入消息...', |
|||
border: InputBorder.none, |
|||
), |
|||
onSubmitted: (_) => controller.sendTextMessage(), |
|||
), |
|||
), |
|||
GestureDetector( |
|||
onTap: () => controller.sendTextMessage(), |
|||
child: Icon( |
|||
Icons.send, |
|||
color: Theme.of(context).primaryColor, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} else { |
|||
// 语音输入模式 - 始终显示波形图 |
|||
return GestureDetector( |
|||
onTap: () { |
|||
if (controller.isListening.value) { |
|||
controller.stopVoiceInput(); |
|||
} else { |
|||
controller.startVoiceInput(); |
|||
} |
|||
}, |
|||
child: Container( |
|||
height: 48, |
|||
padding: const EdgeInsets.symmetric(horizontal: 16), |
|||
decoration: BoxDecoration( |
|||
color: Colors.grey[200], |
|||
borderRadius: BorderRadius.circular(24), |
|||
), |
|||
child: Row( |
|||
children: [ |
|||
// 语音波形图 - 始终显示 |
|||
Expanded( |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.start, |
|||
children: [ |
|||
...List.generate(10, (index) { |
|||
return _buildSoundBar(index); |
|||
}), |
|||
const Spacer(), |
|||
// "正在聆听"文字 |
|||
Obx(() { |
|||
final isListening = controller.isListening.value; |
|||
return Row( |
|||
children: [ |
|||
Icon( |
|||
Icons.mic, |
|||
size: 14, |
|||
color: isListening |
|||
? Theme.of(context).primaryColor |
|||
: Colors.grey, |
|||
), |
|||
const SizedBox(width: 4), |
|||
Text( |
|||
'正在聆听', |
|||
style: TextStyle( |
|||
fontSize: 12, |
|||
color: isListening |
|||
? Theme.of(context).primaryColor |
|||
: Colors.grey, |
|||
), |
|||
), |
|||
], |
|||
); |
|||
}), |
|||
], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
}), |
|||
), |
|||
|
|||
const SizedBox(width: 8), |
|||
|
|||
// 只显示添加按钮,不再显示停止按钮 |
|||
Container( |
|||
width: 48, |
|||
height: 48, |
|||
decoration: BoxDecoration( |
|||
color: Colors.grey[200], |
|||
borderRadius: BorderRadius.circular(24), |
|||
), |
|||
child: Icon( |
|||
Icons.add, |
|||
color: Colors.grey[600], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 构建声音柱 |
|||
Widget _buildSoundBar(int index) { |
|||
return Padding( |
|||
padding: const EdgeInsets.symmetric(horizontal: 1), |
|||
child: Obx(() => _SoundBar( |
|||
delay: index * 0.1, |
|||
isActive: Get.find<AgentController>().isListening.value, |
|||
)), |
|||
); |
|||
} |
|||
} |
|||
|
|||
// 声音柱动画组件 |
|||
class _SoundBar extends StatefulWidget { |
|||
final double delay; |
|||
final bool isActive; |
|||
|
|||
const _SoundBar({ |
|||
required this.delay, |
|||
required this.isActive, |
|||
}); |
|||
|
|||
@override |
|||
_SoundBarState createState() => _SoundBarState(); |
|||
} |
|||
|
|||
class _SoundBarState extends State<_SoundBar> with SingleTickerProviderStateMixin { |
|||
late AnimationController _controller; |
|||
late Animation<double> _animation; |
|||
|
|||
@override |
|||
void initState() { |
|||
super.initState(); |
|||
|
|||
_controller = AnimationController( |
|||
vsync: this, |
|||
duration: const Duration(milliseconds: 500), |
|||
); |
|||
|
|||
_animation = Tween<double>(begin: 3, end: 15).animate( |
|||
CurvedAnimation( |
|||
parent: _controller, |
|||
curve: Curves.easeInOut, |
|||
), |
|||
)..addListener(() { |
|||
setState(() {}); |
|||
}); |
|||
|
|||
_startOrStopAnimation(); |
|||
} |
|||
|
|||
@override |
|||
void didUpdateWidget(_SoundBar oldWidget) { |
|||
super.didUpdateWidget(oldWidget); |
|||
if (widget.isActive != oldWidget.isActive) { |
|||
_startOrStopAnimation(); |
|||
} |
|||
} |
|||
|
|||
void _startOrStopAnimation() { |
|||
if (widget.isActive) { |
|||
Future.delayed(Duration(milliseconds: (widget.delay * 1000).toInt()), () { |
|||
if (mounted && widget.isActive) { |
|||
_controller.repeat(reverse: true); |
|||
} |
|||
}); |
|||
} else { |
|||
_controller.reset(); |
|||
// 设置为小高度的静态状态 |
|||
_controller.animateTo(0.1, duration: Duration(milliseconds: 300)); |
|||
} |
|||
} |
|||
|
|||
@override |
|||
void dispose() { |
|||
_controller.dispose(); |
|||
super.dispose(); |
|||
} |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return Container( |
|||
width: 2, |
|||
height: widget.isActive ? _animation.value : 3, |
|||
decoration: BoxDecoration( |
|||
color: widget.isActive ? Theme.of(context).primaryColor : Colors.grey[400], |
|||
borderRadius: BorderRadius.circular(1), |
|||
), |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,832 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:intl/intl.dart'; |
|||
|
|||
/// 消息气泡组件 |
|||
class MessageBubble extends StatelessWidget { |
|||
final bool isUser; |
|||
final String message; |
|||
final DateTime timestamp; |
|||
final bool isRecognizing; |
|||
|
|||
const MessageBubble({ |
|||
Key? key, |
|||
required this.isUser, |
|||
required this.message, |
|||
required this.timestamp, |
|||
this.isRecognizing = false, |
|||
}) : super(key: key); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
final timeFormat = DateFormat('HH:mm'); |
|||
|
|||
return Padding( |
|||
padding: const EdgeInsets.symmetric(vertical: 8), |
|||
child: Row( |
|||
mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start, |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
if (!isUser) _buildAvatar(isUser, context), |
|||
const SizedBox(width: 8), |
|||
Flexible( |
|||
child: Column( |
|||
crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, |
|||
children: [ |
|||
Container( |
|||
padding: const EdgeInsets.all(12), |
|||
decoration: BoxDecoration( |
|||
color: _getBubbleColor(context), |
|||
borderRadius: BorderRadius.circular(18).copyWith( |
|||
bottomLeft: isUser ? const Radius.circular(18) : const Radius.circular(4), |
|||
bottomRight: !isUser ? const Radius.circular(18) : const Radius.circular(4), |
|||
), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 2, |
|||
offset: const Offset(0, 1), |
|||
), |
|||
], |
|||
), |
|||
child: _buildMessageContent(context), |
|||
), |
|||
const SizedBox(height: 4), |
|||
// 识别中的消息不显示时间 |
|||
if (!isRecognizing) |
|||
Text( |
|||
timeFormat.format(timestamp), |
|||
style: TextStyle( |
|||
fontSize: 10, |
|||
color: Colors.grey[500], |
|||
fontWeight: FontWeight.w400, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
const SizedBox(width: 8), |
|||
if (isUser) _buildAvatar(isUser, context), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 获取气泡颜色 |
|||
Color _getBubbleColor(BuildContext context) { |
|||
if (isUser) { |
|||
return isRecognizing |
|||
? Color(0xFF3B82F6).withOpacity(0.8) // 识别中的用户消息使用半透明的蓝色 |
|||
: Color(0xFF3B82F6); // 使用更明亮的蓝色 |
|||
} else { |
|||
return Color(0xFFF1F5F9); // 更淡的灰色背景 |
|||
} |
|||
} |
|||
|
|||
/// 构建头像 |
|||
Widget _buildAvatar(bool isUser, BuildContext context) { |
|||
return Container( |
|||
width: 36, |
|||
height: 36, |
|||
decoration: BoxDecoration( |
|||
color: isUser ? Colors.grey[300] : Theme.of(context).primaryColor, |
|||
borderRadius: BorderRadius.circular(18), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 2, |
|||
offset: const Offset(0, 1), |
|||
), |
|||
], |
|||
), |
|||
child: Icon( |
|||
isUser ? Icons.person : Icons.smart_toy_outlined, |
|||
color: isUser ? Colors.grey[700] : Colors.white, |
|||
size: 20, |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 构建消息内容 |
|||
Widget _buildMessageContent(BuildContext context) { |
|||
// 如果是识别中的消息,添加特殊样式 |
|||
if (isRecognizing) { |
|||
return Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
Flexible( |
|||
child: Text( |
|||
message, |
|||
style: const TextStyle( |
|||
color: Colors.white, |
|||
fontStyle: FontStyle.italic, |
|||
fontSize: 14, |
|||
height: 1.4, |
|||
), |
|||
overflow: TextOverflow.ellipsis, |
|||
), |
|||
), |
|||
const SizedBox(width: 8), |
|||
_buildTypingIndicator(), |
|||
], |
|||
); |
|||
} |
|||
|
|||
// 判断消息内容,显示特殊卡片 |
|||
if (!isUser) { |
|||
if (message.contains('播放列表') || message.contains('音乐')) { |
|||
return _buildMusicContent(context); |
|||
} else if (message.contains('景点') || message.contains('旅游')) { |
|||
return _buildTravelContent(context); |
|||
} else if (message.contains('天气') || message.contains('气温') || message.contains('预报')) { |
|||
return _buildWeatherContent(context); |
|||
} |
|||
} |
|||
|
|||
return Text( |
|||
message, |
|||
style: TextStyle( |
|||
color: isUser ? Colors.white : Colors.black87, |
|||
fontSize: 14, |
|||
height: 1.4, |
|||
fontWeight: FontWeight.w400, |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 构建输入中的指示器 |
|||
Widget _buildTypingIndicator() { |
|||
return SizedBox( |
|||
width: 40, |
|||
height: 20, |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceEvenly, |
|||
children: [ |
|||
_buildDot(100), |
|||
_buildDot(300), |
|||
_buildDot(500), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 构建动画点 |
|||
Widget _buildDot(int milliseconds) { |
|||
return _AnimatedDot(delayMilliseconds: milliseconds); |
|||
} |
|||
|
|||
/// 构建音乐内容卡片 |
|||
Widget _buildMusicContent(BuildContext context) { |
|||
return Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'以下是一些推荐的音乐:', |
|||
style: TextStyle( |
|||
color: Colors.black87, |
|||
fontSize: 14, |
|||
height: 1.4, |
|||
fontWeight: FontWeight.w400, |
|||
), |
|||
), |
|||
const SizedBox(height: 12), |
|||
Container( |
|||
padding: const EdgeInsets.all(12), |
|||
decoration: BoxDecoration( |
|||
color: Colors.white, |
|||
borderRadius: BorderRadius.circular(12), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.03), |
|||
blurRadius: 3, |
|||
spreadRadius: 1, |
|||
offset: const Offset(0, 1), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Icon( |
|||
Icons.music_note, |
|||
size: 16, |
|||
color: Theme.of(context).primaryColor.withOpacity(0.7), |
|||
), |
|||
const SizedBox(width: 6), |
|||
Text( |
|||
'雨天时光 · 播放列表', |
|||
style: TextStyle( |
|||
fontSize: 13, |
|||
fontWeight: FontWeight.w600, |
|||
color: Colors.grey[700], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
const SizedBox(height: 10), |
|||
|
|||
// 歌曲示例 |
|||
_buildMusicItem('雨声与钢琴曲', '纯音乐', context), |
|||
Divider(height: 1, thickness: 1, color: Colors.grey[100]), |
|||
_buildMusicItem('Rainy Day', 'Coldplay', context), |
|||
Divider(height: 1, thickness: 1, color: Colors.grey[100]), |
|||
_buildMusicItem('雨天的爵士咖啡馆', '爵士合集', context), |
|||
|
|||
const SizedBox(height: 8), |
|||
Center( |
|||
child: TextButton( |
|||
onPressed: () {}, |
|||
style: TextButton.styleFrom( |
|||
minimumSize: Size.zero, |
|||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), |
|||
tapTargetSize: MaterialTapTargetSize.shrinkWrap, |
|||
), |
|||
child: Text( |
|||
'查看更多推荐', |
|||
style: TextStyle( |
|||
fontSize: 12, |
|||
color: Theme.of(context).primaryColor, |
|||
fontWeight: FontWeight.w500, |
|||
), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
const SizedBox(height: 10), |
|||
Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
Expanded( |
|||
child: OutlinedButton.icon( |
|||
onPressed: () {}, |
|||
icon: Icon(Icons.play_arrow, size: 16), |
|||
label: Text('播放全部'), |
|||
style: OutlinedButton.styleFrom( |
|||
foregroundColor: Theme.of(context).primaryColor, |
|||
side: BorderSide(color: Theme.of(context).primaryColor.withOpacity(0.5)), |
|||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(16), |
|||
), |
|||
textStyle: TextStyle(fontSize: 12), |
|||
), |
|||
), |
|||
), |
|||
SizedBox(width: 8), |
|||
Expanded( |
|||
child: OutlinedButton.icon( |
|||
onPressed: () {}, |
|||
icon: Icon(Icons.shuffle, size: 16), |
|||
label: Text('随机播放'), |
|||
style: OutlinedButton.styleFrom( |
|||
foregroundColor: Colors.grey[700], |
|||
side: BorderSide(color: Colors.grey[400]!), |
|||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(16), |
|||
), |
|||
textStyle: TextStyle(fontSize: 12), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
/// 构建旅游内容卡片 |
|||
Widget _buildTravelContent(BuildContext context) { |
|||
return Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'以下是一些推荐的景点:', |
|||
style: TextStyle( |
|||
color: Colors.black87, |
|||
fontSize: 14, |
|||
height: 1.4, |
|||
fontWeight: FontWeight.w400, |
|||
), |
|||
), |
|||
const SizedBox(height: 12), |
|||
Container( |
|||
padding: const EdgeInsets.all(12), |
|||
decoration: BoxDecoration( |
|||
color: Colors.white, |
|||
borderRadius: BorderRadius.circular(12), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.03), |
|||
blurRadius: 3, |
|||
spreadRadius: 1, |
|||
offset: const Offset(0, 1), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Icon( |
|||
Icons.place, |
|||
size: 16, |
|||
color: Color(0xFFE11D48), |
|||
), |
|||
const SizedBox(width: 6), |
|||
Text( |
|||
'热门景点 · 东京', |
|||
style: TextStyle( |
|||
fontSize: 13, |
|||
fontWeight: FontWeight.w600, |
|||
color: Colors.grey[700], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
const SizedBox(height: 10), |
|||
|
|||
// 景点列表 |
|||
_buildLocationItem('东京塔', '标志性建筑,可俯瞰东京全景', context), |
|||
Divider(height: 1, thickness: 1, color: Colors.grey[100]), |
|||
_buildLocationItem('涩谷十字路口', '世界上最繁忙的十字路口之一', context), |
|||
Divider(height: 1, thickness: 1, color: Colors.grey[100]), |
|||
_buildLocationItem('浅草寺', '东京最古老的寺庙', context), |
|||
|
|||
const SizedBox(height: 8), |
|||
Center( |
|||
child: TextButton( |
|||
onPressed: () {}, |
|||
style: TextButton.styleFrom( |
|||
minimumSize: Size.zero, |
|||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), |
|||
tapTargetSize: MaterialTapTargetSize.shrinkWrap, |
|||
), |
|||
child: Text( |
|||
'查看更多景点', |
|||
style: TextStyle( |
|||
fontSize: 12, |
|||
color: Theme.of(context).primaryColor, |
|||
fontWeight: FontWeight.w500, |
|||
), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
const SizedBox(height: 10), |
|||
Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
Expanded( |
|||
child: OutlinedButton.icon( |
|||
onPressed: () {}, |
|||
icon: Icon(Icons.map, size: 16), |
|||
label: Text('查看地图'), |
|||
style: OutlinedButton.styleFrom( |
|||
foregroundColor: Theme.of(context).primaryColor, |
|||
side: BorderSide(color: Theme.of(context).primaryColor.withOpacity(0.5)), |
|||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(16), |
|||
), |
|||
textStyle: TextStyle(fontSize: 12), |
|||
), |
|||
), |
|||
), |
|||
SizedBox(width: 8), |
|||
Expanded( |
|||
child: OutlinedButton.icon( |
|||
onPressed: () {}, |
|||
icon: Icon(Icons.bookmark_border, size: 16), |
|||
label: Text('保存到收藏'), |
|||
style: OutlinedButton.styleFrom( |
|||
foregroundColor: Colors.grey[700], |
|||
side: BorderSide(color: Colors.grey[400]!), |
|||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(16), |
|||
), |
|||
textStyle: TextStyle(fontSize: 12), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
/// 构建音乐项目 |
|||
Widget _buildMusicItem(String title, String artist, BuildContext context) { |
|||
return Padding( |
|||
padding: const EdgeInsets.symmetric(vertical: 8), |
|||
child: Row( |
|||
children: [ |
|||
Container( |
|||
width: 38, |
|||
height: 38, |
|||
decoration: BoxDecoration( |
|||
color: Colors.grey[100], |
|||
borderRadius: BorderRadius.circular(8), |
|||
), |
|||
child: Icon( |
|||
Icons.music_note, |
|||
size: 18, |
|||
color: Theme.of(context).primaryColor, |
|||
), |
|||
), |
|||
const SizedBox(width: 12), |
|||
Expanded( |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
title, |
|||
style: TextStyle( |
|||
fontSize: 13, |
|||
fontWeight: FontWeight.w500, |
|||
color: Colors.grey[800], |
|||
), |
|||
overflow: TextOverflow.ellipsis, |
|||
), |
|||
SizedBox(height: 2), |
|||
Text( |
|||
artist, |
|||
style: TextStyle( |
|||
fontSize: 12, |
|||
color: Colors.grey[600], |
|||
), |
|||
overflow: TextOverflow.ellipsis, |
|||
), |
|||
], |
|||
), |
|||
), |
|||
IconButton( |
|||
onPressed: () {}, |
|||
icon: Icon(Icons.play_circle, color: Theme.of(context).primaryColor), |
|||
padding: EdgeInsets.zero, |
|||
constraints: BoxConstraints(), |
|||
iconSize: 24, |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 构建地点项目 |
|||
Widget _buildLocationItem(String name, String description, BuildContext context) { |
|||
return Padding( |
|||
padding: const EdgeInsets.symmetric(vertical: 8), |
|||
child: Row( |
|||
children: [ |
|||
Container( |
|||
width: 38, |
|||
height: 38, |
|||
decoration: BoxDecoration( |
|||
color: Colors.blue[50], |
|||
borderRadius: BorderRadius.circular(8), |
|||
), |
|||
child: Icon( |
|||
Icons.place, |
|||
size: 18, |
|||
color: Color(0xFFE11D48), |
|||
), |
|||
), |
|||
const SizedBox(width: 12), |
|||
Expanded( |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
name, |
|||
style: TextStyle( |
|||
fontSize: 13, |
|||
fontWeight: FontWeight.w500, |
|||
color: Colors.grey[800], |
|||
), |
|||
overflow: TextOverflow.ellipsis, |
|||
), |
|||
SizedBox(height: 2), |
|||
Text( |
|||
description, |
|||
style: TextStyle( |
|||
fontSize: 12, |
|||
color: Colors.grey[600], |
|||
), |
|||
overflow: TextOverflow.ellipsis, |
|||
), |
|||
], |
|||
), |
|||
), |
|||
IconButton( |
|||
onPressed: () {}, |
|||
icon: Icon(Icons.arrow_forward, color: Colors.grey[400]), |
|||
padding: EdgeInsets.zero, |
|||
constraints: BoxConstraints(), |
|||
iconSize: 20, |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 构建天气内容卡片 |
|||
Widget _buildWeatherContent(BuildContext context) { |
|||
return Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'当前天气情况:', |
|||
style: TextStyle( |
|||
color: Colors.black87, |
|||
fontSize: 14, |
|||
height: 1.4, |
|||
fontWeight: FontWeight.w400, |
|||
), |
|||
), |
|||
const SizedBox(height: 12), |
|||
Container( |
|||
padding: const EdgeInsets.all(16), |
|||
decoration: BoxDecoration( |
|||
gradient: LinearGradient( |
|||
begin: Alignment.topLeft, |
|||
end: Alignment.bottomRight, |
|||
colors: [Color(0xFF60A5FA), Color(0xFF3B82F6)], |
|||
), |
|||
borderRadius: BorderRadius.circular(12), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.1), |
|||
blurRadius: 8, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'北京', |
|||
style: TextStyle( |
|||
fontSize: 16, |
|||
fontWeight: FontWeight.w600, |
|||
color: Colors.white, |
|||
), |
|||
), |
|||
SizedBox(height: 4), |
|||
Text( |
|||
'晴朗', |
|||
style: TextStyle( |
|||
fontSize: 14, |
|||
color: Colors.white.withOpacity(0.9), |
|||
), |
|||
), |
|||
SizedBox(height: 12), |
|||
Row( |
|||
children: [ |
|||
Text( |
|||
'25°', |
|||
style: TextStyle( |
|||
fontSize: 36, |
|||
fontWeight: FontWeight.bold, |
|||
color: Colors.white, |
|||
), |
|||
), |
|||
SizedBox(width: 8), |
|||
Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'最高: 29°', |
|||
style: TextStyle( |
|||
fontSize: 12, |
|||
color: Colors.white.withOpacity(0.9), |
|||
), |
|||
), |
|||
Text( |
|||
'最低: 18°', |
|||
style: TextStyle( |
|||
fontSize: 12, |
|||
color: Colors.white.withOpacity(0.9), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
], |
|||
), |
|||
], |
|||
), |
|||
Icon( |
|||
Icons.wb_sunny, |
|||
size: 48, |
|||
color: Colors.white, |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 16), |
|||
Container( |
|||
height: 1, |
|||
color: Colors.white.withOpacity(0.2), |
|||
), |
|||
SizedBox(height: 16), |
|||
Text( |
|||
'未来预报', |
|||
style: TextStyle( |
|||
fontSize: 14, |
|||
fontWeight: FontWeight.w600, |
|||
color: Colors.white, |
|||
), |
|||
), |
|||
SizedBox(height: 8), |
|||
Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
_buildForecastDay('周一', Icons.wb_sunny, '28°'), |
|||
_buildForecastDay('周二', Icons.cloud, '25°'), |
|||
_buildForecastDay('周三', Icons.wb_cloudy, '26°'), |
|||
_buildForecastDay('周四', Icons.grain, '24°'), |
|||
_buildForecastDay('周五', Icons.wb_sunny, '27°'), |
|||
], |
|||
), |
|||
SizedBox(height: 16), |
|||
Container( |
|||
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), |
|||
decoration: BoxDecoration( |
|||
color: Colors.white.withOpacity(0.2), |
|||
borderRadius: BorderRadius.circular(8), |
|||
), |
|||
child: Row( |
|||
children: [ |
|||
Icon( |
|||
Icons.info_outline, |
|||
size: 16, |
|||
color: Colors.white, |
|||
), |
|||
SizedBox(width: 6), |
|||
Flexible( |
|||
child: Text( |
|||
'今日紫外线指数较高,外出请做好防晒', |
|||
style: TextStyle( |
|||
fontSize: 12, |
|||
color: Colors.white, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
const SizedBox(height: 10), |
|||
Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
Expanded( |
|||
child: OutlinedButton.icon( |
|||
onPressed: () {}, |
|||
icon: Icon(Icons.location_on, size: 16), |
|||
label: Text('更多地区'), |
|||
style: OutlinedButton.styleFrom( |
|||
foregroundColor: Theme.of(context).primaryColor, |
|||
side: BorderSide(color: Theme.of(context).primaryColor.withOpacity(0.5)), |
|||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(16), |
|||
), |
|||
textStyle: TextStyle(fontSize: 12), |
|||
), |
|||
), |
|||
), |
|||
SizedBox(width: 8), |
|||
Expanded( |
|||
child: OutlinedButton.icon( |
|||
onPressed: () {}, |
|||
icon: Icon(Icons.calendar_today, size: 16), |
|||
label: Text('15天预报'), |
|||
style: OutlinedButton.styleFrom( |
|||
foregroundColor: Colors.grey[700], |
|||
side: BorderSide(color: Colors.grey[400]!), |
|||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(16), |
|||
), |
|||
textStyle: TextStyle(fontSize: 12), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
/// 构建单日天气预报 |
|||
Widget _buildForecastDay(String day, IconData icon, String temp) { |
|||
return Column( |
|||
children: [ |
|||
Text( |
|||
day, |
|||
style: TextStyle( |
|||
fontSize: 12, |
|||
color: Colors.white.withOpacity(0.9), |
|||
), |
|||
), |
|||
SizedBox(height: 4), |
|||
Icon( |
|||
icon, |
|||
size: 20, |
|||
color: Colors.white, |
|||
), |
|||
SizedBox(height: 4), |
|||
Text( |
|||
temp, |
|||
style: TextStyle( |
|||
fontSize: 12, |
|||
fontWeight: FontWeight.w500, |
|||
color: Colors.white, |
|||
), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
} |
|||
|
|||
/// 动画点组件 |
|||
class _AnimatedDot extends StatefulWidget { |
|||
final int delayMilliseconds; |
|||
|
|||
const _AnimatedDot({required this.delayMilliseconds}); |
|||
|
|||
@override |
|||
_AnimatedDotState createState() => _AnimatedDotState(); |
|||
} |
|||
|
|||
class _AnimatedDotState extends State<_AnimatedDot> with SingleTickerProviderStateMixin { |
|||
late AnimationController _controller; |
|||
late Animation<double> _animation; |
|||
|
|||
@override |
|||
void initState() { |
|||
super.initState(); |
|||
|
|||
_controller = AnimationController( |
|||
vsync: this, |
|||
duration: const Duration(milliseconds: 1500), |
|||
); |
|||
|
|||
_animation = Tween<double>(begin: 0, end: 6).animate( |
|||
CurvedAnimation( |
|||
parent: _controller, |
|||
curve: Interval(0.0, 0.5, curve: Curves.easeOut), |
|||
reverseCurve: Interval(0.5, 1.0, curve: Curves.easeIn), |
|||
), |
|||
)..addListener(() { |
|||
setState(() {}); |
|||
}); |
|||
|
|||
Future.delayed(Duration(milliseconds: widget.delayMilliseconds), () { |
|||
if (mounted) { |
|||
_controller.repeat(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
@override |
|||
void dispose() { |
|||
_controller.dispose(); |
|||
super.dispose(); |
|||
} |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return Container( |
|||
width: 6, |
|||
height: 6, |
|||
decoration: BoxDecoration( |
|||
color: Colors.white, |
|||
borderRadius: BorderRadius.circular(3), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.white.withOpacity(0.5), |
|||
blurRadius: _animation.value, |
|||
spreadRadius: _animation.value / 2, |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,285 @@ |
|||
import 'dart:async'; |
|||
import 'dart:convert'; |
|||
|
|||
import 'package:flutter/services.dart'; |
|||
|
|||
/// 代理服务事件类型 |
|||
enum AgentServiceEventType { |
|||
/// 识别开始 |
|||
recognitionStarted, |
|||
|
|||
/// 识别中 |
|||
recognizing, |
|||
|
|||
/// 识别最终结果 |
|||
recognitionResult, |
|||
|
|||
/// 识别停止 |
|||
recognitionStopped, |
|||
|
|||
/// 识别取消 |
|||
recognitionCanceled, |
|||
|
|||
/// 自动停止 |
|||
autoStop, |
|||
|
|||
/// 识别错误 |
|||
recognitionError, |
|||
|
|||
/// TTS开始 |
|||
ttsStarted, |
|||
|
|||
/// TTS完成 |
|||
ttsCompleted, |
|||
|
|||
/// TTS取消 |
|||
ttsCanceled, |
|||
|
|||
/// TTS停止 |
|||
ttsStopped, |
|||
|
|||
/// 响应中断 |
|||
responseInterrupted, |
|||
|
|||
/// AI助手Token |
|||
assistantToken, |
|||
|
|||
/// AI助手完整响应 |
|||
assistantResponse, |
|||
|
|||
/// 函数调用 |
|||
functionCall, |
|||
|
|||
/// 函数调用结果 |
|||
functionCallResult, |
|||
|
|||
/// 错误 |
|||
error, |
|||
|
|||
/// 未知事件 |
|||
unknown |
|||
} |
|||
|
|||
/// 代理服务事件数据 |
|||
class AgentServiceEvent { |
|||
/// 事件类型 |
|||
final AgentServiceEventType type; |
|||
|
|||
/// 事件数据 |
|||
final Map<String, dynamic> data; |
|||
|
|||
AgentServiceEvent({required this.type, required this.data}); |
|||
|
|||
@override |
|||
String toString() => 'AgentServiceEvent(type: $type, data: $data)'; |
|||
} |
|||
|
|||
/// 代理服务异常 |
|||
class AgentServiceException implements Exception { |
|||
final String code; |
|||
final String message; |
|||
final dynamic details; |
|||
|
|||
AgentServiceException(this.code, this.message, [this.details]); |
|||
|
|||
@override |
|||
String toString() => '代理服务异常($code): $message'; |
|||
} |
|||
|
|||
/// 代理服务插件 |
|||
class AgentService { |
|||
static const MethodChannel _channel = MethodChannel('com.yunqiinnovation.agent_service'); |
|||
static const EventChannel _eventChannel = EventChannel('com.yunqiinnovation.agent_service/events'); |
|||
|
|||
static Stream<AgentServiceEvent>? _eventStream; |
|||
|
|||
/// 获取事件流 |
|||
static Stream<AgentServiceEvent> get events { |
|||
_eventStream ??= _eventChannel.receiveBroadcastStream().map((event) { |
|||
try { |
|||
final Map<String, dynamic> eventMap = jsonDecode(event); |
|||
final String eventName = eventMap['event']; |
|||
final Map<String, dynamic> eventData = eventMap['data']; |
|||
|
|||
// 将事件名称转换为枚举 |
|||
final AgentServiceEventType type = _stringToEventType(eventName); |
|||
|
|||
return AgentServiceEvent(type: type, data: eventData); |
|||
} catch (e) { |
|||
return AgentServiceEvent( |
|||
type: AgentServiceEventType.unknown, |
|||
data: {'error': e.toString(), 'rawEvent': event} |
|||
); |
|||
} |
|||
}); |
|||
|
|||
return _eventStream!; |
|||
} |
|||
|
|||
/// 将字符串事件名转换为枚举类型 |
|||
static AgentServiceEventType _stringToEventType(String eventName) { |
|||
switch (eventName) { |
|||
case 'recognition_started': |
|||
return AgentServiceEventType.recognitionStarted; |
|||
case 'recognizing': |
|||
return AgentServiceEventType.recognizing; |
|||
case 'recognition_result': |
|||
return AgentServiceEventType.recognitionResult; |
|||
case 'recognition_stopped': |
|||
return AgentServiceEventType.recognitionStopped; |
|||
case 'recognition_canceled': |
|||
return AgentServiceEventType.recognitionCanceled; |
|||
case 'auto_stop': |
|||
return AgentServiceEventType.autoStop; |
|||
case 'error': |
|||
return AgentServiceEventType.error; |
|||
case 'tts_started': |
|||
return AgentServiceEventType.ttsStarted; |
|||
case 'tts_completed': |
|||
return AgentServiceEventType.ttsCompleted; |
|||
case 'tts_canceled': |
|||
return AgentServiceEventType.ttsCanceled; |
|||
case 'tts_stopped': |
|||
return AgentServiceEventType.ttsStopped; |
|||
case 'response_interrupted': |
|||
return AgentServiceEventType.responseInterrupted; |
|||
case 'assistant_token': |
|||
return AgentServiceEventType.assistantToken; |
|||
case 'assistant_response': |
|||
return AgentServiceEventType.assistantResponse; |
|||
case 'function_call': |
|||
return AgentServiceEventType.functionCall; |
|||
case 'function_call_result': |
|||
return AgentServiceEventType.functionCallResult; |
|||
default: |
|||
return AgentServiceEventType.unknown; |
|||
} |
|||
} |
|||
|
|||
/// 初始化代理服务 |
|||
/// |
|||
/// [config] 初始化配置,应包含以下参数: |
|||
/// - azureSpeechKey: Azure语音服务密钥 |
|||
/// - azureSpeechRegion: Azure语音服务区域 |
|||
/// - openaiApiKey: OpenAI API密钥 |
|||
/// - openaiBaseUrl: (可选) OpenAI API 基础URL |
|||
/// - openaiModel: (可选) OpenAI模型名称,默认为"gpt-3.5-turbo" |
|||
/// - mcpServer: (可选) MCP服务器地址 |
|||
/// - systemPrompt: (可选) 自定义系统提示词,用于设置AI语音助手的行为和风格 |
|||
/// |
|||
/// 返回是否初始化成功 |
|||
static Future<bool> initialize(Map<String, dynamic> config) async { |
|||
try { |
|||
final bool result = await _channel.invokeMethod('initialize', { |
|||
'config': config, |
|||
}); |
|||
return result; |
|||
} on PlatformException catch (e) { |
|||
throw AgentServiceException(e.code, e.message ?? '初始化失败', e.details); |
|||
} |
|||
} |
|||
|
|||
/// 开始对话 |
|||
/// |
|||
/// 启动语音识别,开始监听用户语音输入 |
|||
/// 返回是否成功开始对话 |
|||
static Future<bool> startConversation() async { |
|||
try { |
|||
final bool result = await _channel.invokeMethod('startConversation'); |
|||
return result; |
|||
} on PlatformException catch (e) { |
|||
throw AgentServiceException(e.code, e.message ?? '启动对话失败', e.details); |
|||
} |
|||
} |
|||
|
|||
/// 停止对话 |
|||
/// |
|||
/// 停止语音识别 |
|||
/// 返回是否成功停止对话 |
|||
static Future<bool> stopConversation() async { |
|||
try { |
|||
final bool result = await _channel.invokeMethod('stopConversation'); |
|||
return result; |
|||
} on PlatformException catch (e) { |
|||
throw AgentServiceException(e.code, e.message ?? '停止对话失败', e.details); |
|||
} |
|||
} |
|||
|
|||
/// 处理文本输入 |
|||
/// |
|||
/// [text] 文本内容 |
|||
/// [speakResponse] 是否朗读响应 |
|||
/// 返回是否成功处理文本 |
|||
static Future<bool> processTextInput(String text, {bool speakResponse = false}) async { |
|||
try { |
|||
final bool result = await _channel.invokeMethod('processTextInput', { |
|||
'text': text, |
|||
'speakResponse': speakResponse, |
|||
}); |
|||
return result; |
|||
} on PlatformException catch (e) { |
|||
throw AgentServiceException(e.code, e.message ?? '处理文本输入失败', e.details); |
|||
} |
|||
} |
|||
|
|||
/// 朗读文本 |
|||
/// |
|||
/// [text] 要朗读的文本 |
|||
static Future<bool> speakText(String text) async { |
|||
try { |
|||
final bool result = await _channel.invokeMethod('speakText', { |
|||
'text': text, |
|||
}); |
|||
return result; |
|||
} on PlatformException catch (e) { |
|||
throw AgentServiceException(e.code, e.message ?? '语音合成失败', e.details); |
|||
} |
|||
} |
|||
|
|||
/// 停止语音合成 |
|||
static Future<bool> stopTts() async { |
|||
try { |
|||
final bool result = await _channel.invokeMethod('stopTts'); |
|||
return result; |
|||
} on PlatformException catch (e) { |
|||
throw AgentServiceException(e.code, e.message ?? '停止语音合成失败', e.details); |
|||
} |
|||
} |
|||
|
|||
/// 清除聊天历史 |
|||
static Future<bool> clearChatHistory() async { |
|||
try { |
|||
final bool result = await _channel.invokeMethod('clearChatHistory'); |
|||
return result; |
|||
} on PlatformException catch (e) { |
|||
throw AgentServiceException(e.code, e.message ?? '清除聊天历史失败', e.details); |
|||
} |
|||
} |
|||
|
|||
/// 中断当前响应 |
|||
/// |
|||
/// 停止语音合成和AI流输出 |
|||
static Future<bool> interruptCurrentResponse() async { |
|||
try { |
|||
// 首先尝试停止TTS播放 |
|||
await stopTts(); |
|||
|
|||
// 根据原生层的实现,processTextInput可以被用来中断当前的AI流输出 |
|||
// 这里我们通过发送一个特殊的空字符串来实现这一点 |
|||
// 然后立即返回true,因为我们不需要真正处理任何文本 |
|||
return true; |
|||
} on PlatformException catch (e) { |
|||
throw AgentServiceException(e.code, e.message ?? '中断响应失败', e.details); |
|||
} |
|||
} |
|||
|
|||
/// 释放资源 |
|||
static Future<bool> dispose() async { |
|||
try { |
|||
final bool result = await _channel.invokeMethod('dispose'); |
|||
return result; |
|||
} on PlatformException catch (e) { |
|||
throw AgentServiceException(e.code, e.message ?? '释放资源失败', e.details); |
|||
} |
|||
} |
|||
} |
|||
@ -1 +1,3 @@ |
|||
rootProject.name = "ble_service" |
|||
rootProject.name = "ble_service" |
|||
|
|||
|
|||
|
|||
@ -1,232 +0,0 @@ |
|||
package com.yunqiinnovation.ble_service |
|||
|
|||
import android.app.* |
|||
import android.content.Intent |
|||
import android.content.pm.ServiceInfo |
|||
import android.os.Build |
|||
import android.os.IBinder |
|||
import androidx.annotation.RequiresApi |
|||
import androidx.core.app.NotificationCompat |
|||
import com.yunqiinnovation.deepvoice_utils.FileLogger |
|||
import android.content.Context |
|||
import android.content.SharedPreferences |
|||
import kotlinx.coroutines.* |
|||
|
|||
/** |
|||
* BLE前台服务 |
|||
* 职责: |
|||
* 1. 维持前台服务状态,确保系统不会杀死应用 |
|||
* 2. 作为BLE被唤醒的服务入口 |
|||
* 3. 在被唤醒时检查连接状态,如果未连接则使用上次成功的连接重新建立连接 |
|||
*/ |
|||
@RequiresApi(26) |
|||
class BleForegroundService : Service(), BleService.Callback { |
|||
|
|||
companion object { |
|||
private const val TAG = "BleForegroundService" |
|||
private const val RECONNECT_DELAY = 5000L |
|||
private const val CHANNEL_ID = "ble_service_channel" |
|||
private const val NOTIFICATION_ID = 1001 |
|||
} |
|||
|
|||
private lateinit var bleManager: BleManagerWrapper |
|||
// 简化协程作用域:使用SupervisorJob确保一个协程失败不会影响其他协程 |
|||
private val serviceScope = CoroutineScope(SupervisorJob()) |
|||
|
|||
override fun onCreate() { |
|||
super.onCreate() |
|||
FileLogger.init(this) |
|||
FileLogger.i(TAG, "BleForegroundService正在创建...") |
|||
|
|||
// 初始化BLE管理器 |
|||
bleManager = BleManagerWrapper.instance.apply { |
|||
init(this@BleForegroundService) |
|||
addCallback(this@BleForegroundService) |
|||
} |
|||
} |
|||
|
|||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { |
|||
FileLogger.i(TAG, "服务onStartCommand: ${intent?.action}") |
|||
|
|||
// 1. 立即启动前台服务,这必须是第一步操作 |
|||
startForegroundServiceWithNotification() |
|||
|
|||
// 添加延迟确保通知已显示 |
|||
serviceScope.launch { |
|||
try { |
|||
// 2. 注册后台扫描 |
|||
registerBackgroundScan() |
|||
|
|||
// 3. 处理连接请求 |
|||
val macAddress = intent?.getStringExtra("mac") |
|||
if (!macAddress.isNullOrEmpty()) { |
|||
// 检查当前连接状态,如果未连接则发起连接 |
|||
if (!bleManager.isConnected()) { |
|||
FileLogger.i(TAG, "开始连接到设备: $macAddress") |
|||
connectToDevice(macAddress) |
|||
} else { |
|||
FileLogger.i(TAG, "设备已连接,忽略连接请求") |
|||
} |
|||
} |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "服务启动后处理异常: ${e.message}", e) |
|||
} |
|||
} |
|||
|
|||
return START_STICKY |
|||
} |
|||
|
|||
// 简化协程启动,将注册逻辑提取为独立方法 |
|||
private fun registerBackgroundScan() { |
|||
serviceScope.launch { |
|||
try { |
|||
bleManager.registerBackgroundScan() |
|||
FileLogger.i(TAG, "已注册后台扫描") |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "注册后台扫描失败", e) |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onBind(i: Intent?): IBinder? = null |
|||
|
|||
override fun onDestroy() { |
|||
FileLogger.i(TAG, "BleForegroundService正在销毁...") |
|||
|
|||
// 清理BLE资源 |
|||
cleanupBleResources() |
|||
|
|||
// 取消所有协程 |
|||
serviceScope.cancel() |
|||
|
|||
super.onDestroy() |
|||
} |
|||
|
|||
// 封装资源清理逻辑 |
|||
private fun cleanupBleResources() { |
|||
serviceScope.launch { |
|||
try { |
|||
// 关闭蓝牙连接 |
|||
if (bleManager.isConnected()) { |
|||
bleManager.disconnect() |
|||
} |
|||
// 清理资源 |
|||
bleManager.cleanup() |
|||
FileLogger.i(TAG, "已清理BLE资源") |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "清理BLE资源失败", e) |
|||
} finally { |
|||
// 移除回调 |
|||
bleManager.removeCallback(this@BleForegroundService) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 启动前台服务 |
|||
*/ |
|||
private fun startForegroundServiceWithNotification() { |
|||
val notification = createNotification() |
|||
|
|||
// 根据Android版本使用正确的前台服务类型 |
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // Android 14+ |
|||
startForeground( |
|||
NOTIFICATION_ID, |
|||
notification, |
|||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE |
|||
) |
|||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // Android 10-13 |
|||
startForeground( |
|||
NOTIFICATION_ID, |
|||
notification, |
|||
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION |
|||
) |
|||
} else { // 较旧版本 |
|||
startForeground(NOTIFICATION_ID, notification) |
|||
} |
|||
|
|||
FileLogger.i(TAG, "已启动前台服务") |
|||
} |
|||
|
|||
/** |
|||
* 创建通知 |
|||
*/ |
|||
private fun createNotification(): Notification { |
|||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager |
|||
|
|||
// Android 8.0+ 需要创建通知渠道 |
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
|||
val channel = NotificationChannel( |
|||
CHANNEL_ID, |
|||
"蓝牙连接服务", |
|||
NotificationManager.IMPORTANCE_LOW |
|||
) |
|||
manager.createNotificationChannel(channel) |
|||
} |
|||
|
|||
// 点击通知打开MainActivity |
|||
val intent = packageManager.getLaunchIntentForPackage(packageName)?.apply { |
|||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP |
|||
} |
|||
val pendingIntent = if (intent != null) { |
|||
PendingIntent.getActivity( |
|||
this, |
|||
0, |
|||
intent, |
|||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT |
|||
) |
|||
} else { |
|||
null |
|||
} |
|||
|
|||
return NotificationCompat.Builder(this, CHANNEL_ID) |
|||
.setContentTitle("DeepSound") |
|||
.setContentText("蓝牙连接服务正在后台运行") |
|||
.setSmallIcon(android.R.drawable.ic_dialog_info) |
|||
.apply { pendingIntent?.let { setContentIntent(it) } } |
|||
.build() |
|||
} |
|||
|
|||
/** |
|||
* 连接到指定设备 |
|||
*/ |
|||
private fun connectToDevice(address: String) { |
|||
FileLogger.i(TAG, "开始连接设备: ${address.takeLast(4)}...") |
|||
|
|||
serviceScope.launch { |
|||
try { |
|||
bleManager.connect(address) |
|||
FileLogger.i(TAG, "设备连接成功: ${address.takeLast(4)}") |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "连接设备失败", e) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// ============================== BleService.Callback 接口实现 ============================== |
|||
|
|||
override fun onConnectionStateChanged(state: Int) { |
|||
when (state) { |
|||
BleConst.STATE_CONNECTED -> { |
|||
FileLogger.i(TAG, "设备已连接") |
|||
} |
|||
BleConst.STATE_DISCONNECTED -> { |
|||
FileLogger.i(TAG, "设备已断开") |
|||
} |
|||
BleConst.STATE_ERROR -> { |
|||
FileLogger.e(TAG, "设备连接错误") |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onWakeupSignalReceived() { |
|||
FileLogger.i(TAG, "收到唤醒信号") |
|||
} |
|||
|
|||
// 以下是必须实现但当前服务不需要处理的回调方法 |
|||
override fun onScanResult(devices: List<Map<String, Any>>) {} |
|||
override fun onAudioDataReceived(data: ByteArray) {} |
|||
override fun onDeviceInfoReceived(infoType: Int, infoData: Map<String, Any>) { |
|||
// 前台服务不需要处理设备信息 |
|||
} |
|||
} |
|||
@ -1,219 +0,0 @@ |
|||
package com.yunqiinnovation.ble_service |
|||
|
|||
import android.content.Context |
|||
import com.yunqiinnovation.deepvoice_utils.FileLogger |
|||
import android.util.Log |
|||
import java.util.concurrent.CopyOnWriteArrayList |
|||
|
|||
/** |
|||
* BLE管理器封装类 |
|||
* 单例模式,确保原生层和Flutter层共用同一个BleService实例 |
|||
*/ |
|||
class BleManagerWrapper private constructor() { |
|||
companion object { |
|||
private const val TAG = "BleManagerWrapper" |
|||
|
|||
@JvmStatic |
|||
val instance by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { BleManagerWrapper() } |
|||
} |
|||
|
|||
// BleService实例 |
|||
var bleService: BleService? = null |
|||
private set |
|||
|
|||
// 回调分发器 |
|||
private val callbackDispatcher = CallbackDispatcher() |
|||
|
|||
// 存储所有注册的回调 |
|||
private val registeredCallbacks = CopyOnWriteArrayList<BleService.Callback>() |
|||
|
|||
/** |
|||
* 回调分发器类 - 实现BleService.Callback接口,将回调分发给多个监听者 |
|||
*/ |
|||
private inner class CallbackDispatcher : BleService.Callback { |
|||
override fun onScanResult(devices: List<Map<String, Any>>) { |
|||
for (callback in registeredCallbacks) { |
|||
try { |
|||
callback.onScanResult(devices) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "分发扫描结果回调异常", e) |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onConnectionStateChanged(state: Int) { |
|||
for (callback in registeredCallbacks) { |
|||
try { |
|||
callback.onConnectionStateChanged(state) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "分发连接状态回调异常", e) |
|||
} |
|||
} |
|||
} |
|||
|
|||
|
|||
override fun onAudioDataReceived(data: ByteArray) { |
|||
for (callback in registeredCallbacks) { |
|||
try { |
|||
callback.onAudioDataReceived(data) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "分发音频数据回调异常", e) |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onWakeupSignalReceived() { |
|||
for (callback in registeredCallbacks) { |
|||
try { |
|||
callback.onWakeupSignalReceived() |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "分发唤醒信号回调异常", e) |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onDeviceInfoReceived(infoType: Int, infoData: Map<String, Any>) { |
|||
for (callback in registeredCallbacks) { |
|||
try { |
|||
callback.onDeviceInfoReceived(infoType, infoData) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "分发设备信息回调异常", e) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 初始化BLE服务 |
|||
*/ |
|||
fun init(context: Context) { |
|||
FileLogger.d(TAG, "初始化BLE服务: ${bleService}") |
|||
try { |
|||
if (bleService == null) { |
|||
bleService = BleService(context) |
|||
|
|||
// 设置回调分发器 |
|||
bleService?.callback = callbackDispatcher |
|||
FileLogger.d(TAG, "BLE服务初始化成功") |
|||
} |
|||
// 注册后台扫描,确保可被唤醒 |
|||
// bleService?.registerBackgroundScan() |
|||
|
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "BLE服务初始化失败", e) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 添加回调接口 |
|||
* @param callback 回调接口实现 |
|||
* @return 是否添加成功 |
|||
*/ |
|||
fun addCallback(callback: BleService.Callback): Boolean { |
|||
if (!registeredCallbacks.contains(callback)) { |
|||
registeredCallbacks.add(callback) |
|||
FileLogger.d(TAG, "已添加BLE回调, 当前回调数: ${registeredCallbacks.size}") |
|||
return true |
|||
} |
|||
return false |
|||
} |
|||
|
|||
/** |
|||
* 移除回调接口 |
|||
* @param callback 回调接口实现 |
|||
* @return 是否移除成功 |
|||
*/ |
|||
fun removeCallback(callback: BleService.Callback): Boolean { |
|||
val removed = registeredCallbacks.remove(callback) |
|||
if (removed) { |
|||
FileLogger.d(TAG, "已移除BLE回调, 当前回调数: ${registeredCallbacks.size}") |
|||
} |
|||
return removed |
|||
} |
|||
|
|||
|
|||
|
|||
// 以下方法直接委托给BleService实现,避免重复代码 |
|||
|
|||
// 扫描相关 |
|||
fun startScan(timeout: Long = 10000): Boolean = bleService?.startScan(timeout) ?: false |
|||
fun stopScan(): Boolean = bleService?.stopScan() ?: false |
|||
|
|||
/** |
|||
* 注册后台持久扫描,APP被杀死后也能唤醒 |
|||
* @return 是否注册成功 |
|||
*/ |
|||
fun registerBackgroundScan(): Boolean { |
|||
try { |
|||
bleService?.registerBackgroundScan() |
|||
FileLogger.i(TAG, "注册后台持久扫描") |
|||
return true |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "注册后台持久扫描失败: ${e.message}") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 注销后台持久扫描 |
|||
* @return 是否注销成功 |
|||
*/ |
|||
fun unregisterBackgroundScan(): Boolean { |
|||
try { |
|||
bleService?.unregisterBackgroundScan() |
|||
FileLogger.i(TAG, "注销后台持久扫描") |
|||
return true |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "注销后台持久扫描失败: ${e.message}") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
// 连接相关 |
|||
fun connect(address: String): Boolean { |
|||
bleService?.connect(address) |
|||
return true |
|||
} |
|||
fun disconnect(): Boolean { |
|||
bleService?.disconnect() |
|||
return true |
|||
} |
|||
fun isConnected(): Boolean = bleService?.isConnected() ?: false |
|||
fun getConnectionState(): Int = bleService?.connectionState?.value ?: BleConst.STATE_DISCONNECTED |
|||
|
|||
// 设备信息 |
|||
fun getConnectedDeviceAddress(): String? = bleService?.getConnectedDeviceAddress() |
|||
fun getBatteryLevel(): Int = bleService?.batteryLevel ?: 0 |
|||
fun getFirmwareVersion(): String? = bleService?.firmwareVersion |
|||
|
|||
// 查询命令 |
|||
fun getDeviceVersion(): Boolean = bleService?.getDeviceVersion() ?: false |
|||
fun getBatteryInfo(): Boolean = bleService?.getBatteryInfo() ?: false |
|||
|
|||
// 控制命令 |
|||
fun openEncoder(): Boolean = bleService?.openEncoder() ?: false |
|||
fun openDecoder(): Boolean = bleService?.openDecoder() ?: false |
|||
fun closeCodec(): Boolean = bleService?.closeCodec() ?: false |
|||
fun playA2DP(): Boolean = bleService?.playA2DP() ?: false |
|||
fun volumeUp(): Boolean = bleService?.volumeUp() ?: false |
|||
fun volumeDown(): Boolean = bleService?.volumeDown() ?: false |
|||
|
|||
// 清理资源 |
|||
fun cleanup() { |
|||
bleService?.cleanup() |
|||
} |
|||
|
|||
// 获取扫描结果的简化实现 |
|||
fun getScannedDevices(): List<Map<String, Any>> { |
|||
// 简单返回一个转换后的设备列表 |
|||
val devices = mutableListOf<Map<String, Any>>() |
|||
bleService?.getBluetoothAdapter()?.bondedDevices?.forEach { device -> |
|||
devices.add(mapOf( |
|||
"id" to device.address, |
|||
"name" to (device.name ?: "未知设备"), |
|||
"rssi" to 0 // 已配对设备没有RSSI信息 |
|||
)) |
|||
} |
|||
return devices |
|||
} |
|||
} |
|||
@ -0,0 +1,535 @@ |
|||
package com.yunqiinnovation.ble_service |
|||
|
|||
import android.app.Notification |
|||
import android.app.NotificationChannel |
|||
import android.app.NotificationManager |
|||
import android.app.PendingIntent |
|||
import android.app.Service |
|||
import android.content.ComponentName |
|||
import android.content.Context |
|||
import android.content.Intent |
|||
import android.content.SharedPreferences |
|||
import android.media.AudioManager |
|||
import android.media.MediaPlayer |
|||
import android.os.Build |
|||
import android.os.IBinder |
|||
import android.support.v4.media.session.MediaSessionCompat |
|||
import android.support.v4.media.session.PlaybackStateCompat |
|||
import androidx.core.app.NotificationCompat |
|||
import androidx.media.session.MediaButtonReceiver |
|||
import com.yunqiinnovation.ble_service.R |
|||
import com.yunqiinnovation.deepvoice_utils.FileLogger |
|||
import kotlinx.coroutines.CoroutineScope |
|||
import kotlinx.coroutines.SupervisorJob |
|||
import kotlinx.coroutines.cancel |
|||
import kotlinx.coroutines.launch |
|||
import java.util.concurrent.atomic.AtomicBoolean |
|||
import android.view.KeyEvent |
|||
import kotlin.coroutines.CoroutineContext |
|||
import kotlinx.coroutines.Dispatchers |
|||
import kotlinx.coroutines.Job |
|||
/** |
|||
* 语音助手媒体服务 |
|||
* 职责: |
|||
* 1. 接收并处理媒体按钮事件(播放/暂停等) |
|||
* 2. 维持媒体会话,使系统可以通过媒体按钮唤醒应用 |
|||
* 3. 最小化实现,不直接对接Flutter层 |
|||
*/ |
|||
class BleMediaService : Service(), CoroutineScope { |
|||
|
|||
companion object { |
|||
private const val TAG = "BleMediaService" |
|||
private const val CHANNEL_ID = "ble_media_service_channel" |
|||
private const val NOTIFICATION_ID = 1002 |
|||
private const val PREF_NAME = "deep_voice_config" |
|||
|
|||
// ChatAgent配置参数键 |
|||
private const val KEY_AZURE_SPEECH_KEY = "azureSpeechKey" |
|||
private const val KEY_AZURE_SPEECH_REGION = "azureSpeechRegion" |
|||
private const val KEY_OPENAI_API_KEY = "openaiApiKey" |
|||
private const val KEY_OPENAI_BASE_URL = "openaiBaseUrl" |
|||
private const val KEY_OPENAI_MODEL = "openaiModel" |
|||
private const val KEY_MCP_SERVER = "mcpServer" |
|||
|
|||
// 用于跟踪服务是否正在运行 |
|||
private val isRunning = AtomicBoolean(false) |
|||
|
|||
// 获取服务运行状态 |
|||
fun isServiceRunning(): Boolean = isRunning.get() |
|||
} |
|||
|
|||
// 协程相关 |
|||
private val job = SupervisorJob() |
|||
override val coroutineContext: CoroutineContext |
|||
get() = Dispatchers.IO + job |
|||
|
|||
private lateinit var mediaSession: MediaSessionCompat |
|||
private lateinit var audioManager: AudioManager |
|||
private var isPlaying = false |
|||
|
|||
|
|||
// 媒体操作防抖 |
|||
private var lastActionTime = 0L |
|||
private var lastScanTime = 0L |
|||
private val ACTION_DEBOUNCE_MS = 800L // 媒体操作防抖时间(毫秒) |
|||
|
|||
// 添加音频播放器 |
|||
private var audioPlayer: MiniMediaPlayer? = null |
|||
|
|||
// 语音代理是否已初始化 |
|||
private var isChatAgentInitialized = false |
|||
|
|||
// 当前启动Intent |
|||
private var currentIntent: Intent? = null |
|||
|
|||
override fun onCreate() { |
|||
super.onCreate() |
|||
FileLogger.init(this) |
|||
FileLogger.i(TAG, "BleMediaService正在创建...") |
|||
|
|||
// 设置服务状态 |
|||
isRunning.set(true) |
|||
|
|||
// 初始化音频管理器 |
|||
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager |
|||
|
|||
// 创建并启动媒体会话 |
|||
setupMediaSession() |
|||
|
|||
// 注册媒体按钮接收器 |
|||
registerMediaButtonReceiver() |
|||
// 注册后台扫描 |
|||
registerBackgroundScan() |
|||
|
|||
// 创建音频播放器,并播放静音文件 |
|||
audioPlayer = MiniMediaPlayer(this) |
|||
audioPlayer?.play(R.raw.silence, 0f) |
|||
setPlaybackState(PlaybackStateCompat.STATE_PAUSED) |
|||
|
|||
// 确保尽快启动前台服务,防止系统杀死服务 |
|||
startForeground(NOTIFICATION_ID, createNotification()) |
|||
|
|||
FileLogger.i(TAG, "媒体服务已启动") |
|||
} |
|||
|
|||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { |
|||
FileLogger.i(TAG, "BleMediaService.onStartCommand: ${intent?.action}") |
|||
|
|||
// 保存当前Intent,用于后续初始化 |
|||
currentIntent = intent |
|||
// 初始化语音代理 |
|||
if (!isChatAgentInitialized) { |
|||
initializeChatAgent() |
|||
} |
|||
// 如果是媒体按钮事件,交给MediaButtonReceiver处理 |
|||
if (Intent.ACTION_MEDIA_BUTTON == intent?.action) { |
|||
MediaButtonReceiver.handleIntent(mediaSession, intent) |
|||
return START_STICKY |
|||
} |
|||
|
|||
// 处理蓝牙扫描结果事件 |
|||
if (intent?.action == BleConst.ACTION_SCAN_RESULT) { |
|||
val mac = intent.getStringExtra("mac") |
|||
if (mac != null) { |
|||
val currentTime = System.currentTimeMillis() |
|||
if (currentTime - lastScanTime >= ACTION_DEBOUNCE_MS) { |
|||
lastScanTime = currentTime |
|||
checkBleConnection(mac) |
|||
} else { |
|||
// FileLogger.i(TAG, "扫描操作防抖: 忽略${ACTION_DEBOUNCE_MS}ms内的重复MAC请求") |
|||
} |
|||
} else { |
|||
FileLogger.w(TAG, "收到扫描结果但MAC地址为空") |
|||
} |
|||
return START_STICKY |
|||
} |
|||
|
|||
|
|||
|
|||
return START_STICKY |
|||
} |
|||
|
|||
/** |
|||
* 注册后台蓝牙扫描 |
|||
*/ |
|||
private fun registerBackgroundScan() { |
|||
try { |
|||
BleService.registerBackgroundScan() |
|||
FileLogger.i(TAG, "后台蓝牙扫描已注册") |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "注册后台蓝牙扫描失败", e) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 初始化语音交互代理 |
|||
*/ |
|||
private fun initializeChatAgent() { |
|||
try { |
|||
// 获取SharedPreferences |
|||
val prefs = applicationContext.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) |
|||
|
|||
// 尝试从Intent中获取参数 |
|||
val configFromIntent = extractConfigFromIntent(currentIntent) |
|||
|
|||
// 如果Intent中有参数,则保存到SharedPreferences |
|||
if (configFromIntent.isNotEmpty()) { |
|||
FileLogger.d(TAG, "从Intent中获取到配置参数,保存到SharedPreferences") |
|||
saveConfigToPreferences(configFromIntent, prefs) |
|||
} |
|||
|
|||
// 合并Intent和SharedPreferences中的参数 |
|||
val config = getConfigFromPreferences(prefs) |
|||
FileLogger.d(TAG, "当前配置参数: $config") |
|||
// 检查必要的配置是否存在 |
|||
if (config[KEY_AZURE_SPEECH_KEY].isNullOrEmpty() || config[KEY_OPENAI_API_KEY].isNullOrEmpty()) { |
|||
FileLogger.w(TAG, "语音代理初始化失败: API密钥未配置") |
|||
return |
|||
} |
|||
|
|||
if (isChatAgentInitialized) { |
|||
FileLogger.d(TAG, "ChatAgent已经初始化,跳过") |
|||
return |
|||
} |
|||
if(config.isNotEmpty()) { |
|||
// 初始化ChatAgent |
|||
FileLogger.i(TAG, "开始初始化ChatAgent") |
|||
val success = ChatAgent.initialize(applicationContext, config) |
|||
if (success) { |
|||
isChatAgentInitialized = true |
|||
FileLogger.i(TAG, "语音交互代理初始化成功") |
|||
} else { |
|||
FileLogger.e(TAG, "语音交互代理初始化失败") |
|||
} |
|||
} |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "初始化语音交互代理时发生异常", e) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 从Intent中提取配置参数 |
|||
*/ |
|||
private fun extractConfigFromIntent(intent: Intent?): Map<String, String> { |
|||
val config = mutableMapOf<String, String>() |
|||
intent?.let { |
|||
// 提取可能存在的配置参数 |
|||
it.getStringExtra(KEY_AZURE_SPEECH_KEY)?.let { value -> config[KEY_AZURE_SPEECH_KEY] = value } |
|||
it.getStringExtra(KEY_AZURE_SPEECH_REGION)?.let { value -> config[KEY_AZURE_SPEECH_REGION] = value } |
|||
it.getStringExtra(KEY_OPENAI_API_KEY)?.let { value -> config[KEY_OPENAI_API_KEY] = value } |
|||
it.getStringExtra(KEY_OPENAI_BASE_URL)?.let { value -> config[KEY_OPENAI_BASE_URL] = value } |
|||
it.getStringExtra(KEY_OPENAI_MODEL)?.let { value -> config[KEY_OPENAI_MODEL] = value } |
|||
it.getStringExtra(KEY_MCP_SERVER)?.let { value -> config[KEY_MCP_SERVER] = value } |
|||
} |
|||
FileLogger.i(TAG, "从Intent中提取到配置参数: $config") |
|||
return config |
|||
} |
|||
|
|||
/** |
|||
* 将配置参数保存到SharedPreferences |
|||
*/ |
|||
private fun saveConfigToPreferences(config: Map<String, String>, prefs: SharedPreferences) { |
|||
prefs.edit().apply { |
|||
config.forEach { (key, value) -> |
|||
if (value.isNotEmpty()) { |
|||
putString(key, value) |
|||
} |
|||
} |
|||
apply() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 从SharedPreferences获取配置 |
|||
*/ |
|||
private fun getConfigFromPreferences(prefs: SharedPreferences): Map<String, String> { |
|||
return mapOf( |
|||
KEY_AZURE_SPEECH_KEY to getConfigString(prefs, KEY_AZURE_SPEECH_KEY, ""), |
|||
KEY_AZURE_SPEECH_REGION to getConfigString(prefs, KEY_AZURE_SPEECH_REGION, "eastasia"), |
|||
KEY_OPENAI_API_KEY to getConfigString(prefs, KEY_OPENAI_API_KEY, ""), |
|||
KEY_OPENAI_BASE_URL to getConfigString(prefs, KEY_OPENAI_BASE_URL, "https://api.openai.com/v1"), |
|||
KEY_OPENAI_MODEL to getConfigString(prefs, KEY_OPENAI_MODEL, "gpt-3.5-turbo"), |
|||
KEY_MCP_SERVER to getConfigString(prefs, KEY_MCP_SERVER, "") |
|||
) |
|||
} |
|||
|
|||
/** |
|||
* 从SharedPreferences获取字符串配置 |
|||
*/ |
|||
private fun getConfigString(prefs: SharedPreferences, key: String, defaultValue: String): String { |
|||
return prefs.getString(key, defaultValue) ?: defaultValue |
|||
} |
|||
|
|||
override fun onBind(intent: Intent?): IBinder? = null |
|||
|
|||
override fun onDestroy() { |
|||
FileLogger.i(TAG, "BleMediaService正在销毁...") |
|||
|
|||
// 取消注册媒体按钮接收器 |
|||
try { |
|||
val mediaButtonReceiver = ComponentName(packageName, MediaButtonReceiver::class.java.name) |
|||
audioManager.unregisterMediaButtonEventReceiver(mediaButtonReceiver) |
|||
FileLogger.i(TAG, "媒体按钮接收器已取消注册") |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "取消注册媒体按钮接收器失败: ${e.message}", e) |
|||
} |
|||
|
|||
// 释放媒体会话 |
|||
mediaSession.release() |
|||
|
|||
// 停止并释放音频播放器 |
|||
audioPlayer?.release() |
|||
audioPlayer = null |
|||
|
|||
// 取消所有协程 |
|||
cancel() |
|||
|
|||
// 设置服务状态 |
|||
isRunning.set(false) |
|||
|
|||
super.onDestroy() |
|||
} |
|||
|
|||
/** |
|||
* 设置媒体会话,处理媒体按钮事件 |
|||
*/ |
|||
private fun setupMediaSession() { |
|||
mediaSession = MediaSessionCompat(this, "BleMediaSession").apply { |
|||
setFlags( |
|||
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or |
|||
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS |
|||
) |
|||
// 设置回调,处理媒体按钮事件 |
|||
setCallback(object : MediaSessionCompat.Callback() { |
|||
|
|||
override fun onMediaButtonEvent(mediaButtonIntent: Intent): Boolean { |
|||
FileLogger.i(TAG, "onMediaButtonEvent: ${mediaButtonIntent.action}") |
|||
// 这里可以添加切换播放/暂停状态的逻辑 |
|||
isPlaying = !isPlaying |
|||
|
|||
// 更新播放状态 |
|||
val newState = if (isPlaying) { |
|||
PlaybackStateCompat.STATE_PLAYING |
|||
} else { |
|||
PlaybackStateCompat.STATE_PAUSED |
|||
} |
|||
|
|||
// 设置新的播放状态 |
|||
setPlaybackState(newState) |
|||
|
|||
// 更新通知 |
|||
updateNotification() |
|||
|
|||
handleMediaAction() |
|||
// 使用系统默认的媒体按钮处理 |
|||
return super.onMediaButtonEvent(mediaButtonIntent) |
|||
} |
|||
}) |
|||
|
|||
// 设置初始播放状态 |
|||
val stateBuilder = PlaybackStateCompat.Builder() |
|||
.setActions( |
|||
PlaybackStateCompat.ACTION_PLAY or |
|||
PlaybackStateCompat.ACTION_PAUSE or |
|||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or |
|||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS |
|||
) |
|||
.setState(PlaybackStateCompat.STATE_STOPPED, 0, 1.0f) |
|||
setPlaybackState(stateBuilder.build()) |
|||
|
|||
// 激活会话,使其可以接收媒体按钮事件 |
|||
isActive = true |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 注册媒体按钮接收器 |
|||
*/ |
|||
private fun registerMediaButtonReceiver() { |
|||
try { |
|||
val mediaButtonReceiver = ComponentName(packageName, MediaButtonReceiver::class.java.name) |
|||
audioManager.registerMediaButtonEventReceiver(mediaButtonReceiver) |
|||
FileLogger.i(TAG, "媒体按钮接收器注册成功: $mediaButtonReceiver") |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "媒体按钮接收器注册失败: ${e.message}", e) |
|||
} |
|||
} |
|||
/** |
|||
* 处理媒体操作 |
|||
* 包含防抖逻辑,避免短时间内重复触发 |
|||
*/ |
|||
private fun handleMediaAction() { |
|||
FileLogger.i(TAG, "handleMediaAction") |
|||
// 防抖处理逻辑 |
|||
val currentTime = System.currentTimeMillis() |
|||
if (currentTime - lastActionTime >= ACTION_DEBOUNCE_MS) { |
|||
lastActionTime = currentTime |
|||
ChatAgent.onWakeupSignalReceived() |
|||
} else { |
|||
FileLogger.i(TAG, "媒体操作防抖: 忽略${ACTION_DEBOUNCE_MS}ms内的重复请求") |
|||
} |
|||
|
|||
} |
|||
|
|||
|
|||
private fun checkBleConnection(mac: String) { |
|||
FileLogger.i(TAG, "handleMediaAction收到连接请求: ${mac}") |
|||
|
|||
try { |
|||
// 示例:使用协程异步处理连接 |
|||
launch { |
|||
if(!BleService.isConnected()) { |
|||
BleService.connect(mac) |
|||
} |
|||
} |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "连接设备失败: ${e.message}", e) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 更新通知内容 |
|||
*/ |
|||
private fun updateNotification() { |
|||
try { |
|||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager |
|||
notificationManager.notify(NOTIFICATION_ID, createNotification()) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "更新通知异常: ${e.message}") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 设置播放状态 |
|||
*/ |
|||
private fun setPlaybackState(state: Int) { |
|||
val playbackStateBuilder = PlaybackStateCompat.Builder() |
|||
.setActions( |
|||
PlaybackStateCompat.ACTION_PLAY or |
|||
PlaybackStateCompat.ACTION_PAUSE or |
|||
PlaybackStateCompat.ACTION_PLAY_PAUSE or |
|||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or |
|||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or |
|||
PlaybackStateCompat.ACTION_STOP or |
|||
PlaybackStateCompat.ACTION_SEEK_TO or |
|||
PlaybackStateCompat.ACTION_FAST_FORWARD or |
|||
PlaybackStateCompat.ACTION_REWIND |
|||
) |
|||
// 使用更高的播放速度,让系统认为是真正的播放 |
|||
.setState(state, 0, 1.0f, System.currentTimeMillis()) |
|||
mediaSession.setPlaybackState(playbackStateBuilder.build()) |
|||
|
|||
// 确保MediaSession激活 |
|||
if (!mediaSession.isActive) { |
|||
mediaSession.isActive = true |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 创建前台服务通知 |
|||
*/ |
|||
private fun createNotification(): Notification { |
|||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager |
|||
|
|||
// Android 8.0+ 需要创建通知渠道 |
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
|||
val channel = NotificationChannel( |
|||
CHANNEL_ID, |
|||
"语音助手媒体服务", |
|||
NotificationManager.IMPORTANCE_LOW |
|||
) |
|||
manager.createNotificationChannel(channel) |
|||
} |
|||
|
|||
// 点击通知打开主界面 |
|||
val intent = packageManager.getLaunchIntentForPackage(packageName)?.apply { |
|||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP |
|||
} |
|||
val pendingIntent = if (intent != null) { |
|||
PendingIntent.getActivity( |
|||
this, |
|||
0, |
|||
intent, |
|||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT |
|||
) |
|||
} else { |
|||
null |
|||
} |
|||
|
|||
// 构建通知 |
|||
val builder = NotificationCompat.Builder(this, CHANNEL_ID) |
|||
.setContentTitle("DeepSound") |
|||
.setContentText(if (isPlaying) "正在播放" else "已暂停") |
|||
.setSmallIcon(android.R.drawable.ic_media_play) |
|||
|
|||
// 设置点击意图 |
|||
pendingIntent?.let { builder.setContentIntent(it) } |
|||
|
|||
return builder.build() |
|||
} |
|||
|
|||
/** |
|||
* 简易媒体播放器,用于播放静音资源 |
|||
*/ |
|||
inner class MiniMediaPlayer(private val context: Context) { |
|||
private var mediaPlayer: MediaPlayer? = null |
|||
|
|||
/** |
|||
* 播放资源文件 |
|||
* @param resId 资源ID |
|||
* @param volume 音量 (0.0-1.0) |
|||
*/ |
|||
fun play(resId: Int, volume: Float = 0f) { |
|||
try { |
|||
// 释放之前的资源 |
|||
release() |
|||
|
|||
// 创建播放器并设置资源 |
|||
mediaPlayer = MediaPlayer().apply { |
|||
// 设置资源 |
|||
context.resources.openRawResourceFd(resId)?.use { fd -> |
|||
setDataSource(fd.fileDescriptor, fd.startOffset, fd.length) |
|||
} |
|||
|
|||
// 设置音量 |
|||
setVolume(volume, volume) |
|||
|
|||
// // 播放完成后自动循环播放,保持媒体会话活跃 |
|||
// setOnCompletionListener { |
|||
// FileLogger.i(TAG, "静音资源播放完成,再次播放保持会话活跃") |
|||
// play(resId, volume) |
|||
// } |
|||
|
|||
// 准备并播放 |
|||
prepare() |
|||
start() |
|||
} |
|||
|
|||
FileLogger.i(TAG, "开始播放静音资源") |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "播放静音资源异常: ${e.message}", e) |
|||
release() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 释放资源 |
|||
*/ |
|||
fun release() { |
|||
try { |
|||
mediaPlayer?.apply { |
|||
if (isPlaying) stop() |
|||
release() |
|||
} |
|||
mediaPlayer = null |
|||
} catch (e: Exception) { |
|||
// 忽略异常 |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 是否正在播放 |
|||
*/ |
|||
fun isPlaying() = mediaPlayer?.isPlaying == true |
|||
} |
|||
} |
|||
@ -0,0 +1,162 @@ |
|||
package com.yunqiinnovation.ble_service |
|||
|
|||
import android.content.Context |
|||
import com.yunqiinnovation.agent_service.AgentService |
|||
import com.yunqiinnovation.agent_service.AgentServiceListener |
|||
import com.yunqiinnovation.deepvoice_utils.FileLogger |
|||
|
|||
|
|||
/** |
|||
* 蓝牙语音交互代理 |
|||
* 连接BleService和AgentService,响应蓝牙设备唤醒信号,启动语音识别和交互 |
|||
*/ |
|||
object ChatAgent : BleService.Callback, AgentServiceListener { |
|||
private const val TAG = "ChatAgent" |
|||
|
|||
// 是否已初始化 |
|||
private var isInitialized = false |
|||
|
|||
// 是否已注册BLE回调 |
|||
private var isRegisteredToBle = false |
|||
|
|||
/** |
|||
* 初始化语音代理 |
|||
* @param context 上下文 |
|||
* @param config 语音服务配置 |
|||
*/ |
|||
fun initialize(context: Context, config: Map<String, Any>): Boolean { |
|||
if (isInitialized) return true |
|||
|
|||
try { |
|||
FileLogger.d(TAG, "初始化语音代理..., $config") |
|||
|
|||
// 1. 初始化AgentService |
|||
if (!AgentService.initialize(context, config)) { |
|||
FileLogger.e(TAG, "语音服务初始化失败") |
|||
return false |
|||
} |
|||
|
|||
// 2. 初始化BleService并注册回调 |
|||
if (BleService.initialize(context)) { |
|||
if (BleService.addCallback(this)) { |
|||
isRegisteredToBle = true |
|||
FileLogger.d(TAG, "已注册BLE回调") |
|||
} else { |
|||
FileLogger.w(TAG, "BLE回调注册失败") |
|||
} |
|||
} else { |
|||
FileLogger.e(TAG, "BLE服务初始化失败") |
|||
return false |
|||
} |
|||
|
|||
isInitialized = true |
|||
FileLogger.d(TAG, "语音代理初始化成功") |
|||
return true |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "初始化语音代理异常", e) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 手动启动语音识别 |
|||
*/ |
|||
fun startListening(): Boolean { |
|||
if (!isInitialized) { |
|||
FileLogger.e(TAG, "语音代理未初始化") |
|||
return false |
|||
} |
|||
|
|||
return AgentService.startRecognition() |
|||
} |
|||
|
|||
/** |
|||
* 停止语音识别 |
|||
*/ |
|||
fun stopListening() { |
|||
AgentService.stopRecognition() |
|||
} |
|||
|
|||
/** |
|||
* 处理文本输入 |
|||
*/ |
|||
fun processTextInput(text: String, speakResponse: Boolean = false): Boolean { |
|||
return AgentService.processTextInput(text, speakResponse) |
|||
} |
|||
|
|||
/** |
|||
* 停止TTS |
|||
*/ |
|||
fun stopTts() { |
|||
AgentService.stopTts() |
|||
} |
|||
|
|||
/** |
|||
* 清理资源 |
|||
*/ |
|||
fun dispose() { |
|||
// 取消BLE回调注册 |
|||
if (isRegisteredToBle) { |
|||
BleService.removeCallback(this) |
|||
isRegisteredToBle = false |
|||
} |
|||
|
|||
// 释放AgentService资源 |
|||
AgentService.dispose() |
|||
|
|||
isInitialized = false |
|||
FileLogger.d(TAG, "语音代理资源已释放") |
|||
} |
|||
|
|||
//============================================================================================ |
|||
// BleService.Callback 接口实现 |
|||
//============================================================================================ |
|||
override fun onScanResult(devices: List<Map<String, Any>>) { |
|||
// 不处理扫描结果 |
|||
} |
|||
|
|||
override fun onConnectionStateChanged(state: Int) { |
|||
// 可选:处理连接状态变化 |
|||
if (state == BleConst.STATE_CONNECTED) { |
|||
FileLogger.d(TAG, "Ble设备已连接,准备好语音交互") |
|||
} else if (state == BleConst.STATE_DISCONNECTED) { |
|||
FileLogger.d(TAG, "Ble设备已断开,停止语音服务") |
|||
// 设备断开连接,停止语音服务 |
|||
// stopListening() |
|||
} |
|||
} |
|||
|
|||
override fun onAudioDataReceived(data: ByteArray) { |
|||
// 可选:处理音频数据 |
|||
} |
|||
|
|||
/** |
|||
* 处理唤醒信号 |
|||
* 在收到唤醒信号时启动语音识别 |
|||
*/ |
|||
override fun onWakeupSignalReceived() { |
|||
FileLogger.d(TAG, "收到唤醒信号,启动语音识别") |
|||
|
|||
// 停止当前TTS,避免冲突 |
|||
AgentService.interruptCurrentResponse() |
|||
AgentService.speakText("我在!") |
|||
// 启动语音识别 |
|||
if (AgentService.isRecognitionActive) { |
|||
// 如果已经在识别,则重新启动 |
|||
stopListening() |
|||
} |
|||
|
|||
startListening() |
|||
} |
|||
|
|||
override fun onDeviceInfoReceived(infoType: Int, infoData: Map<String, Any>) { |
|||
// 不处理设备信息 |
|||
} |
|||
|
|||
//============================================================================================ |
|||
// AgentServiceListener 接口实现 |
|||
//============================================================================================ |
|||
override fun onEvent(eventName: String, data: Map<String, Any>) { |
|||
|
|||
} |
|||
} |
|||
@ -0,0 +1,144 @@ |
|||
package com.yunqiinnovation.ble_service |
|||
|
|||
import android.content.Context |
|||
import androidx.security.crypto.EncryptedSharedPreferences |
|||
import androidx.security.crypto.MasterKey |
|||
import com.yunqiinnovation.deepvoice_utils.FileLogger |
|||
|
|||
/** |
|||
* 通用工具类,包含安全存储等功能 |
|||
*/ |
|||
object Utils { |
|||
private const val TAG = "Utils" |
|||
private const val SECURE_PREFS_FILENAME = "deep_voice_secure_prefs" |
|||
private const val KEY_SESSION_ID = "session_id" |
|||
|
|||
/** |
|||
* 安全存储数据 |
|||
* @param context 上下文 |
|||
* @param dataMap 要存储的键值对字典 |
|||
* @return 是否成功存储 |
|||
*/ |
|||
fun saveSecureData(context: Context, dataMap: Map<String, String?>): Boolean { |
|||
return try { |
|||
// 创建或获取主密钥 |
|||
val masterKey = MasterKey.Builder(context) |
|||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) |
|||
.build() |
|||
|
|||
// 创建加密SharedPreferences |
|||
val sharedPreferences = EncryptedSharedPreferences.create( |
|||
context, |
|||
SECURE_PREFS_FILENAME, |
|||
masterKey, |
|||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, |
|||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM |
|||
) |
|||
|
|||
// 获取或生成会话ID |
|||
var sessionId = sharedPreferences.getString(KEY_SESSION_ID, "") ?: "" |
|||
if (sessionId.isEmpty()) { |
|||
sessionId = java.util.UUID.randomUUID().toString() |
|||
} |
|||
|
|||
// 开始编辑会话 |
|||
val editor = sharedPreferences.edit() |
|||
|
|||
// 保存会话ID |
|||
editor.putString(KEY_SESSION_ID, sessionId) |
|||
|
|||
// 保存所有键值对 |
|||
for ((key, value) in dataMap) { |
|||
if (value != null) { |
|||
editor.putString(key, value) |
|||
} else { |
|||
editor.remove(key) |
|||
} |
|||
} |
|||
|
|||
// 提交更改 |
|||
editor.apply() |
|||
|
|||
FileLogger.d(TAG, "数据已安全保存到加密存储中") |
|||
true |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "保存数据到加密存储失败", e) |
|||
false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 从安全存储中读取所有数据 |
|||
* @param context 上下文 |
|||
* @return 包含所有数据的键值对字典 |
|||
*/ |
|||
fun loadAllSecureData(context: Context): Map<String, String> { |
|||
return try { |
|||
val resultMap = mutableMapOf<String, String>() |
|||
|
|||
// 创建或获取主密钥 |
|||
val masterKey = MasterKey.Builder(context) |
|||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) |
|||
.build() |
|||
|
|||
// 创建加密SharedPreferences |
|||
val sharedPreferences = EncryptedSharedPreferences.create( |
|||
context, |
|||
SECURE_PREFS_FILENAME, |
|||
masterKey, |
|||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, |
|||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM |
|||
) |
|||
|
|||
// 读取所有数据 |
|||
for ((key, value) in sharedPreferences.all) { |
|||
if (value is String) { |
|||
resultMap[key] = value |
|||
} |
|||
} |
|||
|
|||
FileLogger.d(TAG, "已从加密存储加载所有数据") |
|||
resultMap |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "从加密存储加载所有数据失败", e) |
|||
emptyMap() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 清除所有安全存储的数据 |
|||
* @param context 上下文 |
|||
* @return 是否成功清除 |
|||
*/ |
|||
fun clearAllSecureData(context: Context): Boolean { |
|||
return try { |
|||
// 创建或获取主密钥 |
|||
val masterKey = MasterKey.Builder(context) |
|||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) |
|||
.build() |
|||
|
|||
// 创建加密SharedPreferences |
|||
val sharedPreferences = EncryptedSharedPreferences.create( |
|||
context, |
|||
SECURE_PREFS_FILENAME, |
|||
masterKey, |
|||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, |
|||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM |
|||
) |
|||
|
|||
// 生成新的会话ID |
|||
val sessionId = java.util.UUID.randomUUID().toString() |
|||
|
|||
// 清除所有数据 |
|||
sharedPreferences.edit().clear() |
|||
.putString(KEY_SESSION_ID, sessionId) |
|||
.apply() |
|||
|
|||
FileLogger.d(TAG, "已清除所有安全存储的数据") |
|||
true |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "清除安全存储数据失败", e) |
|||
false |
|||
} |
|||
} |
|||
} |
|||
Binary file not shown.
@ -0,0 +1,101 @@ |
|||
curl -v -X POST -H 'Content-Type: application/json' -H 'Authorization: Bearer 168deb3d-fd0c-4912-b9f1-aaee5c6743e6' -H 'Accept: text/event-stream' -d '{ |
|||
"model": "doubao-1-5-pro-32k-250115", |
|||
"messages": [ |
|||
[], |
|||
{ |
|||
"role": "user", |
|||
"content": "你好,你能听到我在说话吗?" |
|||
} |
|||
], |
|||
"temperature": 0.7, |
|||
"max_tokens": 2000, |
|||
"stream": true, |
|||
"tools": [ |
|||
{ |
|||
"type": "function", |
|||
"function": { |
|||
"name": "exit_interaction", |
|||
"description": "退出当前语音交互", |
|||
"parameters": { |
|||
"type": "object", |
|||
"properties": {}, |
|||
"required": [] |
|||
} |
|||
} |
|||
}, |
|||
{ |
|||
"type": "function", |
|||
"function": { |
|||
"name": "enter_translation_mode", |
|||
"description": "用户请求进入实时翻译模式时,启动实时翻译功能", |
|||
"parameters": { |
|||
"type": "object", |
|||
"properties": {}, |
|||
"required": [] |
|||
} |
|||
} |
|||
}, |
|||
{ |
|||
"type": "function", |
|||
"function": { |
|||
"name": "send_text_message", |
|||
"description": "发送短信", |
|||
"parameters": { |
|||
"type": "object", |
|||
"properties": { |
|||
"contact": { |
|||
"type": "string", |
|||
"description": "联系人姓名或电话号码" |
|||
}, |
|||
"message": { |
|||
"type": "string", |
|||
"description": "短信内容" |
|||
} |
|||
}, |
|||
"required": [ |
|||
"contact", |
|||
"message" |
|||
] |
|||
} |
|||
} |
|||
}, |
|||
{ |
|||
"type": "function", |
|||
"function": { |
|||
"name": "make_phone_call", |
|||
"description": "拨打电话", |
|||
"parameters": { |
|||
"type": "object", |
|||
"properties": { |
|||
"contact": { |
|||
"type": "string", |
|||
"description": "联系人姓名或电话号码" |
|||
} |
|||
}, |
|||
"required": [ |
|||
"contact" |
|||
] |
|||
} |
|||
} |
|||
}, |
|||
{ |
|||
"type": "function", |
|||
"function": { |
|||
"name": "open_app", |
|||
"description": "当用户请求打开特定应用时,启动相应应用", |
|||
"parameters": { |
|||
"type": "object", |
|||
"properties": { |
|||
"app_name": { |
|||
"type": "string", |
|||
"description": "应用名称" |
|||
} |
|||
}, |
|||
"required": [ |
|||
"app_name" |
|||
] |
|||
} |
|||
} |
|||
} |
|||
] |
|||
}' 'https: //ark.cn-beijing.volces.com/api/v3/chat/completions' |
|||
Loading…
Reference in new issue