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": { |
"mcpServers": { |
||||
"deepsound-mcp": { |
|
||||
"url": "https://api.deapsound.com/sse" |
|
||||
}, |
|
||||
"voitrans-mcp": { |
|
||||
"url": "https://www.voitrans.net/sse" |
|
||||
}, |
|
||||
"web-search": { |
"web-search": { |
||||
"url": "http://mcp.ideapsound.com:8000/sse" |
"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