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; |
|||
// 定时器,用于检测无活动超时 |
|||
Timer? _inactivityTimer; |
|||
|
|||
// 当前系统提示词 |
|||
String? _currentSystemPrompt; |
|||
// 当前处理的Agent |
|||
Agent? _currentAgent; |
|||
|
|||
BackgroundAgentService() |
|||
: _aiService = VolcanoAIService(), |
|||
_ttsService = Get.find<VolcanoTtsApiService>(), |
|||
_voiceRecognitionService = Get.find<AzureAsrService>(); |
|||
|
|||
// 启动或重置交互计时器 |
|||
void _resetInteractionTimer() { |
|||
// 取消之前的计时器 |
|||
_interactionTimer?.cancel(); |
|||
// 消息历史 - 与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(); |
|||
} |
|||
} |
|||
|
|||
// 设置30秒的超时计时器 |
|||
_interactionTimer = Timer(const Duration(seconds: 30), () { |
|||
// 检查TTS是否正在播放 |
|||
if (_ttsService.isPlaying) { |
|||
// 如果TTS正在播放,重置定时器 |
|||
_resetInteractionTimer(); |
|||
} else { |
|||
// 如果TTS不在播放,且30秒内没有检测到用户输入,退出交互 |
|||
Logger.info('30秒内没有检测到用户输入,退出交互'); |
|||
_endInteraction(); |
|||
// 如果无法从ChatController获取,则使用本地存储的历史记录 |
|||
if (_localMessageHistory.isEmpty) { |
|||
_loadChatHistory(); |
|||
} |
|||
}); |
|||
|
|||
return _localMessageHistory; |
|||
} |
|||
|
|||
// 结束交互 |
|||
Future<void> _endInteraction() async { |
|||
// 完成当前的recognitionCompleter(如果有) |
|||
if (_recognitionCompleter != null && !_recognitionCompleter!.isCompleted) { |
|||
_recognitionCompleter!.complete(''); |
|||
// 本地消息历史备份 |
|||
final List<Map<String, String>> _localMessageHistory = []; |
|||
|
|||
// 构造函数 |
|||
BackgroundAgentService() { |
|||
Logger.info('BackgroundAgentService 已创建'); |
|||
} |
|||
|
|||
_isProcessing = false; |
|||
// TTS是否正在播放,直接从VolcanoTtsApiService获取 |
|||
bool get isTtsSpeaking => _ttsService.isPlaying; |
|||
|
|||
// 播放退出提示 |
|||
await _ttsService.speakSingle("没有听到您说话,已退出语音交互", speaker: _agentVoice); |
|||
@override |
|||
void onClose() { |
|||
// 取消所有订阅 |
|||
_recognitionSubscription?.cancel(); |
|||
_aiResponseSubscription?.cancel(); |
|||
|
|||
// 停止语音识别 |
|||
if (_isListening) { |
|||
await stopVoiceRecognition(); |
|||
Logger.info('退出交互,已停止语音识别'); |
|||
// 停止TTS |
|||
if (_ttsService.isPlaying) { |
|||
_ttsService.stop(); |
|||
} |
|||
|
|||
// 清除当前系统提示词 |
|||
_currentSystemPrompt = null; |
|||
// 清除当前agent ID |
|||
_currentAgentId = null; |
|||
// 重置agent voice为默认值 |
|||
_agentVoice = 'zh_female_meilinvyou_moon_bigtts'; |
|||
// 停止语音识别 |
|||
if (isListening.value) { |
|||
_asrService.stopContinuousRecognition(); |
|||
} |
|||
|
|||
// 开始语音交互 |
|||
Future<void> startAgentInteraction(String agentId) async { |
|||
// 设置当前agent ID |
|||
_currentAgentId = agentId; |
|||
// 取消定时器 |
|||
_inactivityTimer?.cancel(); |
|||
|
|||
// 获取agent信息,只查询一次 |
|||
final agent = AgentProvider.getAgentById(agentId); |
|||
if (agent == null) { |
|||
Logger.error('无法获取agent: $agentId'); |
|||
return; |
|||
super.onClose(); |
|||
} |
|||
|
|||
// 存储需要的值 |
|||
_agentVoice = agent.voice; |
|||
_currentSystemPrompt = agent.systemPrompt; |
|||
|
|||
// 播放提示音 |
|||
await _ttsService.speakSingle("我在听", speaker: _agentVoice); |
|||
/// 开始代理交互 |
|||
/// |
|||
/// [agentId] 要交互的代理ID |
|||
Future<void> startAgentInteraction(String agentId) async { |
|||
// 先说"我在听",然后再播放欢迎消息 |
|||
const listeningMessage = "我在听"; |
|||
_ttsService.speakSingle(listeningMessage); |
|||
|
|||
if (_isProcessing) { |
|||
print('已经在处理交互,忽略此次请求'); |
|||
// 防止重复处理 |
|||
if (isProcessing.value) { |
|||
Logger.warning('已经在处理交互,忽略此次请求'); |
|||
return; |
|||
} |
|||
|
|||
_isProcessing = true; |
|||
isProcessing.value = true; |
|||
|
|||
try { |
|||
// 确保之前的语音识别已经停止 |
|||
if (_isListening) { |
|||
await stopVoiceRecognition(); |
|||
// 获取Agent |
|||
final agent = AgentProvider.getAgentById(agentId); |
|||
if (agent == null) { |
|||
Logger.error('无法获取Agent: $agentId'); |
|||
isProcessing.value = false; |
|||
return; |
|||
} |
|||
|
|||
// 启动语音识别 |
|||
await startVoiceRecognition(); |
|||
_currentAgent = agent; |
|||
|
|||
// 启动交互计时器 |
|||
_resetInteractionTimer(); |
|||
} catch (e) { |
|||
Logger.error('启动语音交互失败: $e'); |
|||
_isProcessing = false; |
|||
// 加载该Agent的聊天历史 |
|||
_loadChatHistory(); |
|||
|
|||
// 播放错误提示 |
|||
await _ttsService.speakSingle("抱歉,启动语音交互失败", speaker: _agentVoice); |
|||
} |
|||
} |
|||
// 启动语音识别 - 保持持续识别状态 |
|||
await _startVoiceRecognition(); |
|||
|
|||
// 停止语音交互 |
|||
Future<void> stopAgentInteraction() async { |
|||
// 取消交互计时器 |
|||
_interactionTimer?.cancel(); |
|||
_interactionTimer = null; |
|||
// 启动无活动定时器 |
|||
_startInactivityTimer(); |
|||
|
|||
// 停止TTS播放 |
|||
if (_ttsService.isPlaying) { |
|||
await _ttsService.stop(); |
|||
} |
|||
|
|||
// 停止语音识别 |
|||
if (_isListening) { |
|||
await stopVoiceRecognition(); |
|||
Logger.info('手动停止交互,已停止语音识别'); |
|||
} catch (e) { |
|||
Logger.error('启动代理交互失败', e); |
|||
isProcessing.value = false; |
|||
} |
|||
|
|||
// 清除处理状态 |
|||
_isProcessing = false; |
|||
_currentSystemPrompt = null; |
|||
// 重置agent voice为默认值 |
|||
_agentVoice = 'zh_female_meilinvyou_moon_bigtts'; |
|||
|
|||
Logger.info('手动停止交互,已结束TTS会话'); |
|||
} |
|||
|
|||
// 添加一个 Completer 用于在识别到最终结果时完成 |
|||
Completer<String>? _recognitionCompleter; |
|||
|
|||
// 开始语音识别 |
|||
Future<void> startVoiceRecognition() async { |
|||
if (_isListening) { |
|||
await stopVoiceRecognition(); |
|||
/// 启动语音识别 |
|||
Future<void> _startVoiceRecognition() async { |
|||
if (isListening.value) { |
|||
// 如果已经在监听,不需要重新启动 |
|||
Logger.info('语音识别已经在运行,无需重新启动'); |
|||
return; |
|||
} |
|||
|
|||
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('无法初始化语音识别服务'); |
|||
} |
|||
|
|||
// 开始连续识别 |
|||
final success = await _voiceRecognitionService.startContinuousRecognition(); |
|||
if (!success) { |
|||
Logger.error('启动连续语音识别失败'); |
|||
throw Exception('无法启动语音识别'); |
|||
} |
|||
|
|||
_isListening = true; |
|||
// 启动语音识别 |
|||
final success = await _asrService.startContinuousRecognition(); |
|||
|
|||
if (success) { |
|||
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}'); |
|||
// 监听识别事件 |
|||
_recognitionSubscription = _asrService.recognitionStream?.listen( |
|||
(event) { |
|||
// 重置无活动定时器 |
|||
_resetInactivityTimer(); |
|||
|
|||
switch (event.type) { |
|||
case RecognitionEventType.recognizing: |
|||
// 实时更新识别文本 |
|||
if (event.text.isNotEmpty) { |
|||
// 将最终结果添加到完整输入中,并添加适当的标点符号 |
|||
if (fullUserInput.isNotEmpty && !fullUserInput.endsWith('。') && |
|||
!fullUserInput.endsWith('?') && !fullUserInput.endsWith('!') && |
|||
!fullUserInput.endsWith('.') && !fullUserInput.endsWith('?') && |
|||
!fullUserInput.endsWith('!')) { |
|||
fullUserInput += ','; |
|||
} |
|||
fullUserInput += event.text; |
|||
|
|||
// 更新可观察的识别文本 |
|||
recognizedText.value = fullUserInput; |
|||
|
|||
_hasRecognizedSpeech = true; |
|||
_hasFinalResult = true; |
|||
|
|||
// 重置交互计时器,因为用户刚刚说了话 |
|||
_resetInteractionTimer(); |
|||
|
|||
recognizedText.value = event.text; |
|||
|
|||
_processUserInput(fullUserInput); |
|||
|
|||
// 重置用户输入 |
|||
fullUserInput = ''; |
|||
recognizedText.value = ''; |
|||
_hasRecognizedSpeech = false; |
|||
_hasFinalResult = false; |
|||
// 如果正在播放TTS,检测到用户说话则中断当前播放和AI回复 |
|||
if (_ttsService.isPlaying && event.text.trim().length > 3) { |
|||
_interruptCurrentResponse("检测到用户说话,中断当前响应"); |
|||
} |
|||
} else if (event.type == RecognitionEventType.recognizing) { |
|||
// 添加日志输出中间识别结果 |
|||
Logger.info('语音识别中间结果: ${event.text}'); |
|||
|
|||
} |
|||
break; |
|||
case RecognitionEventType.finalResult: |
|||
// 最终结果 |
|||
if (event.text.isNotEmpty) { |
|||
// 更新当前的中间结果,但不添加到完整输入中 |
|||
recognizedText.value = fullUserInput + (fullUserInput.isEmpty ? "" : ",") + event.text; |
|||
|
|||
_hasRecognizedSpeech = true; |
|||
|
|||
// 检测到用户说话,重置交互计时器 |
|||
_resetInteractionTimer(); |
|||
|
|||
// 如果系统正在播放TTS或接收AI响应,检测到用户开始说话时立即中断 |
|||
if (_ttsService.isPlaying && event.text.trim().isNotEmpty) { |
|||
// 停止当前TTS播放 |
|||
_ttsService.stop(); |
|||
|
|||
// 设置标志,表示应该取消当前的AI响应 |
|||
_shouldCancelAiResponse = true; |
|||
|
|||
// 取消当前的AI响应流订阅 |
|||
_aiResponseSubscription?.cancel(); |
|||
_aiResponseSubscription = null; |
|||
recognizedText.value = event.text; |
|||
|
|||
// 处理识别到的文本 |
|||
_processRecognizedText(event.text); |
|||
|
|||
// 添加日志输出中断TTS播放 |
|||
Logger.info('检测到用户说话,中断TTS播放'); |
|||
} |
|||
// 重置识别文本但保持识别状态 |
|||
recognizedText.value = ''; |
|||
} |
|||
} else if (event.type == RecognitionEventType.error) { |
|||
// 添加日志输出识别错误 |
|||
Logger.error('语音识别错误: ${event.error}'); |
|||
print('识别错误: ${event.error}'); |
|||
break; |
|||
case RecognitionEventType.sessionStopped: |
|||
case RecognitionEventType.canceled: |
|||
// 会话结束,表示识别在其它地方被启动,直接退出后台服务 |
|||
isListening.value = false; |
|||
Logger.info('语音识别会话被中止,退出后台代理服务'); |
|||
stopInteraction(); |
|||
break; |
|||
default: |
|||
break; |
|||
} |
|||
}, onError: (error) { |
|||
// 添加日志输出语音识别流错误 |
|||
Logger.error('语音识别流错误: $error'); |
|||
print('语音识别流错误: $error'); |
|||
_isListening = false; |
|||
}, |
|||
onError: (error) { |
|||
Logger.error('语音识别错误', error); |
|||
isListening.value = false; |
|||
}); |
|||
|
|||
// 启动交互计时器 |
|||
_resetInteractionTimer(); |
|||
|
|||
// 如果出错,尝试重新启动语音识别 |
|||
_restartVoiceRecognition(); |
|||
}, |
|||
); |
|||
} else { |
|||
Logger.error('启动语音识别失败'); |
|||
isProcessing.value = false; |
|||
} |
|||
} catch (e) { |
|||
Logger.error('启动语音识别失败: $e'); |
|||
print('启动语音识别失败: $e'); |
|||
_isListening = false; |
|||
Logger.error('启动语音识别失败', e); |
|||
isListening.value = false; |
|||
rethrow; |
|||
isProcessing.value = false; |
|||
} |
|||
} |
|||
|
|||
// 处理用户输入 |
|||
Future<void> _processUserInput(String userInput) async { |
|||
if (userInput.isEmpty || _currentSystemPrompt == null) { |
|||
return; |
|||
} |
|||
/// 处理识别到的文本 |
|||
Future<void> _processRecognizedText(String text) async { |
|||
if (text.trim().isEmpty || _currentAgent == null) return; |
|||
|
|||
try { |
|||
// 保存用户消息到历史记录 |
|||
_messageHistory.add(Message( |
|||
role: 'user', |
|||
content: userInput, |
|||
timestamp: DateTime.now(), |
|||
)); |
|||
|
|||
// 构建用于生成回应的消息列表 |
|||
final messages = [ |
|||
{'role': 'system', 'content': _currentSystemPrompt!}, |
|||
{'role': 'user', 'content': userInput}, |
|||
]; |
|||
// 添加用户消息到历史记录 |
|||
_addMessageToHistory('user', text); |
|||
|
|||
// 启动TTS会话,使用当前Agent的voice |
|||
_ttsService.startSession(_currentAgent!.voice); |
|||
// 获取AI响应 |
|||
String fullResponse = ''; |
|||
|
|||
Logger.info('开始生成AI响应...'); |
|||
// 重置取消标志 |
|||
_shouldCancelAiResponse = false; |
|||
|
|||
// 创建一个本地变量来跟踪是否已取消 |
|||
bool isCancelled = false; |
|||
|
|||
// 使用成员变量中存储的agent voice |
|||
_ttsService.startSession(_agentVoice); |
|||
|
|||
// 获取AI响应流 |
|||
final responseStream = _aiService.sendMessageStream( |
|||
messages: messages, |
|||
systemPrompt: _currentSystemPrompt!, |
|||
messages: messageHistory, |
|||
systemPrompt: _currentAgent!.systemPrompt, |
|||
); |
|||
|
|||
// 创建一个订阅来处理响应流 |
|||
// 订阅响应流 |
|||
_aiResponseSubscription = responseStream.listen( |
|||
(chunk) { |
|||
// 如果已经设置了取消标志,则不处理这个块 |
|||
if (_shouldCancelAiResponse) { |
|||
isCancelled = true; |
|||
return; |
|||
} |
|||
|
|||
fullResponse += chunk; |
|||
|
|||
// 直接将每个文本块传递给 TTS 接口,不进行切句或攒句处理 |
|||
try { |
|||
// 使用成员变量中存储的agent voice |
|||
_ttsService.speak(chunk, speaker: _agentVoice); |
|||
// 如果TTS启用,发送到TTS |
|||
_ttsService.speak(chunk); |
|||
|
|||
// Logger.info('直接播放 AI 响应块: $chunk'); |
|||
} catch (e) { |
|||
Logger.error('TTS 播放出错: $e'); |
|||
} |
|||
}, |
|||
onError: (e) { |
|||
Logger.error('AI响应流错误: $e'); |
|||
print('AI响应流错误: $e'); |
|||
// 清理订阅 |
|||
_aiResponseSubscription = null; |
|||
// 重置无活动定时器 |
|||
_resetInactivityTimer(); |
|||
}, |
|||
onDone: () { |
|||
// 添加AI响应到历史记录 |
|||
_addMessageToHistory('assistant', fullResponse); |
|||
|
|||
// 结束TTS会话 |
|||
_ttsService.endSession(); |
|||
// 如果已取消,不处理剩余的文本 |
|||
if (!isCancelled && !_shouldCancelAiResponse) { |
|||
Logger.info('AI 响应生成完成'); |
|||
} |
|||
|
|||
_aiResponseSubscription = null; |
|||
// 注意:我们不再在这里重启语音识别,因为它应该已经在运行 |
|||
}, |
|||
onError: (error) { |
|||
Logger.error('AI响应错误', error); |
|||
|
|||
// AI响应完成后,重新启动超时计时器 |
|||
_resetInteractionTimer(); |
|||
// 播放错误消息 |
|||
const errorMessage = '抱歉,我遇到了一些问题,无法回应您的请求。'; |
|||
_ttsService.speak(errorMessage); |
|||
|
|||
// 如果响应被取消,不保存到历史记录 |
|||
if (!_shouldCancelAiResponse && fullResponse.isNotEmpty) { |
|||
// 保存助手回复到历史记录 |
|||
_messageHistory.add(Message( |
|||
role: 'assistant', |
|||
content: fullResponse, |
|||
timestamp: DateTime.now(), |
|||
)); |
|||
// 添加错误消息到历史记录 |
|||
_addMessageToHistory('assistant', errorMessage); |
|||
|
|||
// Logger.info('保存AI响应到历史记录,长度: ${fullResponse.length}'); |
|||
} |
|||
// 结束TTS会话 |
|||
_ttsService.endSession(); |
|||
}, |
|||
); |
|||
} catch (e) { |
|||
Logger.error('处理识别文本失败', e); |
|||
|
|||
// 限制历史记录长度 |
|||
if (_messageHistory.length > 20) { |
|||
_messageHistory.removeRange(0, _messageHistory.length - 20); |
|||
Logger.info('历史记录超过20条,已裁剪'); |
|||
// 播放错误消息 |
|||
const errorMessage = '抱歉,我遇到了一些问题,无法回应您的请求。'; |
|||
_ttsService.speak(errorMessage); |
|||
|
|||
// 添加错误消息到历史记录 |
|||
_addMessageToHistory('assistant', errorMessage); |
|||
} |
|||
} |
|||
); |
|||
} catch (e) { |
|||
Logger.error('处理用户输入失败: $e'); |
|||
print('处理用户输入失败: $e'); |
|||
|
|||
// 播放错误提示,使用成员变量中存储的agent voice |
|||
await _ttsService.speak("抱歉,出现了一些问题", speaker: _agentVoice); |
|||
/// 重新启动语音识别(仅在识别会话意外终止时使用) |
|||
Future<void> _restartVoiceRecognition() async { |
|||
// 停止当前的语音识别 |
|||
if (isListening.value) { |
|||
await _asrService.stopContinuousRecognition(); |
|||
isListening.value = false; |
|||
|
|||
// 取消当前的订阅 |
|||
_recognitionSubscription?.cancel(); |
|||
_recognitionSubscription = null; |
|||
} |
|||
|
|||
// 等待一段时间,确保资源被正确释放 |
|||
await Future.delayed(Duration(milliseconds: 500)); |
|||
|
|||
// 重新启动语音识别 |
|||
await _startVoiceRecognition(); |
|||
} |
|||
|
|||
// 停止语音识别并返回识别的文本 |
|||
Future<String> stopVoiceRecognition() async { |
|||
if (!_isListening) { |
|||
return ''; |
|||
/// 添加消息到历史记录 |
|||
void _addMessageToHistory(String role, String content) { |
|||
// 添加到本地历史记录 |
|||
_localMessageHistory.add({ |
|||
'role': role, |
|||
'content': content, |
|||
}); |
|||
|
|||
// 保持本地历史记录不超过10条消息 |
|||
if (_localMessageHistory.length > 10) { |
|||
_localMessageHistory.removeAt(0); |
|||
} |
|||
|
|||
try { |
|||
// 取消订阅 |
|||
await _recognitionSubscription?.cancel(); |
|||
_recognitionSubscription = null; |
|||
// 如果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(); |
|||
} |
|||
} |
|||
|
|||
// 停止语音识别 |
|||
await _voiceRecognitionService.stopContinuousRecognition(); |
|||
// 加载聊天历史 |
|||
void _loadChatHistory() { |
|||
if (_currentAgent == null) return; |
|||
|
|||
// 获取最终识别结果 |
|||
final result = recognizedText.value; |
|||
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); |
|||
} |
|||
} |
|||
|
|||
// 添加日志输出停止语音识别的最终结果 |
|||
Logger.info('停止语音识别,最终结果: $result'); |
|||
// 保存聊天历史 |
|||
void _saveChatHistory() { |
|||
if (_currentAgent == null) return; |
|||
|
|||
// 重置状态 |
|||
_isListening = false; |
|||
isListening.value = false; |
|||
try { |
|||
// 将本地历史转换为Message列表 |
|||
final messages = _localMessageHistory.map((m) => Message( |
|||
role: m['role']!, |
|||
content: m['content']!, |
|||
timestamp: DateTime.now(), |
|||
)).toList(); |
|||
|
|||
return result; |
|||
// 保存到ChatHistoryService |
|||
_historyService.saveHistory(_currentAgent!.id, messages); |
|||
} catch (e) { |
|||
// 添加日志输出停止语音识别失败 |
|||
Logger.error('停止语音识别失败: $e'); |
|||
print('停止语音识别失败: $e'); |
|||
_isListening = false; |
|||
isListening.value = false; |
|||
Logger.error('保存聊天历史记录失败', e); |
|||
} |
|||
} |
|||
|
|||
// 直接返回当前已识别的文本,不尝试重置 |
|||
final result = recognizedText.value; |
|||
Logger.info('停止语音识别失败,使用当前识别结果: $result'); |
|||
return result; |
|||
/// 启动无活动定时器 |
|||
void _startInactivityTimer() { |
|||
_inactivityTimer?.cancel(); |
|||
_inactivityTimer = Timer(Duration(seconds: 30), () { |
|||
// 如果30秒内没有TTS输出或用户说话,停止语音识别 |
|||
if (!_ttsService.isPlaying && isProcessing.value) { |
|||
Logger.info('检测到30秒内无活动,停止语音识别'); |
|||
stopInteraction(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
List<Message> get messageHistory => List.unmodifiable(_messageHistory); |
|||
/// 重置无活动定时器 |
|||
void _resetInactivityTimer() { |
|||
if (_inactivityTimer != null) { |
|||
_inactivityTimer!.cancel(); |
|||
_startInactivityTimer(); |
|||
} |
|||
} |
|||
|
|||
/// 中断当前响应 |
|||
void _interruptCurrentResponse(String reason) { |
|||
Logger.info(reason); |
|||
|
|||
void clearHistory() { |
|||
_messageHistory.clear(); |
|||
// 停止TTS |
|||
if (_ttsService.isPlaying) { |
|||
_ttsService.stop(); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
// 取消AI响应流订阅 |
|||
_aiResponseSubscription?.cancel(); |
|||
_aiResponseSubscription = null; |
|||
} |
|||
|
|||
/// 响应新的媒体事件(双击媒体按钮) |
|||
Future<void> respondToNewMediaEvent() async { |
|||
// 不管当前状态如何,总是中断当前响应 |
|||
_interruptCurrentResponse("检测到新的媒体事件,中断当前响应"); |
|||
|
|||
// 回答"我在听" |
|||
const listeningMessage = "我在听"; |
|||
_ttsService.speakSingle(listeningMessage); |
|||
|
|||
// 不需要重启语音识别,因为它应该已经在运行 |
|||
// 如果识别会话已经终止,确保重新启动 |
|||
if (!isListening.value) { |
|||
await _restartVoiceRecognition(); |
|||
} |
|||
|
|||
// 重置无活动定时器 |
|||
_resetInactivityTimer(); |
|||
} |
|||
|
|||
/// 停止当前交互 |
|||
Future<void> stopInteraction() async { |
|||
try { |
|||
// 保存聊天历史 |
|||
_saveChatHistory(); |
|||
|
|||
// 取消所有订阅 |
|||
_recognitionSubscription?.cancel(); |
|||
_interactionTimer?.cancel(); |
|||
_interactionTimer = null; |
|||
_aiResponseSubscription?.cancel(); |
|||
|
|||
// 停止TTS播放 |
|||
// 停止TTS |
|||
if (_ttsService.isPlaying) { |
|||
_ttsService.stop(); |
|||
} |
|||
|
|||
super.onClose(); |
|||
// 停止语音识别 |
|||
// if (isListening.value) { |
|||
// await _asrService.stopContinuousRecognition(); |
|||
// isListening.value = false; |
|||
// } |
|||
|
|||
// 取消定时器 |
|||
_inactivityTimer?.cancel(); |
|||
|
|||
// 重置状态 |
|||
isProcessing.value = false; |
|||
_currentAgent = null; |
|||
|
|||
Logger.info('已停止后台代理交互'); |
|||
} catch (e) { |
|||
Logger.error('停止交互失败', e); |
|||
} |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -1,625 +1,490 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'dart:math'; |
|||
import '../controllers/chat_controller.dart'; |
|||
import '../models/message_model.dart'; |
|||
import '../../../data/services/volcano_tts_api_service.dart'; |
|||
import '../widgets/voice_input_panel.dart'; |
|||
import '../../../core/theme/app_colors.dart'; |
|||
import '../../../core/widgets/message_bubble.dart'; |
|||
|
|||
class ChatView extends GetView<ChatController> { |
|||
const ChatView({Key? key}) : super(key: key); |
|||
ChatView({Key? key}) : super(key: key); |
|||
|
|||
// 添加ScrollController用于控制滚动 |
|||
final ScrollController _scrollController = ScrollController(); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return WillPopScope( |
|||
onWillPop: () async { |
|||
// 停止TTS服务 |
|||
final tts = Get.find<VolcanoTtsApiService>(); |
|||
await tts.endSession(); |
|||
|
|||
// 停止语音输入 |
|||
if (controller.isVoiceInputVisible.value) { |
|||
controller.stopVoiceInput(); |
|||
} |
|||
|
|||
return true; |
|||
}, |
|||
child: Scaffold( |
|||
backgroundColor: Colors.white, |
|||
appBar: _buildAppBar(), |
|||
body: SafeArea( |
|||
child: Stack( |
|||
children: [ |
|||
// 主要内容 |
|||
Column( |
|||
children: [ |
|||
Expanded( |
|||
child: _buildMessageList(), |
|||
), |
|||
_buildInputSection(), |
|||
], |
|||
), |
|||
], |
|||
), |
|||
), |
|||
), |
|||
// 监听消息列表变化,自动滚动到底部 |
|||
ever(controller.messages, (_) { |
|||
// 使用Future.delayed确保在UI更新后滚动 |
|||
Future.delayed(Duration(milliseconds: 50), () { |
|||
if (_scrollController.hasClients && |
|||
_scrollController.position.maxScrollExtent > 0 && |
|||
(_scrollController.position.maxScrollExtent - _scrollController.offset) < 200) { |
|||
_scrollController.animateTo( |
|||
_scrollController.position.maxScrollExtent, |
|||
duration: Duration(milliseconds: 100), |
|||
curve: Curves.easeOut, |
|||
); |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
PreferredSizeWidget _buildAppBar() { |
|||
return AppBar( |
|||
backgroundColor: Colors.white, |
|||
elevation: 0, |
|||
return Scaffold( |
|||
backgroundColor: Colors.white, // 设置Scaffold背景为白色 |
|||
appBar: AppBar( |
|||
leading: IconButton( |
|||
icon: Icon( |
|||
Icons.arrow_back_ios, |
|||
color: Colors.black, |
|||
size: 20.sp, |
|||
icon: Icon(Icons.arrow_back_ios, color: Colors.black), |
|||
onPressed: () => Get.back(), |
|||
), |
|||
onPressed: () async { |
|||
// 停止TTS服务 |
|||
final tts = Get.find<VolcanoTtsApiService>(); |
|||
await tts.endSession(); |
|||
|
|||
// 停止语音输入 |
|||
if (controller.isVoiceInputVisible.value) { |
|||
controller.stopVoiceInput(); |
|||
} |
|||
|
|||
Get.back(); |
|||
}, |
|||
title: Row( |
|||
children: [ |
|||
CircleAvatar( |
|||
backgroundImage: AssetImage(controller.agent.avatarUrl), |
|||
radius: 18.r, |
|||
), |
|||
title: Column( |
|||
SizedBox(width: 10.w), |
|||
Expanded( |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
Text( |
|||
controller.agentName, |
|||
controller.agent.name, |
|||
style: TextStyle( |
|||
fontSize: 17.sp, |
|||
fontWeight: FontWeight.w600, |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.bold, |
|||
color: Colors.black, |
|||
), |
|||
), |
|||
if (controller.agentSubtitle != null) |
|||
Text( |
|||
controller.agentSubtitle!, |
|||
controller.agent.description.length > 20 |
|||
? '${controller.agent.description.substring(0, 20)}...' |
|||
: controller.agent.description, |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: Colors.grey[600], |
|||
color: Colors.grey, |
|||
), |
|||
maxLines: 1, |
|||
overflow: TextOverflow.ellipsis, |
|||
), |
|||
], |
|||
), |
|||
centerTitle: true, |
|||
), |
|||
], |
|||
), |
|||
actions: [ |
|||
IconButton( |
|||
icon: Obx(() => Icon( |
|||
// TTS开关 |
|||
Obx(() => IconButton( |
|||
icon: Icon( |
|||
controller.isTtsEnabled.value |
|||
? Icons.volume_up |
|||
: Icons.volume_off, |
|||
color: Colors.black, |
|||
size: 24.sp, |
|||
)), |
|||
onPressed: controller.toggleTTS, |
|||
color: controller.isTtsEnabled.value |
|||
? AppColors.primary |
|||
: Colors.grey, |
|||
), |
|||
IconButton( |
|||
icon: Icon( |
|||
Icons.more_horiz, |
|||
color: Colors.black, |
|||
size: 24.sp, |
|||
onPressed: controller.toggleTts, |
|||
)), |
|||
// 更多选项 |
|||
PopupMenuButton( |
|||
icon: Icon(Icons.more_vert, color: Colors.black), |
|||
itemBuilder: (context) => [ |
|||
PopupMenuItem( |
|||
value: 'clear', |
|||
child: Row( |
|||
children: [ |
|||
Icon(Icons.delete_outline, color: Colors.red), |
|||
SizedBox(width: 8.w), |
|||
Text('清空对话'), |
|||
], |
|||
), |
|||
onPressed: () => _showMoreOptions(), |
|||
), |
|||
], |
|||
); |
|||
onSelected: (value) { |
|||
if (value == 'clear') { |
|||
controller.clearChat(); |
|||
} |
|||
|
|||
Widget _buildMessageList() { |
|||
return Stack( |
|||
}, |
|||
), |
|||
], |
|||
elevation: 0, |
|||
backgroundColor: Colors.white, |
|||
), |
|||
body: Container( |
|||
color: Colors.white, // 确保body背景也是白色 |
|||
child: Column( |
|||
children: [ |
|||
Obx(() => ListView.builder( |
|||
// 消息列表 |
|||
Expanded( |
|||
child: Obx(() => ListView.builder( |
|||
controller: controller.scrollController, |
|||
padding: EdgeInsets.only( |
|||
top: 16.h, |
|||
bottom: controller.isVoiceInputVisible.value |
|||
? 160.h |
|||
: (controller.isInputCollapsed.value ? 70.h : 90.h), |
|||
), |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), |
|||
itemCount: controller.messages.length, |
|||
itemBuilder: (context, index) => _buildMessage(controller.messages[index]), |
|||
reverse: false, |
|||
physics: const AlwaysScrollableScrollPhysics(), |
|||
clipBehavior: Clip.none, |
|||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, |
|||
cacheExtent: 1000, // 增加缓存范围,减少重建 |
|||
itemBuilder: (context, index) { |
|||
final message = controller.messages[index]; |
|||
return MessageBubble( |
|||
key: ObjectKey(message), // 使用ObjectKey而不是ValueKey |
|||
message: message, |
|||
isUser: message.role == 'user', |
|||
); |
|||
}, |
|||
)), |
|||
Obx(() { |
|||
if (!controller.isLoading.value) return const SizedBox.shrink(); |
|||
return Positioned( |
|||
bottom: controller.isVoiceInputVisible.value |
|||
? 170.h |
|||
: (controller.isInputCollapsed.value ? 80.h : 100.h), |
|||
left: 0, |
|||
right: 0, |
|||
child: Container( |
|||
padding: EdgeInsets.symmetric(vertical: 8.h), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: [ |
|||
SizedBox( |
|||
width: 16.w, |
|||
height: 16.w, |
|||
child: const CircularProgressIndicator( |
|||
strokeWidth: 2, |
|||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF4A6FE5)), |
|||
), |
|||
), |
|||
|
|||
// 输入区域 - 根据当前模式显示不同的输入面板 |
|||
Obx(() => controller.isVoiceRecognitionActive.value |
|||
? _buildVoiceInputPanel() |
|||
: _buildTextInputPanel()), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
}), |
|||
], |
|||
); |
|||
} |
|||
|
|||
Widget _buildInputSection() { |
|||
return Obx(() { |
|||
if (controller.isVoiceInputVisible.value) { |
|||
return Column( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
VoiceInputPanel( |
|||
onRecordingResult: controller.handleVoiceResult, |
|||
onClose: controller.stopVoiceInput, |
|||
onRecognizing: controller.handleRecognizing, |
|||
), |
|||
], |
|||
); |
|||
} |
|||
return _buildTextInput(); |
|||
// 构建文本输入面板 |
|||
Widget _buildTextInputPanel() { |
|||
// 创建一个TextEditingController来控制输入框 |
|||
final TextEditingController textController = TextEditingController(text: controller.inputText.value); |
|||
|
|||
// 监听输入变化 |
|||
textController.addListener(() { |
|||
controller.inputText.value = textController.text; |
|||
}); |
|||
} |
|||
|
|||
Widget _buildTextInput() { |
|||
return Container( |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
|||
decoration: BoxDecoration( |
|||
color: Colors.white, |
|||
border: Border( |
|||
top: BorderSide( |
|||
color: Colors.grey[200]!, |
|||
width: 1, |
|||
), |
|||
), |
|||
), |
|||
child: Obx(() { |
|||
if (controller.isRecordingVoice.value) { |
|||
return _buildRecordingInput(); |
|||
} else if (!controller.isVoiceMode.value) { |
|||
return _buildExpandedInput(); |
|||
} else { |
|||
return _buildDefaultInput(); |
|||
} |
|||
}), |
|||
); |
|||
} |
|||
|
|||
Widget _buildDefaultInput() { |
|||
return Row( |
|||
children: [ |
|||
Container( |
|||
width: 40.w, |
|||
height: 40.w, |
|||
decoration: BoxDecoration( |
|||
shape: BoxShape.circle, |
|||
color: Colors.grey[100], |
|||
border: Border.all( |
|||
color: Colors.grey[300]!, |
|||
width: 0.5, |
|||
), |
|||
), |
|||
child: IconButton( |
|||
icon: Icon( |
|||
Icons.camera_alt, |
|||
color: Colors.grey[600], |
|||
size: 20.sp, |
|||
), |
|||
onPressed: () { |
|||
// 拍照功能 |
|||
}, |
|||
padding: EdgeInsets.zero, |
|||
), |
|||
), |
|||
SizedBox(width: 8.w), |
|||
Expanded( |
|||
child: GestureDetector( |
|||
onTap: () { |
|||
// 点击打开VoiceInputPanel |
|||
controller.startVoiceInput(); |
|||
}, |
|||
child: Container( |
|||
height: 48.h, |
|||
decoration: BoxDecoration( |
|||
color: Colors.grey[100], |
|||
borderRadius: BorderRadius.circular(24.r), |
|||
border: Border.all( |
|||
color: Colors.grey[300]!, |
|||
width: 0.5, |
|||
), |
|||
), |
|||
child: Center( |
|||
child: Text( |
|||
'点击开始连续语音对话', |
|||
style: TextStyle( |
|||
color: Colors.grey[600], |
|||
fontSize: 16.sp, |
|||
), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
SizedBox(width: 8.w), |
|||
Container( |
|||
width: 40.w, |
|||
height: 40.w, |
|||
decoration: BoxDecoration( |
|||
shape: BoxShape.circle, |
|||
color: Colors.grey[100], |
|||
border: Border.all( |
|||
color: Colors.grey[300]!, |
|||
width: 0.5, |
|||
), |
|||
), |
|||
child: IconButton( |
|||
icon: Icon( |
|||
Icons.keyboard, |
|||
color: Colors.grey[600], |
|||
size: 20.sp, |
|||
), |
|||
onPressed: controller.toggleInputMode, |
|||
padding: EdgeInsets.zero, |
|||
), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 5, |
|||
offset: Offset(0, -1), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
Widget _buildExpandedInput() { |
|||
return Row( |
|||
children: [ |
|||
Container( |
|||
width: 40.w, |
|||
height: 40.w, |
|||
decoration: BoxDecoration( |
|||
shape: BoxShape.circle, |
|||
color: Colors.grey[100], |
|||
border: Border.all( |
|||
color: Colors.grey[300]!, |
|||
width: 0.5, |
|||
), |
|||
), |
|||
child: IconButton( |
|||
child: Row( |
|||
children: [ |
|||
// 语音输入按钮 |
|||
IconButton( |
|||
icon: Icon( |
|||
Icons.mic, |
|||
color: const Color(0xFF4A6FE5), |
|||
size: 20.sp, |
|||
), |
|||
onPressed: controller.toggleInputMode, |
|||
padding: EdgeInsets.zero, |
|||
Icons.mic_none, |
|||
color: Colors.grey, |
|||
), |
|||
onPressed: controller.startVoiceRecognition, |
|||
), |
|||
SizedBox(width: 8.w), |
|||
// 文本输入框 |
|||
Expanded( |
|||
child: Container( |
|||
padding: EdgeInsets.symmetric(horizontal: 12.w), |
|||
decoration: BoxDecoration( |
|||
color: Colors.grey[100], |
|||
borderRadius: BorderRadius.circular(24.r), |
|||
border: Border.all( |
|||
color: Colors.grey[300]!, |
|||
width: 0.5, |
|||
), |
|||
), |
|||
child: TextField( |
|||
controller: controller.messageController, |
|||
controller: textController, |
|||
decoration: InputDecoration( |
|||
hintText: '输入消息...', |
|||
hintStyle: TextStyle( |
|||
color: Colors.grey[400], |
|||
fontSize: 14.sp, |
|||
hintText: '发消息...', |
|||
border: OutlineInputBorder( |
|||
borderRadius: BorderRadius.circular(20.r), |
|||
borderSide: BorderSide.none, |
|||
), |
|||
border: InputBorder.none, |
|||
contentPadding: EdgeInsets.symmetric(vertical: 12.h), |
|||
filled: true, |
|||
fillColor: Colors.grey[200], |
|||
contentPadding: EdgeInsets.symmetric( |
|||
horizontal: 16.w, |
|||
vertical: 8.h, |
|||
), |
|||
maxLines: 5, |
|||
minLines: 1, |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: Colors.black87, |
|||
), |
|||
maxLines: null, |
|||
textInputAction: TextInputAction.send, |
|||
onSubmitted: (_) => controller.sendMessage(), |
|||
), |
|||
onSubmitted: (value) { |
|||
if (value.trim().isNotEmpty) { |
|||
controller.sendMessage(value); |
|||
textController.clear(); |
|||
} |
|||
}, |
|||
), |
|||
), |
|||
SizedBox(width: 8.w), |
|||
Container( |
|||
width: 40.w, |
|||
height: 40.w, |
|||
decoration: const BoxDecoration( |
|||
shape: BoxShape.circle, |
|||
color: Color(0xFF4A6FE5), |
|||
), |
|||
child: IconButton( |
|||
// 发送按钮 |
|||
Obx(() => IconButton( |
|||
icon: Icon( |
|||
Icons.send, |
|||
color: Colors.white, |
|||
size: 20.sp, |
|||
), |
|||
onPressed: controller.sendMessage, |
|||
padding: EdgeInsets.zero, |
|||
), |
|||
), |
|||
color: controller.inputText.value.trim().isEmpty |
|||
? Colors.grey |
|||
: AppColors.primary, |
|||
), |
|||
onPressed: controller.inputText.value.trim().isEmpty |
|||
? null |
|||
: () { |
|||
controller.sendMessage(controller.inputText.value); |
|||
textController.clear(); |
|||
}, |
|||
)), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildRecordingInput() { |
|||
// 构建语音输入面板 |
|||
Widget _buildVoiceInputPanel() { |
|||
final screenWidth = Get.width; |
|||
|
|||
return Container( |
|||
height: 48.h, |
|||
decoration: BoxDecoration( |
|||
color: const Color(0xFF4A6FE5), |
|||
borderRadius: BorderRadius.circular(24.r), |
|||
width: screenWidth, |
|||
decoration: const BoxDecoration( |
|||
gradient: LinearGradient( |
|||
begin: Alignment.topCenter, |
|||
end: Alignment.bottomCenter, |
|||
colors: [ |
|||
Color(0xFFFFFFFF), // 纯白色开始 |
|||
Color(0xFFE8D5F5), // 更深的紫色结束 |
|||
], |
|||
stops: [0.3, 1.0], // 调整颜色分布,让白色占据更多空间 |
|||
), |
|||
), |
|||
child: SafeArea( |
|||
child: SizedBox( |
|||
height: 100.h, |
|||
child: Center( |
|||
child: Padding( |
|||
padding: EdgeInsets.symmetric(horizontal: 20.w), |
|||
child: Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: List.generate( |
|||
40, |
|||
(index) => Container( |
|||
width: 4.w, |
|||
height: 4.h, |
|||
margin: EdgeInsets.symmetric(horizontal: 1.w), |
|||
decoration: BoxDecoration( |
|||
color: Colors.white, |
|||
borderRadius: BorderRadius.circular(2.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
); |
|||
} |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
crossAxisAlignment: CrossAxisAlignment.center, |
|||
children: [ |
|||
// 左侧麦克风按钮 |
|||
_buildMicrophoneButton(), |
|||
|
|||
void _showMoreOptions() { |
|||
Get.bottomSheet( |
|||
Container( |
|||
padding: EdgeInsets.all(20.w), |
|||
decoration: BoxDecoration( |
|||
color: Colors.white, |
|||
borderRadius: BorderRadius.only( |
|||
topLeft: Radius.circular(20.r), |
|||
topRight: Radius.circular(20.r), |
|||
), |
|||
), |
|||
// 中间音频波形和状态文本 |
|||
Expanded( |
|||
child: Column( |
|||
mainAxisSize: MainAxisSize.min, |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: [ |
|||
ListTile( |
|||
leading: const Icon(Icons.delete_outline, color: Colors.red), |
|||
title: const Text('清空对话'), |
|||
onTap: () { |
|||
Get.back(); |
|||
controller.clearChat(); |
|||
}, |
|||
// 音频波形 |
|||
_buildAudioWaveform(), |
|||
SizedBox(height: 10.h), |
|||
// 状态文本 |
|||
Obx(() => Text( |
|||
controller.recognizedText.isEmpty ? '你可以开始说话' : '正在聆听...', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
color: Colors.grey.shade700, |
|||
fontWeight: FontWeight.w500, |
|||
), |
|||
)), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildMessage(Message message) { |
|||
final isUser = message.role == 'user'; |
|||
final isSystem = message.role == 'system'; |
|||
final isRecognizing = isUser && message.content.startsWith('🎤 '); |
|||
|
|||
if (isSystem) { |
|||
// 系统消息使用特殊样式 |
|||
return Padding( |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
|||
child: Container( |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), |
|||
decoration: BoxDecoration( |
|||
color: Colors.grey[100], |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
border: Border.all( |
|||
color: Colors.grey[300]!, |
|||
width: 0.5, |
|||
), |
|||
), |
|||
child: Row( |
|||
children: [ |
|||
Icon( |
|||
Icons.info_outline, |
|||
color: Colors.grey[600], |
|||
size: 20.sp, |
|||
), |
|||
SizedBox(width: 8.w), |
|||
Expanded( |
|||
child: Text( |
|||
message.content, |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: Colors.grey[700], |
|||
height: 1.5, |
|||
// 右侧关闭按钮 |
|||
_buildCloseButton(), |
|||
], |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
return Padding( |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
|||
child: Row( |
|||
mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start, |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
if (!isUser) ...[ |
|||
CircleAvatar( |
|||
radius: 20.r, |
|||
backgroundImage: AssetImage(controller.agentAvatar), |
|||
backgroundColor: Colors.transparent, |
|||
), |
|||
SizedBox(width: 12.w), |
|||
], |
|||
Flexible( |
|||
child: Container( |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), |
|||
// 麦克风按钮 |
|||
Widget _buildMicrophoneButton() { |
|||
return Container( |
|||
width: 60.w, |
|||
height: 60.w, |
|||
decoration: BoxDecoration( |
|||
color: isUser |
|||
? (isRecognizing ? Colors.purple.withOpacity(0.7) : const Color(0xFF4A6FE5)) |
|||
: const Color(0xFFF5F5F5), |
|||
borderRadius: BorderRadius.only( |
|||
topLeft: Radius.circular(isUser ? 18.r : 4.r), |
|||
topRight: Radius.circular(isUser ? 4.r : 18.r), |
|||
bottomLeft: Radius.circular(18.r), |
|||
bottomRight: Radius.circular(18.r), |
|||
), |
|||
// 为正在识别的消息添加脉动效果 |
|||
boxShadow: isRecognizing |
|||
? [ |
|||
BoxShadow( |
|||
color: Colors.purple.withOpacity(0.3), |
|||
blurRadius: 8, |
|||
spreadRadius: 2, |
|||
), |
|||
] |
|||
: [], |
|||
), |
|||
child: Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
if (isRecognizing) ...[ |
|||
Icon( |
|||
Icons.mic, |
|||
color: Colors.white, |
|||
size: 16.sp, |
|||
shape: BoxShape.circle, |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
spreadRadius: 1, |
|||
), |
|||
SizedBox(width: 8.w), |
|||
], |
|||
Flexible( |
|||
child: Text( |
|||
// 如果是正在识别的消息,移除麦克风图标前缀 |
|||
isRecognizing ? message.content.substring(2) : message.content, |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
color: isUser ? Colors.white : Colors.black87, |
|||
height: 1.5, |
|||
), |
|||
), |
|||
child: Center( |
|||
child: Obx(() => IconButton( |
|||
icon: Icon( |
|||
controller.isListening.value ? Icons.mic : Icons.mic_none, |
|||
color: controller.isListening.value ? AppColors.primary : Colors.grey.shade400, |
|||
size: 28.sp, |
|||
), |
|||
if (isRecognizing) ...[ |
|||
SizedBox(width: 8.w), |
|||
_buildPulsatingDot(), |
|||
], |
|||
], |
|||
onPressed: controller.stopVoiceRecognition, |
|||
padding: EdgeInsets.zero, |
|||
)), |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 关闭按钮 |
|||
Widget _buildCloseButton() { |
|||
return 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, |
|||
), |
|||
], |
|||
), |
|||
if (isUser) ...[ |
|||
SizedBox(width: 12.w), |
|||
CircleAvatar( |
|||
radius: 20.r, |
|||
backgroundColor: const Color(0xFFEEF4FF), |
|||
child: Icon( |
|||
Icons.person, |
|||
color: const Color(0xFF4A6FE5), |
|||
size: 24.sp, |
|||
child: Center( |
|||
child: IconButton( |
|||
icon: Icon( |
|||
Icons.close, |
|||
color: Colors.grey.shade600, |
|||
size: 28.sp, |
|||
), |
|||
onPressed: controller.stopVoiceRecognition, |
|||
padding: EdgeInsets.zero, |
|||
), |
|||
], |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 创建脉动点动画 |
|||
Widget _buildPulsatingDot() { |
|||
return Obx(() { |
|||
// 使用controller.isRecognizing来控制动画是否显示 |
|||
if (!controller.isRecognizing.value) return const SizedBox.shrink(); |
|||
// 音频波形 |
|||
Widget _buildAudioWaveform() { |
|||
final barCount = 23; // 条形数量 |
|||
|
|||
return _PulsatingDot(); |
|||
}); |
|||
} |
|||
} |
|||
return SizedBox( |
|||
width: 220.w, |
|||
height: 50.h, |
|||
child: Obx(() { |
|||
final isSpeaking = controller.recognizedText.isNotEmpty; |
|||
final isRec = controller.isListening.value; |
|||
|
|||
// 脉动点动画组件 |
|||
class _PulsatingDot extends StatefulWidget { |
|||
@override |
|||
_PulsatingDotState createState() => _PulsatingDotState(); |
|||
} |
|||
return Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: List.generate( |
|||
barCount, |
|||
(index) { |
|||
// 创建动态高度效果 |
|||
double height = isRec |
|||
? _getWaveHeight(index, isSpeaking, barCount).clamp(1.h, 22.5.h) |
|||
: 2.h; |
|||
|
|||
class _PulsatingDotState extends State<_PulsatingDot> with SingleTickerProviderStateMixin { |
|||
late AnimationController _animationController; |
|||
late Animation<double> _animation; |
|||
// 动画持续时间 |
|||
final duration = Duration( |
|||
milliseconds: isSpeaking ? 60 + (index % 4) * 20 : 150 + (index % 3) * 50 |
|||
); |
|||
|
|||
@override |
|||
void initState() { |
|||
super.initState(); |
|||
_animationController = AnimationController( |
|||
vsync: this, |
|||
duration: const Duration(milliseconds: 1000), |
|||
)..repeat(reverse: true); |
|||
|
|||
_animation = Tween<double>(begin: 0.5, end: 1.0).animate(_animationController); |
|||
} |
|||
// 颜色计算 |
|||
Color barColor; |
|||
if (isRec) { |
|||
if (isSpeaking) { |
|||
final middleIndex = barCount ~/ 2; |
|||
final distance = (index - middleIndex).abs(); |
|||
final maxDistance = middleIndex; |
|||
final colorIntensity = 1.0 - (distance / maxDistance) * 0.2; |
|||
|
|||
@override |
|||
void dispose() { |
|||
_animationController.dispose(); |
|||
super.dispose(); |
|||
barColor = index % 3 == 0 |
|||
? AppColors.primary |
|||
: AppColors.primary.withOpacity(colorIntensity); |
|||
} else { |
|||
barColor = AppColors.primary.withOpacity(0.3); |
|||
} |
|||
} else { |
|||
barColor = Colors.grey.shade400; |
|||
} |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return SizedBox( |
|||
width: 16.w, |
|||
height: 16.w, |
|||
child: AnimatedBuilder( |
|||
animation: _animation, |
|||
builder: (context, child) { |
|||
return Stack( |
|||
alignment: Alignment.center, |
|||
children: [ |
|||
Opacity( |
|||
opacity: 1.0 - _animation.value, |
|||
child: Container( |
|||
width: 16.w * _animation.value, |
|||
height: 16.w * _animation.value, |
|||
width: 5.5.w, |
|||
child: Center( |
|||
child: AnimatedContainer( |
|||
duration: duration, |
|||
curve: isSpeaking ? Curves.elasticOut : Curves.easeInOut, |
|||
width: isSpeaking ? 2.8.w : 2.5.w, |
|||
height: height, |
|||
margin: EdgeInsets.symmetric(horizontal: 1.2.w), |
|||
decoration: BoxDecoration( |
|||
color: Colors.white.withOpacity(0.5), |
|||
shape: BoxShape.circle, |
|||
), |
|||
color: barColor, |
|||
borderRadius: BorderRadius.circular(2.r), |
|||
boxShadow: isSpeaking ? [ |
|||
BoxShadow( |
|||
color: AppColors.primary.withOpacity(0.4), |
|||
blurRadius: 5, |
|||
spreadRadius: 1.2, |
|||
) |
|||
] : null, |
|||
), |
|||
), |
|||
Container( |
|||
width: 6.w, |
|||
height: 6.w, |
|||
decoration: const BoxDecoration( |
|||
color: Colors.white, |
|||
shape: BoxShape.circle, |
|||
), |
|||
), |
|||
], |
|||
); |
|||
}, |
|||
), |
|||
); |
|||
}), |
|||
); |
|||
} |
|||
|
|||
// 生成动态波形高度 |
|||
double _getWaveHeight(int index, bool isSpeaking, int barCount) { |
|||
try { |
|||
// 基础高度 |
|||
double baseHeight = isSpeaking ? 5.h : 3.h; |
|||
|
|||
// 获取当前时间毫秒数 |
|||
final DateTime now = DateTime.now(); |
|||
final int milliseconds = now.millisecondsSinceEpoch; |
|||
|
|||
// 计算中心位置 |
|||
final middleIndex = barCount ~/ 2; |
|||
final distanceFromMiddle = (index - middleIndex).abs(); |
|||
|
|||
// 使用多个正弦波叠加 |
|||
double freqMultiplier = isSpeaking ? 2.2 : 1.0; |
|||
double ampMultiplier = isSpeaking ? 1.8 : 1.0; |
|||
|
|||
double wave1 = sin((milliseconds / 150) * freqMultiplier + (index * 0.8)) * 2.h * ampMultiplier; |
|||
double wave2 = sin((milliseconds / 280) * freqMultiplier + (index * 0.5)) * 1.5.h * ampMultiplier; |
|||
double wave3 = sin((milliseconds / 340) * freqMultiplier + (index * 0.3)) * 1.25.h * ampMultiplier; |
|||
double wave4 = cos((milliseconds / 220) * freqMultiplier + (index * 0.7)) * 1.h * ampMultiplier; |
|||
|
|||
// 基于距离中心的位置计算高度衰减 |
|||
double positionFactor = isSpeaking |
|||
? 1.0 - (distanceFromMiddle / middleIndex) * 0.2 |
|||
: 1.0 - (distanceFromMiddle / middleIndex) * 0.5; |
|||
|
|||
if (isSpeaking) { |
|||
// 用户说话时的波形 |
|||
double timeFactor = 0.9 + sin(milliseconds / 600) * 0.5; |
|||
|
|||
// 增强中心突出效果 |
|||
double centerBoost = pow(positionFactor, 1.2) * 10.h; |
|||
|
|||
// 增加随机性 |
|||
double randomFactor = (Random().nextDouble() * 4 - 2) * 1.75.h; |
|||
|
|||
// 增强脉冲效果 |
|||
double pulseEffect = sin(milliseconds / 300) * 2.5.h * positionFactor; |
|||
|
|||
// 添加额外的随机波动 |
|||
double voiceVariation = sin(milliseconds / 80 + index * 1.2) * 1.5.h * Random().nextDouble(); |
|||
|
|||
// 添加爆发效果 |
|||
double burstEffect = 0; |
|||
if (Random().nextDouble() > 0.97) { |
|||
burstEffect = 5.h * positionFactor; |
|||
} |
|||
|
|||
return baseHeight + (wave1 + wave2 + wave3 + wave4) * timeFactor + |
|||
centerBoost + randomFactor + pulseEffect + voiceVariation + burstEffect; |
|||
} else { |
|||
// 用户不说话时的波形 |
|||
double idleWave = (wave1 + wave2) * 0.35 * positionFactor; |
|||
double gentlePulse = sin(milliseconds / 800) * 1.h * positionFactor; |
|||
return baseHeight + idleWave + gentlePulse; |
|||
} |
|||
} catch (e) { |
|||
// 发生错误时返回默认高度 |
|||
return 2.h; |
|||
} |
|||
} |
|||
} |
|||
@ -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