26 changed files with 2062 additions and 2867 deletions
@ -0,0 +1,94 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import '../../modules/chat/models/message_model.dart'; |
||||
|
import '../theme/app_colors.dart'; |
||||
|
|
||||
|
class MessageBubble extends StatelessWidget { |
||||
|
final Message message; |
||||
|
final bool isUser; |
||||
|
final String? userAvatar; |
||||
|
final String? agentAvatar; |
||||
|
|
||||
|
const MessageBubble({ |
||||
|
Key? key, |
||||
|
required this.message, |
||||
|
required this.isUser, |
||||
|
this.userAvatar, |
||||
|
this.agentAvatar, |
||||
|
}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Padding( |
||||
|
padding: EdgeInsets.only(bottom: 16.h), |
||||
|
child: Row( |
||||
|
mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start, |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Flexible( |
||||
|
child: Container( |
||||
|
padding: EdgeInsets.all(12.r), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isUser ? AppColors.primary : Colors.grey[200], |
||||
|
borderRadius: BorderRadius.circular(16.r), |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
// 消息内容 |
||||
|
if (message.content.isNotEmpty) |
||||
|
SelectableText( |
||||
|
message.content, |
||||
|
style: TextStyle( |
||||
|
color: isUser ? Colors.white : Colors.black, |
||||
|
fontSize: 16.sp, |
||||
|
height: 1.5, |
||||
|
letterSpacing: 0.2, |
||||
|
), |
||||
|
textAlign: TextAlign.left, |
||||
|
maxLines: null, |
||||
|
enableInteractiveSelection: true, |
||||
|
textWidthBasis: TextWidthBasis.longestLine, |
||||
|
), |
||||
|
|
||||
|
// 加载指示器 |
||||
|
if (message.isLoading) |
||||
|
Padding( |
||||
|
padding: EdgeInsets.only(top: message.content.isNotEmpty ? 8.h : 0), |
||||
|
child: _buildLoadingIndicator(), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildLoadingIndicator() { |
||||
|
return Row( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
SizedBox( |
||||
|
width: 12.w, |
||||
|
height: 12.h, |
||||
|
child: CircularProgressIndicator( |
||||
|
strokeWidth: 2, |
||||
|
valueColor: AlwaysStoppedAnimation<Color>( |
||||
|
isUser ? Colors.white : AppColors.primary, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 8.w), |
||||
|
Text( |
||||
|
'思考中...', |
||||
|
style: TextStyle( |
||||
|
color: isUser ? Colors.white : Colors.black, |
||||
|
fontSize: 12.sp, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -1,465 +1,458 @@ |
|||||
import 'package:get/get.dart'; |
|
||||
import 'dart:async'; |
import 'dart:async'; |
||||
import 'volcano_ai_service.dart'; |
import 'package:get/get.dart'; |
||||
import 'azure_asr_service.dart'; |
import 'package:flutter/widgets.dart'; |
||||
import 'volcano_tts_api_service.dart'; |
|
||||
import '../../modules/chat/models/message_model.dart'; |
|
||||
import '../../core/utils/logger.dart'; |
import '../../core/utils/logger.dart'; |
||||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|
||||
import '../providers/agent_provider.dart'; |
import '../providers/agent_provider.dart'; |
||||
|
import '../models/agent.dart'; |
||||
|
import '../../modules/chat/controllers/chat_controller.dart'; |
||||
|
import '../../modules/chat/models/message_model.dart'; |
||||
|
import 'azure_asr_service.dart'; |
||||
|
import 'volcano_ai_service.dart'; |
||||
|
import 'volcano_tts_api_service.dart'; |
||||
|
import 'chat_history_service.dart'; |
||||
|
|
||||
|
/// 后台代理服务,用于在应用处于后台时处理AI交互 |
||||
class BackgroundAgentService extends GetxService { |
class BackgroundAgentService extends GetxService { |
||||
static BackgroundAgentService get to => Get.find(); |
// 服务实例 |
||||
|
final AzureAsrService _asrService = Get.find<AzureAsrService>(); |
||||
final VolcanoAIService _aiService; |
final VolcanoAIService _aiService = Get.find<VolcanoAIService>(); |
||||
final VolcanoTtsApiService _ttsService; |
final VolcanoTtsApiService _ttsService = Get.find<VolcanoTtsApiService>(); |
||||
final AzureAsrService _voiceRecognitionService; |
final ChatHistoryService _historyService = Get.find<ChatHistoryService>(); |
||||
final List<Message> _messageHistory = []; |
|
||||
bool _isProcessing = false; |
// 状态控制 |
||||
bool _isListening = false; |
final RxBool isProcessing = false.obs; |
||||
StreamSubscription? _recognitionSubscription; |
|
||||
|
|
||||
|
|
||||
// 当前使用的agent ID |
|
||||
String? _currentAgentId; |
|
||||
|
|
||||
// 当前使用的agent voice |
|
||||
String _agentVoice = 'zh_female_meilinvyou_moon_bigtts'; |
|
||||
|
|
||||
// 可观察的状态 |
|
||||
final RxBool isListening = false.obs; |
final RxBool isListening = false.obs; |
||||
final RxString recognizedText = ''.obs; |
|
||||
|
|
||||
// 添加一个标志,表示是否已经识别到语音 |
|
||||
bool _hasRecognizedSpeech = false; |
|
||||
|
|
||||
// 添加一个标志,表示是否已经收到最终结果 |
// 语音识别状态 |
||||
bool _hasFinalResult = false; |
final RxString recognizedText = ''.obs; |
||||
|
|
||||
// 单一的交互超时计时器 (30秒) |
|
||||
Timer? _interactionTimer; |
|
||||
|
|
||||
// 添加一个变量来跟踪当前的AI响应流订阅 |
// 流式响应控制 |
||||
|
StreamSubscription? _recognitionSubscription; |
||||
StreamSubscription? _aiResponseSubscription; |
StreamSubscription? _aiResponseSubscription; |
||||
|
|
||||
// 添加一个标志,表示是否应该取消当前的AI响应 |
// 定时器,用于检测无活动超时 |
||||
bool _shouldCancelAiResponse = false; |
Timer? _inactivityTimer; |
||||
|
|
||||
// 当前系统提示词 |
// 当前处理的Agent |
||||
String? _currentSystemPrompt; |
Agent? _currentAgent; |
||||
|
|
||||
BackgroundAgentService() |
// 消息历史 - 与ChatController共享 |
||||
: _aiService = VolcanoAIService(), |
List<Map<String, String>> get messageHistory { |
||||
_ttsService = Get.find<VolcanoTtsApiService>(), |
// 首先尝试从ChatController获取消息历史 |
||||
_voiceRecognitionService = Get.find<AzureAsrService>(); |
if (Get.isRegistered<ChatController>()) { |
||||
|
final chatController = Get.find<ChatController>(); |
||||
// 启动或重置交互计时器 |
// 检查ChatController是否正在与personal_assistant交互 |
||||
void _resetInteractionTimer() { |
if (chatController.agent?.id == 'personal_assistant') { |
||||
// 取消之前的计时器 |
return chatController.messages.map((m) => { |
||||
_interactionTimer?.cancel(); |
'role': m.role, |
||||
|
'content': m.content, |
||||
|
}).toList(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
// 设置30秒的超时计时器 |
// 如果无法从ChatController获取,则使用本地存储的历史记录 |
||||
_interactionTimer = Timer(const Duration(seconds: 30), () { |
if (_localMessageHistory.isEmpty) { |
||||
// 检查TTS是否正在播放 |
_loadChatHistory(); |
||||
if (_ttsService.isPlaying) { |
|
||||
// 如果TTS正在播放,重置定时器 |
|
||||
_resetInteractionTimer(); |
|
||||
} else { |
|
||||
// 如果TTS不在播放,且30秒内没有检测到用户输入,退出交互 |
|
||||
Logger.info('30秒内没有检测到用户输入,退出交互'); |
|
||||
_endInteraction(); |
|
||||
} |
} |
||||
}); |
|
||||
|
|
||||
|
return _localMessageHistory; |
||||
} |
} |
||||
|
|
||||
// 结束交互 |
// 本地消息历史备份 |
||||
Future<void> _endInteraction() async { |
final List<Map<String, String>> _localMessageHistory = []; |
||||
// 完成当前的recognitionCompleter(如果有) |
|
||||
if (_recognitionCompleter != null && !_recognitionCompleter!.isCompleted) { |
// 构造函数 |
||||
_recognitionCompleter!.complete(''); |
BackgroundAgentService() { |
||||
|
Logger.info('BackgroundAgentService 已创建'); |
||||
} |
} |
||||
|
|
||||
_isProcessing = false; |
// TTS是否正在播放,直接从VolcanoTtsApiService获取 |
||||
|
bool get isTtsSpeaking => _ttsService.isPlaying; |
||||
|
|
||||
// 播放退出提示 |
@override |
||||
await _ttsService.speakSingle("没有听到您说话,已退出语音交互", speaker: _agentVoice); |
void onClose() { |
||||
|
// 取消所有订阅 |
||||
|
_recognitionSubscription?.cancel(); |
||||
|
_aiResponseSubscription?.cancel(); |
||||
|
|
||||
// 停止语音识别 |
// 停止TTS |
||||
if (_isListening) { |
if (_ttsService.isPlaying) { |
||||
await stopVoiceRecognition(); |
_ttsService.stop(); |
||||
Logger.info('退出交互,已停止语音识别'); |
|
||||
} |
} |
||||
|
|
||||
// 清除当前系统提示词 |
// 停止语音识别 |
||||
_currentSystemPrompt = null; |
if (isListening.value) { |
||||
// 清除当前agent ID |
_asrService.stopContinuousRecognition(); |
||||
_currentAgentId = null; |
|
||||
// 重置agent voice为默认值 |
|
||||
_agentVoice = 'zh_female_meilinvyou_moon_bigtts'; |
|
||||
} |
} |
||||
|
|
||||
// 开始语音交互 |
// 取消定时器 |
||||
Future<void> startAgentInteraction(String agentId) async { |
_inactivityTimer?.cancel(); |
||||
// 设置当前agent ID |
|
||||
_currentAgentId = agentId; |
|
||||
|
|
||||
// 获取agent信息,只查询一次 |
super.onClose(); |
||||
final agent = AgentProvider.getAgentById(agentId); |
|
||||
if (agent == null) { |
|
||||
Logger.error('无法获取agent: $agentId'); |
|
||||
return; |
|
||||
} |
} |
||||
|
|
||||
// 存储需要的值 |
/// 开始代理交互 |
||||
_agentVoice = agent.voice; |
/// |
||||
_currentSystemPrompt = agent.systemPrompt; |
/// [agentId] 要交互的代理ID |
||||
|
Future<void> startAgentInteraction(String agentId) async { |
||||
// 播放提示音 |
// 先说"我在听",然后再播放欢迎消息 |
||||
await _ttsService.speakSingle("我在听", speaker: _agentVoice); |
const listeningMessage = "我在听"; |
||||
|
_ttsService.speakSingle(listeningMessage); |
||||
|
|
||||
if (_isProcessing) { |
// 防止重复处理 |
||||
print('已经在处理交互,忽略此次请求'); |
if (isProcessing.value) { |
||||
|
Logger.warning('已经在处理交互,忽略此次请求'); |
||||
return; |
return; |
||||
} |
} |
||||
|
|
||||
_isProcessing = true; |
isProcessing.value = true; |
||||
|
|
||||
try { |
try { |
||||
// 确保之前的语音识别已经停止 |
// 获取Agent |
||||
if (_isListening) { |
final agent = AgentProvider.getAgentById(agentId); |
||||
await stopVoiceRecognition(); |
if (agent == null) { |
||||
|
Logger.error('无法获取Agent: $agentId'); |
||||
|
isProcessing.value = false; |
||||
|
return; |
||||
} |
} |
||||
|
|
||||
// 启动语音识别 |
_currentAgent = agent; |
||||
await startVoiceRecognition(); |
|
||||
|
|
||||
// 启动交互计时器 |
// 加载该Agent的聊天历史 |
||||
_resetInteractionTimer(); |
_loadChatHistory(); |
||||
} catch (e) { |
|
||||
Logger.error('启动语音交互失败: $e'); |
|
||||
_isProcessing = false; |
|
||||
|
|
||||
// 播放错误提示 |
// 启动语音识别 - 保持持续识别状态 |
||||
await _ttsService.speakSingle("抱歉,启动语音交互失败", speaker: _agentVoice); |
await _startVoiceRecognition(); |
||||
} |
|
||||
} |
|
||||
|
|
||||
// 停止语音交互 |
// 启动无活动定时器 |
||||
Future<void> stopAgentInteraction() async { |
_startInactivityTimer(); |
||||
// 取消交互计时器 |
|
||||
_interactionTimer?.cancel(); |
|
||||
_interactionTimer = null; |
|
||||
|
|
||||
// 停止TTS播放 |
} catch (e) { |
||||
if (_ttsService.isPlaying) { |
Logger.error('启动代理交互失败', e); |
||||
await _ttsService.stop(); |
isProcessing.value = false; |
||||
} |
|
||||
|
|
||||
// 停止语音识别 |
|
||||
if (_isListening) { |
|
||||
await stopVoiceRecognition(); |
|
||||
Logger.info('手动停止交互,已停止语音识别'); |
|
||||
} |
} |
||||
|
|
||||
// 清除处理状态 |
|
||||
_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.value) { |
||||
// 开始语音识别 |
// 如果已经在监听,不需要重新启动 |
||||
Future<void> startVoiceRecognition() async { |
Logger.info('语音识别已经在运行,无需重新启动'); |
||||
if (_isListening) { |
return; |
||||
await stopVoiceRecognition(); |
|
||||
} |
} |
||||
|
|
||||
try { |
try { |
||||
Logger.info('开始初始化语音识别服务...'); |
// 启动语音识别 |
||||
// 确保语音识别服务已初始化 |
final success = await _asrService.startContinuousRecognition(); |
||||
if (!await _voiceRecognitionService.initialize( |
|
||||
subscriptionKey: dotenv.env['AZURE_ASR_SUBSCRIPTION_KEY'] ?? '', |
if (success) { |
||||
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; |
|
||||
isListening.value = true; |
isListening.value = true; |
||||
recognizedText.value = ''; |
recognizedText.value = ''; |
||||
_hasRecognizedSpeech = false; |
|
||||
_hasFinalResult = false; |
|
||||
|
|
||||
// 添加一个变量来存储完整的用户输入 |
|
||||
String fullUserInput = ''; |
|
||||
|
|
||||
// 使用语音识别服务的recognitionStream而不是方法的返回值 |
// 监听识别事件 |
||||
_recognitionSubscription = _voiceRecognitionService.recognitionStream?.listen((event) { |
_recognitionSubscription = _asrService.recognitionStream?.listen( |
||||
if (event.type == RecognitionEventType.finalResult) { |
(event) { |
||||
// 添加日志输出最终识别结果 |
// 重置无活动定时器 |
||||
Logger.info('语音识别最终结果: ${event.text}'); |
_resetInactivityTimer(); |
||||
|
|
||||
|
switch (event.type) { |
||||
|
case RecognitionEventType.recognizing: |
||||
|
// 实时更新识别文本 |
||||
if (event.text.isNotEmpty) { |
if (event.text.isNotEmpty) { |
||||
// 将最终结果添加到完整输入中,并添加适当的标点符号 |
recognizedText.value = event.text; |
||||
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(); |
|
||||
|
|
||||
|
|
||||
_processUserInput(fullUserInput); |
// 如果正在播放TTS,检测到用户说话则中断当前播放和AI回复 |
||||
|
if (_ttsService.isPlaying && event.text.trim().length > 3) { |
||||
// 重置用户输入 |
_interruptCurrentResponse("检测到用户说话,中断当前响应"); |
||||
fullUserInput = ''; |
|
||||
recognizedText.value = ''; |
|
||||
_hasRecognizedSpeech = false; |
|
||||
_hasFinalResult = false; |
|
||||
} |
} |
||||
} else if (event.type == RecognitionEventType.recognizing) { |
} |
||||
// 添加日志输出中间识别结果 |
break; |
||||
Logger.info('语音识别中间结果: ${event.text}'); |
case RecognitionEventType.finalResult: |
||||
|
// 最终结果 |
||||
if (event.text.isNotEmpty) { |
if (event.text.isNotEmpty) { |
||||
// 更新当前的中间结果,但不添加到完整输入中 |
recognizedText.value = event.text; |
||||
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; |
|
||||
|
|
||||
|
// 处理识别到的文本 |
||||
|
_processRecognizedText(event.text); |
||||
|
|
||||
// 添加日志输出中断TTS播放 |
// 重置识别文本但保持识别状态 |
||||
Logger.info('检测到用户说话,中断TTS播放'); |
recognizedText.value = ''; |
||||
} |
|
||||
} |
} |
||||
} else if (event.type == RecognitionEventType.error) { |
break; |
||||
// 添加日志输出识别错误 |
case RecognitionEventType.sessionStopped: |
||||
Logger.error('语音识别错误: ${event.error}'); |
case RecognitionEventType.canceled: |
||||
print('识别错误: ${event.error}'); |
// 会话结束,表示识别在其它地方被启动,直接退出后台服务 |
||||
|
isListening.value = false; |
||||
|
Logger.info('语音识别会话被中止,退出后台代理服务'); |
||||
|
stopInteraction(); |
||||
|
break; |
||||
|
default: |
||||
|
break; |
||||
} |
} |
||||
}, onError: (error) { |
}, |
||||
// 添加日志输出语音识别流错误 |
onError: (error) { |
||||
Logger.error('语音识别流错误: $error'); |
Logger.error('语音识别错误', error); |
||||
print('语音识别流错误: $error'); |
|
||||
_isListening = false; |
|
||||
isListening.value = false; |
isListening.value = false; |
||||
}); |
|
||||
|
|
||||
// 启动交互计时器 |
|
||||
_resetInteractionTimer(); |
|
||||
|
|
||||
|
// 如果出错,尝试重新启动语音识别 |
||||
|
_restartVoiceRecognition(); |
||||
|
}, |
||||
|
); |
||||
|
} else { |
||||
|
Logger.error('启动语音识别失败'); |
||||
|
isProcessing.value = false; |
||||
|
} |
||||
} catch (e) { |
} catch (e) { |
||||
Logger.error('启动语音识别失败: $e'); |
Logger.error('启动语音识别失败', e); |
||||
print('启动语音识别失败: $e'); |
|
||||
_isListening = false; |
|
||||
isListening.value = false; |
isListening.value = false; |
||||
rethrow; |
isProcessing.value = false; |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
// 处理用户输入 |
/// 处理识别到的文本 |
||||
Future<void> _processUserInput(String userInput) async { |
Future<void> _processRecognizedText(String text) async { |
||||
if (userInput.isEmpty || _currentSystemPrompt == null) { |
if (text.trim().isEmpty || _currentAgent == null) return; |
||||
return; |
|
||||
} |
|
||||
|
|
||||
try { |
try { |
||||
// 保存用户消息到历史记录 |
// 添加用户消息到历史记录 |
||||
_messageHistory.add(Message( |
_addMessageToHistory('user', text); |
||||
role: 'user', |
|
||||
content: userInput, |
|
||||
timestamp: DateTime.now(), |
|
||||
)); |
|
||||
|
|
||||
// 构建用于生成回应的消息列表 |
|
||||
final messages = [ |
|
||||
{'role': 'system', 'content': _currentSystemPrompt!}, |
|
||||
{'role': 'user', 'content': userInput}, |
|
||||
]; |
|
||||
|
|
||||
|
// 启动TTS会话,使用当前Agent的voice |
||||
|
_ttsService.startSession(_currentAgent!.voice); |
||||
|
// 获取AI响应 |
||||
String fullResponse = ''; |
String fullResponse = ''; |
||||
|
|
||||
Logger.info('开始生成AI响应...'); |
|
||||
// 重置取消标志 |
|
||||
_shouldCancelAiResponse = false; |
|
||||
|
|
||||
// 创建一个本地变量来跟踪是否已取消 |
|
||||
bool isCancelled = false; |
|
||||
|
|
||||
// 使用成员变量中存储的agent voice |
|
||||
_ttsService.startSession(_agentVoice); |
|
||||
|
|
||||
// 获取AI响应流 |
// 获取AI响应流 |
||||
final responseStream = _aiService.sendMessageStream( |
final responseStream = _aiService.sendMessageStream( |
||||
messages: messages, |
messages: messageHistory, |
||||
systemPrompt: _currentSystemPrompt!, |
systemPrompt: _currentAgent!.systemPrompt, |
||||
); |
); |
||||
|
|
||||
// 创建一个订阅来处理响应流 |
// 订阅响应流 |
||||
_aiResponseSubscription = responseStream.listen( |
_aiResponseSubscription = responseStream.listen( |
||||
(chunk) { |
(chunk) { |
||||
// 如果已经设置了取消标志,则不处理这个块 |
|
||||
if (_shouldCancelAiResponse) { |
|
||||
isCancelled = true; |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
fullResponse += chunk; |
fullResponse += chunk; |
||||
|
|
||||
// 直接将每个文本块传递给 TTS 接口,不进行切句或攒句处理 |
// 如果TTS启用,发送到TTS |
||||
try { |
_ttsService.speak(chunk); |
||||
// 使用成员变量中存储的agent voice |
|
||||
_ttsService.speak(chunk, speaker: _agentVoice); |
|
||||
|
|
||||
// Logger.info('直接播放 AI 响应块: $chunk'); |
// 重置无活动定时器 |
||||
} catch (e) { |
_resetInactivityTimer(); |
||||
Logger.error('TTS 播放出错: $e'); |
|
||||
} |
|
||||
}, |
|
||||
onError: (e) { |
|
||||
Logger.error('AI响应流错误: $e'); |
|
||||
print('AI响应流错误: $e'); |
|
||||
// 清理订阅 |
|
||||
_aiResponseSubscription = null; |
|
||||
}, |
}, |
||||
onDone: () { |
onDone: () { |
||||
|
// 添加AI响应到历史记录 |
||||
|
_addMessageToHistory('assistant', fullResponse); |
||||
|
|
||||
|
// 结束TTS会话 |
||||
_ttsService.endSession(); |
_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) { |
_addMessageToHistory('assistant', errorMessage); |
||||
// 保存助手回复到历史记录 |
|
||||
_messageHistory.add(Message( |
|
||||
role: 'assistant', |
|
||||
content: fullResponse, |
|
||||
timestamp: DateTime.now(), |
|
||||
)); |
|
||||
|
|
||||
// Logger.info('保存AI响应到历史记录,长度: ${fullResponse.length}'); |
// 结束TTS会话 |
||||
} |
_ttsService.endSession(); |
||||
|
}, |
||||
|
); |
||||
|
} catch (e) { |
||||
|
Logger.error('处理识别文本失败', e); |
||||
|
|
||||
// 限制历史记录长度 |
// 播放错误消息 |
||||
if (_messageHistory.length > 20) { |
const errorMessage = '抱歉,我遇到了一些问题,无法回应您的请求。'; |
||||
_messageHistory.removeRange(0, _messageHistory.length - 20); |
_ttsService.speak(errorMessage); |
||||
Logger.info('历史记录超过20条,已裁剪'); |
|
||||
|
// 添加错误消息到历史记录 |
||||
|
_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 { |
void _addMessageToHistory(String role, String content) { |
||||
if (!_isListening) { |
// 添加到本地历史记录 |
||||
return ''; |
_localMessageHistory.add({ |
||||
|
'role': role, |
||||
|
'content': content, |
||||
|
}); |
||||
|
|
||||
|
// 保持本地历史记录不超过10条消息 |
||||
|
if (_localMessageHistory.length > 10) { |
||||
|
_localMessageHistory.removeAt(0); |
||||
} |
} |
||||
|
|
||||
try { |
// 如果ChatController可用,同步消息到ChatController |
||||
// 取消订阅 |
if (Get.isRegistered<ChatController>()) { |
||||
await _recognitionSubscription?.cancel(); |
final chatController = Get.find<ChatController>(); |
||||
_recognitionSubscription = null; |
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; |
||||
|
|
||||
// 获取最终识别结果 |
try { |
||||
final result = recognizedText.value; |
// 加载聊天历史 |
||||
|
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; |
||||
|
|
||||
// 重置状态 |
try { |
||||
_isListening = false; |
// 将本地历史转换为Message列表 |
||||
isListening.value = false; |
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) { |
} catch (e) { |
||||
// 添加日志输出停止语音识别失败 |
Logger.error('保存聊天历史记录失败', e); |
||||
Logger.error('停止语音识别失败: $e'); |
} |
||||
print('停止语音识别失败: $e'); |
} |
||||
_isListening = false; |
|
||||
isListening.value = false; |
|
||||
|
|
||||
// 直接返回当前已识别的文本,不尝试重置 |
/// 启动无活动定时器 |
||||
final result = recognizedText.value; |
void _startInactivityTimer() { |
||||
Logger.info('停止语音识别失败,使用当前识别结果: $result'); |
_inactivityTimer?.cancel(); |
||||
return result; |
_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() { |
// 停止TTS |
||||
_messageHistory.clear(); |
if (_ttsService.isPlaying) { |
||||
|
_ttsService.stop(); |
||||
} |
} |
||||
|
|
||||
@override |
// 取消AI响应流订阅 |
||||
void onClose() { |
_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(); |
_recognitionSubscription?.cancel(); |
||||
_interactionTimer?.cancel(); |
|
||||
_interactionTimer = null; |
|
||||
_aiResponseSubscription?.cancel(); |
_aiResponseSubscription?.cancel(); |
||||
|
|
||||
// 停止TTS播放 |
// 停止TTS |
||||
if (_ttsService.isPlaying) { |
if (_ttsService.isPlaying) { |
||||
_ttsService.stop(); |
_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 'package:get/get.dart'; |
||||
import '../controllers/chat_controller.dart'; |
import '../controllers/chat_controller.dart'; |
||||
import '../../../data/services/volcano_tts_api_service.dart'; |
|
||||
|
|
||||
class ChatBinding implements Bindings { |
class ChatBinding extends Bindings { |
||||
@override |
@override |
||||
void dependencies() { |
void dependencies() { |
||||
// Ensure TTS service is registered first |
Get.lazyPut<ChatController>(() => ChatController()); |
||||
if (!Get.isRegistered<VolcanoTtsApiService>()) { |
|
||||
Get.put(VolcanoTtsApiService()); |
|
||||
} |
|
||||
|
|
||||
// Create new controller instance |
|
||||
Get.put(ChatController()); |
|
||||
} |
} |
||||
} |
} |
||||
File diff suppressed because it is too large
@ -1,348 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'dart:math'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:flutter/foundation.dart'; |
|
||||
import '../../../data/services/azure_asr_service.dart'; |
|
||||
import '../../../data/services/volcano_tts_api_service.dart'; |
|
||||
import '../../../core/utils/logger.dart'; |
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|
||||
|
|
||||
class VoiceInputController extends GetxController { |
|
||||
// Observable states |
|
||||
final isConnecting = true.obs; |
|
||||
final isMuted = false.obs; |
|
||||
final isRecording = false.obs; |
|
||||
final recognizedText = ''.obs; |
|
||||
|
|
||||
// 添加一个新的可观察状态,表示用户是否正在说话 |
|
||||
final isUserSpeaking = false.obs; |
|
||||
|
|
||||
// 语音识别服务 |
|
||||
late final AzureAsrService _voiceService; |
|
||||
late final VolcanoTtsApiService _ttsService; |
|
||||
|
|
||||
// 连续识别相关 |
|
||||
StreamSubscription? _recognitionSubscription; |
|
||||
|
|
||||
// TTS相关订阅 |
|
||||
StreamSubscription? _ttsEventSubscription; |
|
||||
bool _isTtsSpeaking = false; |
|
||||
|
|
||||
// 添加一个标志,表示是否已经识别到语音 |
|
||||
bool _hasRecognizedSpeech = false; |
|
||||
|
|
||||
// 添加一个 Completer 用于在识别到最终结果时完成 |
|
||||
Completer<String>? _recognitionCompleter; |
|
||||
|
|
||||
// 添加一个标志,表示是否已经收到最终结果 |
|
||||
bool _hasFinalResult = false; |
|
||||
|
|
||||
// 添加一个计时器,用于定期更新用户说话状态 |
|
||||
Timer? _speakingCheckTimer; |
|
||||
|
|
||||
// 最后一次识别到语音的时间 |
|
||||
DateTime? _lastSpeechTime; |
|
||||
|
|
||||
// Callbacks |
|
||||
final Function(String) onRecordingResult; |
|
||||
final VoidCallback onClosePanel; |
|
||||
final Function(String) onRecognizing; |
|
||||
|
|
||||
VoiceInputController({ |
|
||||
required this.onRecordingResult, |
|
||||
required this.onClosePanel, |
|
||||
required this.onRecognizing, |
|
||||
}); |
|
||||
|
|
||||
@override |
|
||||
void onInit() { |
|
||||
super.onInit(); |
|
||||
_initializeVoiceService(); |
|
||||
_ttsService = Get.find<VolcanoTtsApiService>(); |
|
||||
|
|
||||
// 设置TTS事件监听 |
|
||||
_setupTtsEventListener(); |
|
||||
|
|
||||
// 启动定期检查用户是否正在说话的计时器 |
|
||||
_startSpeakingCheckTimer(); |
|
||||
} |
|
||||
|
|
||||
// 设置TTS事件监听 |
|
||||
void _setupTtsEventListener() { |
|
||||
_ttsEventSubscription?.cancel(); |
|
||||
_ttsEventSubscription = _ttsService.eventStream.listen(_handleTtsEvent); |
|
||||
} |
|
||||
|
|
||||
// 处理TTS事件 |
|
||||
void _handleTtsEvent(Map<String, dynamic> event) { |
|
||||
final eventType = event['eventType']; |
|
||||
|
|
||||
switch (eventType) { |
|
||||
case 'ttsSentenceStart': |
|
||||
_isTtsSpeaking = true; |
|
||||
break; |
|
||||
case 'ttsSentenceEnd': |
|
||||
case 'sessionFinished': |
|
||||
case 'error': |
|
||||
_isTtsSpeaking = false; |
|
||||
break; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> _initializeVoiceService() async { |
|
||||
try { |
|
||||
// 获取语音识别服务实例 |
|
||||
_voiceService = Get.find<AzureAsrService>(); |
|
||||
|
|
||||
// 初始化语音识别服务 |
|
||||
await _voiceService.initialize( |
|
||||
subscriptionKey: dotenv.env['AZURE_ASR_SUBSCRIPTION_KEY'] ?? '', |
|
||||
serviceRegion: dotenv.env['AZURE_ASR_SERVICE_REGION'] ?? 'eastasia', |
|
||||
language: dotenv.env['AZURE_ASR_LANGUAGE'] ?? 'zh-CN', |
|
||||
); |
|
||||
|
|
||||
// 初始化完成后更新状态 |
|
||||
isConnecting.value = false; |
|
||||
|
|
||||
// 自动开始连续语音识别 |
|
||||
await startContinuousRecognition(); |
|
||||
} catch (e) { |
|
||||
Logger.error('初始化语音识别服务失败', e); |
|
||||
Get.snackbar( |
|
||||
'Error', |
|
||||
'初始化语音识别服务失败: $e', |
|
||||
snackPosition: SnackPosition.BOTTOM, |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> startContinuousRecognition() async { |
|
||||
if (isConnecting.value) return; |
|
||||
if (isRecording.value) return; // 已经在录音中 |
|
||||
|
|
||||
try { |
|
||||
isRecording.value = true; |
|
||||
recognizedText.value = ''; // 清空识别文本,但不再在面板中显示 |
|
||||
_hasRecognizedSpeech = false; |
|
||||
_hasFinalResult = false; |
|
||||
|
|
||||
// 创建一个 Completer 来处理语音识别完成 |
|
||||
_recognitionCompleter = Completer<String>(); |
|
||||
|
|
||||
// 开始连续语音识别 |
|
||||
final success = await _voiceService.startContinuousRecognition(); |
|
||||
if (!success) { |
|
||||
isRecording.value = false; |
|
||||
throw Exception('无法启动语音识别'); |
|
||||
} |
|
||||
|
|
||||
// 订阅识别事件流 |
|
||||
_recognitionSubscription = _voiceService.recognitionStream?.listen((event) { |
|
||||
switch (event.type) { |
|
||||
case RecognitionEventType.recognizing: |
|
||||
// 不再更新面板中的识别文本,而是通过回调传递给ChatController |
|
||||
if (event.text.isNotEmpty) { |
|
||||
_hasRecognizedSpeech = true; |
|
||||
_lastSpeechTime = DateTime.now(); |
|
||||
|
|
||||
// 设置用户正在说话状态 |
|
||||
isUserSpeaking.value = true; |
|
||||
|
|
||||
// 如果系统正在播放TTS,检测到用户开始说话时立即中断 |
|
||||
if (event.text.trim().isNotEmpty) { |
|
||||
print('检测到用户开始说话,中断TTS播放'); |
|
||||
_ttsService.stop(); |
|
||||
_ttsService.endSession(); // 结束当前TTS会话 |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 调用识别中回调,将中间结果传递给ChatController |
|
||||
onRecognizing(event.text); |
|
||||
break; |
|
||||
|
|
||||
case RecognitionEventType.finalResult: |
|
||||
// 更新最终识别结果 |
|
||||
_hasFinalResult = true; |
|
||||
|
|
||||
// 重置用户说话状态 |
|
||||
isUserSpeaking.value = false; |
|
||||
|
|
||||
if (event.text.isNotEmpty) { |
|
||||
// 不再更新面板中的识别文本 |
|
||||
_hasRecognizedSpeech = true; |
|
||||
_lastSpeechTime = DateTime.now(); |
|
||||
|
|
||||
// 调用最终结果回调,发送识别到的句子 |
|
||||
onRecordingResult(event.text); |
|
||||
|
|
||||
// 清空识别状态,准备下一句,但不停止识别 |
|
||||
_hasRecognizedSpeech = false; |
|
||||
_hasFinalResult = false; |
|
||||
|
|
||||
// 完成当前识别,但不停止连续识别 |
|
||||
if (_recognitionCompleter != null && !_recognitionCompleter!.isCompleted) { |
|
||||
_recognitionCompleter!.complete(event.text); |
|
||||
} |
|
||||
} else { |
|
||||
// 如果最终结果为空,但之前有中间结果,仍然标记为有语音 |
|
||||
if (_hasRecognizedSpeech) { |
|
||||
print('最终识别结果为空,但之前有中间结果,使用最后的中间结果'); |
|
||||
|
|
||||
// 清空识别状态,准备下一句,但不停止识别 |
|
||||
_hasRecognizedSpeech = false; |
|
||||
_hasFinalResult = false; |
|
||||
} |
|
||||
} |
|
||||
break; |
|
||||
|
|
||||
case RecognitionEventType.error: |
|
||||
print('语音识别错误: ${event.error}'); |
|
||||
Get.snackbar( |
|
||||
'Error', |
|
||||
'语音识别错误: ${event.error}', |
|
||||
snackPosition: SnackPosition.BOTTOM, |
|
||||
); |
|
||||
// 尝试重新启动识别 |
|
||||
restartRecognition(); |
|
||||
break; |
|
||||
|
|
||||
case RecognitionEventType.sessionStopped: |
|
||||
// 会话结束,尝试重新启动 |
|
||||
isRecording.value = false; |
|
||||
restartRecognition(); |
|
||||
break; |
|
||||
|
|
||||
default: |
|
||||
break; |
|
||||
} |
|
||||
}, onError: (error) { |
|
||||
print('语音识别流错误: $error'); |
|
||||
Get.snackbar( |
|
||||
'Error', |
|
||||
'语音识别流错误: $error', |
|
||||
snackPosition: SnackPosition.BOTTOM, |
|
||||
); |
|
||||
isRecording.value = false; |
|
||||
// 尝试重新启动识别 |
|
||||
restartRecognition(); |
|
||||
}); |
|
||||
|
|
||||
} catch (e) { |
|
||||
print('语音识别失败: $e'); |
|
||||
Get.snackbar( |
|
||||
'Error', |
|
||||
'启动语音识别失败: $e', |
|
||||
snackPosition: SnackPosition.BOTTOM, |
|
||||
); |
|
||||
isRecording.value = false; |
|
||||
} finally { |
|
||||
// 清理资源 |
|
||||
if (_recognitionCompleter != null && !_recognitionCompleter!.isCompleted) { |
|
||||
_recognitionCompleter!.complete(''); |
|
||||
} |
|
||||
_recognitionCompleter = null; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 重新启动识别 |
|
||||
Future<void> restartRecognition() async { |
|
||||
try { |
|
||||
// 先停止当前识别 |
|
||||
await stopRecording(); |
|
||||
// 延迟一下再重新启动 |
|
||||
await Future.delayed(const Duration(milliseconds: 500)); |
|
||||
// 重新启动连续识别 |
|
||||
await startContinuousRecognition(); |
|
||||
} catch (e) { |
|
||||
print('重新启动语音识别失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> stopRecording() async { |
|
||||
if (!isRecording.value) return; |
|
||||
|
|
||||
try { |
|
||||
// 取消事件订阅 |
|
||||
await _recognitionSubscription?.cancel(); |
|
||||
_recognitionSubscription = null; |
|
||||
|
|
||||
// 停止连续识别 |
|
||||
if (_voiceService.isContinuousRecognitionActive()) { |
|
||||
await _voiceService.stopContinuousRecognition(); |
|
||||
} |
|
||||
|
|
||||
isRecording.value = false; |
|
||||
_hasRecognizedSpeech = false; |
|
||||
_hasFinalResult = false; |
|
||||
} catch (e) { |
|
||||
print('停止语音识别失败: $e'); |
|
||||
isRecording.value = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
void toggleMute() { |
|
||||
// 只切换静音状态,不再影响录音 |
|
||||
// 因为已启用回声消除,不需要在录音时停止TTS |
|
||||
isMuted.value = !isMuted.value; |
|
||||
} |
|
||||
|
|
||||
// 手动开始录音(用户点击按钮) |
|
||||
void startRecording() { |
|
||||
if (!isRecording.value) { |
|
||||
startContinuousRecognition(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 启动定期检查用户是否正在说话的计时器 |
|
||||
void _startSpeakingCheckTimer() { |
|
||||
_speakingCheckTimer?.cancel(); |
|
||||
_speakingCheckTimer = Timer.periodic(const Duration(milliseconds: 200), (_) { |
|
||||
_checkIfUserIsSpeaking(); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// 检查用户是否正在说话 |
|
||||
void _checkIfUserIsSpeaking() { |
|
||||
// 如果有最近的识别文本,且距离现在不超过1秒,认为用户正在说话 |
|
||||
if (_hasRecognizedSpeech && !_hasFinalResult && _lastSpeechTime != null) { |
|
||||
final now = DateTime.now(); |
|
||||
final timeSinceLastSpeech = now.difference(_lastSpeechTime!); |
|
||||
|
|
||||
// 如果最近1秒内有语音识别,认为用户正在说话 |
|
||||
if (timeSinceLastSpeech.inMilliseconds < 1000) { |
|
||||
isUserSpeaking.value = true; |
|
||||
return; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 默认情况下,如果正在录音但没有最近的语音识别,随机生成一个说话状态 |
|
||||
// 保持这个功能,让麦克风图标有动画效果 |
|
||||
if (isRecording.value) { |
|
||||
// 随机生成一个值,有20%的概率认为用户正在说话 |
|
||||
isUserSpeaking.value = Random().nextDouble() < 0.2; |
|
||||
} else { |
|
||||
isUserSpeaking.value = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
// 停止录音 |
|
||||
stopRecording(); |
|
||||
|
|
||||
// 取消计时器 |
|
||||
_speakingCheckTimer?.cancel(); |
|
||||
|
|
||||
// 取消TTS事件订阅 |
|
||||
_ttsEventSubscription?.cancel(); |
|
||||
|
|
||||
// 重置状态 |
|
||||
isConnecting.value = false; |
|
||||
isMuted.value = false; |
|
||||
isRecording.value = false; |
|
||||
recognizedText.value = ''; |
|
||||
isUserSpeaking.value = false; |
|
||||
|
|
||||
super.onClose(); |
|
||||
} |
|
||||
} |
|
||||
@ -1,625 +1,490 @@ |
|||||
import 'package:flutter/material.dart'; |
import 'package:flutter/material.dart'; |
||||
import 'package:get/get.dart'; |
import 'package:get/get.dart'; |
||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import 'dart:math'; |
||||
import '../controllers/chat_controller.dart'; |
import '../controllers/chat_controller.dart'; |
||||
import '../models/message_model.dart'; |
import '../models/message_model.dart'; |
||||
import '../../../data/services/volcano_tts_api_service.dart'; |
import '../../../core/theme/app_colors.dart'; |
||||
import '../widgets/voice_input_panel.dart'; |
import '../../../core/widgets/message_bubble.dart'; |
||||
|
|
||||
class ChatView extends GetView<ChatController> { |
class ChatView extends GetView<ChatController> { |
||||
const ChatView({Key? key}) : super(key: key); |
ChatView({Key? key}) : super(key: key); |
||||
|
|
||||
|
// 添加ScrollController用于控制滚动 |
||||
|
final ScrollController _scrollController = ScrollController(); |
||||
|
|
||||
@override |
@override |
||||
Widget build(BuildContext context) { |
Widget build(BuildContext context) { |
||||
return WillPopScope( |
// 监听消息列表变化,自动滚动到底部 |
||||
onWillPop: () async { |
ever(controller.messages, (_) { |
||||
// 停止TTS服务 |
// 使用Future.delayed确保在UI更新后滚动 |
||||
final tts = Get.find<VolcanoTtsApiService>(); |
Future.delayed(Duration(milliseconds: 50), () { |
||||
await tts.endSession(); |
if (_scrollController.hasClients && |
||||
|
_scrollController.position.maxScrollExtent > 0 && |
||||
// 停止语音输入 |
(_scrollController.position.maxScrollExtent - _scrollController.offset) < 200) { |
||||
if (controller.isVoiceInputVisible.value) { |
_scrollController.animateTo( |
||||
controller.stopVoiceInput(); |
_scrollController.position.maxScrollExtent, |
||||
} |
duration: Duration(milliseconds: 100), |
||||
|
curve: Curves.easeOut, |
||||
return true; |
|
||||
}, |
|
||||
child: Scaffold( |
|
||||
backgroundColor: Colors.white, |
|
||||
appBar: _buildAppBar(), |
|
||||
body: SafeArea( |
|
||||
child: Stack( |
|
||||
children: [ |
|
||||
// 主要内容 |
|
||||
Column( |
|
||||
children: [ |
|
||||
Expanded( |
|
||||
child: _buildMessageList(), |
|
||||
), |
|
||||
_buildInputSection(), |
|
||||
], |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
); |
); |
||||
} |
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
PreferredSizeWidget _buildAppBar() { |
return Scaffold( |
||||
return AppBar( |
backgroundColor: Colors.white, // 设置Scaffold背景为白色 |
||||
backgroundColor: Colors.white, |
appBar: AppBar( |
||||
elevation: 0, |
|
||||
leading: IconButton( |
leading: IconButton( |
||||
icon: Icon( |
icon: Icon(Icons.arrow_back_ios, color: Colors.black), |
||||
Icons.arrow_back_ios, |
onPressed: () => Get.back(), |
||||
color: Colors.black, |
|
||||
size: 20.sp, |
|
||||
), |
), |
||||
onPressed: () async { |
title: Row( |
||||
// 停止TTS服务 |
children: [ |
||||
final tts = Get.find<VolcanoTtsApiService>(); |
CircleAvatar( |
||||
await tts.endSession(); |
backgroundImage: AssetImage(controller.agent.avatarUrl), |
||||
|
radius: 18.r, |
||||
// 停止语音输入 |
|
||||
if (controller.isVoiceInputVisible.value) { |
|
||||
controller.stopVoiceInput(); |
|
||||
} |
|
||||
|
|
||||
Get.back(); |
|
||||
}, |
|
||||
), |
), |
||||
title: Column( |
SizedBox(width: 10.w), |
||||
|
Expanded( |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
children: [ |
children: [ |
||||
Text( |
Text( |
||||
controller.agentName, |
controller.agent.name, |
||||
style: TextStyle( |
style: TextStyle( |
||||
fontSize: 17.sp, |
fontSize: 16.sp, |
||||
fontWeight: FontWeight.w600, |
fontWeight: FontWeight.bold, |
||||
color: Colors.black, |
color: Colors.black, |
||||
), |
), |
||||
), |
), |
||||
if (controller.agentSubtitle != null) |
|
||||
Text( |
Text( |
||||
controller.agentSubtitle!, |
controller.agent.description.length > 20 |
||||
|
? '${controller.agent.description.substring(0, 20)}...' |
||||
|
: controller.agent.description, |
||||
style: TextStyle( |
style: TextStyle( |
||||
fontSize: 12.sp, |
fontSize: 12.sp, |
||||
color: Colors.grey[600], |
color: Colors.grey, |
||||
), |
), |
||||
maxLines: 1, |
maxLines: 1, |
||||
overflow: TextOverflow.ellipsis, |
overflow: TextOverflow.ellipsis, |
||||
), |
), |
||||
], |
], |
||||
), |
), |
||||
centerTitle: true, |
), |
||||
|
], |
||||
|
), |
||||
actions: [ |
actions: [ |
||||
IconButton( |
// TTS开关 |
||||
icon: Obx(() => Icon( |
Obx(() => IconButton( |
||||
|
icon: Icon( |
||||
controller.isTtsEnabled.value |
controller.isTtsEnabled.value |
||||
? Icons.volume_up |
? Icons.volume_up |
||||
: Icons.volume_off, |
: Icons.volume_off, |
||||
color: Colors.black, |
color: controller.isTtsEnabled.value |
||||
size: 24.sp, |
? AppColors.primary |
||||
)), |
: Colors.grey, |
||||
onPressed: controller.toggleTTS, |
|
||||
), |
), |
||||
IconButton( |
onPressed: controller.toggleTts, |
||||
icon: Icon( |
)), |
||||
Icons.more_horiz, |
// 更多选项 |
||||
color: Colors.black, |
PopupMenuButton( |
||||
size: 24.sp, |
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: [ |
children: [ |
||||
Obx(() => ListView.builder( |
// 消息列表 |
||||
|
Expanded( |
||||
|
child: Obx(() => ListView.builder( |
||||
controller: controller.scrollController, |
controller: controller.scrollController, |
||||
padding: EdgeInsets.only( |
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), |
||||
top: 16.h, |
|
||||
bottom: controller.isVoiceInputVisible.value |
|
||||
? 160.h |
|
||||
: (controller.isInputCollapsed.value ? 70.h : 90.h), |
|
||||
), |
|
||||
itemCount: controller.messages.length, |
itemCount: controller.messages.length, |
||||
itemBuilder: (context, index) => _buildMessage(controller.messages[index]), |
reverse: false, |
||||
physics: const AlwaysScrollableScrollPhysics(), |
physics: const AlwaysScrollableScrollPhysics(), |
||||
clipBehavior: Clip.none, |
cacheExtent: 1000, // 增加缓存范围,减少重建 |
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, |
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(() { |
Widget _buildTextInputPanel() { |
||||
if (controller.isVoiceInputVisible.value) { |
// 创建一个TextEditingController来控制输入框 |
||||
return Column( |
final TextEditingController textController = TextEditingController(text: controller.inputText.value); |
||||
mainAxisSize: MainAxisSize.min, |
|
||||
children: [ |
// 监听输入变化 |
||||
VoiceInputPanel( |
textController.addListener(() { |
||||
onRecordingResult: controller.handleVoiceResult, |
controller.inputText.value = textController.text; |
||||
onClose: controller.stopVoiceInput, |
|
||||
onRecognizing: controller.handleRecognizing, |
|
||||
), |
|
||||
], |
|
||||
); |
|
||||
} |
|
||||
return _buildTextInput(); |
|
||||
}); |
}); |
||||
} |
|
||||
|
|
||||
Widget _buildTextInput() { |
|
||||
return Container( |
return Container( |
||||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
||||
decoration: BoxDecoration( |
decoration: BoxDecoration( |
||||
color: Colors.white, |
color: Colors.white, |
||||
border: Border( |
boxShadow: [ |
||||
top: BorderSide( |
BoxShadow( |
||||
color: Colors.grey[200]!, |
color: Colors.black.withOpacity(0.05), |
||||
width: 1, |
blurRadius: 5, |
||||
), |
offset: Offset(0, -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, |
|
||||
), |
|
||||
), |
), |
||||
], |
], |
||||
); |
|
||||
} |
|
||||
|
|
||||
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( |
icon: Icon( |
||||
Icons.mic, |
Icons.mic_none, |
||||
color: const Color(0xFF4A6FE5), |
color: Colors.grey, |
||||
size: 20.sp, |
|
||||
), |
|
||||
onPressed: controller.toggleInputMode, |
|
||||
padding: EdgeInsets.zero, |
|
||||
), |
), |
||||
|
onPressed: controller.startVoiceRecognition, |
||||
), |
), |
||||
SizedBox(width: 8.w), |
// 文本输入框 |
||||
Expanded( |
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( |
child: TextField( |
||||
controller: controller.messageController, |
controller: textController, |
||||
decoration: InputDecoration( |
decoration: InputDecoration( |
||||
hintText: '输入消息...', |
hintText: '发消息...', |
||||
hintStyle: TextStyle( |
border: OutlineInputBorder( |
||||
color: Colors.grey[400], |
borderRadius: BorderRadius.circular(20.r), |
||||
fontSize: 14.sp, |
borderSide: BorderSide.none, |
||||
), |
), |
||||
border: InputBorder.none, |
filled: true, |
||||
contentPadding: EdgeInsets.symmetric(vertical: 12.h), |
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, |
textInputAction: TextInputAction.send, |
||||
onSubmitted: (_) => controller.sendMessage(), |
onSubmitted: (value) { |
||||
), |
if (value.trim().isNotEmpty) { |
||||
|
controller.sendMessage(value); |
||||
|
textController.clear(); |
||||
|
} |
||||
|
}, |
||||
), |
), |
||||
), |
), |
||||
SizedBox(width: 8.w), |
// 发送按钮 |
||||
Container( |
Obx(() => IconButton( |
||||
width: 40.w, |
|
||||
height: 40.w, |
|
||||
decoration: const BoxDecoration( |
|
||||
shape: BoxShape.circle, |
|
||||
color: Color(0xFF4A6FE5), |
|
||||
), |
|
||||
child: IconButton( |
|
||||
icon: Icon( |
icon: Icon( |
||||
Icons.send, |
Icons.send, |
||||
color: Colors.white, |
color: controller.inputText.value.trim().isEmpty |
||||
size: 20.sp, |
? Colors.grey |
||||
), |
: AppColors.primary, |
||||
onPressed: controller.sendMessage, |
), |
||||
padding: EdgeInsets.zero, |
onPressed: controller.inputText.value.trim().isEmpty |
||||
), |
? null |
||||
), |
: () { |
||||
|
controller.sendMessage(controller.inputText.value); |
||||
|
textController.clear(); |
||||
|
}, |
||||
|
)), |
||||
], |
], |
||||
|
), |
||||
); |
); |
||||
} |
} |
||||
|
|
||||
Widget _buildRecordingInput() { |
// 构建语音输入面板 |
||||
|
Widget _buildVoiceInputPanel() { |
||||
|
final screenWidth = Get.width; |
||||
|
|
||||
return Container( |
return Container( |
||||
height: 48.h, |
width: screenWidth, |
||||
decoration: BoxDecoration( |
decoration: const BoxDecoration( |
||||
color: const Color(0xFF4A6FE5), |
gradient: LinearGradient( |
||||
borderRadius: BorderRadius.circular(24.r), |
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: Center( |
||||
|
child: Padding( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 20.w), |
||||
child: Row( |
child: Row( |
||||
mainAxisSize: MainAxisSize.min, |
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
children: List.generate( |
crossAxisAlignment: CrossAxisAlignment.center, |
||||
40, |
children: [ |
||||
(index) => Container( |
// 左侧麦克风按钮 |
||||
width: 4.w, |
_buildMicrophoneButton(), |
||||
height: 4.h, |
|
||||
margin: EdgeInsets.symmetric(horizontal: 1.w), |
|
||||
decoration: BoxDecoration( |
|
||||
color: Colors.white, |
|
||||
borderRadius: BorderRadius.circular(2.r), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
void _showMoreOptions() { |
// 中间音频波形和状态文本 |
||||
Get.bottomSheet( |
Expanded( |
||||
Container( |
|
||||
padding: EdgeInsets.all(20.w), |
|
||||
decoration: BoxDecoration( |
|
||||
color: Colors.white, |
|
||||
borderRadius: BorderRadius.only( |
|
||||
topLeft: Radius.circular(20.r), |
|
||||
topRight: Radius.circular(20.r), |
|
||||
), |
|
||||
), |
|
||||
child: Column( |
child: Column( |
||||
mainAxisSize: MainAxisSize.min, |
mainAxisSize: MainAxisSize.min, |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
children: [ |
children: [ |
||||
ListTile( |
// 音频波形 |
||||
leading: const Icon(Icons.delete_outline, color: Colors.red), |
_buildAudioWaveform(), |
||||
title: const Text('清空对话'), |
SizedBox(height: 10.h), |
||||
onTap: () { |
// 状态文本 |
||||
Get.back(); |
Obx(() => Text( |
||||
controller.clearChat(); |
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) { |
// 右侧关闭按钮 |
||||
// 系统消息使用特殊样式 |
_buildCloseButton(), |
||||
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, |
|
||||
), |
), |
||||
), |
), |
||||
), |
), |
||||
], |
|
||||
), |
), |
||||
), |
), |
||||
); |
); |
||||
} |
} |
||||
|
|
||||
return Padding( |
// 麦克风按钮 |
||||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
Widget _buildMicrophoneButton() { |
||||
child: Row( |
return Container( |
||||
mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start, |
width: 60.w, |
||||
crossAxisAlignment: CrossAxisAlignment.start, |
height: 60.w, |
||||
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), |
|
||||
decoration: BoxDecoration( |
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, |
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) ...[ |
onPressed: controller.stopVoiceRecognition, |
||||
SizedBox(width: 8.w), |
padding: EdgeInsets.zero, |
||||
_buildPulsatingDot(), |
)), |
||||
], |
|
||||
], |
|
||||
), |
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 关闭按钮 |
||||
|
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) ...[ |
child: Center( |
||||
SizedBox(width: 12.w), |
child: IconButton( |
||||
CircleAvatar( |
icon: Icon( |
||||
radius: 20.r, |
Icons.close, |
||||
backgroundColor: const Color(0xFFEEF4FF), |
color: Colors.grey.shade600, |
||||
child: Icon( |
size: 28.sp, |
||||
Icons.person, |
|
||||
color: const Color(0xFF4A6FE5), |
|
||||
size: 24.sp, |
|
||||
), |
), |
||||
|
onPressed: controller.stopVoiceRecognition, |
||||
|
padding: EdgeInsets.zero, |
||||
), |
), |
||||
], |
|
||||
], |
|
||||
), |
), |
||||
); |
); |
||||
} |
} |
||||
|
|
||||
// 创建脉动点动画 |
// 音频波形 |
||||
Widget _buildPulsatingDot() { |
Widget _buildAudioWaveform() { |
||||
return Obx(() { |
final barCount = 23; // 条形数量 |
||||
// 使用controller.isRecognizing来控制动画是否显示 |
|
||||
if (!controller.isRecognizing.value) return const SizedBox.shrink(); |
|
||||
|
|
||||
return _PulsatingDot(); |
return SizedBox( |
||||
}); |
width: 220.w, |
||||
} |
height: 50.h, |
||||
} |
child: Obx(() { |
||||
|
final isSpeaking = controller.recognizedText.isNotEmpty; |
||||
|
final isRec = controller.isListening.value; |
||||
|
|
||||
// 脉动点动画组件 |
return Row( |
||||
class _PulsatingDot extends StatefulWidget { |
mainAxisSize: MainAxisSize.min, |
||||
@override |
mainAxisAlignment: MainAxisAlignment.center, |
||||
_PulsatingDotState createState() => _PulsatingDotState(); |
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; |
final duration = Duration( |
||||
late Animation<double> _animation; |
milliseconds: isSpeaking ? 60 + (index % 4) * 20 : 150 + (index % 3) * 50 |
||||
|
); |
||||
|
|
||||
@override |
// 颜色计算 |
||||
void initState() { |
Color barColor; |
||||
super.initState(); |
if (isRec) { |
||||
_animationController = AnimationController( |
if (isSpeaking) { |
||||
vsync: this, |
final middleIndex = barCount ~/ 2; |
||||
duration: const Duration(milliseconds: 1000), |
final distance = (index - middleIndex).abs(); |
||||
)..repeat(reverse: true); |
final maxDistance = middleIndex; |
||||
|
final colorIntensity = 1.0 - (distance / maxDistance) * 0.2; |
||||
_animation = Tween<double>(begin: 0.5, end: 1.0).animate(_animationController); |
|
||||
} |
|
||||
|
|
||||
@override |
barColor = index % 3 == 0 |
||||
void dispose() { |
? AppColors.primary |
||||
_animationController.dispose(); |
: AppColors.primary.withOpacity(colorIntensity); |
||||
super.dispose(); |
} else { |
||||
|
barColor = AppColors.primary.withOpacity(0.3); |
||||
|
} |
||||
|
} else { |
||||
|
barColor = Colors.grey.shade400; |
||||
} |
} |
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
return SizedBox( |
return SizedBox( |
||||
width: 16.w, |
width: 5.5.w, |
||||
height: 16.w, |
child: Center( |
||||
child: AnimatedBuilder( |
child: AnimatedContainer( |
||||
animation: _animation, |
duration: duration, |
||||
builder: (context, child) { |
curve: isSpeaking ? Curves.elasticOut : Curves.easeInOut, |
||||
return Stack( |
width: isSpeaking ? 2.8.w : 2.5.w, |
||||
alignment: Alignment.center, |
height: height, |
||||
children: [ |
margin: EdgeInsets.symmetric(horizontal: 1.2.w), |
||||
Opacity( |
|
||||
opacity: 1.0 - _animation.value, |
|
||||
child: Container( |
|
||||
width: 16.w * _animation.value, |
|
||||
height: 16.w * _animation.value, |
|
||||
decoration: BoxDecoration( |
decoration: BoxDecoration( |
||||
color: Colors.white.withOpacity(0.5), |
color: barColor, |
||||
shape: BoxShape.circle, |
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