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 'volcano_ai_service.dart'; |
|||
import 'azure_asr_service.dart'; |
|||
import 'volcano_tts_api_service.dart'; |
|||
import '../../modules/chat/models/message_model.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:flutter/widgets.dart'; |
|||
import '../../core/utils/logger.dart'; |
|||
import 'package:flutter_dotenv/flutter_dotenv.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 { |
|||
static BackgroundAgentService get to => Get.find(); |
|||
|
|||
final VolcanoAIService _aiService; |
|||
final VolcanoTtsApiService _ttsService; |
|||
final AzureAsrService _voiceRecognitionService; |
|||
final List<Message> _messageHistory = []; |
|||
bool _isProcessing = false; |
|||
bool _isListening = false; |
|||
StreamSubscription? _recognitionSubscription; |
|||
|
|||
|
|||
// 当前使用的agent ID |
|||
String? _currentAgentId; |
|||
|
|||
// 当前使用的agent voice |
|||
String _agentVoice = 'zh_female_meilinvyou_moon_bigtts'; |
|||
|
|||
// 可观察的状态 |
|||
// 服务实例 |
|||
final AzureAsrService _asrService = Get.find<AzureAsrService>(); |
|||
final VolcanoAIService _aiService = Get.find<VolcanoAIService>(); |
|||
final VolcanoTtsApiService _ttsService = Get.find<VolcanoTtsApiService>(); |
|||
final ChatHistoryService _historyService = Get.find<ChatHistoryService>(); |
|||
|
|||
// 状态控制 |
|||
final RxBool isProcessing = 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; |
|||
|
|||
// 添加一个标志,表示是否应该取消当前的AI响应 |
|||
bool _shouldCancelAiResponse = false; |
|||
|
|||
// 当前系统提示词 |
|||
String? _currentSystemPrompt; |
|||
// 定时器,用于检测无活动超时 |
|||
Timer? _inactivityTimer; |
|||
|
|||
// 当前处理的Agent |
|||
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() |
|||
: _aiService = VolcanoAIService(), |
|||
_ttsService = Get.find<VolcanoTtsApiService>(), |
|||
_voiceRecognitionService = Get.find<AzureAsrService>(); |
|||
// 如果无法从ChatController获取,则使用本地存储的历史记录 |
|||
if (_localMessageHistory.isEmpty) { |
|||
_loadChatHistory(); |
|||
} |
|||
|
|||
// 启动或重置交互计时器 |
|||
void _resetInteractionTimer() { |
|||
// 取消之前的计时器 |
|||
_interactionTimer?.cancel(); |
|||
return _localMessageHistory; |
|||
} |
|||
|
|||
// 设置30秒的超时计时器 |
|||
_interactionTimer = Timer(const Duration(seconds: 30), () { |
|||
// 检查TTS是否正在播放 |
|||
if (_ttsService.isPlaying) { |
|||
// 如果TTS正在播放,重置定时器 |
|||
_resetInteractionTimer(); |
|||
} else { |
|||
// 如果TTS不在播放,且30秒内没有检测到用户输入,退出交互 |
|||
Logger.info('30秒内没有检测到用户输入,退出交互'); |
|||
_endInteraction(); |
|||
} |
|||
}); |
|||
// 本地消息历史备份 |
|||
final List<Map<String, String>> _localMessageHistory = []; |
|||
|
|||
// 构造函数 |
|||
BackgroundAgentService() { |
|||
Logger.info('BackgroundAgentService 已创建'); |
|||
} |
|||
|
|||
// 结束交互 |
|||
Future<void> _endInteraction() async { |
|||
// 完成当前的recognitionCompleter(如果有) |
|||
if (_recognitionCompleter != null && !_recognitionCompleter!.isCompleted) { |
|||
_recognitionCompleter!.complete(''); |
|||
} |
|||
// TTS是否正在播放,直接从VolcanoTtsApiService获取 |
|||
bool get isTtsSpeaking => _ttsService.isPlaying; |
|||
|
|||
_isProcessing = false; |
|||
@override |
|||
void onClose() { |
|||
// 取消所有订阅 |
|||
_recognitionSubscription?.cancel(); |
|||
_aiResponseSubscription?.cancel(); |
|||
|
|||
// 播放退出提示 |
|||
await _ttsService.speakSingle("没有听到您说话,已退出语音交互", speaker: _agentVoice); |
|||
// 停止TTS |
|||
if (_ttsService.isPlaying) { |
|||
_ttsService.stop(); |
|||
} |
|||
|
|||
// 停止语音识别 |
|||
if (_isListening) { |
|||
await stopVoiceRecognition(); |
|||
Logger.info('退出交互,已停止语音识别'); |
|||
if (isListening.value) { |
|||
_asrService.stopContinuousRecognition(); |
|||
} |
|||
|
|||
// 清除当前系统提示词 |
|||
_currentSystemPrompt = null; |
|||
// 清除当前agent ID |
|||
_currentAgentId = null; |
|||
// 重置agent voice为默认值 |
|||
_agentVoice = 'zh_female_meilinvyou_moon_bigtts'; |
|||
// 取消定时器 |
|||
_inactivityTimer?.cancel(); |
|||
|
|||
super.onClose(); |
|||
} |
|||
|
|||
// 开始语音交互 |
|||
/// 开始代理交互 |
|||
/// |
|||
/// [agentId] 要交互的代理ID |
|||
Future<void> startAgentInteraction(String agentId) async { |
|||
// 设置当前agent ID |
|||
_currentAgentId = agentId; |
|||
// 先说"我在听",然后再播放欢迎消息 |
|||
const listeningMessage = "我在听"; |
|||
_ttsService.speakSingle(listeningMessage); |
|||
|
|||
// 获取agent信息,只查询一次 |
|||
final agent = AgentProvider.getAgentById(agentId); |
|||
if (agent == null) { |
|||
Logger.error('无法获取agent: $agentId'); |
|||
// 防止重复处理 |
|||
if (isProcessing.value) { |
|||
Logger.warning('已经在处理交互,忽略此次请求'); |
|||
return; |
|||
} |
|||
|
|||
// 存储需要的值 |
|||
_agentVoice = agent.voice; |
|||
_currentSystemPrompt = agent.systemPrompt; |
|||
isProcessing.value = true; |
|||
|
|||
// 播放提示音 |
|||
await _ttsService.speakSingle("我在听", speaker: _agentVoice); |
|||
try { |
|||
// 获取Agent |
|||
final agent = AgentProvider.getAgentById(agentId); |
|||
if (agent == null) { |
|||
Logger.error('无法获取Agent: $agentId'); |
|||
isProcessing.value = false; |
|||
return; |
|||
} |
|||
|
|||
if (_isProcessing) { |
|||
print('已经在处理交互,忽略此次请求'); |
|||
return; |
|||
} |
|||
_currentAgent = agent; |
|||
|
|||
_isProcessing = true; |
|||
// 加载该Agent的聊天历史 |
|||
_loadChatHistory(); |
|||
|
|||
try { |
|||
// 确保之前的语音识别已经停止 |
|||
if (_isListening) { |
|||
await stopVoiceRecognition(); |
|||
} |
|||
// 启动语音识别 - 保持持续识别状态 |
|||
await _startVoiceRecognition(); |
|||
|
|||
// 启动语音识别 |
|||
await startVoiceRecognition(); |
|||
// 启动无活动定时器 |
|||
_startInactivityTimer(); |
|||
|
|||
// 启动交互计时器 |
|||
_resetInteractionTimer(); |
|||
} catch (e) { |
|||
Logger.error('启动语音交互失败: $e'); |
|||
_isProcessing = false; |
|||
|
|||
// 播放错误提示 |
|||
await _ttsService.speakSingle("抱歉,启动语音交互失败", speaker: _agentVoice); |
|||
Logger.error('启动代理交互失败', e); |
|||
isProcessing.value = false; |
|||
} |
|||
} |
|||
|
|||
// 停止语音交互 |
|||
Future<void> stopAgentInteraction() async { |
|||
// 取消交互计时器 |
|||
_interactionTimer?.cancel(); |
|||
_interactionTimer = null; |
|||
|
|||
// 停止TTS播放 |
|||
if (_ttsService.isPlaying) { |
|||
await _ttsService.stop(); |
|||
/// 启动语音识别 |
|||
Future<void> _startVoiceRecognition() async { |
|||
if (isListening.value) { |
|||
// 如果已经在监听,不需要重新启动 |
|||
Logger.info('语音识别已经在运行,无需重新启动'); |
|||
return; |
|||
} |
|||
|
|||
// 停止语音识别 |
|||
if (_isListening) { |
|||
await stopVoiceRecognition(); |
|||
Logger.info('手动停止交互,已停止语音识别'); |
|||
try { |
|||
// 启动语音识别 |
|||
final success = await _asrService.startContinuousRecognition(); |
|||
|
|||
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; |
|||
_currentSystemPrompt = null; |
|||
// 重置agent voice为默认值 |
|||
_agentVoice = 'zh_female_meilinvyou_moon_bigtts'; |
|||
/// 处理识别到的文本 |
|||
Future<void> _processRecognizedText(String text) async { |
|||
if (text.trim().isEmpty || _currentAgent == null) return; |
|||
|
|||
Logger.info('手动停止交互,已结束TTS会话'); |
|||
} |
|||
try { |
|||
// 添加用户消息到历史记录 |
|||
_addMessageToHistory('user', text); |
|||
|
|||
// 添加一个 Completer 用于在识别到最终结果时完成 |
|||
Completer<String>? _recognitionCompleter; |
|||
// 启动TTS会话,使用当前Agent的voice |
|||
_ttsService.startSession(_currentAgent!.voice); |
|||
// 获取AI响应 |
|||
String fullResponse = ''; |
|||
|
|||
// 开始语音识别 |
|||
Future<void> startVoiceRecognition() async { |
|||
if (_isListening) { |
|||
await stopVoiceRecognition(); |
|||
} |
|||
// 获取AI响应流 |
|||
final responseStream = _aiService.sendMessageStream( |
|||
messages: messageHistory, |
|||
systemPrompt: _currentAgent!.systemPrompt, |
|||
); |
|||
|
|||
try { |
|||
Logger.info('开始初始化语音识别服务...'); |
|||
// 确保语音识别服务已初始化 |
|||
if (!await _voiceRecognitionService.initialize( |
|||
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('无法初始化语音识别服务'); |
|||
} |
|||
// 订阅响应流 |
|||
_aiResponseSubscription = responseStream.listen( |
|||
(chunk) { |
|||
fullResponse += chunk; |
|||
|
|||
// 开始连续识别 |
|||
final success = await _voiceRecognitionService.startContinuousRecognition(); |
|||
if (!success) { |
|||
Logger.error('启动连续语音识别失败'); |
|||
throw Exception('无法启动语音识别'); |
|||
} |
|||
// 如果TTS启用,发送到TTS |
|||
_ttsService.speak(chunk); |
|||
|
|||
_isListening = true; |
|||
isListening.value = true; |
|||
recognizedText.value = ''; |
|||
_hasRecognizedSpeech = false; |
|||
_hasFinalResult = false; |
|||
|
|||
// 添加一个变量来存储完整的用户输入 |
|||
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; |
|||
// 重置无活动定时器 |
|||
_resetInactivityTimer(); |
|||
}, |
|||
onDone: () { |
|||
// 添加AI响应到历史记录 |
|||
_addMessageToHistory('assistant', fullResponse); |
|||
|
|||
// 更新可观察的识别文本 |
|||
recognizedText.value = fullUserInput; |
|||
// 结束TTS会话 |
|||
_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 = ''; |
|||
recognizedText.value = ''; |
|||
_hasRecognizedSpeech = false; |
|||
_hasFinalResult = false; |
|||
} |
|||
} else if (event.type == RecognitionEventType.recognizing) { |
|||
// 添加日志输出中间识别结果 |
|||
Logger.info('语音识别中间结果: ${event.text}'); |
|||
// 播放错误消息 |
|||
const errorMessage = '抱歉,我遇到了一些问题,无法回应您的请求。'; |
|||
_ttsService.speak(errorMessage); |
|||
|
|||
if (event.text.isNotEmpty) { |
|||
// 更新当前的中间结果,但不添加到完整输入中 |
|||
recognizedText.value = fullUserInput + (fullUserInput.isEmpty ? "" : ",") + event.text; |
|||
// 添加错误消息到历史记录 |
|||
_addMessageToHistory('assistant', errorMessage); |
|||
} |
|||
} |
|||
|
|||
_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) { |
|||
// 停止当前TTS播放 |
|||
_ttsService.stop(); |
|||
// 等待一段时间,确保资源被正确释放 |
|||
await Future.delayed(Duration(milliseconds: 500)); |
|||
|
|||
// 设置标志,表示应该取消当前的AI响应 |
|||
_shouldCancelAiResponse = true; |
|||
// 重新启动语音识别 |
|||
await _startVoiceRecognition(); |
|||
} |
|||
|
|||
// 取消当前的AI响应流订阅 |
|||
_aiResponseSubscription?.cancel(); |
|||
_aiResponseSubscription = null; |
|||
/// 添加消息到历史记录 |
|||
void _addMessageToHistory(String role, String content) { |
|||
// 添加到本地历史记录 |
|||
_localMessageHistory.add({ |
|||
'role': role, |
|||
'content': content, |
|||
}); |
|||
|
|||
// 保持本地历史记录不超过10条消息 |
|||
if (_localMessageHistory.length > 10) { |
|||
_localMessageHistory.removeAt(0); |
|||
} |
|||
|
|||
// 添加日志输出中断TTS播放 |
|||
Logger.info('检测到用户说话,中断TTS播放'); |
|||
} |
|||
} |
|||
} else if (event.type == RecognitionEventType.error) { |
|||
// 添加日志输出识别错误 |
|||
Logger.error('语音识别错误: ${event.error}'); |
|||
print('识别错误: ${event.error}'); |
|||
} |
|||
}, onError: (error) { |
|||
// 添加日志输出语音识别流错误 |
|||
Logger.error('语音识别流错误: $error'); |
|||
print('语音识别流错误: $error'); |
|||
_isListening = false; |
|||
isListening.value = false; |
|||
}); |
|||
// 如果ChatController可用,同步消息到ChatController |
|||
if (Get.isRegistered<ChatController>()) { |
|||
final chatController = Get.find<ChatController>(); |
|||
if (chatController.agent?.id == 'personal_assistant') { |
|||
// 向ChatController添加消息 |
|||
final message = Message( |
|||
role: role, |
|||
content: content, |
|||
timestamp: DateTime.now(), |
|||
); |
|||
chatController.addMessage(message); |
|||
} |
|||
} else { |
|||
// 如果ChatController不可用,保存到本地存储 |
|||
_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) { |
|||
Logger.error('启动语音识别失败: $e'); |
|||
print('启动语音识别失败: $e'); |
|||
_isListening = false; |
|||
isListening.value = false; |
|||
rethrow; |
|||
Logger.error('加载聊天历史记录失败', e); |
|||
} |
|||
} |
|||
|
|||
// 处理用户输入 |
|||
Future<void> _processUserInput(String userInput) async { |
|||
if (userInput.isEmpty || _currentSystemPrompt == null) { |
|||
return; |
|||
} |
|||
// 保存聊天历史 |
|||
void _saveChatHistory() { |
|||
if (_currentAgent == null) return; |
|||
|
|||
try { |
|||
// 保存用户消息到历史记录 |
|||
_messageHistory.add(Message( |
|||
role: 'user', |
|||
content: userInput, |
|||
// 将本地历史转换为Message列表 |
|||
final messages = _localMessageHistory.map((m) => Message( |
|||
role: m['role']!, |
|||
content: m['content']!, |
|||
timestamp: DateTime.now(), |
|||
)); |
|||
|
|||
// 构建用于生成回应的消息列表 |
|||
final messages = [ |
|||
{'role': 'system', 'content': _currentSystemPrompt!}, |
|||
{'role': 'user', 'content': userInput}, |
|||
]; |
|||
|
|||
String fullResponse = ''; |
|||
)).toList(); |
|||
|
|||
Logger.info('开始生成AI响应...'); |
|||
// 重置取消标志 |
|||
_shouldCancelAiResponse = false; |
|||
// 保存到ChatHistoryService |
|||
_historyService.saveHistory(_currentAgent!.id, messages); |
|||
} 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( |
|||
messages: messages, |
|||
systemPrompt: _currentSystemPrompt!, |
|||
); |
|||
/// 中断当前响应 |
|||
void _interruptCurrentResponse(String reason) { |
|||
Logger.info(reason); |
|||
|
|||
// 创建一个订阅来处理响应流 |
|||
_aiResponseSubscription = responseStream.listen( |
|||
(chunk) { |
|||
// 如果已经设置了取消标志,则不处理这个块 |
|||
if (_shouldCancelAiResponse) { |
|||
isCancelled = true; |
|||
return; |
|||
} |
|||
// 停止TTS |
|||
if (_ttsService.isPlaying) { |
|||
_ttsService.stop(); |
|||
} |
|||
|
|||
fullResponse += chunk; |
|||
// 取消AI响应流订阅 |
|||
_aiResponseSubscription?.cancel(); |
|||
_aiResponseSubscription = null; |
|||
} |
|||
|
|||
// 直接将每个文本块传递给 TTS 接口,不进行切句或攒句处理 |
|||
try { |
|||
// 使用成员变量中存储的agent voice |
|||
_ttsService.speak(chunk, speaker: _agentVoice); |
|||
/// 响应新的媒体事件(双击媒体按钮) |
|||
Future<void> respondToNewMediaEvent() async { |
|||
// 不管当前状态如何,总是中断当前响应 |
|||
_interruptCurrentResponse("检测到新的媒体事件,中断当前响应"); |
|||
|
|||
// Logger.info('直接播放 AI 响应块: $chunk'); |
|||
} catch (e) { |
|||
Logger.error('TTS 播放出错: $e'); |
|||
} |
|||
}, |
|||
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'); |
|||
// 回答"我在听" |
|||
const listeningMessage = "我在听"; |
|||
_ttsService.speakSingle(listeningMessage); |
|||
|
|||
// 播放错误提示,使用成员变量中存储的agent voice |
|||
await _ttsService.speak("抱歉,出现了一些问题", speaker: _agentVoice); |
|||
// 不需要重启语音识别,因为它应该已经在运行 |
|||
// 如果识别会话已经终止,确保重新启动 |
|||
if (!isListening.value) { |
|||
await _restartVoiceRecognition(); |
|||
} |
|||
} |
|||
|
|||
// 停止语音识别并返回识别的文本 |
|||
Future<String> stopVoiceRecognition() async { |
|||
if (!_isListening) { |
|||
return ''; |
|||
} |
|||
// 重置无活动定时器 |
|||
_resetInactivityTimer(); |
|||
} |
|||
|
|||
/// 停止当前交互 |
|||
Future<void> stopInteraction() async { |
|||
try { |
|||
// 取消订阅 |
|||
await _recognitionSubscription?.cancel(); |
|||
_recognitionSubscription = null; |
|||
// 保存聊天历史 |
|||
_saveChatHistory(); |
|||
|
|||
// 停止语音识别 |
|||
await _voiceRecognitionService.stopContinuousRecognition(); |
|||
// 取消所有订阅 |
|||
_recognitionSubscription?.cancel(); |
|||
_aiResponseSubscription?.cancel(); |
|||
|
|||
// 获取最终识别结果 |
|||
final result = recognizedText.value; |
|||
// 停止TTS |
|||
if (_ttsService.isPlaying) { |
|||
_ttsService.stop(); |
|||
} |
|||
|
|||
// 添加日志输出停止语音识别的最终结果 |
|||
Logger.info('停止语音识别,最终结果: $result'); |
|||
// 停止语音识别 |
|||
// if (isListening.value) { |
|||
// await _asrService.stopContinuousRecognition(); |
|||
// isListening.value = false; |
|||
// } |
|||
|
|||
// 取消定时器 |
|||
_inactivityTimer?.cancel(); |
|||
|
|||
// 重置状态 |
|||
_isListening = false; |
|||
isListening.value = false; |
|||
isProcessing.value = false; |
|||
_currentAgent = null; |
|||
|
|||
return result; |
|||
Logger.info('已停止后台代理交互'); |
|||
} catch (e) { |
|||
// 添加日志输出停止语音识别失败 |
|||
Logger.error('停止语音识别失败: $e'); |
|||
print('停止语音识别失败: $e'); |
|||
_isListening = false; |
|||
isListening.value = false; |
|||
|
|||
// 直接返回当前已识别的文本,不尝试重置 |
|||
final result = recognizedText.value; |
|||
Logger.info('停止语音识别失败,使用当前识别结果: $result'); |
|||
return result; |
|||
Logger.error('停止交互失败', e); |
|||
} |
|||
} |
|||
|
|||
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 '../controllers/chat_controller.dart'; |
|||
import '../../../data/services/volcano_tts_api_service.dart'; |
|||
|
|||
class ChatBinding implements Bindings { |
|||
class ChatBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
// Ensure TTS service is registered first |
|||
if (!Get.isRegistered<VolcanoTtsApiService>()) { |
|||
Get.put(VolcanoTtsApiService()); |
|||
} |
|||
|
|||
// Create new controller instance |
|||
Get.put(ChatController()); |
|||
Get.lazyPut<ChatController>(() => 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