26 changed files with 2062 additions and 2867 deletions
@ -0,0 +1,94 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import '../../modules/chat/models/message_model.dart'; |
||||
|
import '../theme/app_colors.dart'; |
||||
|
|
||||
|
class MessageBubble extends StatelessWidget { |
||||
|
final Message message; |
||||
|
final bool isUser; |
||||
|
final String? userAvatar; |
||||
|
final String? agentAvatar; |
||||
|
|
||||
|
const MessageBubble({ |
||||
|
Key? key, |
||||
|
required this.message, |
||||
|
required this.isUser, |
||||
|
this.userAvatar, |
||||
|
this.agentAvatar, |
||||
|
}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Padding( |
||||
|
padding: EdgeInsets.only(bottom: 16.h), |
||||
|
child: Row( |
||||
|
mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start, |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Flexible( |
||||
|
child: Container( |
||||
|
padding: EdgeInsets.all(12.r), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isUser ? AppColors.primary : Colors.grey[200], |
||||
|
borderRadius: BorderRadius.circular(16.r), |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
// 消息内容 |
||||
|
if (message.content.isNotEmpty) |
||||
|
SelectableText( |
||||
|
message.content, |
||||
|
style: TextStyle( |
||||
|
color: isUser ? Colors.white : Colors.black, |
||||
|
fontSize: 16.sp, |
||||
|
height: 1.5, |
||||
|
letterSpacing: 0.2, |
||||
|
), |
||||
|
textAlign: TextAlign.left, |
||||
|
maxLines: null, |
||||
|
enableInteractiveSelection: true, |
||||
|
textWidthBasis: TextWidthBasis.longestLine, |
||||
|
), |
||||
|
|
||||
|
// 加载指示器 |
||||
|
if (message.isLoading) |
||||
|
Padding( |
||||
|
padding: EdgeInsets.only(top: message.content.isNotEmpty ? 8.h : 0), |
||||
|
child: _buildLoadingIndicator(), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildLoadingIndicator() { |
||||
|
return Row( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
SizedBox( |
||||
|
width: 12.w, |
||||
|
height: 12.h, |
||||
|
child: CircularProgressIndicator( |
||||
|
strokeWidth: 2, |
||||
|
valueColor: AlwaysStoppedAnimation<Color>( |
||||
|
isUser ? Colors.white : AppColors.primary, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 8.w), |
||||
|
Text( |
||||
|
'思考中...', |
||||
|
style: TextStyle( |
||||
|
color: isUser ? Colors.white : Colors.black, |
||||
|
fontSize: 12.sp, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -1,465 +1,458 @@ |
|||||
import 'package:get/get.dart'; |
|
||||
import 'dart:async'; |
import 'dart:async'; |
||||
import 'volcano_ai_service.dart'; |
import 'package:get/get.dart'; |
||||
import 'azure_asr_service.dart'; |
import 'package:flutter/widgets.dart'; |
||||
import 'volcano_tts_api_service.dart'; |
|
||||
import '../../modules/chat/models/message_model.dart'; |
|
||||
import '../../core/utils/logger.dart'; |
import '../../core/utils/logger.dart'; |
||||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|
||||
import '../providers/agent_provider.dart'; |
import '../providers/agent_provider.dart'; |
||||
|
import '../models/agent.dart'; |
||||
|
import '../../modules/chat/controllers/chat_controller.dart'; |
||||
|
import '../../modules/chat/models/message_model.dart'; |
||||
|
import 'azure_asr_service.dart'; |
||||
|
import 'volcano_ai_service.dart'; |
||||
|
import 'volcano_tts_api_service.dart'; |
||||
|
import 'chat_history_service.dart'; |
||||
|
|
||||
|
/// 后台代理服务,用于在应用处于后台时处理AI交互 |
||||
class BackgroundAgentService extends GetxService { |
class BackgroundAgentService extends GetxService { |
||||
static BackgroundAgentService get to => Get.find(); |
// 服务实例 |
||||
|
final AzureAsrService _asrService = Get.find<AzureAsrService>(); |
||||
final VolcanoAIService _aiService; |
final VolcanoAIService _aiService = Get.find<VolcanoAIService>(); |
||||
final VolcanoTtsApiService _ttsService; |
final VolcanoTtsApiService _ttsService = Get.find<VolcanoTtsApiService>(); |
||||
final AzureAsrService _voiceRecognitionService; |
final ChatHistoryService _historyService = Get.find<ChatHistoryService>(); |
||||
final List<Message> _messageHistory = []; |
|
||||
bool _isProcessing = false; |
// 状态控制 |
||||
bool _isListening = false; |
final RxBool isProcessing = false.obs; |
||||
StreamSubscription? _recognitionSubscription; |
|
||||
|
|
||||
|
|
||||
// 当前使用的agent ID |
|
||||
String? _currentAgentId; |
|
||||
|
|
||||
// 当前使用的agent voice |
|
||||
String _agentVoice = 'zh_female_meilinvyou_moon_bigtts'; |
|
||||
|
|
||||
// 可观察的状态 |
|
||||
final RxBool isListening = false.obs; |
final RxBool isListening = false.obs; |
||||
final RxString recognizedText = ''.obs; |
|
||||
|
|
||||
// 添加一个标志,表示是否已经识别到语音 |
|
||||
bool _hasRecognizedSpeech = false; |
|
||||
|
|
||||
// 添加一个标志,表示是否已经收到最终结果 |
|
||||
bool _hasFinalResult = false; |
|
||||
|
|
||||
// 单一的交互超时计时器 (30秒) |
// 语音识别状态 |
||||
Timer? _interactionTimer; |
final RxString recognizedText = ''.obs; |
||||
|
|
||||
// 添加一个变量来跟踪当前的AI响应流订阅 |
// 流式响应控制 |
||||
|
StreamSubscription? _recognitionSubscription; |
||||
StreamSubscription? _aiResponseSubscription; |
StreamSubscription? _aiResponseSubscription; |
||||
|
|
||||
// 添加一个标志,表示是否应该取消当前的AI响应 |
// 定时器,用于检测无活动超时 |
||||
bool _shouldCancelAiResponse = false; |
Timer? _inactivityTimer; |
||||
|
|
||||
// 当前系统提示词 |
// 当前处理的Agent |
||||
String? _currentSystemPrompt; |
Agent? _currentAgent; |
||||
|
|
||||
|
// 消息历史 - 与ChatController共享 |
||||
|
List<Map<String, String>> get messageHistory { |
||||
|
// 首先尝试从ChatController获取消息历史 |
||||
|
if (Get.isRegistered<ChatController>()) { |
||||
|
final chatController = Get.find<ChatController>(); |
||||
|
// 检查ChatController是否正在与personal_assistant交互 |
||||
|
if (chatController.agent?.id == 'personal_assistant') { |
||||
|
return chatController.messages.map((m) => { |
||||
|
'role': m.role, |
||||
|
'content': m.content, |
||||
|
}).toList(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
BackgroundAgentService() |
// 如果无法从ChatController获取,则使用本地存储的历史记录 |
||||
: _aiService = VolcanoAIService(), |
if (_localMessageHistory.isEmpty) { |
||||
_ttsService = Get.find<VolcanoTtsApiService>(), |
_loadChatHistory(); |
||||
_voiceRecognitionService = Get.find<AzureAsrService>(); |
} |
||||
|
|
||||
// 启动或重置交互计时器 |
return _localMessageHistory; |
||||
void _resetInteractionTimer() { |
} |
||||
// 取消之前的计时器 |
|
||||
_interactionTimer?.cancel(); |
|
||||
|
|
||||
// 设置30秒的超时计时器 |
// 本地消息历史备份 |
||||
_interactionTimer = Timer(const Duration(seconds: 30), () { |
final List<Map<String, String>> _localMessageHistory = []; |
||||
// 检查TTS是否正在播放 |
|
||||
if (_ttsService.isPlaying) { |
|
||||
// 如果TTS正在播放,重置定时器 |
|
||||
_resetInteractionTimer(); |
|
||||
} else { |
|
||||
// 如果TTS不在播放,且30秒内没有检测到用户输入,退出交互 |
|
||||
Logger.info('30秒内没有检测到用户输入,退出交互'); |
|
||||
_endInteraction(); |
|
||||
} |
|
||||
}); |
|
||||
|
|
||||
|
// 构造函数 |
||||
|
BackgroundAgentService() { |
||||
|
Logger.info('BackgroundAgentService 已创建'); |
||||
} |
} |
||||
|
|
||||
// 结束交互 |
// TTS是否正在播放,直接从VolcanoTtsApiService获取 |
||||
Future<void> _endInteraction() async { |
bool get isTtsSpeaking => _ttsService.isPlaying; |
||||
// 完成当前的recognitionCompleter(如果有) |
|
||||
if (_recognitionCompleter != null && !_recognitionCompleter!.isCompleted) { |
|
||||
_recognitionCompleter!.complete(''); |
|
||||
} |
|
||||
|
|
||||
_isProcessing = false; |
@override |
||||
|
void onClose() { |
||||
|
// 取消所有订阅 |
||||
|
_recognitionSubscription?.cancel(); |
||||
|
_aiResponseSubscription?.cancel(); |
||||
|
|
||||
// 播放退出提示 |
// 停止TTS |
||||
await _ttsService.speakSingle("没有听到您说话,已退出语音交互", speaker: _agentVoice); |
if (_ttsService.isPlaying) { |
||||
|
_ttsService.stop(); |
||||
|
} |
||||
|
|
||||
// 停止语音识别 |
// 停止语音识别 |
||||
if (_isListening) { |
if (isListening.value) { |
||||
await stopVoiceRecognition(); |
_asrService.stopContinuousRecognition(); |
||||
Logger.info('退出交互,已停止语音识别'); |
|
||||
} |
} |
||||
|
|
||||
// 清除当前系统提示词 |
// 取消定时器 |
||||
_currentSystemPrompt = null; |
_inactivityTimer?.cancel(); |
||||
// 清除当前agent ID |
|
||||
_currentAgentId = null; |
super.onClose(); |
||||
// 重置agent voice为默认值 |
|
||||
_agentVoice = 'zh_female_meilinvyou_moon_bigtts'; |
|
||||
} |
} |
||||
|
|
||||
// 开始语音交互 |
/// 开始代理交互 |
||||
|
/// |
||||
|
/// [agentId] 要交互的代理ID |
||||
Future<void> startAgentInteraction(String agentId) async { |
Future<void> startAgentInteraction(String agentId) async { |
||||
// 设置当前agent ID |
// 先说"我在听",然后再播放欢迎消息 |
||||
_currentAgentId = agentId; |
const listeningMessage = "我在听"; |
||||
|
_ttsService.speakSingle(listeningMessage); |
||||
|
|
||||
// 获取agent信息,只查询一次 |
// 防止重复处理 |
||||
final agent = AgentProvider.getAgentById(agentId); |
if (isProcessing.value) { |
||||
if (agent == null) { |
Logger.warning('已经在处理交互,忽略此次请求'); |
||||
Logger.error('无法获取agent: $agentId'); |
|
||||
return; |
return; |
||||
} |
} |
||||
|
|
||||
// 存储需要的值 |
isProcessing.value = true; |
||||
_agentVoice = agent.voice; |
|
||||
_currentSystemPrompt = agent.systemPrompt; |
|
||||
|
|
||||
// 播放提示音 |
try { |
||||
await _ttsService.speakSingle("我在听", speaker: _agentVoice); |
// 获取Agent |
||||
|
final agent = AgentProvider.getAgentById(agentId); |
||||
|
if (agent == null) { |
||||
|
Logger.error('无法获取Agent: $agentId'); |
||||
|
isProcessing.value = false; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
if (_isProcessing) { |
_currentAgent = agent; |
||||
print('已经在处理交互,忽略此次请求'); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
_isProcessing = true; |
// 加载该Agent的聊天历史 |
||||
|
_loadChatHistory(); |
||||
|
|
||||
try { |
// 启动语音识别 - 保持持续识别状态 |
||||
// 确保之前的语音识别已经停止 |
await _startVoiceRecognition(); |
||||
if (_isListening) { |
|
||||
await stopVoiceRecognition(); |
|
||||
} |
|
||||
|
|
||||
// 启动语音识别 |
// 启动无活动定时器 |
||||
await startVoiceRecognition(); |
_startInactivityTimer(); |
||||
|
|
||||
// 启动交互计时器 |
|
||||
_resetInteractionTimer(); |
|
||||
} catch (e) { |
} catch (e) { |
||||
Logger.error('启动语音交互失败: $e'); |
Logger.error('启动代理交互失败', e); |
||||
_isProcessing = false; |
isProcessing.value = false; |
||||
|
|
||||
// 播放错误提示 |
|
||||
await _ttsService.speakSingle("抱歉,启动语音交互失败", speaker: _agentVoice); |
|
||||
} |
} |
||||
} |
} |
||||
|
|
||||
// 停止语音交互 |
/// 启动语音识别 |
||||
Future<void> stopAgentInteraction() async { |
Future<void> _startVoiceRecognition() async { |
||||
// 取消交互计时器 |
if (isListening.value) { |
||||
_interactionTimer?.cancel(); |
// 如果已经在监听,不需要重新启动 |
||||
_interactionTimer = null; |
Logger.info('语音识别已经在运行,无需重新启动'); |
||||
|
return; |
||||
// 停止TTS播放 |
|
||||
if (_ttsService.isPlaying) { |
|
||||
await _ttsService.stop(); |
|
||||
} |
} |
||||
|
|
||||
// 停止语音识别 |
try { |
||||
if (_isListening) { |
// 启动语音识别 |
||||
await stopVoiceRecognition(); |
final success = await _asrService.startContinuousRecognition(); |
||||
Logger.info('手动停止交互,已停止语音识别'); |
|
||||
|
if (success) { |
||||
|
isListening.value = true; |
||||
|
recognizedText.value = ''; |
||||
|
|
||||
|
// 监听识别事件 |
||||
|
_recognitionSubscription = _asrService.recognitionStream?.listen( |
||||
|
(event) { |
||||
|
// 重置无活动定时器 |
||||
|
_resetInactivityTimer(); |
||||
|
|
||||
|
switch (event.type) { |
||||
|
case RecognitionEventType.recognizing: |
||||
|
// 实时更新识别文本 |
||||
|
if (event.text.isNotEmpty) { |
||||
|
recognizedText.value = event.text; |
||||
|
|
||||
|
// 如果正在播放TTS,检测到用户说话则中断当前播放和AI回复 |
||||
|
if (_ttsService.isPlaying && event.text.trim().length > 3) { |
||||
|
_interruptCurrentResponse("检测到用户说话,中断当前响应"); |
||||
|
} |
||||
|
} |
||||
|
break; |
||||
|
case RecognitionEventType.finalResult: |
||||
|
// 最终结果 |
||||
|
if (event.text.isNotEmpty) { |
||||
|
recognizedText.value = event.text; |
||||
|
|
||||
|
// 处理识别到的文本 |
||||
|
_processRecognizedText(event.text); |
||||
|
|
||||
|
// 重置识别文本但保持识别状态 |
||||
|
recognizedText.value = ''; |
||||
|
} |
||||
|
break; |
||||
|
case RecognitionEventType.sessionStopped: |
||||
|
case RecognitionEventType.canceled: |
||||
|
// 会话结束,表示识别在其它地方被启动,直接退出后台服务 |
||||
|
isListening.value = false; |
||||
|
Logger.info('语音识别会话被中止,退出后台代理服务'); |
||||
|
stopInteraction(); |
||||
|
break; |
||||
|
default: |
||||
|
break; |
||||
|
} |
||||
|
}, |
||||
|
onError: (error) { |
||||
|
Logger.error('语音识别错误', error); |
||||
|
isListening.value = false; |
||||
|
|
||||
|
// 如果出错,尝试重新启动语音识别 |
||||
|
_restartVoiceRecognition(); |
||||
|
}, |
||||
|
); |
||||
|
} else { |
||||
|
Logger.error('启动语音识别失败'); |
||||
|
isProcessing.value = false; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('启动语音识别失败', e); |
||||
|
isListening.value = false; |
||||
|
isProcessing.value = false; |
||||
} |
} |
||||
|
} |
||||
|
|
||||
// 清除处理状态 |
/// 处理识别到的文本 |
||||
_isProcessing = false; |
Future<void> _processRecognizedText(String text) async { |
||||
_currentSystemPrompt = null; |
if (text.trim().isEmpty || _currentAgent == null) return; |
||||
// 重置agent voice为默认值 |
|
||||
_agentVoice = 'zh_female_meilinvyou_moon_bigtts'; |
|
||||
|
|
||||
Logger.info('手动停止交互,已结束TTS会话'); |
try { |
||||
} |
// 添加用户消息到历史记录 |
||||
|
_addMessageToHistory('user', text); |
||||
|
|
||||
// 添加一个 Completer 用于在识别到最终结果时完成 |
// 启动TTS会话,使用当前Agent的voice |
||||
Completer<String>? _recognitionCompleter; |
_ttsService.startSession(_currentAgent!.voice); |
||||
|
// 获取AI响应 |
||||
|
String fullResponse = ''; |
||||
|
|
||||
// 开始语音识别 |
// 获取AI响应流 |
||||
Future<void> startVoiceRecognition() async { |
final responseStream = _aiService.sendMessageStream( |
||||
if (_isListening) { |
messages: messageHistory, |
||||
await stopVoiceRecognition(); |
systemPrompt: _currentAgent!.systemPrompt, |
||||
} |
); |
||||
|
|
||||
try { |
// 订阅响应流 |
||||
Logger.info('开始初始化语音识别服务...'); |
_aiResponseSubscription = responseStream.listen( |
||||
// 确保语音识别服务已初始化 |
(chunk) { |
||||
if (!await _voiceRecognitionService.initialize( |
fullResponse += chunk; |
||||
subscriptionKey: dotenv.env['AZURE_ASR_SUBSCRIPTION_KEY'] ?? '', |
|
||||
serviceRegion: dotenv.env['AZURE_ASR_SERVICE_REGION'] ?? 'eastasia', |
|
||||
language: dotenv.env['AZURE_ASR_LANGUAGE'] ?? 'zh-CN', |
|
||||
)) { |
|
||||
// 初始化失败,直接抛出异常 |
|
||||
Logger.error('语音识别服务初始化失败'); |
|
||||
throw Exception('无法初始化语音识别服务'); |
|
||||
} |
|
||||
|
|
||||
// 开始连续识别 |
// 如果TTS启用,发送到TTS |
||||
final success = await _voiceRecognitionService.startContinuousRecognition(); |
_ttsService.speak(chunk); |
||||
if (!success) { |
|
||||
Logger.error('启动连续语音识别失败'); |
|
||||
throw Exception('无法启动语音识别'); |
|
||||
} |
|
||||
|
|
||||
_isListening = true; |
// 重置无活动定时器 |
||||
isListening.value = true; |
_resetInactivityTimer(); |
||||
recognizedText.value = ''; |
}, |
||||
_hasRecognizedSpeech = false; |
onDone: () { |
||||
_hasFinalResult = false; |
// 添加AI响应到历史记录 |
||||
|
_addMessageToHistory('assistant', fullResponse); |
||||
// 添加一个变量来存储完整的用户输入 |
|
||||
String fullUserInput = ''; |
|
||||
|
|
||||
// 使用语音识别服务的recognitionStream而不是方法的返回值 |
|
||||
_recognitionSubscription = _voiceRecognitionService.recognitionStream?.listen((event) { |
|
||||
if (event.type == RecognitionEventType.finalResult) { |
|
||||
// 添加日志输出最终识别结果 |
|
||||
Logger.info('语音识别最终结果: ${event.text}'); |
|
||||
|
|
||||
if (event.text.isNotEmpty) { |
|
||||
// 将最终结果添加到完整输入中,并添加适当的标点符号 |
|
||||
if (fullUserInput.isNotEmpty && !fullUserInput.endsWith('。') && |
|
||||
!fullUserInput.endsWith('?') && !fullUserInput.endsWith('!') && |
|
||||
!fullUserInput.endsWith('.') && !fullUserInput.endsWith('?') && |
|
||||
!fullUserInput.endsWith('!')) { |
|
||||
fullUserInput += ','; |
|
||||
} |
|
||||
fullUserInput += event.text; |
|
||||
|
|
||||
// 更新可观察的识别文本 |
// 结束TTS会话 |
||||
recognizedText.value = fullUserInput; |
_ttsService.endSession(); |
||||
|
|
||||
_hasRecognizedSpeech = true; |
// 注意:我们不再在这里重启语音识别,因为它应该已经在运行 |
||||
_hasFinalResult = true; |
}, |
||||
|
onError: (error) { |
||||
|
Logger.error('AI响应错误', error); |
||||
|
|
||||
// 重置交互计时器,因为用户刚刚说了话 |
// 播放错误消息 |
||||
_resetInteractionTimer(); |
const errorMessage = '抱歉,我遇到了一些问题,无法回应您的请求。'; |
||||
|
_ttsService.speak(errorMessage); |
||||
|
|
||||
|
// 添加错误消息到历史记录 |
||||
|
_addMessageToHistory('assistant', errorMessage); |
||||
|
|
||||
_processUserInput(fullUserInput); |
// 结束TTS会话 |
||||
|
_ttsService.endSession(); |
||||
|
}, |
||||
|
); |
||||
|
} catch (e) { |
||||
|
Logger.error('处理识别文本失败', e); |
||||
|
|
||||
// 重置用户输入 |
// 播放错误消息 |
||||
fullUserInput = ''; |
const errorMessage = '抱歉,我遇到了一些问题,无法回应您的请求。'; |
||||
recognizedText.value = ''; |
_ttsService.speak(errorMessage); |
||||
_hasRecognizedSpeech = false; |
|
||||
_hasFinalResult = false; |
|
||||
} |
|
||||
} else if (event.type == RecognitionEventType.recognizing) { |
|
||||
// 添加日志输出中间识别结果 |
|
||||
Logger.info('语音识别中间结果: ${event.text}'); |
|
||||
|
|
||||
if (event.text.isNotEmpty) { |
// 添加错误消息到历史记录 |
||||
// 更新当前的中间结果,但不添加到完整输入中 |
_addMessageToHistory('assistant', errorMessage); |
||||
recognizedText.value = fullUserInput + (fullUserInput.isEmpty ? "" : ",") + event.text; |
} |
||||
|
} |
||||
|
|
||||
_hasRecognizedSpeech = true; |
/// 重新启动语音识别(仅在识别会话意外终止时使用) |
||||
|
Future<void> _restartVoiceRecognition() async { |
||||
|
// 停止当前的语音识别 |
||||
|
if (isListening.value) { |
||||
|
await _asrService.stopContinuousRecognition(); |
||||
|
isListening.value = false; |
||||
|
|
||||
// 检测到用户说话,重置交互计时器 |
// 取消当前的订阅 |
||||
_resetInteractionTimer(); |
_recognitionSubscription?.cancel(); |
||||
|
_recognitionSubscription = null; |
||||
|
} |
||||
|
|
||||
// 如果系统正在播放TTS或接收AI响应,检测到用户开始说话时立即中断 |
// 等待一段时间,确保资源被正确释放 |
||||
if (_ttsService.isPlaying && event.text.trim().isNotEmpty) { |
await Future.delayed(Duration(milliseconds: 500)); |
||||
// 停止当前TTS播放 |
|
||||
_ttsService.stop(); |
|
||||
|
|
||||
// 设置标志,表示应该取消当前的AI响应 |
// 重新启动语音识别 |
||||
_shouldCancelAiResponse = true; |
await _startVoiceRecognition(); |
||||
|
} |
||||
|
|
||||
// 取消当前的AI响应流订阅 |
/// 添加消息到历史记录 |
||||
_aiResponseSubscription?.cancel(); |
void _addMessageToHistory(String role, String content) { |
||||
_aiResponseSubscription = null; |
// 添加到本地历史记录 |
||||
|
_localMessageHistory.add({ |
||||
|
'role': role, |
||||
|
'content': content, |
||||
|
}); |
||||
|
|
||||
|
// 保持本地历史记录不超过10条消息 |
||||
|
if (_localMessageHistory.length > 10) { |
||||
|
_localMessageHistory.removeAt(0); |
||||
|
} |
||||
|
|
||||
// 添加日志输出中断TTS播放 |
// 如果ChatController可用,同步消息到ChatController |
||||
Logger.info('检测到用户说话,中断TTS播放'); |
if (Get.isRegistered<ChatController>()) { |
||||
} |
final chatController = Get.find<ChatController>(); |
||||
} |
if (chatController.agent?.id == 'personal_assistant') { |
||||
} else if (event.type == RecognitionEventType.error) { |
// 向ChatController添加消息 |
||||
// 添加日志输出识别错误 |
final message = Message( |
||||
Logger.error('语音识别错误: ${event.error}'); |
role: role, |
||||
print('识别错误: ${event.error}'); |
content: content, |
||||
} |
timestamp: DateTime.now(), |
||||
}, onError: (error) { |
); |
||||
// 添加日志输出语音识别流错误 |
chatController.addMessage(message); |
||||
Logger.error('语音识别流错误: $error'); |
} |
||||
print('语音识别流错误: $error'); |
} else { |
||||
_isListening = false; |
// 如果ChatController不可用,保存到本地存储 |
||||
isListening.value = false; |
_saveChatHistory(); |
||||
}); |
} |
||||
|
} |
||||
|
|
||||
// 启动交互计时器 |
// 加载聊天历史 |
||||
_resetInteractionTimer(); |
void _loadChatHistory() { |
||||
|
if (_currentAgent == null) return; |
||||
|
|
||||
|
try { |
||||
|
// 加载聊天历史 |
||||
|
final messages = _historyService.loadHistory(_currentAgent!.id); |
||||
|
|
||||
|
// 清空本地历史,然后添加加载的历史 |
||||
|
_localMessageHistory.clear(); |
||||
|
|
||||
|
// 将Message转换为Map格式 |
||||
|
if (messages.isNotEmpty) { |
||||
|
for (final message in messages) { |
||||
|
_localMessageHistory.add({ |
||||
|
'role': message.role, |
||||
|
'content': message.content, |
||||
|
}); |
||||
|
} |
||||
|
Logger.info('已加载${messages.length}条聊天历史记录,代理: ${_currentAgent!.id}'); |
||||
|
} |
||||
} catch (e) { |
} catch (e) { |
||||
Logger.error('启动语音识别失败: $e'); |
Logger.error('加载聊天历史记录失败', e); |
||||
print('启动语音识别失败: $e'); |
|
||||
_isListening = false; |
|
||||
isListening.value = false; |
|
||||
rethrow; |
|
||||
} |
} |
||||
} |
} |
||||
|
|
||||
// 处理用户输入 |
// 保存聊天历史 |
||||
Future<void> _processUserInput(String userInput) async { |
void _saveChatHistory() { |
||||
if (userInput.isEmpty || _currentSystemPrompt == null) { |
if (_currentAgent == null) return; |
||||
return; |
|
||||
} |
|
||||
|
|
||||
try { |
try { |
||||
// 保存用户消息到历史记录 |
// 将本地历史转换为Message列表 |
||||
_messageHistory.add(Message( |
final messages = _localMessageHistory.map((m) => Message( |
||||
role: 'user', |
role: m['role']!, |
||||
content: userInput, |
content: m['content']!, |
||||
timestamp: DateTime.now(), |
timestamp: DateTime.now(), |
||||
)); |
)).toList(); |
||||
|
|
||||
// 构建用于生成回应的消息列表 |
|
||||
final messages = [ |
|
||||
{'role': 'system', 'content': _currentSystemPrompt!}, |
|
||||
{'role': 'user', 'content': userInput}, |
|
||||
]; |
|
||||
|
|
||||
String fullResponse = ''; |
|
||||
|
|
||||
Logger.info('开始生成AI响应...'); |
// 保存到ChatHistoryService |
||||
// 重置取消标志 |
_historyService.saveHistory(_currentAgent!.id, messages); |
||||
_shouldCancelAiResponse = false; |
} catch (e) { |
||||
|
Logger.error('保存聊天历史记录失败', e); |
||||
|
} |
||||
|
} |
||||
|
|
||||
// 创建一个本地变量来跟踪是否已取消 |
/// 启动无活动定时器 |
||||
bool isCancelled = false; |
void _startInactivityTimer() { |
||||
|
_inactivityTimer?.cancel(); |
||||
|
_inactivityTimer = Timer(Duration(seconds: 30), () { |
||||
|
// 如果30秒内没有TTS输出或用户说话,停止语音识别 |
||||
|
if (!_ttsService.isPlaying && isProcessing.value) { |
||||
|
Logger.info('检测到30秒内无活动,停止语音识别'); |
||||
|
stopInteraction(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
// 使用成员变量中存储的agent voice |
/// 重置无活动定时器 |
||||
_ttsService.startSession(_agentVoice); |
void _resetInactivityTimer() { |
||||
|
if (_inactivityTimer != null) { |
||||
|
_inactivityTimer!.cancel(); |
||||
|
_startInactivityTimer(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
// 获取AI响应流 |
/// 中断当前响应 |
||||
final responseStream = _aiService.sendMessageStream( |
void _interruptCurrentResponse(String reason) { |
||||
messages: messages, |
Logger.info(reason); |
||||
systemPrompt: _currentSystemPrompt!, |
|
||||
); |
|
||||
|
|
||||
// 创建一个订阅来处理响应流 |
// 停止TTS |
||||
_aiResponseSubscription = responseStream.listen( |
if (_ttsService.isPlaying) { |
||||
(chunk) { |
_ttsService.stop(); |
||||
// 如果已经设置了取消标志,则不处理这个块 |
} |
||||
if (_shouldCancelAiResponse) { |
|
||||
isCancelled = true; |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
fullResponse += chunk; |
// 取消AI响应流订阅 |
||||
|
_aiResponseSubscription?.cancel(); |
||||
|
_aiResponseSubscription = null; |
||||
|
} |
||||
|
|
||||
// 直接将每个文本块传递给 TTS 接口,不进行切句或攒句处理 |
/// 响应新的媒体事件(双击媒体按钮) |
||||
try { |
Future<void> respondToNewMediaEvent() async { |
||||
// 使用成员变量中存储的agent voice |
// 不管当前状态如何,总是中断当前响应 |
||||
_ttsService.speak(chunk, speaker: _agentVoice); |
_interruptCurrentResponse("检测到新的媒体事件,中断当前响应"); |
||||
|
|
||||
// Logger.info('直接播放 AI 响应块: $chunk'); |
// 回答"我在听" |
||||
} catch (e) { |
const listeningMessage = "我在听"; |
||||
Logger.error('TTS 播放出错: $e'); |
_ttsService.speakSingle(listeningMessage); |
||||
} |
|
||||
}, |
|
||||
onError: (e) { |
|
||||
Logger.error('AI响应流错误: $e'); |
|
||||
print('AI响应流错误: $e'); |
|
||||
// 清理订阅 |
|
||||
_aiResponseSubscription = null; |
|
||||
}, |
|
||||
onDone: () { |
|
||||
_ttsService.endSession(); |
|
||||
// 如果已取消,不处理剩余的文本 |
|
||||
if (!isCancelled && !_shouldCancelAiResponse) { |
|
||||
Logger.info('AI 响应生成完成'); |
|
||||
} |
|
||||
|
|
||||
_aiResponseSubscription = null; |
|
||||
|
|
||||
// AI响应完成后,重新启动超时计时器 |
|
||||
_resetInteractionTimer(); |
|
||||
|
|
||||
// 如果响应被取消,不保存到历史记录 |
|
||||
if (!_shouldCancelAiResponse && fullResponse.isNotEmpty) { |
|
||||
// 保存助手回复到历史记录 |
|
||||
_messageHistory.add(Message( |
|
||||
role: 'assistant', |
|
||||
content: fullResponse, |
|
||||
timestamp: DateTime.now(), |
|
||||
)); |
|
||||
|
|
||||
// Logger.info('保存AI响应到历史记录,长度: ${fullResponse.length}'); |
|
||||
} |
|
||||
|
|
||||
// 限制历史记录长度 |
|
||||
if (_messageHistory.length > 20) { |
|
||||
_messageHistory.removeRange(0, _messageHistory.length - 20); |
|
||||
Logger.info('历史记录超过20条,已裁剪'); |
|
||||
} |
|
||||
} |
|
||||
); |
|
||||
} catch (e) { |
|
||||
Logger.error('处理用户输入失败: $e'); |
|
||||
print('处理用户输入失败: $e'); |
|
||||
|
|
||||
// 播放错误提示,使用成员变量中存储的agent voice |
// 不需要重启语音识别,因为它应该已经在运行 |
||||
await _ttsService.speak("抱歉,出现了一些问题", speaker: _agentVoice); |
// 如果识别会话已经终止,确保重新启动 |
||||
|
if (!isListening.value) { |
||||
|
await _restartVoiceRecognition(); |
||||
} |
} |
||||
} |
|
||||
|
|
||||
// 停止语音识别并返回识别的文本 |
// 重置无活动定时器 |
||||
Future<String> stopVoiceRecognition() async { |
_resetInactivityTimer(); |
||||
if (!_isListening) { |
} |
||||
return ''; |
|
||||
} |
|
||||
|
|
||||
|
/// 停止当前交互 |
||||
|
Future<void> stopInteraction() async { |
||||
try { |
try { |
||||
// 取消订阅 |
// 保存聊天历史 |
||||
await _recognitionSubscription?.cancel(); |
_saveChatHistory(); |
||||
_recognitionSubscription = null; |
|
||||
|
|
||||
// 停止语音识别 |
// 取消所有订阅 |
||||
await _voiceRecognitionService.stopContinuousRecognition(); |
_recognitionSubscription?.cancel(); |
||||
|
_aiResponseSubscription?.cancel(); |
||||
|
|
||||
// 获取最终识别结果 |
// 停止TTS |
||||
final result = recognizedText.value; |
if (_ttsService.isPlaying) { |
||||
|
_ttsService.stop(); |
||||
|
} |
||||
|
|
||||
// 添加日志输出停止语音识别的最终结果 |
// 停止语音识别 |
||||
Logger.info('停止语音识别,最终结果: $result'); |
// if (isListening.value) { |
||||
|
// await _asrService.stopContinuousRecognition(); |
||||
|
// isListening.value = false; |
||||
|
// } |
||||
|
|
||||
|
// 取消定时器 |
||||
|
_inactivityTimer?.cancel(); |
||||
|
|
||||
// 重置状态 |
// 重置状态 |
||||
_isListening = false; |
isProcessing.value = false; |
||||
isListening.value = false; |
_currentAgent = null; |
||||
|
|
||||
return result; |
Logger.info('已停止后台代理交互'); |
||||
} catch (e) { |
} catch (e) { |
||||
// 添加日志输出停止语音识别失败 |
Logger.error('停止交互失败', e); |
||||
Logger.error('停止语音识别失败: $e'); |
|
||||
print('停止语音识别失败: $e'); |
|
||||
_isListening = false; |
|
||||
isListening.value = false; |
|
||||
|
|
||||
// 直接返回当前已识别的文本,不尝试重置 |
|
||||
final result = recognizedText.value; |
|
||||
Logger.info('停止语音识别失败,使用当前识别结果: $result'); |
|
||||
return result; |
|
||||
} |
} |
||||
} |
} |
||||
|
|
||||
List<Message> get messageHistory => List.unmodifiable(_messageHistory); |
|
||||
|
|
||||
void clearHistory() { |
|
||||
_messageHistory.clear(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
_recognitionSubscription?.cancel(); |
|
||||
_interactionTimer?.cancel(); |
|
||||
_interactionTimer = null; |
|
||||
_aiResponseSubscription?.cancel(); |
|
||||
|
|
||||
// 停止TTS播放 |
|
||||
if (_ttsService.isPlaying) { |
|
||||
_ttsService.stop(); |
|
||||
} |
|
||||
|
|
||||
super.onClose(); |
|
||||
} |
|
||||
} |
} |
||||
@ -0,0 +1,97 @@ |
|||||
|
import 'dart:convert'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:get_storage/get_storage.dart'; |
||||
|
import '../../modules/chat/models/message_model.dart'; |
||||
|
import '../../core/utils/logger.dart'; |
||||
|
|
||||
|
/// 聊天记录管理服务,负责保存和加载聊天记录 |
||||
|
class ChatHistoryService extends GetxService { |
||||
|
final GetStorage _storage = GetStorage(); |
||||
|
|
||||
|
// 存储键名 |
||||
|
static const String _historyKeyPrefix = 'chat_history_'; |
||||
|
static const int _maxHistoryCount = 20; // 每个对话最多保存的消息数 |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
Logger.info('ChatHistoryService 已初始化'); |
||||
|
} |
||||
|
|
||||
|
/// 生成历史记录存储键 |
||||
|
String _getHistoryKey(String agentId) => '$_historyKeyPrefix$agentId'; |
||||
|
|
||||
|
/// 保存聊天记录 |
||||
|
/// |
||||
|
/// [agentId] 代理ID |
||||
|
/// [messages] 消息列表 |
||||
|
Future<void> saveHistory(String agentId, List<Message> messages) async { |
||||
|
try { |
||||
|
// 过滤掉加载中的消息 |
||||
|
final filteredMessages = messages.where((m) => !m.isLoading).toList(); |
||||
|
|
||||
|
// 如果消息超过最大数量,只保留最新的消息 |
||||
|
final messagesToSave = filteredMessages.length > _maxHistoryCount |
||||
|
? filteredMessages.sublist(filteredMessages.length - _maxHistoryCount) |
||||
|
: filteredMessages; |
||||
|
|
||||
|
// 转换为JSON |
||||
|
final jsonList = messagesToSave.map((m) => m.toJson()).toList(); |
||||
|
final jsonString = jsonEncode(jsonList); |
||||
|
|
||||
|
// 保存到存储 |
||||
|
await _storage.write(_getHistoryKey(agentId), jsonString); |
||||
|
Logger.info('已保存${messagesToSave.length}条聊天记录,代理: $agentId'); |
||||
|
} catch (e) { |
||||
|
Logger.error('保存聊天记录失败', e); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 加载聊天记录 |
||||
|
/// |
||||
|
/// [agentId] 代理ID |
||||
|
/// 返回消息列表,如果没有找到或解析错误则返回空列表 |
||||
|
List<Message> loadHistory(String agentId) { |
||||
|
try { |
||||
|
final jsonString = _storage.read<String>(_getHistoryKey(agentId)); |
||||
|
|
||||
|
if (jsonString == null) { |
||||
|
return []; |
||||
|
} |
||||
|
|
||||
|
final jsonList = jsonDecode(jsonString) as List; |
||||
|
final messages = jsonList.map((json) => Message.fromJson(json)).toList(); |
||||
|
|
||||
|
Logger.info('已加载${messages.length}条聊天记录,代理: $agentId'); |
||||
|
return messages; |
||||
|
} catch (e) { |
||||
|
Logger.error('加载聊天记录失败', e); |
||||
|
return []; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 清除特定代理的聊天记录 |
||||
|
Future<void> clearHistory(String agentId) async { |
||||
|
try { |
||||
|
await _storage.remove(_getHistoryKey(agentId)); |
||||
|
Logger.info('已清除聊天记录,代理: $agentId'); |
||||
|
} catch (e) { |
||||
|
Logger.error('清除聊天记录失败', e); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 清除所有聊天记录 |
||||
|
Future<void> clearAllHistory() async { |
||||
|
try { |
||||
|
final keys = _storage.getKeys(); |
||||
|
for (final key in keys) { |
||||
|
if (key.startsWith(_historyKeyPrefix)) { |
||||
|
await _storage.remove(key); |
||||
|
} |
||||
|
} |
||||
|
Logger.info('已清除所有聊天记录'); |
||||
|
} catch (e) { |
||||
|
Logger.error('清除所有聊天记录失败', e); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -1,16 +1,9 @@ |
|||||
import 'package:get/get.dart'; |
import 'package:get/get.dart'; |
||||
import '../controllers/chat_controller.dart'; |
import '../controllers/chat_controller.dart'; |
||||
import '../../../data/services/volcano_tts_api_service.dart'; |
|
||||
|
|
||||
class ChatBinding implements Bindings { |
class ChatBinding extends Bindings { |
||||
@override |
@override |
||||
void dependencies() { |
void dependencies() { |
||||
// Ensure TTS service is registered first |
Get.lazyPut<ChatController>(() => ChatController()); |
||||
if (!Get.isRegistered<VolcanoTtsApiService>()) { |
|
||||
Get.put(VolcanoTtsApiService()); |
|
||||
} |
|
||||
|
|
||||
// Create new controller instance |
|
||||
Get.put(ChatController()); |
|
||||
} |
} |
||||
} |
} |
||||
File diff suppressed because it is too large
@ -1,348 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'dart:math'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:flutter/foundation.dart'; |
|
||||
import '../../../data/services/azure_asr_service.dart'; |
|
||||
import '../../../data/services/volcano_tts_api_service.dart'; |
|
||||
import '../../../core/utils/logger.dart'; |
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|
||||
|
|
||||
class VoiceInputController extends GetxController { |
|
||||
// Observable states |
|
||||
final isConnecting = true.obs; |
|
||||
final isMuted = false.obs; |
|
||||
final isRecording = false.obs; |
|
||||
final recognizedText = ''.obs; |
|
||||
|
|
||||
// 添加一个新的可观察状态,表示用户是否正在说话 |
|
||||
final isUserSpeaking = false.obs; |
|
||||
|
|
||||
// 语音识别服务 |
|
||||
late final AzureAsrService _voiceService; |
|
||||
late final VolcanoTtsApiService _ttsService; |
|
||||
|
|
||||
// 连续识别相关 |
|
||||
StreamSubscription? _recognitionSubscription; |
|
||||
|
|
||||
// TTS相关订阅 |
|
||||
StreamSubscription? _ttsEventSubscription; |
|
||||
bool _isTtsSpeaking = false; |
|
||||
|
|
||||
// 添加一个标志,表示是否已经识别到语音 |
|
||||
bool _hasRecognizedSpeech = false; |
|
||||
|
|
||||
// 添加一个 Completer 用于在识别到最终结果时完成 |
|
||||
Completer<String>? _recognitionCompleter; |
|
||||
|
|
||||
// 添加一个标志,表示是否已经收到最终结果 |
|
||||
bool _hasFinalResult = false; |
|
||||
|
|
||||
// 添加一个计时器,用于定期更新用户说话状态 |
|
||||
Timer? _speakingCheckTimer; |
|
||||
|
|
||||
// 最后一次识别到语音的时间 |
|
||||
DateTime? _lastSpeechTime; |
|
||||
|
|
||||
// Callbacks |
|
||||
final Function(String) onRecordingResult; |
|
||||
final VoidCallback onClosePanel; |
|
||||
final Function(String) onRecognizing; |
|
||||
|
|
||||
VoiceInputController({ |
|
||||
required this.onRecordingResult, |
|
||||
required this.onClosePanel, |
|
||||
required this.onRecognizing, |
|
||||
}); |
|
||||
|
|
||||
@override |
|
||||
void onInit() { |
|
||||
super.onInit(); |
|
||||
_initializeVoiceService(); |
|
||||
_ttsService = Get.find<VolcanoTtsApiService>(); |
|
||||
|
|
||||
// 设置TTS事件监听 |
|
||||
_setupTtsEventListener(); |
|
||||
|
|
||||
// 启动定期检查用户是否正在说话的计时器 |
|
||||
_startSpeakingCheckTimer(); |
|
||||
} |
|
||||
|
|
||||
// 设置TTS事件监听 |
|
||||
void _setupTtsEventListener() { |
|
||||
_ttsEventSubscription?.cancel(); |
|
||||
_ttsEventSubscription = _ttsService.eventStream.listen(_handleTtsEvent); |
|
||||
} |
|
||||
|
|
||||
// 处理TTS事件 |
|
||||
void _handleTtsEvent(Map<String, dynamic> event) { |
|
||||
final eventType = event['eventType']; |
|
||||
|
|
||||
switch (eventType) { |
|
||||
case 'ttsSentenceStart': |
|
||||
_isTtsSpeaking = true; |
|
||||
break; |
|
||||
case 'ttsSentenceEnd': |
|
||||
case 'sessionFinished': |
|
||||
case 'error': |
|
||||
_isTtsSpeaking = false; |
|
||||
break; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> _initializeVoiceService() async { |
|
||||
try { |
|
||||
// 获取语音识别服务实例 |
|
||||
_voiceService = Get.find<AzureAsrService>(); |
|
||||
|
|
||||
// 初始化语音识别服务 |
|
||||
await _voiceService.initialize( |
|
||||
subscriptionKey: dotenv.env['AZURE_ASR_SUBSCRIPTION_KEY'] ?? '', |
|
||||
serviceRegion: dotenv.env['AZURE_ASR_SERVICE_REGION'] ?? 'eastasia', |
|
||||
language: dotenv.env['AZURE_ASR_LANGUAGE'] ?? 'zh-CN', |
|
||||
); |
|
||||
|
|
||||
// 初始化完成后更新状态 |
|
||||
isConnecting.value = false; |
|
||||
|
|
||||
// 自动开始连续语音识别 |
|
||||
await startContinuousRecognition(); |
|
||||
} catch (e) { |
|
||||
Logger.error('初始化语音识别服务失败', e); |
|
||||
Get.snackbar( |
|
||||
'Error', |
|
||||
'初始化语音识别服务失败: $e', |
|
||||
snackPosition: SnackPosition.BOTTOM, |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> startContinuousRecognition() async { |
|
||||
if (isConnecting.value) return; |
|
||||
if (isRecording.value) return; // 已经在录音中 |
|
||||
|
|
||||
try { |
|
||||
isRecording.value = true; |
|
||||
recognizedText.value = ''; // 清空识别文本,但不再在面板中显示 |
|
||||
_hasRecognizedSpeech = false; |
|
||||
_hasFinalResult = false; |
|
||||
|
|
||||
// 创建一个 Completer 来处理语音识别完成 |
|
||||
_recognitionCompleter = Completer<String>(); |
|
||||
|
|
||||
// 开始连续语音识别 |
|
||||
final success = await _voiceService.startContinuousRecognition(); |
|
||||
if (!success) { |
|
||||
isRecording.value = false; |
|
||||
throw Exception('无法启动语音识别'); |
|
||||
} |
|
||||
|
|
||||
// 订阅识别事件流 |
|
||||
_recognitionSubscription = _voiceService.recognitionStream?.listen((event) { |
|
||||
switch (event.type) { |
|
||||
case RecognitionEventType.recognizing: |
|
||||
// 不再更新面板中的识别文本,而是通过回调传递给ChatController |
|
||||
if (event.text.isNotEmpty) { |
|
||||
_hasRecognizedSpeech = true; |
|
||||
_lastSpeechTime = DateTime.now(); |
|
||||
|
|
||||
// 设置用户正在说话状态 |
|
||||
isUserSpeaking.value = true; |
|
||||
|
|
||||
// 如果系统正在播放TTS,检测到用户开始说话时立即中断 |
|
||||
if (event.text.trim().isNotEmpty) { |
|
||||
print('检测到用户开始说话,中断TTS播放'); |
|
||||
_ttsService.stop(); |
|
||||
_ttsService.endSession(); // 结束当前TTS会话 |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 调用识别中回调,将中间结果传递给ChatController |
|
||||
onRecognizing(event.text); |
|
||||
break; |
|
||||
|
|
||||
case RecognitionEventType.finalResult: |
|
||||
// 更新最终识别结果 |
|
||||
_hasFinalResult = true; |
|
||||
|
|
||||
// 重置用户说话状态 |
|
||||
isUserSpeaking.value = false; |
|
||||
|
|
||||
if (event.text.isNotEmpty) { |
|
||||
// 不再更新面板中的识别文本 |
|
||||
_hasRecognizedSpeech = true; |
|
||||
_lastSpeechTime = DateTime.now(); |
|
||||
|
|
||||
// 调用最终结果回调,发送识别到的句子 |
|
||||
onRecordingResult(event.text); |
|
||||
|
|
||||
// 清空识别状态,准备下一句,但不停止识别 |
|
||||
_hasRecognizedSpeech = false; |
|
||||
_hasFinalResult = false; |
|
||||
|
|
||||
// 完成当前识别,但不停止连续识别 |
|
||||
if (_recognitionCompleter != null && !_recognitionCompleter!.isCompleted) { |
|
||||
_recognitionCompleter!.complete(event.text); |
|
||||
} |
|
||||
} else { |
|
||||
// 如果最终结果为空,但之前有中间结果,仍然标记为有语音 |
|
||||
if (_hasRecognizedSpeech) { |
|
||||
print('最终识别结果为空,但之前有中间结果,使用最后的中间结果'); |
|
||||
|
|
||||
// 清空识别状态,准备下一句,但不停止识别 |
|
||||
_hasRecognizedSpeech = false; |
|
||||
_hasFinalResult = false; |
|
||||
} |
|
||||
} |
|
||||
break; |
|
||||
|
|
||||
case RecognitionEventType.error: |
|
||||
print('语音识别错误: ${event.error}'); |
|
||||
Get.snackbar( |
|
||||
'Error', |
|
||||
'语音识别错误: ${event.error}', |
|
||||
snackPosition: SnackPosition.BOTTOM, |
|
||||
); |
|
||||
// 尝试重新启动识别 |
|
||||
restartRecognition(); |
|
||||
break; |
|
||||
|
|
||||
case RecognitionEventType.sessionStopped: |
|
||||
// 会话结束,尝试重新启动 |
|
||||
isRecording.value = false; |
|
||||
restartRecognition(); |
|
||||
break; |
|
||||
|
|
||||
default: |
|
||||
break; |
|
||||
} |
|
||||
}, onError: (error) { |
|
||||
print('语音识别流错误: $error'); |
|
||||
Get.snackbar( |
|
||||
'Error', |
|
||||
'语音识别流错误: $error', |
|
||||
snackPosition: SnackPosition.BOTTOM, |
|
||||
); |
|
||||
isRecording.value = false; |
|
||||
// 尝试重新启动识别 |
|
||||
restartRecognition(); |
|
||||
}); |
|
||||
|
|
||||
} catch (e) { |
|
||||
print('语音识别失败: $e'); |
|
||||
Get.snackbar( |
|
||||
'Error', |
|
||||
'启动语音识别失败: $e', |
|
||||
snackPosition: SnackPosition.BOTTOM, |
|
||||
); |
|
||||
isRecording.value = false; |
|
||||
} finally { |
|
||||
// 清理资源 |
|
||||
if (_recognitionCompleter != null && !_recognitionCompleter!.isCompleted) { |
|
||||
_recognitionCompleter!.complete(''); |
|
||||
} |
|
||||
_recognitionCompleter = null; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 重新启动识别 |
|
||||
Future<void> restartRecognition() async { |
|
||||
try { |
|
||||
// 先停止当前识别 |
|
||||
await stopRecording(); |
|
||||
// 延迟一下再重新启动 |
|
||||
await Future.delayed(const Duration(milliseconds: 500)); |
|
||||
// 重新启动连续识别 |
|
||||
await startContinuousRecognition(); |
|
||||
} catch (e) { |
|
||||
print('重新启动语音识别失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> stopRecording() async { |
|
||||
if (!isRecording.value) return; |
|
||||
|
|
||||
try { |
|
||||
// 取消事件订阅 |
|
||||
await _recognitionSubscription?.cancel(); |
|
||||
_recognitionSubscription = null; |
|
||||
|
|
||||
// 停止连续识别 |
|
||||
if (_voiceService.isContinuousRecognitionActive()) { |
|
||||
await _voiceService.stopContinuousRecognition(); |
|
||||
} |
|
||||
|
|
||||
isRecording.value = false; |
|
||||
_hasRecognizedSpeech = false; |
|
||||
_hasFinalResult = false; |
|
||||
} catch (e) { |
|
||||
print('停止语音识别失败: $e'); |
|
||||
isRecording.value = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
void toggleMute() { |
|
||||
// 只切换静音状态,不再影响录音 |
|
||||
// 因为已启用回声消除,不需要在录音时停止TTS |
|
||||
isMuted.value = !isMuted.value; |
|
||||
} |
|
||||
|
|
||||
// 手动开始录音(用户点击按钮) |
|
||||
void startRecording() { |
|
||||
if (!isRecording.value) { |
|
||||
startContinuousRecognition(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 启动定期检查用户是否正在说话的计时器 |
|
||||
void _startSpeakingCheckTimer() { |
|
||||
_speakingCheckTimer?.cancel(); |
|
||||
_speakingCheckTimer = Timer.periodic(const Duration(milliseconds: 200), (_) { |
|
||||
_checkIfUserIsSpeaking(); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// 检查用户是否正在说话 |
|
||||
void _checkIfUserIsSpeaking() { |
|
||||
// 如果有最近的识别文本,且距离现在不超过1秒,认为用户正在说话 |
|
||||
if (_hasRecognizedSpeech && !_hasFinalResult && _lastSpeechTime != null) { |
|
||||
final now = DateTime.now(); |
|
||||
final timeSinceLastSpeech = now.difference(_lastSpeechTime!); |
|
||||
|
|
||||
// 如果最近1秒内有语音识别,认为用户正在说话 |
|
||||
if (timeSinceLastSpeech.inMilliseconds < 1000) { |
|
||||
isUserSpeaking.value = true; |
|
||||
return; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 默认情况下,如果正在录音但没有最近的语音识别,随机生成一个说话状态 |
|
||||
// 保持这个功能,让麦克风图标有动画效果 |
|
||||
if (isRecording.value) { |
|
||||
// 随机生成一个值,有20%的概率认为用户正在说话 |
|
||||
isUserSpeaking.value = Random().nextDouble() < 0.2; |
|
||||
} else { |
|
||||
isUserSpeaking.value = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
// 停止录音 |
|
||||
stopRecording(); |
|
||||
|
|
||||
// 取消计时器 |
|
||||
_speakingCheckTimer?.cancel(); |
|
||||
|
|
||||
// 取消TTS事件订阅 |
|
||||
_ttsEventSubscription?.cancel(); |
|
||||
|
|
||||
// 重置状态 |
|
||||
isConnecting.value = false; |
|
||||
isMuted.value = false; |
|
||||
isRecording.value = false; |
|
||||
recognizedText.value = ''; |
|
||||
isUserSpeaking.value = false; |
|
||||
|
|
||||
super.onClose(); |
|
||||
} |
|
||||
} |
|
||||
File diff suppressed because it is too large
@ -1,302 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
||||
import 'dart:math'; |
|
||||
import 'dart:async'; |
|
||||
import '../controllers/voice_input_controller.dart'; |
|
||||
|
|
||||
class VoiceInputPanel extends GetView<VoiceInputController> { |
|
||||
const VoiceInputPanel({ |
|
||||
Key? key, |
|
||||
required this.onRecordingResult, |
|
||||
required this.onClose, |
|
||||
required this.onRecognizing, |
|
||||
}) : super(key: key); |
|
||||
|
|
||||
final Function(String) onRecordingResult; |
|
||||
final VoidCallback onClose; |
|
||||
final Function(String) onRecognizing; |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
return Container( |
|
||||
decoration: const BoxDecoration( |
|
||||
gradient: LinearGradient( |
|
||||
begin: Alignment.topCenter, |
|
||||
end: Alignment.bottomCenter, |
|
||||
colors: [ |
|
||||
Color(0xFFFFFFFF), // 纯白色开始 |
|
||||
Color(0xFFE8D5F5), // 更深的紫色结束 |
|
||||
], |
|
||||
stops: [0.3, 1.0], // 调整颜色分布,让白色占据更多空间 |
|
||||
), |
|
||||
), |
|
||||
child: SafeArea( |
|
||||
child: Container( |
|
||||
height: 120.h, |
|
||||
child: Center( |
|
||||
child: Column( |
|
||||
mainAxisAlignment: MainAxisAlignment.center, |
|
||||
children: [ |
|
||||
// 主要内容:麦克风图标、音频波形、关闭按钮 |
|
||||
Padding( |
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w), |
|
||||
child: Row( |
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|
||||
crossAxisAlignment: CrossAxisAlignment.center, |
|
||||
children: [ |
|
||||
// 左侧麦克风图标 |
|
||||
Container( |
|
||||
width: 60.w, |
|
||||
height: 60.w, |
|
||||
decoration: BoxDecoration( |
|
||||
color: Colors.white, |
|
||||
shape: BoxShape.circle, |
|
||||
boxShadow: [ |
|
||||
BoxShadow( |
|
||||
color: Colors.black.withOpacity(0.05), |
|
||||
blurRadius: 10, |
|
||||
spreadRadius: 1, |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
child: Center( |
|
||||
child: Obx(() => Icon( |
|
||||
controller.isRecording.value ? Icons.mic : Icons.mic_none, |
|
||||
color: controller.isRecording.value ? Colors.purple : Colors.purple.shade300, |
|
||||
size: 28.sp, |
|
||||
)), |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
// 中间音频波形和状态文本 |
|
||||
Column( |
|
||||
mainAxisSize: MainAxisSize.min, |
|
||||
mainAxisAlignment: MainAxisAlignment.center, |
|
||||
children: [ |
|
||||
_buildAudioWaveform(), |
|
||||
SizedBox(height: 10.h), |
|
||||
// 文字提示 - 根据状态显示不同文本 |
|
||||
Obx(() => Text( |
|
||||
_getStatusText(), |
|
||||
style: TextStyle( |
|
||||
fontSize: 16.sp, |
|
||||
color: Colors.grey.shade700, |
|
||||
fontWeight: FontWeight.w500, |
|
||||
), |
|
||||
)), |
|
||||
], |
|
||||
), |
|
||||
|
|
||||
// 右侧关闭按钮 |
|
||||
Container( |
|
||||
width: 60.w, |
|
||||
height: 60.w, |
|
||||
decoration: BoxDecoration( |
|
||||
color: Colors.white, |
|
||||
shape: BoxShape.circle, |
|
||||
boxShadow: [ |
|
||||
BoxShadow( |
|
||||
color: Colors.black.withOpacity(0.05), |
|
||||
blurRadius: 10, |
|
||||
spreadRadius: 1, |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
child: Center( |
|
||||
child: IconButton( |
|
||||
icon: Icon( |
|
||||
Icons.close, |
|
||||
color: Colors.grey.shade600, |
|
||||
size: 28.sp, |
|
||||
), |
|
||||
onPressed: onClose, |
|
||||
padding: EdgeInsets.zero, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
// 底部进度条和识别文本 |
|
||||
Padding( |
|
||||
padding: EdgeInsets.only(top: 15.h), |
|
||||
child: Column( |
|
||||
children: [ |
|
||||
// 移除显示当前识别的文本,因为现在直接显示在聊天窗口中 |
|
||||
|
|
||||
// 进度条 |
|
||||
Container( |
|
||||
width: 250.w, |
|
||||
height: 4.h, |
|
||||
decoration: BoxDecoration( |
|
||||
color: Colors.purple.withOpacity(0.2), |
|
||||
borderRadius: BorderRadius.circular(2.r), |
|
||||
), |
|
||||
child: Obx(() => controller.isRecording.value |
|
||||
? _buildProgressIndicator() |
|
||||
: Container() |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 根据状态返回不同的提示文本 |
|
||||
String _getStatusText() { |
|
||||
if (controller.isConnecting.value) { |
|
||||
return '正在连接语音服务...'; |
|
||||
} else if (controller.isMuted.value) { |
|
||||
return '语音已静音'; |
|
||||
} else if (controller.isRecording.value) { |
|
||||
return '正在聆听...'; |
|
||||
} else { |
|
||||
return '你可以开始说话'; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 构建进度指示器 |
|
||||
Widget _buildProgressIndicator() { |
|
||||
return Container( |
|
||||
width: 250.w, |
|
||||
height: 4.h, |
|
||||
decoration: BoxDecoration( |
|
||||
gradient: LinearGradient( |
|
||||
colors: [Colors.purple.shade300, Colors.purple.shade100], |
|
||||
begin: Alignment.centerLeft, |
|
||||
end: Alignment.centerRight, |
|
||||
), |
|
||||
borderRadius: BorderRadius.circular(2.r), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 检查用户是否正在说话 |
|
||||
bool _isUserSpeaking() { |
|
||||
// 使用控制器的isUserSpeaking属性 |
|
||||
return controller.isUserSpeaking.value; |
|
||||
} |
|
||||
|
|
||||
Widget _buildAudioWaveform() { |
|
||||
return Obx(() { |
|
||||
final isSpeaking = _isUserSpeaking(); |
|
||||
final barCount = 15; // 增加条形数量,使波形更丰富 |
|
||||
|
|
||||
// 根据控制器状态动态生成波形高度 |
|
||||
return Container( |
|
||||
width: 180.w, // 增加宽度以容纳更多条形 |
|
||||
height: 40.h, // 增加高度以允许更大的波动 |
|
||||
child: Row( |
|
||||
mainAxisSize: MainAxisSize.min, |
|
||||
mainAxisAlignment: MainAxisAlignment.center, |
|
||||
children: List.generate( |
|
||||
barCount, |
|
||||
(index) { |
|
||||
// 创建动态高度效果,模拟声波 |
|
||||
double height = controller.isRecording.value |
|
||||
? _getWaveHeight(index, isSpeaking, barCount) // 当正在录音时显示动态高度 |
|
||||
: 4.h; // 默认高度 |
|
||||
|
|
||||
// 根据位置计算不同的动画持续时间,使波形更自然 |
|
||||
final duration = Duration( |
|
||||
milliseconds: 100 + (index % 3) * 50 |
|
||||
); |
|
||||
|
|
||||
// 根据是否说话和位置计算颜色 |
|
||||
Color barColor; |
|
||||
if (controller.isRecording.value) { |
|
||||
if (isSpeaking) { |
|
||||
// 创建渐变色效果,从中间向两边渐变 |
|
||||
final middleIndex = barCount ~/ 2; |
|
||||
final distance = (index - middleIndex).abs(); |
|
||||
final maxDistance = middleIndex; |
|
||||
final colorIntensity = 1.0 - (distance / maxDistance) * 0.5; |
|
||||
|
|
||||
barColor = Colors.purple.withOpacity(colorIntensity); |
|
||||
} else { |
|
||||
barColor = Colors.purple.shade200; |
|
||||
} |
|
||||
} else { |
|
||||
barColor = Colors.grey.shade400; |
|
||||
} |
|
||||
|
|
||||
return AnimatedContainer( |
|
||||
duration: duration, |
|
||||
curve: Curves.easeInOut, // 使用更平滑的动画曲线 |
|
||||
width: 3.w, // 减小宽度,使波形更精细 |
|
||||
height: height, |
|
||||
margin: EdgeInsets.symmetric(horizontal: 1.5.w), // 减小间距 |
|
||||
decoration: BoxDecoration( |
|
||||
color: barColor, |
|
||||
borderRadius: BorderRadius.circular(2.r), |
|
||||
// 添加轻微的阴影效果,增强视觉效果 |
|
||||
boxShadow: isSpeaking ? [ |
|
||||
BoxShadow( |
|
||||
color: Colors.purple.withOpacity(0.2), |
|
||||
blurRadius: 3, |
|
||||
spreadRadius: 0.5, |
|
||||
) |
|
||||
] : null, |
|
||||
), |
|
||||
); |
|
||||
}, |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// 生成动态波形高度 |
|
||||
double _getWaveHeight(int index, bool isSpeaking, int barCount) { |
|
||||
// 基础高度 |
|
||||
double baseHeight = 6.h; |
|
||||
|
|
||||
// 获取当前时间毫秒数 |
|
||||
final DateTime now = DateTime.now(); |
|
||||
final int milliseconds = now.millisecondsSinceEpoch; |
|
||||
|
|
||||
// 计算中心位置 |
|
||||
final middleIndex = barCount ~/ 2; |
|
||||
final distanceFromMiddle = (index - middleIndex).abs(); |
|
||||
|
|
||||
// 使用多个正弦波叠加,创建更自然的波形 |
|
||||
// 每个条形使用不同的频率和相位,创建不规则波形 |
|
||||
double wave1 = sin((milliseconds / 200) + (index * 0.7)) * 3.h; |
|
||||
double wave2 = sin((milliseconds / 350) + (index * 0.5)) * 2.h; |
|
||||
double wave3 = sin((milliseconds / 500) + (index * 0.3)) * 1.5.h; |
|
||||
|
|
||||
// 基于距离中心的位置计算高度衰减 |
|
||||
double positionFactor = 1.0 - (distanceFromMiddle / middleIndex) * 0.5; |
|
||||
|
|
||||
if (isSpeaking) { |
|
||||
// 用户说话时的波形 |
|
||||
|
|
||||
// 创建一个随时间变化的振幅因子,使波形看起来更有活力 |
|
||||
double timeFactor = 0.7 + sin(milliseconds / 1000) * 0.3; |
|
||||
|
|
||||
// 中心位置的条形高度最高 |
|
||||
double centerBoost = pow(positionFactor, 1.5) * 10.h; |
|
||||
|
|
||||
// 添加随机因素,使波形更自然 |
|
||||
double randomFactor = (Random().nextDouble() * 2 - 1) * 2.h; |
|
||||
|
|
||||
// 根据时间添加脉动效果 |
|
||||
double pulseEffect = sin(milliseconds / 600) * 3.h * positionFactor; |
|
||||
|
|
||||
// 组合所有效果 |
|
||||
return baseHeight + (wave1 + wave2 + wave3) * timeFactor + centerBoost + randomFactor + pulseEffect; |
|
||||
} else { |
|
||||
// 用户不说话时的波形 - 更平静,但仍有微小变化 |
|
||||
double idleWave = (wave1 + wave2) * 0.5 * positionFactor; |
|
||||
return baseHeight + idleWave; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
Loading…
Reference in new issue